diff --git a/.claude/AGENTS.md b/.claude/AGENTS.md new file mode 100644 index 00000000..9a890175 --- /dev/null +++ b/.claude/AGENTS.md @@ -0,0 +1,50 @@ +# ccproxy Agent Documentation + +## Database Query Commands + +### Quick Reference + +```bash +# Basic query +ccproxy db sql "SELECT COUNT(*) FROM \"CCProxy_HttpTraces\"" + +# From file +ccproxy db sql --file query.sql + +# Output formats +ccproxy db sql "SELECT * FROM \"CCProxy_HttpTraces\" LIMIT 10" --json +ccproxy db sql "SELECT * FROM \"CCProxy_HttpTraces\" LIMIT 10" --csv +``` + +### Key Table: `CCProxy_HttpTraces` + +**Important Fields:** +- `proxy_direction` - 0=reverse (client→LiteLLM), 1=forward (LiteLLM→provider) +- `session_id` - Links related requests across proxy layers (extracted from `metadata.user_id`) +- `method`, `url`, `request_headers`, `response_headers` +- `request_body`, `response_body` - HTTP payload content +- `timestamp` - Request timestamp + +**Common Queries:** + +```sql +-- Filter by session +SELECT * FROM "CCProxy_HttpTraces" WHERE session_id = 'abc123'; + +-- Reverse proxy traffic only +SELECT * FROM "CCProxy_HttpTraces" WHERE proxy_direction = 0; + +-- Forward proxy traffic only +SELECT * FROM "CCProxy_HttpTraces" WHERE proxy_direction = 1; + +-- Recent traces with body content +SELECT timestamp, method, url, request_body +FROM "CCProxy_HttpTraces" +ORDER BY timestamp DESC +LIMIT 20; +``` + +**Database Connection:** +- Set via `CCPROXY_DATABASE_URL` environment variable +- Or configure in `ccproxy.yaml` under `litellm.environment` +- Current: `postgresql://ccproxy:test@localhost:5432/ccproxy_mitm` diff --git a/.claude/TM_COMMANDS_GUIDE.md b/.claude/TM_COMMANDS_GUIDE.md deleted file mode 100644 index 2a312fc1..00000000 --- a/.claude/TM_COMMANDS_GUIDE.md +++ /dev/null @@ -1,147 +0,0 @@ -# Task Master Commands for Claude Code - -Complete guide to using Task Master through Claude Code's slash commands. - -## Overview - -All Task Master functionality is available through the `/project:tm/` namespace with natural language support and intelligent features. - -## Quick Start - -```bash -# Install Task Master -/project:tm/setup/quick-install - -# Initialize project -/project:tm/init/quick - -# Parse requirements -/project:tm/parse-prd requirements.md - -# Start working -/project:tm/next -``` - -## Command Structure - -Commands are organized hierarchically to match Task Master's CLI: -- Main commands at `/project:tm/[command]` -- Subcommands for specific operations `/project:tm/[command]/[subcommand]` -- Natural language arguments accepted throughout - -## Complete Command Reference - -### Setup & Configuration -- `/project:tm/setup/install` - Full installation guide -- `/project:tm/setup/quick-install` - One-line install -- `/project:tm/init` - Initialize project -- `/project:tm/init/quick` - Quick init with -y -- `/project:tm/models` - View AI config -- `/project:tm/models/setup` - Configure AI - -### Task Generation -- `/project:tm/parse-prd` - Generate from PRD -- `/project:tm/parse-prd/with-research` - Enhanced parsing -- `/project:tm/generate` - Create task files - -### Task Management -- `/project:tm/list` - List with natural language filters -- `/project:tm/list/with-subtasks` - Hierarchical view -- `/project:tm/list/by-status ` - Filter by status -- `/project:tm/show ` - Task details -- `/project:tm/add-task` - Create task -- `/project:tm/update` - Update tasks -- `/project:tm/remove-task` - Delete task - -### Status Management -- `/project:tm/set-status/to-pending ` -- `/project:tm/set-status/to-in-progress ` -- `/project:tm/set-status/to-done ` -- `/project:tm/set-status/to-review ` -- `/project:tm/set-status/to-deferred ` -- `/project:tm/set-status/to-cancelled ` - -### Task Analysis -- `/project:tm/analyze-complexity` - AI analysis -- `/project:tm/complexity-report` - View report -- `/project:tm/expand ` - Break down task -- `/project:tm/expand/all` - Expand all complex - -### Dependencies -- `/project:tm/add-dependency` - Add dependency -- `/project:tm/remove-dependency` - Remove dependency -- `/project:tm/validate-dependencies` - Check issues -- `/project:tm/fix-dependencies` - Auto-fix - -### Workflows -- `/project:tm/workflows/smart-flow` - Adaptive workflows -- `/project:tm/workflows/pipeline` - Chain commands -- `/project:tm/workflows/auto-implement` - AI implementation - -### Utilities -- `/project:tm/status` - Project dashboard -- `/project:tm/next` - Next task recommendation -- `/project:tm/utils/analyze` - Project analysis -- `/project:tm/learn` - Interactive help - -## Key Features - -### Natural Language Support -All commands understand natural language: -``` -/project:tm/list pending high priority -/project:tm/update mark 23 as done -/project:tm/add-task implement OAuth login -``` - -### Smart Context -Commands analyze project state and provide intelligent suggestions based on: -- Current task status -- Dependencies -- Team patterns -- Project phase - -### Visual Enhancements -- Progress bars and indicators -- Status badges -- Organized displays -- Clear hierarchies - -## Common Workflows - -### Daily Development -``` -/project:tm/workflows/smart-flow morning -/project:tm/next -/project:tm/set-status/to-in-progress -/project:tm/set-status/to-done -``` - -### Task Breakdown -``` -/project:tm/show -/project:tm/expand -/project:tm/list/with-subtasks -``` - -### Sprint Planning -``` -/project:tm/analyze-complexity -/project:tm/workflows/pipeline init → expand/all → status -``` - -## Migration from Old Commands - -| Old | New | -|-----|-----| -| `/project:task-master:list` | `/project:tm/list` | -| `/project:task-master:complete` | `/project:tm/set-status/to-done` | -| `/project:workflows:auto-implement` | `/project:tm/workflows/auto-implement` | - -## Tips - -1. Use `/project:tm/` + Tab for command discovery -2. Natural language is supported everywhere -3. Commands provide smart defaults -4. Chain commands for automation -5. Check `/project:tm/learn` for interactive help diff --git a/.claude/agents/charm-dev.md b/.claude/agents/charm-dev.md new file mode 100644 index 00000000..a1ed9aff --- /dev/null +++ b/.claude/agents/charm-dev.md @@ -0,0 +1,289 @@ +--- +name: charm-dev +description: | + Expert Go engineer and TUI enthusiast specializing in building beautiful, functional, and performant terminal user interfaces using Bubble Tea by Charm and its associated libraries (Bubbles, Lip Gloss). Has deep knowledge of bubbletea architecture, component design patterns, and terminal styling. Leverages complete source code repositories and comprehensive documentation for charmbracelet libraries. + + Examples: + - + Context: User needs to create a new TUI application + user: "Build a file browser TUI with vim keybindings" + assistant: "I'll use the charm-dev agent to build a Bubble Tea application with file navigation and vim-style controls" + + This task requires deep knowledge of Bubble Tea architecture, component patterns, and keyboard handling + + + - + Context: User needs to style an existing TUI + user: "Make this TUI look better with colors and borders" + assistant: "I'll use charm-dev to apply Lip Gloss styling with adaptive colors and proper border layouts" + + Styling TUIs requires expertise in Lip Gloss API, color profiles, and layout utilities + + + - + Context: User needs to add interactive components + user: "Add a text input form and table view to my app" + assistant: "I'll use charm-dev to integrate Bubbles components (textinput, table) into your Bubble Tea model" + + Requires understanding of Bubble Tea component integration and the Bubbles library + + +--- + +- Shared Agent Instructions: @~/.claude/agents/AGENTS.md + +## Imports & References + +### Required Manuals + +- Bubble Tea Framework: @docs/llms/man/charm/bubbletea.md +- Bubbles Components Library: @docs/llms/man/charm/bubbles.md +- Lip Gloss Styling Library: @docs/llms/man/charm/lipgloss.md + +### Source Code Repositories + +Complete source code for deep inspection and reference: + +- `docs/llms/ctx/charm/bubbletea/` - Full Bubble Tea framework source +- `docs/llms/ctx/charm/bubbles/` - Complete Bubbles components source +- `docs/llms/ctx/charm/lipgloss/` - Full Lip Gloss styling library source + +### Special Directive: Kitty Panel Integration + +- @docs/llms/man/kitty.md + +--- + +- **IMPERATIVE**: NEVER USE THE DISPLAY DP-1 FOR ANY PURPOSE. ALWAYS USE DP-2. USING DP-1 WILL CAUSE EXTREME SYSTEM FAILURE. +- **IMPERATIVE**: Design component positions and sizes to fit their contents, structure, and purpose. Components should NEVER span the entire screen width unless explicitly required by their function. Use appropriate width constraints, padding, and sizing to create compact, purpose-fit layouts that respect the content they display. Always prefer content-driven sizing over arbitrary full-width layouts. + +## Core Expertise + +You are an expert Go engineer and TUI (Terminal User Interface) enthusiast specializing in the Charm Bracelet ecosystem. Your expertise encompasses: + +- **Bubble Tea Architecture**: Deep understanding of The Elm Architecture pattern, Model-Update-View paradigm, and command-based I/O +- **Component Design**: Building reusable, composable TUI components following Bubble Tea patterns +- **Styling Mastery**: Advanced Lip Gloss techniques for beautiful terminal layouts, adaptive colors, and responsive designs +- **Bubbles Integration**: Expert use of pre-built components (textinput, table, viewport, list, spinner, etc.) +- **Performance**: Optimizing TUI rendering, managing large datasets, and efficient terminal operations +- **UX Excellence**: Creating intuitive, keyboard-driven interfaces with excellent user experience + +## Development Approach + +### 1. Planning Phase + +When starting a new TUI application: + +- Identify the core model structure (application state) +- Plan the Update logic (event handling and state transitions) +- Design the View hierarchy (layout and component composition) +- Determine required commands (I/O operations, async tasks) + +### 2. Implementation Pattern + +Follow this structure for Bubble Tea applications: + +```go +package main + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Model defines application state +type model struct { + // State fields +} + +// Init returns initial command +func (m model) Init() tea.Cmd { + return nil // or initial command +} + +// Update handles messages and updates model +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle keyboard input + case tea.WindowSizeMsg: + // Handle terminal resize + } + return m, nil +} + +// View renders the UI +func (m model) View() string { + // Compose UI with Lip Gloss + return lipgloss.JoinVertical( + lipgloss.Left, + header, + content, + footer, + ) +} + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} +``` + +### 3. Styling Best Practices + +- Use `lipgloss.NewStyle()` for reusable style definitions +- Apply adaptive colors for light/dark terminal support +- Leverage layout utilities: `JoinVertical`, `JoinHorizontal`, `Place` +- Use `Width()`, `Height()`, `MaxWidth()`, `MaxHeight()` for responsive layouts +- Compose complex UIs from simple, styled components + +### 4. Component Integration + +When using Bubbles components: + +- Embed component models in your main model +- Forward relevant messages to component Update methods +- Compose component views into your main View +- Handle component-specific commands properly + +Example: + +```go +import "github.com/charmbracelet/bubbles/textinput" + +type model struct { + textInput textinput.Model +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} +``` + +## Key Principles + +1. **The Elm Architecture**: Always follow Model-Update-View separation +2. **Immutability**: Treat model state as immutable, return new instances +3. **Commands for I/O**: All I/O operations must go through commands +4. **Responsive Design**: Handle `tea.WindowSizeMsg` for terminal resizing +5. **Keyboard-First**: Design intuitive keyboard shortcuts and navigation +6. **Type Safety**: Leverage Go's type system for robust message handling +7. **Composability**: Build small, reusable components that compose well + +## Common Patterns + +### Custom Commands + +```go +type dataLoadedMsg struct { data []string } + +func loadDataCmd() tea.Cmd { + return func() tea.Msg { + // Perform I/O operation + data := fetchData() + return dataLoadedMsg{data: data} + } +} +``` + +### Message Handling + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + m.cursor-- + case "down", "j": + m.cursor++ + } + case dataLoadedMsg: + m.data = msg.data + m.loading = false + } + return m, nil +} +``` + +### Layout Composition + +```go +func (m model) View() string { + var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("62")). + Padding(1, 2) + + contentStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("63")). + Padding(1, 2) + ) + + header := headerStyle.Render("My App") + content := contentStyle.Render(m.renderContent()) + + return lipgloss.JoinVertical(lipgloss.Left, header, content) +} +``` + +## Task Execution + +When given a TUI development task: + +1. **Understand Requirements**: Clarify the desired functionality and UX +2. **Reference Documentation**: Consult the imported manuals for API details +3. **Check Source Code**: Use ctx repositories for implementation examples +4. **Build Incrementally**: Start with basic Model-Update-View, add features iteratively +5. **Style Thoughtfully**: Apply Lip Gloss styling for a polished appearance +6. **Test Interactively**: Consider edge cases (terminal resize, keyboard input, etc.) + +## Output Format + +Provide: + +- **Complete, runnable Go code** following Bubble Tea patterns +- **Clear comments** explaining architecture decisions +- **Styling rationale** for Lip Gloss choices +- **Usage instructions** including `go mod` setup and execution +- **Next steps** for further enhancement or integration + +## Error Handling + +- Validate user input before processing +- Handle terminal events gracefully (resize, focus changes) +- Provide clear error messages in the UI +- Never panic - return errors through commands when appropriate + +## Performance Considerations + +- Minimize View re-renders by checking if model state changed +- Use `tea.Batch()` to combine multiple commands efficiently +- Lazy-load large datasets, use pagination or viewports +- Profile rendering performance for complex UIs + +## Integration with Other Tools + +When appropriate, suggest complementary tools: + +- **Harmonica**: Spring animations for smooth motion +- **BubbleZone**: Mouse event tracking +- **Termenv**: Low-level terminal capabilities (already used by Lip Gloss) +- **Reflow**: ANSI-aware text wrapping (useful with Lip Gloss) + +## Continuous Learning + +Stay current with Charm ecosystem by: + +- Referencing latest source code in ctx repositories +- Checking documentation for new APIs and patterns +- Exploring example applications in the Bubble Tea repo +- Consulting GitHub issues for community solutions diff --git a/.claude/commands/tm/add-dependency/add-dependency.md b/.claude/commands/tm/add-dependency/add-dependency.md deleted file mode 100644 index bf826f53..00000000 --- a/.claude/commands/tm/add-dependency/add-dependency.md +++ /dev/null @@ -1,55 +0,0 @@ -Add a dependency between tasks. - -Arguments: $ARGUMENTS - -Parse the task IDs to establish dependency relationship. - -## Adding Dependencies - -Creates a dependency where one task must be completed before another can start. - -## Argument Parsing - -Parse natural language or IDs: -- "make 5 depend on 3" → task 5 depends on task 3 -- "5 needs 3" → task 5 depends on task 3 -- "5 3" → task 5 depends on task 3 -- "5 after 3" → task 5 depends on task 3 - -## Execution - -```bash -task-master add-dependency --id= --depends-on= -``` - -## Validation - -Before adding: -1. **Verify both tasks exist** -2. **Check for circular dependencies** -3. **Ensure dependency makes logical sense** -4. **Warn if creating complex chains** - -## Smart Features - -- Detect if dependency already exists -- Suggest related dependencies -- Show impact on task flow -- Update task priorities if needed - -## Post-Addition - -After adding dependency: -1. Show updated dependency graph -2. Identify any newly blocked tasks -3. Suggest task order changes -4. Update project timeline - -## Example Flows - -``` -/project:tm/add-dependency 5 needs 3 -→ Task #5 now depends on Task #3 -→ Task #5 is now blocked until #3 completes -→ Suggested: Also consider if #5 needs #4 -``` diff --git a/.claude/commands/tm/add-subtask/add-subtask.md b/.claude/commands/tm/add-subtask/add-subtask.md deleted file mode 100644 index 7db6127c..00000000 --- a/.claude/commands/tm/add-subtask/add-subtask.md +++ /dev/null @@ -1,76 +0,0 @@ -Add a subtask to a parent task. - -Arguments: $ARGUMENTS - -Parse arguments to create a new subtask or convert existing task. - -## Adding Subtasks - -Creates subtasks to break down complex parent tasks into manageable pieces. - -## Argument Parsing - -Flexible natural language: -- "add subtask to 5: implement login form" -- "break down 5 with: setup, implement, test" -- "subtask for 5: handle edge cases" -- "5: validate user input" → adds subtask to task 5 - -## Execution Modes - -### 1. Create New Subtask -```bash -task-master add-subtask --parent= --title="" --description="<desc>" -``` - -### 2. Convert Existing Task -```bash -task-master add-subtask --parent=<id> --task-id=<existing-id> -``` - -## Smart Features - -1. **Automatic Subtask Generation** - - If title contains "and" or commas, create multiple - - Suggest common subtask patterns - - Inherit parent's context - -2. **Intelligent Defaults** - - Priority based on parent - - Appropriate time estimates - - Logical dependencies between subtasks - -3. **Validation** - - Check parent task complexity - - Warn if too many subtasks - - Ensure subtask makes sense - -## Creation Process - -1. Parse parent task context -2. Generate subtask with ID like "5.1" -3. Set appropriate defaults -4. Link to parent task -5. Update parent's time estimate - -## Example Flows - -``` -/project:tm/add-subtask to 5: implement user authentication -→ Created subtask #5.1: "implement user authentication" -→ Parent task #5 now has 1 subtask -→ Suggested next subtasks: tests, documentation - -/project:tm/add-subtask 5: setup, implement, test -→ Created 3 subtasks: - #5.1: setup - #5.2: implement - #5.3: test -``` - -## Post-Creation - -- Show updated task hierarchy -- Suggest logical next subtasks -- Update complexity estimates -- Recommend subtask order diff --git a/.claude/commands/tm/add-subtask/convert-task-to-subtask.md b/.claude/commands/tm/add-subtask/convert-task-to-subtask.md deleted file mode 100644 index 4eac680f..00000000 --- a/.claude/commands/tm/add-subtask/convert-task-to-subtask.md +++ /dev/null @@ -1,71 +0,0 @@ -Convert an existing task into a subtask. - -Arguments: $ARGUMENTS - -Parse parent ID and task ID to convert. - -## Task Conversion - -Converts an existing standalone task into a subtask of another task. - -## Argument Parsing - -- "move task 8 under 5" -- "make 8 a subtask of 5" -- "nest 8 in 5" -- "5 8" → make task 8 a subtask of task 5 - -## Execution - -```bash -task-master add-subtask --parent=<parent-id> --task-id=<task-to-convert> -``` - -## Pre-Conversion Checks - -1. **Validation** - - Both tasks exist and are valid - - No circular parent relationships - - Task isn't already a subtask - - Logical hierarchy makes sense - -2. **Impact Analysis** - - Dependencies that will be affected - - Tasks that depend on converting task - - Priority alignment needed - - Status compatibility - -## Conversion Process - -1. Change task ID from "8" to "5.1" (next available) -2. Update all dependency references -3. Inherit parent's context where appropriate -4. Adjust priorities if needed -5. Update time estimates - -## Smart Features - -- Preserve task history -- Maintain dependencies -- Update all references -- Create conversion log - -## Example - -``` -/project:tm/add-subtask/from-task 5 8 -→ Converting: Task #8 becomes subtask #5.1 -→ Updated: 3 dependency references -→ Parent task #5 now has 1 subtask -→ Note: Subtask inherits parent's priority - -Before: #8 "Implement validation" (standalone) -After: #5.1 "Implement validation" (subtask of #5) -``` - -## Post-Conversion - -- Show new task hierarchy -- List updated dependencies -- Verify project integrity -- Suggest related conversions diff --git a/.claude/commands/tm/add-task/add-task.md b/.claude/commands/tm/add-task/add-task.md deleted file mode 100644 index 22c864ba..00000000 --- a/.claude/commands/tm/add-task/add-task.md +++ /dev/null @@ -1,78 +0,0 @@ -Add new tasks with intelligent parsing and context awareness. - -Arguments: $ARGUMENTS - -## Smart Task Addition - -Parse natural language to create well-structured tasks. - -### 1. **Input Understanding** - -I'll intelligently parse your request: -- Natural language → Structured task -- Detect priority from keywords (urgent, ASAP, important) -- Infer dependencies from context -- Suggest complexity based on description -- Determine task type (feature, bug, refactor, test, docs) - -### 2. **Smart Parsing Examples** - -**"Add urgent task to fix login bug"** -→ Title: Fix login bug -→ Priority: high -→ Type: bug -→ Suggested complexity: medium - -**"Create task for API documentation after task 23 is done"** -→ Title: API documentation -→ Dependencies: [23] -→ Type: documentation -→ Priority: medium - -**"Need to refactor auth module - depends on 12 and 15, high complexity"** -→ Title: Refactor auth module -→ Dependencies: [12, 15] -→ Complexity: high -→ Type: refactor - -### 3. **Context Enhancement** - -Based on current project state: -- Suggest related existing tasks -- Warn about potential conflicts -- Recommend dependencies -- Propose subtasks if complex - -### 4. **Interactive Refinement** - -```yaml -Task Preview: -───────────── -Title: [Extracted title] -Priority: [Inferred priority] -Dependencies: [Detected dependencies] -Complexity: [Estimated complexity] - -Suggestions: -- Similar task #34 exists, consider as dependency? -- This seems complex, break into subtasks? -- Tasks #45-47 work on same module -``` - -### 5. **Validation & Creation** - -Before creating: -- Validate dependencies exist -- Check for duplicates -- Ensure logical ordering -- Verify task completeness - -### 6. **Smart Defaults** - -Intelligent defaults based on: -- Task type patterns -- Team conventions -- Historical data -- Current sprint/phase - -Result: High-quality tasks from minimal input. diff --git a/.claude/commands/tm/analyze-complexity/analyze-complexity.md b/.claude/commands/tm/analyze-complexity/analyze-complexity.md deleted file mode 100644 index 336bc761..00000000 --- a/.claude/commands/tm/analyze-complexity/analyze-complexity.md +++ /dev/null @@ -1,121 +0,0 @@ -Analyze task complexity and generate expansion recommendations. - -Arguments: $ARGUMENTS - -Perform deep analysis of task complexity across the project. - -## Complexity Analysis - -Uses AI to analyze tasks and recommend which ones need breakdown. - -## Execution Options - -```bash -task-master analyze-complexity [--research] [--threshold=5] -``` - -## Analysis Parameters - -- `--research` → Use research AI for deeper analysis -- `--threshold=5` → Only flag tasks above complexity 5 -- Default: Analyze all pending tasks - -## Analysis Process - -### 1. **Task Evaluation** -For each task, AI evaluates: -- Technical complexity -- Time requirements -- Dependency complexity -- Risk factors -- Knowledge requirements - -### 2. **Complexity Scoring** -Assigns score 1-10 based on: -- Implementation difficulty -- Integration challenges -- Testing requirements -- Unknown factors -- Technical debt risk - -### 3. **Recommendations** -For complex tasks: -- Suggest expansion approach -- Recommend subtask breakdown -- Identify risk areas -- Propose mitigation strategies - -## Smart Analysis Features - -1. **Pattern Recognition** - - Similar task comparisons - - Historical complexity accuracy - - Team velocity consideration - - Technology stack factors - -2. **Contextual Factors** - - Team expertise - - Available resources - - Timeline constraints - - Business criticality - -3. **Risk Assessment** - - Technical risks - - Timeline risks - - Dependency risks - - Knowledge gaps - -## Output Format - -``` -Task Complexity Analysis Report -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -High Complexity Tasks (>7): -📍 #5 "Implement real-time sync" - Score: 9/10 - Factors: WebSocket complexity, state management, conflict resolution - Recommendation: Expand into 5-7 subtasks - Risks: Performance, data consistency - -📍 #12 "Migrate database schema" - Score: 8/10 - Factors: Data migration, zero downtime, rollback strategy - Recommendation: Expand into 4-5 subtasks - Risks: Data loss, downtime - -Medium Complexity Tasks (5-7): -📍 #23 "Add export functionality" - Score: 6/10 - Consider expansion if timeline tight - -Low Complexity Tasks (<5): -✅ 15 tasks - No expansion needed - -Summary: -- Expand immediately: 2 tasks -- Consider expanding: 5 tasks -- Keep as-is: 15 tasks -``` - -## Actionable Output - -For each high-complexity task: -1. Complexity score with reasoning -2. Specific expansion suggestions -3. Risk mitigation approaches -4. Recommended subtask structure - -## Integration - -Results are: -- Saved to `.taskmaster/reports/complexity-analysis.md` -- Used by expand command -- Inform sprint planning -- Guide resource allocation - -## Next Steps - -After analysis: -``` -/project:tm/expand 5 # Expand specific task -/project:tm/expand/all # Expand all recommended -/project:tm/complexity-report # View detailed report -``` diff --git a/.claude/commands/tm/clear-subtasks/clear-all-subtasks.md b/.claude/commands/tm/clear-subtasks/clear-all-subtasks.md deleted file mode 100644 index 87e31152..00000000 --- a/.claude/commands/tm/clear-subtasks/clear-all-subtasks.md +++ /dev/null @@ -1,93 +0,0 @@ -Clear all subtasks from all tasks globally. - -## Global Subtask Clearing - -Remove all subtasks across the entire project. Use with extreme caution. - -## Execution - -```bash -task-master clear-subtasks --all -``` - -## Pre-Clear Analysis - -1. **Project-Wide Summary** - ``` - Global Subtask Summary - ━━━━━━━━━━━━━━━━━━━━ - Total parent tasks: 12 - Total subtasks: 47 - - Completed: 15 - - In-progress: 8 - - Pending: 24 - - Work at risk: ~120 hours - ``` - -2. **Critical Warnings** - - In-progress subtasks that will lose work - - Completed subtasks with valuable history - - Complex dependency chains - - Integration test results - -## Double Confirmation - -``` -⚠️ DESTRUCTIVE OPERATION WARNING ⚠️ -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -This will remove ALL 47 subtasks from your project -Including 8 in-progress and 15 completed subtasks - -This action CANNOT be undone - -Type 'CLEAR ALL SUBTASKS' to confirm: -``` - -## Smart Safeguards - -- Require explicit confirmation phrase -- Create automatic backup -- Log all removed data -- Option to export first - -## Use Cases - -Valid reasons for global clear: -- Project restructuring -- Major pivot in approach -- Starting fresh breakdown -- Switching to different task organization - -## Process - -1. Full project analysis -2. Create backup file -3. Show detailed impact -4. Require confirmation -5. Execute removal -6. Generate summary report - -## Alternative Suggestions - -Before clearing all: -- Export subtasks to file -- Clear only pending subtasks -- Clear by task category -- Archive instead of delete - -## Post-Clear Report - -``` -Global Subtask Clear Complete -━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Removed: 47 subtasks from 12 tasks -Backup saved: .taskmaster/backup/subtasks-20240115.json -Parent tasks updated: 12 -Time estimates adjusted: Yes - -Next steps: -- Review updated task list -- Re-expand complex tasks as needed -- Check project timeline -``` diff --git a/.claude/commands/tm/clear-subtasks/clear-subtasks.md b/.claude/commands/tm/clear-subtasks/clear-subtasks.md deleted file mode 100644 index 138ae0f9..00000000 --- a/.claude/commands/tm/clear-subtasks/clear-subtasks.md +++ /dev/null @@ -1,86 +0,0 @@ -Clear all subtasks from a specific task. - -Arguments: $ARGUMENTS (task ID) - -Remove all subtasks from a parent task at once. - -## Clearing Subtasks - -Bulk removal of all subtasks from a parent task. - -## Execution - -```bash -task-master clear-subtasks --id=<task-id> -``` - -## Pre-Clear Analysis - -1. **Subtask Summary** - - Number of subtasks - - Completion status of each - - Work already done - - Dependencies affected - -2. **Impact Assessment** - - Data that will be lost - - Dependencies to be removed - - Effect on project timeline - - Parent task implications - -## Confirmation Required - -``` -Clear Subtasks Confirmation -━━━━━━━━━━━━━━━━━━━━━━━━━ -Parent Task: #5 "Implement user authentication" -Subtasks to remove: 4 -- #5.1 "Setup auth framework" (done) -- #5.2 "Create login form" (in-progress) -- #5.3 "Add validation" (pending) -- #5.4 "Write tests" (pending) - -⚠️ This will permanently delete all subtask data -Continue? (y/n) -``` - -## Smart Features - -- Option to convert to standalone tasks -- Backup task data before clearing -- Preserve completed work history -- Update parent task appropriately - -## Process - -1. List all subtasks for confirmation -2. Check for in-progress work -3. Remove all subtasks -4. Update parent task -5. Clean up dependencies - -## Alternative Options - -Suggest alternatives: -- Convert important subtasks to tasks -- Keep completed subtasks -- Archive instead of delete -- Export subtask data first - -## Post-Clear - -- Show updated parent task -- Recalculate time estimates -- Update task complexity -- Suggest next steps - -## Example - -``` -/project:tm/clear-subtasks 5 -→ Found 4 subtasks to remove -→ Warning: Subtask #5.2 is in-progress -→ Cleared all subtasks from task #5 -→ Updated parent task estimates -→ Suggestion: Consider re-expanding with better breakdown -``` diff --git a/.claude/commands/tm/complexity-report/complexity-report.md b/.claude/commands/tm/complexity-report/complexity-report.md deleted file mode 100644 index 026e393d..00000000 --- a/.claude/commands/tm/complexity-report/complexity-report.md +++ /dev/null @@ -1,117 +0,0 @@ -Display the task complexity analysis report. - -Arguments: $ARGUMENTS - -View the detailed complexity analysis generated by analyze-complexity command. - -## Viewing Complexity Report - -Shows comprehensive task complexity analysis with actionable insights. - -## Execution - -```bash -task-master complexity-report [--file=<path>] -``` - -## Report Location - -Default: `.taskmaster/reports/complexity-analysis.md` -Custom: Specify with --file parameter - -## Report Contents - -### 1. **Executive Summary** -``` -Complexity Analysis Summary -━━━━━━━━━━━━━━━━━━━━━━━━ -Analysis Date: 2024-01-15 -Tasks Analyzed: 32 -High Complexity: 5 (16%) -Medium Complexity: 12 (37%) -Low Complexity: 15 (47%) - -Critical Findings: -- 5 tasks need immediate expansion -- 3 tasks have high technical risk -- 2 tasks block critical path -``` - -### 2. **Detailed Task Analysis** -For each complex task: -- Complexity score breakdown -- Contributing factors -- Specific risks identified -- Expansion recommendations -- Similar completed tasks - -### 3. **Risk Matrix** -Visual representation: -``` -Risk vs Complexity Matrix -━━━━━━━━━━━━━━━━━━━━━━━ -High Risk | #5(9) #12(8) | #23(6) -Med Risk | #34(7) | #45(5) #67(5) -Low Risk | #78(8) | [15 tasks] - | High Complex | Med Complex -``` - -### 4. **Recommendations** - -**Immediate Actions:** -1. Expand task #5 - Critical path + high complexity -2. Expand task #12 - High risk + dependencies -3. Review task #34 - Consider splitting - -**Sprint Planning:** -- Don't schedule multiple high-complexity tasks together -- Ensure expertise available for complex tasks -- Build in buffer time for unknowns - -## Interactive Features - -When viewing report: -1. **Quick Actions** - - Press 'e' to expand a task - - Press 'd' for task details - - Press 'r' to refresh analysis - -2. **Filtering** - - View by complexity level - - Filter by risk factors - - Show only actionable items - -3. **Export Options** - - Markdown format - - CSV for spreadsheets - - JSON for tools - -## Report Intelligence - -- Compares with historical data -- Shows complexity trends -- Identifies patterns -- Suggests process improvements - -## Integration - -Use report for: -- Sprint planning sessions -- Resource allocation -- Risk assessment -- Team discussions -- Client updates - -## Example Usage - -``` -/project:tm/complexity-report -→ Opens latest analysis - -/project:tm/complexity-report --file=archived/2024-01-01.md -→ View historical analysis - -After viewing: -/project:tm/expand 5 -→ Expand high-complexity task -``` diff --git a/.claude/commands/tm/expand/expand-all-tasks.md b/.claude/commands/tm/expand/expand-all-tasks.md deleted file mode 100644 index 045a6f65..00000000 --- a/.claude/commands/tm/expand/expand-all-tasks.md +++ /dev/null @@ -1,51 +0,0 @@ -Expand all pending tasks that need subtasks. - -## Bulk Task Expansion - -Intelligently expands all tasks that would benefit from breakdown. - -## Execution - -```bash -task-master expand --all -``` - -## Smart Selection - -Only expands tasks that: -- Are marked as pending -- Have high complexity (>5) -- Lack existing subtasks -- Would benefit from breakdown - -## Expansion Process - -1. **Analysis Phase** - - Identify expansion candidates - - Group related tasks - - Plan expansion strategy - -2. **Batch Processing** - - Expand tasks in logical order - - Maintain consistency - - Preserve relationships - - Optimize for parallelism - -3. **Quality Control** - - Ensure subtask quality - - Avoid over-decomposition - - Maintain task coherence - - Update dependencies - -## Options - -- Add `force` to expand all regardless of complexity -- Add `research` for enhanced AI analysis - -## Results - -After bulk expansion: -- Summary of tasks expanded -- New subtask count -- Updated complexity metrics -- Suggested task order diff --git a/.claude/commands/tm/expand/expand-task.md b/.claude/commands/tm/expand/expand-task.md deleted file mode 100644 index aefa5f64..00000000 --- a/.claude/commands/tm/expand/expand-task.md +++ /dev/null @@ -1,49 +0,0 @@ -Break down a complex task into subtasks. - -Arguments: $ARGUMENTS (task ID) - -## Intelligent Task Expansion - -Analyzes a task and creates detailed subtasks for better manageability. - -## Execution - -```bash -task-master expand --id=$ARGUMENTS -``` - -## Expansion Process - -1. **Task Analysis** - - Review task complexity - - Identify components - - Detect technical challenges - - Estimate time requirements - -2. **Subtask Generation** - - Create 3-7 subtasks typically - - Each subtask 1-4 hours - - Logical implementation order - - Clear acceptance criteria - -3. **Smart Breakdown** - - Setup/configuration tasks - - Core implementation - - Testing components - - Integration steps - - Documentation updates - -## Enhanced Features - -Based on task type: -- **Feature**: Setup → Implement → Test → Integrate -- **Bug Fix**: Reproduce → Diagnose → Fix → Verify -- **Refactor**: Analyze → Plan → Refactor → Validate - -## Post-Expansion - -After expansion: -1. Show subtask hierarchy -2. Update time estimates -3. Suggest implementation order -4. Highlight critical path diff --git a/.claude/commands/tm/fix-dependencies/fix-dependencies.md b/.claude/commands/tm/fix-dependencies/fix-dependencies.md deleted file mode 100644 index aec7ca98..00000000 --- a/.claude/commands/tm/fix-dependencies/fix-dependencies.md +++ /dev/null @@ -1,81 +0,0 @@ -Automatically fix dependency issues found during validation. - -## Automatic Dependency Repair - -Intelligently fixes common dependency problems while preserving project logic. - -## Execution - -```bash -task-master fix-dependencies -``` - -## What Gets Fixed - -### 1. **Auto-Fixable Issues** -- Remove references to deleted tasks -- Break simple circular dependencies -- Remove self-dependencies -- Clean up duplicate dependencies - -### 2. **Smart Resolutions** -- Reorder dependencies to maintain logic -- Suggest task merging for over-dependent tasks -- Flatten unnecessary dependency chains -- Remove redundant transitive dependencies - -### 3. **Manual Review Required** -- Complex circular dependencies -- Critical path modifications -- Business logic dependencies -- High-impact changes - -## Fix Process - -1. **Analysis Phase** - - Run validation check - - Categorize issues by type - - Determine fix strategy - -2. **Execution Phase** - - Apply automatic fixes - - Log all changes made - - Preserve task relationships - -3. **Verification Phase** - - Re-validate after fixes - - Show before/after comparison - - Highlight manual fixes needed - -## Smart Features - -- Preserves intended task flow -- Minimal disruption approach -- Creates fix history/log -- Suggests manual interventions - -## Output Example - -``` -Dependency Auto-Fix Report -━━━━━━━━━━━━━━━━━━━━━━━━ -Fixed Automatically: -✅ Removed 2 references to deleted tasks -✅ Resolved 1 self-dependency -✅ Cleaned 3 redundant dependencies - -Manual Review Needed: -⚠️ Complex circular dependency: #12 → #15 → #18 → #12 - Suggestion: Make #15 not depend on #12 -⚠️ Task #45 has 8 dependencies - Suggestion: Break into subtasks - -Run '/project:tm/validate-dependencies' to verify fixes -``` - -## Safety - -- Preview mode available -- Rollback capability -- Change logging -- No data loss diff --git a/.claude/commands/tm/generate/generate-tasks.md b/.claude/commands/tm/generate/generate-tasks.md deleted file mode 100644 index e260952f..00000000 --- a/.claude/commands/tm/generate/generate-tasks.md +++ /dev/null @@ -1,121 +0,0 @@ -Generate individual task files from tasks.json. - -## Task File Generation - -Creates separate markdown files for each task, perfect for AI agents or documentation. - -## Execution - -```bash -task-master generate -``` - -## What It Creates - -For each task, generates a file like `task_001.txt`: - -``` -Task ID: 1 -Title: Implement user authentication -Status: pending -Priority: high -Dependencies: [] -Created: 2024-01-15 -Complexity: 7 - -## Description -Create a secure user authentication system with login, logout, and session management. - -## Details -- Use JWT tokens for session management -- Implement secure password hashing -- Add remember me functionality -- Include password reset flow - -## Test Strategy -- Unit tests for auth functions -- Integration tests for login flow -- Security testing for vulnerabilities -- Performance tests for concurrent logins - -## Subtasks -1.1 Setup authentication framework (pending) -1.2 Create login endpoints (pending) -1.3 Implement session management (pending) -1.4 Add password reset (pending) -``` - -## File Organization - -Creates structure: -``` -.taskmaster/ -└── tasks/ - ├── task_001.txt - ├── task_002.txt - ├── task_003.txt - └── ... -``` - -## Smart Features - -1. **Consistent Formatting** - - Standardized structure - - Clear sections - - AI-readable format - - Markdown compatible - -2. **Contextual Information** - - Full task details - - Related task references - - Progress indicators - - Implementation notes - -3. **Incremental Updates** - - Only regenerate changed tasks - - Preserve custom additions - - Track generation timestamp - - Version control friendly - -## Use Cases - -- **AI Context**: Provide task context to AI assistants -- **Documentation**: Standalone task documentation -- **Archival**: Task history preservation -- **Sharing**: Send specific tasks to team members -- **Review**: Easier task review process - -## Generation Options - -Based on arguments: -- Filter by status -- Include/exclude completed -- Custom templates -- Different formats - -## Post-Generation - -``` -Task File Generation Complete -━━━━━━━━━━━━━━━━━━━━━━━━━━ -Generated: 45 task files -Location: .taskmaster/tasks/ -Total size: 156 KB - -New files: 5 -Updated files: 12 -Unchanged: 28 - -Ready for: -- AI agent consumption -- Version control -- Team distribution -``` - -## Integration Benefits - -- Git-trackable task history -- Easy task sharing -- AI tool compatibility -- Offline task access -- Backup redundancy diff --git a/.claude/commands/tm/help.md b/.claude/commands/tm/help.md deleted file mode 100644 index 65105006..00000000 --- a/.claude/commands/tm/help.md +++ /dev/null @@ -1,81 +0,0 @@ -Show help for Task Master commands. - -Arguments: $ARGUMENTS - -Display help for Task Master commands. If arguments provided, show specific command help. - -## Task Master Command Help - -### Quick Navigation - -Type `/project:tm/` and use tab completion to explore all commands. - -### Command Categories - -#### 🚀 Setup & Installation -- `/project:tm/setup/install` - Comprehensive installation guide -- `/project:tm/setup/quick-install` - One-line global install - -#### 📋 Project Setup -- `/project:tm/init` - Initialize new project -- `/project:tm/init/quick` - Quick setup with auto-confirm -- `/project:tm/models` - View AI configuration -- `/project:tm/models/setup` - Configure AI providers - -#### 🎯 Task Generation -- `/project:tm/parse-prd` - Generate tasks from PRD -- `/project:tm/parse-prd/with-research` - Enhanced parsing -- `/project:tm/generate` - Create task files - -#### 📝 Task Management -- `/project:tm/list` - List tasks (natural language filters) -- `/project:tm/show <id>` - Display task details -- `/project:tm/add-task` - Create new task -- `/project:tm/update` - Update tasks naturally -- `/project:tm/next` - Get next task recommendation - -#### 🔄 Status Management -- `/project:tm/set-status/to-pending <id>` -- `/project:tm/set-status/to-in-progress <id>` -- `/project:tm/set-status/to-done <id>` -- `/project:tm/set-status/to-review <id>` -- `/project:tm/set-status/to-deferred <id>` -- `/project:tm/set-status/to-cancelled <id>` - -#### 🔍 Analysis & Breakdown -- `/project:tm/analyze-complexity` - Analyze task complexity -- `/project:tm/expand <id>` - Break down complex task -- `/project:tm/expand/all` - Expand all eligible tasks - -#### 🔗 Dependencies -- `/project:tm/add-dependency` - Add task dependency -- `/project:tm/remove-dependency` - Remove dependency -- `/project:tm/validate-dependencies` - Check for issues - -#### 🤖 Workflows -- `/project:tm/workflows/smart-flow` - Intelligent workflows -- `/project:tm/workflows/pipeline` - Command chaining -- `/project:tm/workflows/auto-implement` - Auto-implementation - -#### 📊 Utilities -- `/project:tm/utils/analyze` - Project analysis -- `/project:tm/status` - Project dashboard -- `/project:tm/learn` - Interactive learning - -### Natural Language Examples - -``` -/project:tm/list pending high priority -/project:tm/update mark all API tasks as done -/project:tm/add-task create login system with OAuth -/project:tm/show current -``` - -### Getting Started - -1. Install: `/project:tm/setup/quick-install` -2. Initialize: `/project:tm/init/quick` -3. Learn: `/project:tm/learn start` -4. Work: `/project:tm/workflows/smart-flow` - -For detailed command info: `/project:tm/help <command-name>` diff --git a/.claude/commands/tm/init/init-project-quick.md b/.claude/commands/tm/init/init-project-quick.md deleted file mode 100644 index 7055fb00..00000000 --- a/.claude/commands/tm/init/init-project-quick.md +++ /dev/null @@ -1,46 +0,0 @@ -Quick initialization with auto-confirmation. - -Arguments: $ARGUMENTS - -Initialize a Task Master project without prompts, accepting all defaults. - -## Quick Setup - -```bash -task-master init -y -``` - -## What It Does - -1. Creates `.taskmaster/` directory structure -2. Initializes empty `tasks.json` -3. Sets up default configuration -4. Uses directory name as project name -5. Skips all confirmation prompts - -## Smart Defaults - -- Project name: Current directory name -- Description: "Task Master Project" -- Model config: Existing environment vars -- Task structure: Standard format - -## Next Steps - -After quick init: -1. Configure AI models if needed: - ``` - /project:tm/models/setup - ``` - -2. Parse PRD if available: - ``` - /project:tm/parse-prd <file> - ``` - -3. Or create first task: - ``` - /project:tm/add-task create initial setup - ``` - -Perfect for rapid project setup! diff --git a/.claude/commands/tm/init/init-project.md b/.claude/commands/tm/init/init-project.md deleted file mode 100644 index c1da04e9..00000000 --- a/.claude/commands/tm/init/init-project.md +++ /dev/null @@ -1,50 +0,0 @@ -Initialize a new Task Master project. - -Arguments: $ARGUMENTS - -Parse arguments to determine initialization preferences. - -## Initialization Process - -1. **Parse Arguments** - - PRD file path (if provided) - - Project name - - Auto-confirm flag (-y) - -2. **Project Setup** - ```bash - task-master init - ``` - -3. **Smart Initialization** - - Detect existing project files - - Suggest project name from directory - - Check for git repository - - Verify AI provider configuration - -## Configuration Options - -Based on arguments: -- `quick` / `-y` → Skip confirmations -- `<file.md>` → Use as PRD after init -- `--name=<name>` → Set project name -- `--description=<desc>` → Set description - -## Post-Initialization - -After successful init: -1. Show project structure created -2. Verify AI models configured -3. Suggest next steps: - - Parse PRD if available - - Configure AI providers - - Set up git hooks - - Create first tasks - -## Integration - -If PRD file provided: -``` -/project:tm/init my-prd.md -→ Automatically runs parse-prd after init -``` diff --git a/.claude/commands/tm/learn.md b/.claude/commands/tm/learn.md deleted file mode 100644 index 6580c438..00000000 --- a/.claude/commands/tm/learn.md +++ /dev/null @@ -1,103 +0,0 @@ -Learn about Task Master capabilities through interactive exploration. - -Arguments: $ARGUMENTS - -## Interactive Task Master Learning - -Based on your input, I'll help you discover capabilities: - -### 1. **What are you trying to do?** - -If $ARGUMENTS contains: -- "start" / "begin" → Show project initialization workflows -- "manage" / "organize" → Show task management commands -- "automate" / "auto" → Show automation workflows -- "analyze" / "report" → Show analysis tools -- "fix" / "problem" → Show troubleshooting commands -- "fast" / "quick" → Show efficiency shortcuts - -### 2. **Intelligent Suggestions** - -Based on your project state: - -**No tasks yet?** -``` -You'll want to start with: -1. /project:task-master:init <prd-file> - → Creates tasks from requirements - -2. /project:task-master:parse-prd <file> - → Alternative task generation - -Try: /project:task-master:init demo-prd.md -``` - -**Have tasks?** -Let me analyze what you might need... -- Many pending tasks? → Learn sprint planning -- Complex tasks? → Learn task expansion -- Daily work? → Learn workflow automation - -### 3. **Command Discovery** - -**By Category:** -- 📋 Task Management: list, show, add, update, complete -- 🔄 Workflows: auto-implement, sprint-plan, daily-standup -- 🛠️ Utilities: check-health, complexity-report, sync-memory -- 🔍 Analysis: validate-deps, show dependencies - -**By Scenario:** -- "I want to see what to work on" → `/project:task-master:next` -- "I need to break this down" → `/project:task-master:expand <id>` -- "Show me everything" → `/project:task-master:status` -- "Just do it for me" → `/project:workflows:auto-implement` - -### 4. **Power User Patterns** - -**Command Chaining:** -``` -/project:task-master:next -/project:task-master:start <id> -/project:workflows:auto-implement -``` - -**Smart Filters:** -``` -/project:task-master:list pending high -/project:task-master:list blocked -/project:task-master:list 1-5 tree -``` - -**Automation:** -``` -/project:workflows:pipeline init → expand-all → sprint-plan -``` - -### 5. **Learning Path** - -Based on your experience level: - -**Beginner Path:** -1. init → Create project -2. status → Understand state -3. next → Find work -4. complete → Finish task - -**Intermediate Path:** -1. expand → Break down complex tasks -2. sprint-plan → Organize work -3. complexity-report → Understand difficulty -4. validate-deps → Ensure consistency - -**Advanced Path:** -1. pipeline → Chain operations -2. smart-flow → Context-aware automation -3. Custom commands → Extend the system - -### 6. **Try This Now** - -Based on what you asked about, try: -[Specific command suggestion based on $ARGUMENTS] - -Want to learn more about a specific command? -Type: /project:help <command-name> diff --git a/.claude/commands/tm/list/list-tasks-by-status.md b/.claude/commands/tm/list/list-tasks-by-status.md deleted file mode 100644 index d3d5dd12..00000000 --- a/.claude/commands/tm/list/list-tasks-by-status.md +++ /dev/null @@ -1,39 +0,0 @@ -List tasks filtered by a specific status. - -Arguments: $ARGUMENTS - -Parse the status from arguments and list only tasks matching that status. - -## Status Options -- `pending` - Not yet started -- `in-progress` - Currently being worked on -- `done` - Completed -- `review` - Awaiting review -- `deferred` - Postponed -- `cancelled` - Cancelled - -## Execution - -Based on $ARGUMENTS, run: -```bash -task-master list --status=$ARGUMENTS -``` - -## Enhanced Display - -For the filtered results: -- Group by priority within the status -- Show time in current status -- Highlight tasks approaching deadlines -- Display blockers and dependencies -- Suggest next actions for each status group - -## Intelligent Insights - -Based on the status filter: -- **Pending**: Show recommended start order -- **In-Progress**: Display idle time warnings -- **Done**: Show newly unblocked tasks -- **Review**: Indicate review duration -- **Deferred**: Show reactivation criteria -- **Cancelled**: Display impact analysis diff --git a/.claude/commands/tm/list/list-tasks-with-subtasks.md b/.claude/commands/tm/list/list-tasks-with-subtasks.md deleted file mode 100644 index 7646a365..00000000 --- a/.claude/commands/tm/list/list-tasks-with-subtasks.md +++ /dev/null @@ -1,29 +0,0 @@ -List all tasks including their subtasks in a hierarchical view. - -This command shows all tasks with their nested subtasks, providing a complete project overview. - -## Execution - -Run the Task Master list command with subtasks flag: -```bash -task-master list --with-subtasks -``` - -## Enhanced Display - -I'll organize the output to show: -- Parent tasks with clear indicators -- Nested subtasks with proper indentation -- Status badges for quick scanning -- Dependencies and blockers highlighted -- Progress indicators for tasks with subtasks - -## Smart Filtering - -Based on the task hierarchy: -- Show completion percentage for parent tasks -- Highlight blocked subtask chains -- Group by functional areas -- Indicate critical path items - -This gives you a complete tree view of your project structure. diff --git a/.claude/commands/tm/list/list-tasks.md b/.claude/commands/tm/list/list-tasks.md deleted file mode 100644 index b76a775a..00000000 --- a/.claude/commands/tm/list/list-tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -List tasks with intelligent argument parsing. - -Parse arguments to determine filters and display options: -- Status: pending, in-progress, done, review, deferred, cancelled -- Priority: high, medium, low (or priority:high) -- Special: subtasks, tree, dependencies, blocked -- IDs: Direct numbers (e.g., "1,3,5" or "1-5") -- Complex: "pending high" = pending AND high priority - -Arguments: $ARGUMENTS - -Let me parse your request intelligently: - -1. **Detect Filter Intent** - - If arguments contain status keywords → filter by status - - If arguments contain priority → filter by priority - - If arguments contain "subtasks" → include subtasks - - If arguments contain "tree" → hierarchical view - - If arguments contain numbers → show specific tasks - - If arguments contain "blocked" → show blocked tasks only - -2. **Smart Combinations** - Examples of what I understand: - - "pending high" → pending tasks with high priority - - "done today" → tasks completed today - - "blocked" → tasks with unmet dependencies - - "1-5" → tasks 1 through 5 - - "subtasks tree" → hierarchical view with subtasks - -3. **Execute Appropriate Query** - Based on parsed intent, run the most specific task-master command - -4. **Enhanced Display** - - Group by relevant criteria - - Show most important information first - - Use visual indicators for quick scanning - - Include relevant metrics - -5. **Intelligent Suggestions** - Based on what you're viewing, suggest next actions: - - Many pending? → Suggest priority order - - Many blocked? → Show dependency resolution - - Looking at specific tasks? → Show related tasks diff --git a/.claude/commands/tm/models/setup-models.md b/.claude/commands/tm/models/setup-models.md deleted file mode 100644 index f0dba06e..00000000 --- a/.claude/commands/tm/models/setup-models.md +++ /dev/null @@ -1,51 +0,0 @@ -Run interactive setup to configure AI models. - -## Interactive Model Configuration - -Guides you through setting up AI providers for Task Master. - -## Execution - -```bash -task-master models --setup -``` - -## Setup Process - -1. **Environment Check** - - Detect existing API keys - - Show current configuration - - Identify missing providers - -2. **Provider Selection** - - Choose main provider (required) - - Select research provider (recommended) - - Configure fallback (optional) - -3. **API Key Configuration** - - Prompt for missing keys - - Validate key format - - Test connectivity - - Save configuration - -## Smart Recommendations - -Based on your needs: -- **For best results**: Claude + Perplexity -- **Budget conscious**: GPT-3.5 + Perplexity -- **Maximum capability**: GPT-4 + Perplexity + Claude fallback - -## Configuration Storage - -Keys can be stored in: -1. Environment variables (recommended) -2. `.env` file in project -3. Global `.taskmaster/config` - -## Post-Setup - -After configuration: -- Test each provider -- Show usage examples -- Suggest next steps -- Verify parse-prd works diff --git a/.claude/commands/tm/models/view-models.md b/.claude/commands/tm/models/view-models.md deleted file mode 100644 index a2075f8b..00000000 --- a/.claude/commands/tm/models/view-models.md +++ /dev/null @@ -1,51 +0,0 @@ -View current AI model configuration. - -## Model Configuration Display - -Shows the currently configured AI providers and models for Task Master. - -## Execution - -```bash -task-master models -``` - -## Information Displayed - -1. **Main Provider** - - Model ID and name - - API key status (configured/missing) - - Usage: Primary task generation - -2. **Research Provider** - - Model ID and name - - API key status - - Usage: Enhanced research mode - -3. **Fallback Provider** - - Model ID and name - - API key status - - Usage: Backup when main fails - -## Visual Status - -``` -Task Master AI Model Configuration -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Main: ✅ claude-3-5-sonnet (configured) -Research: ✅ perplexity-sonar (configured) -Fallback: ⚠️ Not configured (optional) - -Available Models: -- claude-3-5-sonnet -- gpt-4-turbo -- gpt-3.5-turbo -- perplexity-sonar -``` - -## Next Actions - -Based on configuration: -- If missing API keys → Suggest setup -- If no research model → Explain benefits -- If all configured → Show usage tips diff --git a/.claude/commands/tm/next/next-task.md b/.claude/commands/tm/next/next-task.md deleted file mode 100644 index c3fa1f0e..00000000 --- a/.claude/commands/tm/next/next-task.md +++ /dev/null @@ -1,66 +0,0 @@ -Intelligently determine and prepare the next action based on comprehensive context. - -This enhanced version of 'next' considers: -- Current task states -- Recent activity -- Time constraints -- Dependencies -- Your working patterns - -Arguments: $ARGUMENTS - -## Intelligent Next Action - -### 1. **Context Gathering** -Let me analyze the current situation: -- Active tasks (in-progress) -- Recently completed tasks -- Blocked tasks -- Time since last activity -- Arguments provided: $ARGUMENTS - -### 2. **Smart Decision Tree** - -**If you have an in-progress task:** -- Has it been idle > 2 hours? → Suggest resuming or switching -- Near completion? → Show remaining steps -- Blocked? → Find alternative task - -**If no in-progress tasks:** -- Unblocked high-priority tasks? → Start highest -- Complex tasks need breakdown? → Suggest expansion -- All tasks blocked? → Show dependency resolution - -**Special arguments handling:** -- "quick" → Find task < 2 hours -- "easy" → Find low complexity task -- "important" → Find high priority regardless of complexity -- "continue" → Resume last worked task - -### 3. **Preparation Workflow** - -Based on selected task: -1. Show full context and history -2. Set up development environment -3. Run relevant tests -4. Open related files -5. Show similar completed tasks -6. Estimate completion time - -### 4. **Alternative Suggestions** - -Always provide options: -- Primary recommendation -- Quick alternative (< 1 hour) -- Strategic option (unblocks most tasks) -- Learning option (new technology/skill) - -### 5. **Workflow Integration** - -Seamlessly connect to: -- `/project:task-master:start [selected]` -- `/project:workflows:auto-implement` -- `/project:task-master:expand` (if complex) -- `/project:utils:complexity-report` (if unsure) - -The goal: Zero friction from decision to implementation. diff --git a/.claude/commands/tm/parse-prd/parse-prd-with-research.md b/.claude/commands/tm/parse-prd/parse-prd-with-research.md deleted file mode 100644 index 23d60539..00000000 --- a/.claude/commands/tm/parse-prd/parse-prd-with-research.md +++ /dev/null @@ -1,48 +0,0 @@ -Parse PRD with enhanced research mode for better task generation. - -Arguments: $ARGUMENTS (PRD file path) - -## Research-Enhanced Parsing - -Uses the research AI provider (typically Perplexity) for more comprehensive task generation with current best practices. - -## Execution - -```bash -task-master parse-prd --input=$ARGUMENTS --research -``` - -## Research Benefits - -1. **Current Best Practices** - - Latest framework patterns - - Security considerations - - Performance optimizations - - Accessibility requirements - -2. **Technical Deep Dive** - - Implementation approaches - - Library recommendations - - Architecture patterns - - Testing strategies - -3. **Comprehensive Coverage** - - Edge cases consideration - - Error handling tasks - - Monitoring setup - - Deployment tasks - -## Enhanced Output - -Research mode typically: -- Generates more detailed tasks -- Includes industry standards -- Adds compliance considerations -- Suggests modern tooling - -## When to Use - -- New technology domains -- Complex requirements -- Regulatory compliance needed -- Best practices crucial diff --git a/.claude/commands/tm/parse-prd/parse-prd.md b/.claude/commands/tm/parse-prd/parse-prd.md deleted file mode 100644 index 88f0f30f..00000000 --- a/.claude/commands/tm/parse-prd/parse-prd.md +++ /dev/null @@ -1,49 +0,0 @@ -Parse a PRD document to generate tasks. - -Arguments: $ARGUMENTS (PRD file path) - -## Intelligent PRD Parsing - -Analyzes your requirements document and generates a complete task breakdown. - -## Execution - -```bash -task-master parse-prd --input=$ARGUMENTS -``` - -## Parsing Process - -1. **Document Analysis** - - Extract key requirements - - Identify technical components - - Detect dependencies - - Estimate complexity - -2. **Task Generation** - - Create 10-15 tasks by default - - Include implementation tasks - - Add testing tasks - - Include documentation tasks - - Set logical dependencies - -3. **Smart Enhancements** - - Group related functionality - - Set appropriate priorities - - Add acceptance criteria - - Include test strategies - -## Options - -Parse arguments for modifiers: -- Number after filename → `--num-tasks` -- `research` → Use research mode -- `comprehensive` → Generate more tasks - -## Post-Generation - -After parsing: -1. Display task summary -2. Show dependency graph -3. Suggest task expansion for complex items -4. Recommend sprint planning diff --git a/.claude/commands/tm/remove-dependency/remove-dependency.md b/.claude/commands/tm/remove-dependency/remove-dependency.md deleted file mode 100644 index a36bebf2..00000000 --- a/.claude/commands/tm/remove-dependency/remove-dependency.md +++ /dev/null @@ -1,62 +0,0 @@ -Remove a dependency between tasks. - -Arguments: $ARGUMENTS - -Parse the task IDs to remove dependency relationship. - -## Removing Dependencies - -Removes a dependency relationship, potentially unblocking tasks. - -## Argument Parsing - -Parse natural language or IDs: -- "remove dependency between 5 and 3" -- "5 no longer needs 3" -- "unblock 5 from 3" -- "5 3" → remove dependency of 5 on 3 - -## Execution - -```bash -task-master remove-dependency --id=<task-id> --depends-on=<dependency-id> -``` - -## Pre-Removal Checks - -1. **Verify dependency exists** -2. **Check impact on task flow** -3. **Warn if it breaks logical sequence** -4. **Show what will be unblocked** - -## Smart Analysis - -Before removing: -- Show why dependency might have existed -- Check if removal makes tasks executable -- Verify no critical path disruption -- Suggest alternative dependencies - -## Post-Removal - -After removing: -1. Show updated task status -2. List newly unblocked tasks -3. Update project timeline -4. Suggest next actions - -## Safety Features - -- Confirm if removing critical dependency -- Show tasks that become immediately actionable -- Warn about potential issues -- Keep removal history - -## Example - -``` -/project:tm/remove-dependency 5 from 3 -→ Removed: Task #5 no longer depends on #3 -→ Task #5 is now UNBLOCKED and ready to start -→ Warning: Consider if #5 still needs #2 completed first -``` diff --git a/.claude/commands/tm/remove-subtask/remove-subtask.md b/.claude/commands/tm/remove-subtask/remove-subtask.md deleted file mode 100644 index 26225103..00000000 --- a/.claude/commands/tm/remove-subtask/remove-subtask.md +++ /dev/null @@ -1,84 +0,0 @@ -Remove a subtask from its parent task. - -Arguments: $ARGUMENTS - -Parse subtask ID to remove, with option to convert to standalone task. - -## Removing Subtasks - -Remove a subtask and optionally convert it back to a standalone task. - -## Argument Parsing - -- "remove subtask 5.1" -- "delete 5.1" -- "convert 5.1 to task" → remove and convert -- "5.1 standalone" → convert to standalone - -## Execution Options - -### 1. Delete Subtask -```bash -task-master remove-subtask --id=<parentId.subtaskId> -``` - -### 2. Convert to Standalone -```bash -task-master remove-subtask --id=<parentId.subtaskId> --convert -``` - -## Pre-Removal Checks - -1. **Validate Subtask** - - Verify subtask exists - - Check completion status - - Review dependencies - -2. **Impact Analysis** - - Other subtasks that depend on it - - Parent task implications - - Data that will be lost - -## Removal Process - -### For Deletion: -1. Confirm if subtask has work done -2. Update parent task estimates -3. Remove subtask and its data -4. Clean up dependencies - -### For Conversion: -1. Assign new standalone task ID -2. Preserve all task data -3. Update dependency references -4. Maintain task history - -## Smart Features - -- Warn if subtask is in-progress -- Show impact on parent task -- Preserve important data -- Update related estimates - -## Example Flows - -``` -/project:tm/remove-subtask 5.1 -→ Warning: Subtask #5.1 is in-progress -→ This will delete all subtask data -→ Parent task #5 will be updated -Confirm deletion? (y/n) - -/project:tm/remove-subtask 5.1 convert -→ Converting subtask #5.1 to standalone task #89 -→ Preserved: All task data and history -→ Updated: 2 dependency references -→ New task #89 is now independent -``` - -## Post-Removal - -- Update parent task status -- Recalculate estimates -- Show updated hierarchy -- Suggest next actions diff --git a/.claude/commands/tm/remove-task/remove-task.md b/.claude/commands/tm/remove-task/remove-task.md deleted file mode 100644 index 6a0e9c73..00000000 --- a/.claude/commands/tm/remove-task/remove-task.md +++ /dev/null @@ -1,107 +0,0 @@ -Remove a task permanently from the project. - -Arguments: $ARGUMENTS (task ID) - -Delete a task and handle all its relationships properly. - -## Task Removal - -Permanently removes a task while maintaining project integrity. - -## Argument Parsing - -- "remove task 5" -- "delete 5" -- "5" → remove task 5 -- Can include "-y" for auto-confirm - -## Execution - -```bash -task-master remove-task --id=<id> [-y] -``` - -## Pre-Removal Analysis - -1. **Task Details** - - Current status - - Work completed - - Time invested - - Associated data - -2. **Relationship Check** - - Tasks that depend on this - - Dependencies this task has - - Subtasks that will be removed - - Blocking implications - -3. **Impact Assessment** - ``` - Task Removal Impact - ━━━━━━━━━━━━━━━━━━ - Task: #5 "Implement authentication" (in-progress) - Status: 60% complete (~8 hours work) - - Will affect: - - 3 tasks depend on this (will be blocked) - - Has 4 subtasks (will be deleted) - - Part of critical path - - ⚠️ This action cannot be undone - ``` - -## Smart Warnings - -- Warn if task is in-progress -- Show dependent tasks that will be blocked -- Highlight if part of critical path -- Note any completed work being lost - -## Removal Process - -1. Show comprehensive impact -2. Require confirmation (unless -y) -3. Update dependent task references -4. Remove task and subtasks -5. Clean up orphaned dependencies -6. Log removal with timestamp - -## Alternative Actions - -Suggest before deletion: -- Mark as cancelled instead -- Convert to documentation -- Archive task data -- Transfer work to another task - -## Post-Removal - -- List affected tasks -- Show broken dependencies -- Update project statistics -- Suggest dependency fixes -- Recalculate timeline - -## Example Flows - -``` -/project:tm/remove-task 5 -→ Task #5 is in-progress with 8 hours logged -→ 3 other tasks depend on this -→ Suggestion: Mark as cancelled instead? -Remove anyway? (y/n) - -/project:tm/remove-task 5 -y -→ Removed: Task #5 and 4 subtasks -→ Updated: 3 task dependencies -→ Warning: Tasks #7, #8, #9 now have missing dependency -→ Run /project:tm/fix-dependencies to resolve -``` - -## Safety Features - -- Confirmation required -- Impact preview -- Removal logging -- Suggest alternatives -- No cascade delete of dependents diff --git a/.claude/commands/tm/set-status/to-cancelled.md b/.claude/commands/tm/set-status/to-cancelled.md deleted file mode 100644 index 58d06361..00000000 --- a/.claude/commands/tm/set-status/to-cancelled.md +++ /dev/null @@ -1,55 +0,0 @@ -Cancel a task permanently. - -Arguments: $ARGUMENTS (task ID) - -## Cancelling a Task - -This status indicates a task is no longer needed and won't be completed. - -## Valid Reasons for Cancellation - -- Requirements changed -- Feature deprecated -- Duplicate of another task -- Strategic pivot -- Technical approach invalidated - -## Pre-Cancellation Checks - -1. Confirm no critical dependencies -2. Check for partial implementation -3. Verify cancellation rationale -4. Document lessons learned - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=cancelled -``` - -## Cancellation Impact - -When cancelling: -1. **Dependency Updates** - - Notify dependent tasks - - Update project scope - - Recalculate timelines - -2. **Clean-up Actions** - - Remove related branches - - Archive any work done - - Update documentation - - Close related issues - -3. **Learning Capture** - - Document why cancelled - - Note what was learned - - Update estimation models - - Prevent future duplicates - -## Historical Preservation - -- Keep for reference -- Tag with cancellation reason -- Link to replacement if any -- Maintain audit trail diff --git a/.claude/commands/tm/set-status/to-deferred.md b/.claude/commands/tm/set-status/to-deferred.md deleted file mode 100644 index 04ce8bc2..00000000 --- a/.claude/commands/tm/set-status/to-deferred.md +++ /dev/null @@ -1,47 +0,0 @@ -Defer a task for later consideration. - -Arguments: $ARGUMENTS (task ID) - -## Deferring a Task - -This status indicates a task is valid but not currently actionable or prioritized. - -## Valid Reasons for Deferral - -- Waiting for external dependencies -- Reprioritized for future sprint -- Blocked by technical limitations -- Resource constraints -- Strategic timing considerations - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=deferred -``` - -## Deferral Management - -When deferring: -1. **Document Reason** - - Capture why it's being deferred - - Set reactivation criteria - - Note any partial work completed - -2. **Impact Analysis** - - Check dependent tasks - - Update project timeline - - Notify affected stakeholders - -3. **Future Planning** - - Set review reminders - - Tag for specific milestone - - Preserve context for reactivation - - Link to blocking issues - -## Smart Tracking - -- Monitor deferral duration -- Alert when criteria met -- Prevent scope creep -- Regular review cycles diff --git a/.claude/commands/tm/set-status/to-done.md b/.claude/commands/tm/set-status/to-done.md deleted file mode 100644 index 941475c4..00000000 --- a/.claude/commands/tm/set-status/to-done.md +++ /dev/null @@ -1,44 +0,0 @@ -Mark a task as completed. - -Arguments: $ARGUMENTS (task ID) - -## Completing a Task - -This command validates task completion and updates project state intelligently. - -## Pre-Completion Checks - -1. Verify test strategy was followed -2. Check if all subtasks are complete -3. Validate acceptance criteria met -4. Ensure code is committed - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=done -``` - -## Post-Completion Actions - -1. **Update Dependencies** - - Identify newly unblocked tasks - - Update sprint progress - - Recalculate project timeline - -2. **Documentation** - - Generate completion summary - - Update CLAUDE.md with learnings - - Log implementation approach - -3. **Next Steps** - - Show newly available tasks - - Suggest logical next task - - Update velocity metrics - -## Celebration & Learning - -- Show impact of completion -- Display unblocked work -- Recognize achievement -- Capture lessons learned diff --git a/.claude/commands/tm/set-status/to-in-progress.md b/.claude/commands/tm/set-status/to-in-progress.md deleted file mode 100644 index c8f5fb7a..00000000 --- a/.claude/commands/tm/set-status/to-in-progress.md +++ /dev/null @@ -1,36 +0,0 @@ -Start working on a task by setting its status to in-progress. - -Arguments: $ARGUMENTS (task ID) - -## Starting Work on Task - -This command does more than just change status - it prepares your environment for productive work. - -## Pre-Start Checks - -1. Verify dependencies are met -2. Check if another task is already in-progress -3. Ensure task details are complete -4. Validate test strategy exists - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=in-progress -``` - -## Environment Setup - -After setting to in-progress: -1. Create/checkout appropriate git branch -2. Open relevant documentation -3. Set up test watchers if applicable -4. Display task details and acceptance criteria -5. Show similar completed tasks for reference - -## Smart Suggestions - -- Estimated completion time based on complexity -- Related files from similar tasks -- Potential blockers to watch for -- Recommended first steps diff --git a/.claude/commands/tm/set-status/to-pending.md b/.claude/commands/tm/set-status/to-pending.md deleted file mode 100644 index 8d3bb29f..00000000 --- a/.claude/commands/tm/set-status/to-pending.md +++ /dev/null @@ -1,32 +0,0 @@ -Set a task's status to pending. - -Arguments: $ARGUMENTS (task ID) - -## Setting Task to Pending - -This moves a task back to the pending state, useful for: -- Resetting erroneously started tasks -- Deferring work that was prematurely begun -- Reorganizing sprint priorities - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=pending -``` - -## Validation - -Before setting to pending: -- Warn if task is currently in-progress -- Check if this will block other tasks -- Suggest documenting why it's being reset -- Preserve any work already done - -## Smart Actions - -After setting to pending: -- Update sprint planning if needed -- Notify about freed resources -- Suggest priority reassessment -- Log the status change with context diff --git a/.claude/commands/tm/set-status/to-review.md b/.claude/commands/tm/set-status/to-review.md deleted file mode 100644 index 8573db6b..00000000 --- a/.claude/commands/tm/set-status/to-review.md +++ /dev/null @@ -1,40 +0,0 @@ -Set a task's status to review. - -Arguments: $ARGUMENTS (task ID) - -## Marking Task for Review - -This status indicates work is complete but needs verification before final approval. - -## When to Use Review Status - -- Code complete but needs peer review -- Implementation done but needs testing -- Documentation written but needs proofreading -- Design complete but needs stakeholder approval - -## Execution - -```bash -task-master set-status --id=$ARGUMENTS --status=review -``` - -## Review Preparation - -When setting to review: -1. **Generate Review Checklist** - - Link to PR/MR if applicable - - Highlight key changes - - Note areas needing attention - - Include test results - -2. **Documentation** - - Update task with review notes - - Link relevant artifacts - - Specify reviewers if known - -3. **Smart Actions** - - Create review reminders - - Track review duration - - Suggest reviewers based on expertise - - Prepare rollback plan if needed diff --git a/.claude/commands/tm/setup/install-taskmaster.md b/.claude/commands/tm/setup/install-taskmaster.md deleted file mode 100644 index d629956d..00000000 --- a/.claude/commands/tm/setup/install-taskmaster.md +++ /dev/null @@ -1,117 +0,0 @@ -Check if Task Master is installed and install it if needed. - -This command helps you get Task Master set up globally on your system. - -## Detection and Installation Process - -1. **Check Current Installation** - ```bash - # Check if task-master command exists - which task-master || echo "Task Master not found" - - # Check npm global packages - npm list -g task-master-ai - ``` - -2. **System Requirements Check** - ```bash - # Verify Node.js is installed - node --version - - # Verify npm is installed - npm --version - - # Check Node version (need 16+) - ``` - -3. **Install Task Master Globally** - If not installed, run: - ```bash - npm install -g task-master-ai - ``` - -4. **Verify Installation** - ```bash - # Check version - task-master --version - - # Verify command is available - which task-master - ``` - -5. **Initial Setup** - ```bash - # Initialize in current directory - task-master init - ``` - -6. **Configure AI Provider** - Ensure you have at least one AI provider API key set: - ```bash - # Check current configuration - task-master models --status - - # If no API keys found, guide setup - echo "You'll need at least one API key:" - echo "- ANTHROPIC_API_KEY for Claude" - echo "- OPENAI_API_KEY for GPT models" - echo "- PERPLEXITY_API_KEY for research" - echo "" - echo "Set them in your shell profile or .env file" - ``` - -7. **Quick Test** - ```bash - # Create a test PRD - echo "Build a simple hello world API" > test-prd.txt - - # Try parsing it - task-master parse-prd test-prd.txt -n 3 - ``` - -## Troubleshooting - -If installation fails: - -**Permission Errors:** -```bash -# Try with sudo (macOS/Linux) -sudo npm install -g task-master-ai - -# Or fix npm permissions -npm config set prefix ~/.npm-global -export PATH=~/.npm-global/bin:$PATH -``` - -**Network Issues:** -```bash -# Use different registry -npm install -g task-master-ai --registry https://registry.npmjs.org/ -``` - -**Node Version Issues:** -```bash -# Install Node 18+ via nvm -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash -nvm install 18 -nvm use 18 -``` - -## Success Confirmation - -Once installed, you should see: -``` -✅ Task Master v0.16.2 (or higher) installed -✅ Command 'task-master' available globally -✅ AI provider configured -✅ Ready to use slash commands! - -Try: /project:task-master:init your-prd.md -``` - -## Next Steps - -After installation: -1. Run `/project:utils:check-health` to verify setup -2. Configure AI providers with `/project:task-master:models` -3. Start using Task Master commands! diff --git a/.claude/commands/tm/setup/quick-install-taskmaster.md b/.claude/commands/tm/setup/quick-install-taskmaster.md deleted file mode 100644 index 7949f4fd..00000000 --- a/.claude/commands/tm/setup/quick-install-taskmaster.md +++ /dev/null @@ -1,22 +0,0 @@ -Quick install Task Master globally if not already installed. - -Execute this streamlined installation: - -```bash -# Check and install in one command -task-master --version 2>/dev/null || npm install -g task-master-ai - -# Verify installation -task-master --version - -# Quick setup check -task-master models --status || echo "Note: You'll need to set up an AI provider API key" -``` - -If you see "command not found" after installation, you may need to: -1. Restart your terminal -2. Or add npm global bin to PATH: `export PATH=$(npm bin -g):$PATH` - -Once installed, you can use all the Task Master commands! - -Quick test: Run `/project:help` to see all available commands. diff --git a/.claude/commands/tm/show/show-task.md b/.claude/commands/tm/show/show-task.md deleted file mode 100644 index 0ffba1c8..00000000 --- a/.claude/commands/tm/show/show-task.md +++ /dev/null @@ -1,82 +0,0 @@ -Show detailed task information with rich context and insights. - -Arguments: $ARGUMENTS - -## Enhanced Task Display - -Parse arguments to determine what to show and how. - -### 1. **Smart Task Selection** - -Based on $ARGUMENTS: -- Number → Show specific task with full context -- "current" → Show active in-progress task(s) -- "next" → Show recommended next task -- "blocked" → Show all blocked tasks with reasons -- "critical" → Show critical path tasks -- Multiple IDs → Comparative view - -### 2. **Contextual Information** - -For each task, intelligently include: - -**Core Details** -- Full task information (id, title, description, details) -- Current status with history -- Test strategy and acceptance criteria -- Priority and complexity analysis - -**Relationships** -- Dependencies (what it needs) -- Dependents (what needs it) -- Parent/subtask hierarchy -- Related tasks (similar work) - -**Time Intelligence** -- Created/updated timestamps -- Time in current status -- Estimated vs actual time -- Historical completion patterns - -### 3. **Visual Enhancements** - -``` -📋 Task #45: Implement User Authentication -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Status: 🟡 in-progress (2 hours) -Priority: 🔴 High | Complexity: 73/100 - -Dependencies: ✅ #41, ✅ #42, ⏳ #43 (blocked) -Blocks: #46, #47, #52 - -Progress: ████████░░ 80% complete - -Recent Activity: -- 2h ago: Status changed to in-progress -- 4h ago: Dependency #42 completed -- Yesterday: Task expanded with 3 subtasks -``` - -### 4. **Intelligent Insights** - -Based on task analysis: -- **Risk Assessment**: Complexity vs time remaining -- **Bottleneck Analysis**: Is this blocking critical work? -- **Recommendation**: Suggested approach or concerns -- **Similar Tasks**: How others completed similar work - -### 5. **Action Suggestions** - -Context-aware next steps: -- If blocked → Show how to unblock -- If complex → Suggest expansion -- If in-progress → Show completion checklist -- If done → Show dependent tasks ready to start - -### 6. **Multi-Task View** - -When showing multiple tasks: -- Common dependencies -- Optimal completion order -- Parallel work opportunities -- Combined complexity analysis diff --git a/.claude/commands/tm/status/project-status.md b/.claude/commands/tm/status/project-status.md deleted file mode 100644 index 370cb10c..00000000 --- a/.claude/commands/tm/status/project-status.md +++ /dev/null @@ -1,64 +0,0 @@ -Enhanced status command with comprehensive project insights. - -Arguments: $ARGUMENTS - -## Intelligent Status Overview - -### 1. **Executive Summary** -Quick dashboard view: -- 🏃 Active work (in-progress tasks) -- 📊 Progress metrics (% complete, velocity) -- 🚧 Blockers and risks -- ⏱️ Time analysis (estimated vs actual) -- 🎯 Sprint/milestone progress - -### 2. **Contextual Analysis** - -Based on $ARGUMENTS, focus on: -- "sprint" → Current sprint progress and burndown -- "blocked" → Dependency chains and resolution paths -- "team" → Task distribution and workload -- "timeline" → Schedule adherence and projections -- "risk" → High complexity or overdue items - -### 3. **Smart Insights** - -**Workflow Health:** -- Idle tasks (in-progress > 24h without updates) -- Bottlenecks (multiple tasks waiting on same dependency) -- Quick wins (low complexity, high impact) - -**Predictive Analytics:** -- Completion projections based on velocity -- Risk of missing deadlines -- Recommended task order for optimal flow - -### 4. **Visual Intelligence** - -Dynamic visualization based on data: -``` -Sprint Progress: ████████░░ 80% (16/20 tasks) -Velocity Trend: ↗️ +15% this week -Blocked Tasks: 🔴 3 critical path items - -Priority Distribution: -High: ████████ 8 tasks (2 blocked) -Medium: ████░░░░ 4 tasks -Low: ██░░░░░░ 2 tasks -``` - -### 5. **Actionable Recommendations** - -Based on analysis: -1. **Immediate actions** (unblock critical path) -2. **Today's focus** (optimal task sequence) -3. **Process improvements** (recurring patterns) -4. **Resource needs** (skills, time, dependencies) - -### 6. **Historical Context** - -Compare to previous periods: -- Velocity changes -- Pattern recognition -- Improvement areas -- Success patterns to repeat diff --git a/.claude/commands/tm/sync-readme/sync-readme.md b/.claude/commands/tm/sync-readme/sync-readme.md deleted file mode 100644 index 5b591890..00000000 --- a/.claude/commands/tm/sync-readme/sync-readme.md +++ /dev/null @@ -1,117 +0,0 @@ -Export tasks to README.md with professional formatting. - -Arguments: $ARGUMENTS - -Generate a well-formatted README with current task information. - -## README Synchronization - -Creates or updates README.md with beautifully formatted task information. - -## Argument Parsing - -Optional filters: -- "pending" → Only pending tasks -- "with-subtasks" → Include subtask details -- "by-priority" → Group by priority -- "sprint" → Current sprint only - -## Execution - -```bash -task-master sync-readme [--with-subtasks] [--status=<status>] -``` - -## README Generation - -### 1. **Project Header** -```markdown -# Project Name - -## 📋 Task Progress - -Last Updated: 2024-01-15 10:30 AM - -### Summary -- Total Tasks: 45 -- Completed: 15 (33%) -- In Progress: 5 (11%) -- Pending: 25 (56%) -``` - -### 2. **Task Sections** -Organized by status or priority: -- Progress indicators -- Task descriptions -- Dependencies noted -- Time estimates - -### 3. **Visual Elements** -- Progress bars -- Status badges -- Priority indicators -- Completion checkmarks - -## Smart Features - -1. **Intelligent Grouping** - - By feature area - - By sprint/milestone - - By assigned developer - - By priority - -2. **Progress Tracking** - - Overall completion - - Sprint velocity - - Burndown indication - - Time tracking - -3. **Formatting Options** - - GitHub-flavored markdown - - Task checkboxes - - Collapsible sections - - Table format available - -## Example Output - -```markdown -## 🚀 Current Sprint - -### In Progress -- [ ] 🔄 #5 **Implement user authentication** (60% complete) - - Dependencies: API design (#3 ✅) - - Subtasks: 4 (2 completed) - - Est: 8h / Spent: 5h - -### Pending (High Priority) -- [ ] ⚡ #8 **Create dashboard UI** - - Blocked by: #5 - - Complexity: High - - Est: 12h -``` - -## Customization - -Based on arguments: -- Include/exclude sections -- Detail level control -- Custom grouping -- Filter by criteria - -## Post-Sync - -After generation: -1. Show diff preview -2. Backup existing README -3. Write new content -4. Commit reminder -5. Update timestamp - -## Integration - -Works well with: -- Git workflows -- CI/CD pipelines -- Project documentation -- Team updates -- Client reports diff --git a/.claude/commands/tm/tm-main.md b/.claude/commands/tm/tm-main.md deleted file mode 100644 index b7c70b6d..00000000 --- a/.claude/commands/tm/tm-main.md +++ /dev/null @@ -1,146 +0,0 @@ -# Task Master Command Reference - -Comprehensive command structure for Task Master integration with Claude Code. - -## Command Organization - -Commands are organized hierarchically to match Task Master's CLI structure while providing enhanced Claude Code integration. - -## Project Setup & Configuration - -### `/project:tm/init` -- `init-project` - Initialize new project (handles PRD files intelligently) -- `init-project-quick` - Quick setup with auto-confirmation (-y flag) - -### `/project:tm/models` -- `view-models` - View current AI model configuration -- `setup-models` - Interactive model configuration -- `set-main` - Set primary generation model -- `set-research` - Set research model -- `set-fallback` - Set fallback model - -## Task Generation - -### `/project:tm/parse-prd` -- `parse-prd` - Generate tasks from PRD document -- `parse-prd-with-research` - Enhanced parsing with research mode - -### `/project:tm/generate` -- `generate-tasks` - Create individual task files from tasks.json - -## Task Management - -### `/project:tm/list` -- `list-tasks` - Smart listing with natural language filters -- `list-tasks-with-subtasks` - Include subtasks in hierarchical view -- `list-tasks-by-status` - Filter by specific status - -### `/project:tm/set-status` -- `to-pending` - Reset task to pending -- `to-in-progress` - Start working on task -- `to-done` - Mark task complete -- `to-review` - Submit for review -- `to-deferred` - Defer task -- `to-cancelled` - Cancel task - -### `/project:tm/sync-readme` -- `sync-readme` - Export tasks to README.md with formatting - -### `/project:tm/update` -- `update-task` - Update tasks with natural language -- `update-tasks-from-id` - Update multiple tasks from a starting point -- `update-single-task` - Update specific task - -### `/project:tm/add-task` -- `add-task` - Add new task with AI assistance - -### `/project:tm/remove-task` -- `remove-task` - Remove task with confirmation - -## Subtask Management - -### `/project:tm/add-subtask` -- `add-subtask` - Add new subtask to parent -- `convert-task-to-subtask` - Convert existing task to subtask - -### `/project:tm/remove-subtask` -- `remove-subtask` - Remove subtask (with optional conversion) - -### `/project:tm/clear-subtasks` -- `clear-subtasks` - Clear subtasks from specific task -- `clear-all-subtasks` - Clear all subtasks globally - -## Task Analysis & Breakdown - -### `/project:tm/analyze-complexity` -- `analyze-complexity` - Analyze and generate expansion recommendations - -### `/project:tm/complexity-report` -- `complexity-report` - Display complexity analysis report - -### `/project:tm/expand` -- `expand-task` - Break down specific task -- `expand-all-tasks` - Expand all eligible tasks -- `with-research` - Enhanced expansion - -## Task Navigation - -### `/project:tm/next` -- `next-task` - Intelligent next task recommendation - -### `/project:tm/show` -- `show-task` - Display detailed task information - -### `/project:tm/status` -- `project-status` - Comprehensive project dashboard - -## Dependency Management - -### `/project:tm/add-dependency` -- `add-dependency` - Add task dependency - -### `/project:tm/remove-dependency` -- `remove-dependency` - Remove task dependency - -### `/project:tm/validate-dependencies` -- `validate-dependencies` - Check for dependency issues - -### `/project:tm/fix-dependencies` -- `fix-dependencies` - Automatically fix dependency problems - -## Workflows & Automation - -### `/project:tm/workflows` -- `smart-workflow` - Context-aware intelligent workflow execution -- `command-pipeline` - Chain multiple commands together -- `auto-implement-tasks` - Advanced auto-implementation with code generation - -## Utilities - -### `/project:tm/utils` -- `analyze-project` - Deep project analysis and insights - -### `/project:tm/setup` -- `install-taskmaster` - Comprehensive installation guide -- `quick-install-taskmaster` - One-line global installation - -## Usage Patterns - -### Natural Language -Most commands accept natural language arguments: -``` -/project:tm/add-task create user authentication system -/project:tm/update mark all API tasks as high priority -/project:tm/list show blocked tasks -``` - -### ID-Based Commands -Commands requiring IDs intelligently parse from $ARGUMENTS: -``` -/project:tm/show 45 -/project:tm/expand 23 -/project:tm/set-status/to-done 67 -``` - -### Smart Defaults -Commands provide intelligent defaults and suggestions based on context. diff --git a/.claude/commands/tm/update/update-single-task.md b/.claude/commands/tm/update/update-single-task.md deleted file mode 100644 index 5a38fc6f..00000000 --- a/.claude/commands/tm/update/update-single-task.md +++ /dev/null @@ -1,119 +0,0 @@ -Update a single specific task with new information. - -Arguments: $ARGUMENTS - -Parse task ID and update details. - -## Single Task Update - -Precisely update one task with AI assistance to maintain consistency. - -## Argument Parsing - -Natural language updates: -- "5: add caching requirement" -- "update 5 to include error handling" -- "task 5 needs rate limiting" -- "5 change priority to high" - -## Execution - -```bash -task-master update-task --id=<id> --prompt="<context>" -``` - -## Update Types - -### 1. **Content Updates** -- Enhance description -- Add requirements -- Clarify details -- Update acceptance criteria - -### 2. **Metadata Updates** -- Change priority -- Adjust time estimates -- Update complexity -- Modify dependencies - -### 3. **Strategic Updates** -- Revise approach -- Change test strategy -- Update implementation notes -- Adjust subtask needs - -## AI-Powered Updates - -The AI: -1. **Understands Context** - - Reads current task state - - Identifies update intent - - Maintains consistency - - Preserves important info - -2. **Applies Changes** - - Updates relevant fields - - Keeps style consistent - - Adds without removing - - Enhances clarity - -3. **Validates Results** - - Checks coherence - - Verifies completeness - - Maintains relationships - - Suggests related updates - -## Example Updates - -``` -/project:tm/update/single 5: add rate limiting -→ Updating Task #5: "Implement API endpoints" - -Current: Basic CRUD endpoints -Adding: Rate limiting requirements - -Updated sections: -✓ Description: Added rate limiting mention -✓ Details: Added specific limits (100/min) -✓ Test Strategy: Added rate limit tests -✓ Complexity: Increased from 5 to 6 -✓ Time Estimate: Increased by 2 hours - -Suggestion: Also update task #6 (API Gateway) for consistency? -``` - -## Smart Features - -1. **Incremental Updates** - - Adds without overwriting - - Preserves work history - - Tracks what changed - - Shows diff view - -2. **Consistency Checks** - - Related task alignment - - Subtask compatibility - - Dependency validity - - Timeline impact - -3. **Update History** - - Timestamp changes - - Track who/what updated - - Reason for update - - Previous versions - -## Field-Specific Updates - -Quick syntax for specific fields: -- "5 priority:high" → Update priority only -- "5 add-time:4h" → Add to time estimate -- "5 status:review" → Change status -- "5 depends:3,4" → Add dependencies - -## Post-Update - -- Show updated task -- Highlight changes -- Check related tasks -- Update suggestions -- Timeline adjustments diff --git a/.claude/commands/tm/update/update-task.md b/.claude/commands/tm/update/update-task.md deleted file mode 100644 index f7194a1c..00000000 --- a/.claude/commands/tm/update/update-task.md +++ /dev/null @@ -1,72 +0,0 @@ -Update tasks with intelligent field detection and bulk operations. - -Arguments: $ARGUMENTS - -## Intelligent Task Updates - -Parse arguments to determine update intent and execute smartly. - -### 1. **Natural Language Processing** - -Understand update requests like: -- "mark 23 as done" → Update status to done -- "increase priority of 45" → Set priority to high -- "add dependency on 12 to task 34" → Add dependency -- "tasks 20-25 need review" → Bulk status update -- "all API tasks high priority" → Pattern-based update - -### 2. **Smart Field Detection** - -Automatically detect what to update: -- Status keywords: done, complete, start, pause, review -- Priority changes: urgent, high, low, deprioritize -- Dependency updates: depends on, blocks, after -- Assignment: assign to, owner, responsible -- Time: estimate, spent, deadline - -### 3. **Bulk Operations** - -Support for multiple task updates: -``` -Examples: -- "complete tasks 12, 15, 18" -- "all pending auth tasks to in-progress" -- "increase priority for tasks blocking 45" -- "defer all documentation tasks" -``` - -### 4. **Contextual Validation** - -Before updating, check: -- Status transitions are valid -- Dependencies don't create cycles -- Priority changes make sense -- Bulk updates won't break project flow - -Show preview: -``` -Update Preview: -───────────────── -Tasks to update: #23, #24, #25 -Change: status → in-progress -Impact: Will unblock tasks #30, #31 -Warning: Task #24 has unmet dependencies -``` - -### 5. **Smart Suggestions** - -Based on update: -- Completing task? → Show newly unblocked tasks -- Changing priority? → Show impact on sprint -- Adding dependency? → Check for conflicts -- Bulk update? → Show summary of changes - -### 6. **Workflow Integration** - -After updates: -- Auto-update dependent task states -- Trigger status recalculation -- Update sprint/milestone progress -- Log changes with context - -Result: Flexible, intelligent task updates with safety checks. diff --git a/.claude/commands/tm/update/update-tasks-from-id.md b/.claude/commands/tm/update/update-tasks-from-id.md deleted file mode 100644 index 0fc08f38..00000000 --- a/.claude/commands/tm/update/update-tasks-from-id.md +++ /dev/null @@ -1,108 +0,0 @@ -Update multiple tasks starting from a specific ID. - -Arguments: $ARGUMENTS - -Parse starting task ID and update context. - -## Bulk Task Updates - -Update multiple related tasks based on new requirements or context changes. - -## Argument Parsing - -- "from 5: add security requirements" -- "5 onwards: update API endpoints" -- "starting at 5: change to use new framework" - -## Execution - -```bash -task-master update --from=<id> --prompt="<context>" -``` - -## Update Process - -### 1. **Task Selection** -Starting from specified ID: -- Include the task itself -- Include all dependent tasks -- Include related subtasks -- Smart boundary detection - -### 2. **Context Application** -AI analyzes the update context and: -- Identifies what needs changing -- Maintains consistency -- Preserves completed work -- Updates related information - -### 3. **Intelligent Updates** -- Modify descriptions appropriately -- Update test strategies -- Adjust time estimates -- Revise dependencies if needed - -## Smart Features - -1. **Scope Detection** - - Find natural task groupings - - Identify related features - - Stop at logical boundaries - - Avoid over-updating - -2. **Consistency Maintenance** - - Keep naming conventions - - Preserve relationships - - Update cross-references - - Maintain task flow - -3. **Change Preview** - ``` - Bulk Update Preview - ━━━━━━━━━━━━━━━━━━ - Starting from: Task #5 - Tasks to update: 8 tasks + 12 subtasks - - Context: "add security requirements" - - Changes will include: - - Add security sections to descriptions - - Update test strategies for security - - Add security-related subtasks where needed - - Adjust time estimates (+20% average) - - Continue? (y/n) - ``` - -## Example Updates - -``` -/project:tm/update/from-id 5: change database to PostgreSQL -→ Analyzing impact starting from task #5 -→ Found 6 related tasks to update -→ Updates will maintain consistency -→ Preview changes? (y/n) - -Applied updates: -✓ Task #5: Updated connection logic references -✓ Task #6: Changed migration approach -✓ Task #7: Updated query syntax notes -✓ Task #8: Revised testing strategy -✓ Task #9: Updated deployment steps -✓ Task #12: Changed backup procedures -``` - -## Safety Features - -- Preview all changes -- Selective confirmation -- Rollback capability -- Change logging -- Validation checks - -## Post-Update - -- Summary of changes -- Consistency verification -- Suggest review tasks -- Update timeline if needed diff --git a/.claude/commands/tm/utils/analyze-project.md b/.claude/commands/tm/utils/analyze-project.md deleted file mode 100644 index 3088a2ae..00000000 --- a/.claude/commands/tm/utils/analyze-project.md +++ /dev/null @@ -1,97 +0,0 @@ -Advanced project analysis with actionable insights and recommendations. - -Arguments: $ARGUMENTS - -## Comprehensive Project Analysis - -Multi-dimensional analysis based on requested focus area. - -### 1. **Analysis Modes** - -Based on $ARGUMENTS: -- "velocity" → Sprint velocity and trends -- "quality" → Code quality metrics -- "risk" → Risk assessment and mitigation -- "dependencies" → Dependency graph analysis -- "team" → Workload and skill distribution -- "architecture" → System design coherence -- Default → Full spectrum analysis - -### 2. **Velocity Analytics** - -``` -📊 Velocity Analysis -━━━━━━━━━━━━━━━━━━━ -Current Sprint: 24 points/week ↗️ +20% -Rolling Average: 20 points/week -Efficiency: 85% (17/20 tasks on time) - -Bottlenecks Detected: -- Code review delays (avg 4h wait) -- Test environment availability -- Dependency on external team - -Recommendations: -1. Implement parallel review process -2. Add staging environment -3. Mock external dependencies -``` - -### 3. **Risk Assessment** - -**Technical Risks** -- High complexity tasks without backup assignee -- Single points of failure in architecture -- Insufficient test coverage in critical paths -- Technical debt accumulation rate - -**Project Risks** -- Critical path dependencies -- Resource availability gaps -- Deadline feasibility analysis -- Scope creep indicators - -### 4. **Dependency Intelligence** - -Visual dependency analysis: -``` -Critical Path: -#12 → #15 → #23 → #45 → #50 (20 days) - ↘ #24 → #46 ↗ - -Optimization: Parallelize #15 and #24 -Time Saved: 3 days -``` - -### 5. **Quality Metrics** - -**Code Quality** -- Test coverage trends -- Complexity scores -- Technical debt ratio -- Review feedback patterns - -**Process Quality** -- Rework frequency -- Bug introduction rate -- Time to resolution -- Knowledge distribution - -### 6. **Predictive Insights** - -Based on patterns: -- Completion probability by deadline -- Resource needs projection -- Risk materialization likelihood -- Suggested interventions - -### 7. **Executive Dashboard** - -High-level summary with: -- Health score (0-100) -- Top 3 risks -- Top 3 opportunities -- Recommended actions -- Success probability - -Result: Data-driven decisions with clear action paths. diff --git a/.claude/commands/tm/validate-dependencies/validate-dependencies.md b/.claude/commands/tm/validate-dependencies/validate-dependencies.md deleted file mode 100644 index a8dec1aa..00000000 --- a/.claude/commands/tm/validate-dependencies/validate-dependencies.md +++ /dev/null @@ -1,71 +0,0 @@ -Validate all task dependencies for issues. - -## Dependency Validation - -Comprehensive check for dependency problems across the entire project. - -## Execution - -```bash -task-master validate-dependencies -``` - -## Validation Checks - -1. **Circular Dependencies** - - A depends on B, B depends on A - - Complex circular chains - - Self-dependencies - -2. **Missing Dependencies** - - References to non-existent tasks - - Deleted task references - - Invalid task IDs - -3. **Logical Issues** - - Completed tasks depending on pending - - Cancelled tasks in dependency chains - - Impossible sequences - -4. **Complexity Warnings** - - Over-complex dependency chains - - Too many dependencies per task - - Bottleneck tasks - -## Smart Analysis - -The validation provides: -- Visual dependency graph -- Critical path analysis -- Bottleneck identification -- Suggested optimizations - -## Report Format - -``` -Dependency Validation Report -━━━━━━━━━━━━━━━━━━━━━━━━━━ -✅ No circular dependencies found -⚠️ 2 warnings found: - - Task #23 has 7 dependencies (consider breaking down) - - Task #45 blocks 5 other tasks (potential bottleneck) -❌ 1 error found: - - Task #67 depends on deleted task #66 - -Critical Path: #1 → #5 → #23 → #45 → #50 (15 days) -``` - -## Actionable Output - -For each issue found: -- Clear description -- Impact assessment -- Suggested fix -- Command to resolve - -## Next Steps - -After validation: -- Run `/project:tm/fix-dependencies` to auto-fix -- Manually adjust problematic dependencies -- Rerun to verify fixes diff --git a/.claude/commands/tm/workflows/auto-implement-tasks.md b/.claude/commands/tm/workflows/auto-implement-tasks.md deleted file mode 100644 index 1f0e1ee1..00000000 --- a/.claude/commands/tm/workflows/auto-implement-tasks.md +++ /dev/null @@ -1,97 +0,0 @@ -Enhanced auto-implementation with intelligent code generation and testing. - -Arguments: $ARGUMENTS - -## Intelligent Auto-Implementation - -Advanced implementation with context awareness and quality checks. - -### 1. **Pre-Implementation Analysis** - -Before starting: -- Analyze task complexity and requirements -- Check codebase patterns and conventions -- Identify similar completed tasks -- Assess test coverage needs -- Detect potential risks - -### 2. **Smart Implementation Strategy** - -Based on task type and context: - -**Feature Tasks** -1. Research existing patterns -2. Design component architecture -3. Implement with tests -4. Integrate with system -5. Update documentation - -**Bug Fix Tasks** -1. Reproduce issue -2. Identify root cause -3. Implement minimal fix -4. Add regression tests -5. Verify side effects - -**Refactoring Tasks** -1. Analyze current structure -2. Plan incremental changes -3. Maintain test coverage -4. Refactor step-by-step -5. Verify behavior unchanged - -### 3. **Code Intelligence** - -**Pattern Recognition** -- Learn from existing code -- Follow team conventions -- Use preferred libraries -- Match style guidelines - -**Test-Driven Approach** -- Write tests first when possible -- Ensure comprehensive coverage -- Include edge cases -- Performance considerations - -### 4. **Progressive Implementation** - -Step-by-step with validation: -``` -Step 1/5: Setting up component structure ✓ -Step 2/5: Implementing core logic ✓ -Step 3/5: Adding error handling ⚡ (in progress) -Step 4/5: Writing tests ⏳ -Step 5/5: Integration testing ⏳ - -Current: Adding try-catch blocks and validation... -``` - -### 5. **Quality Assurance** - -Automated checks: -- Linting and formatting -- Test execution -- Type checking -- Dependency validation -- Performance analysis - -### 6. **Smart Recovery** - -If issues arise: -- Diagnostic analysis -- Suggestion generation -- Fallback strategies -- Manual intervention points -- Learning from failures - -### 7. **Post-Implementation** - -After completion: -- Generate PR description -- Update documentation -- Log lessons learned -- Suggest follow-up tasks -- Update task relationships - -Result: High-quality, production-ready implementations. diff --git a/.claude/commands/tm/workflows/command-pipeline.md b/.claude/commands/tm/workflows/command-pipeline.md deleted file mode 100644 index ae60249c..00000000 --- a/.claude/commands/tm/workflows/command-pipeline.md +++ /dev/null @@ -1,77 +0,0 @@ -Execute a pipeline of commands based on a specification. - -Arguments: $ARGUMENTS - -## Command Pipeline Execution - -Parse pipeline specification from arguments. Supported formats: - -### Simple Pipeline -`init → expand-all → sprint-plan` - -### Conditional Pipeline -`status → if:pending>10 → sprint-plan → else → next` - -### Iterative Pipeline -`for:pending-tasks → expand → complexity-check` - -### Smart Pipeline Patterns - -**1. Project Setup Pipeline** -``` -init [prd] → -expand-all → -complexity-report → -sprint-plan → -show first-sprint -``` - -**2. Daily Work Pipeline** -``` -standup → -if:in-progress → continue → -else → next → start -``` - -**3. Task Completion Pipeline** -``` -complete [id] → -git-commit → -if:blocked-tasks-freed → show-freed → -next -``` - -**4. Quality Check Pipeline** -``` -list in-progress → -for:each → check-idle-time → -if:idle>1day → prompt-update -``` - -### Pipeline Features - -**Variables** -- Store results: `status → $count=pending-count` -- Use in conditions: `if:$count>10` -- Pass between commands: `expand $high-priority-tasks` - -**Error Handling** -- On failure: `try:complete → catch:show-blockers` -- Skip on error: `optional:test-run` -- Retry logic: `retry:3:commit` - -**Parallel Execution** -- Parallel branches: `[analyze | test | lint]` -- Join results: `parallel → join:report` - -### Execution Flow - -1. Parse pipeline specification -2. Validate command sequence -3. Execute with state passing -4. Handle conditions and loops -5. Aggregate results -6. Show summary - -This enables complex workflows like: -`parse-prd → expand-all → filter:complex>70 → assign:senior → sprint-plan:weighted` diff --git a/.claude/commands/tm/workflows/smart-workflow.md b/.claude/commands/tm/workflows/smart-workflow.md deleted file mode 100644 index 120b91b5..00000000 --- a/.claude/commands/tm/workflows/smart-workflow.md +++ /dev/null @@ -1,55 +0,0 @@ -Execute an intelligent workflow based on current project state and recent commands. - -This command analyzes: -1. Recent commands you've run -2. Current project state -3. Time of day / day of week -4. Your working patterns - -Arguments: $ARGUMENTS - -## Intelligent Workflow Selection - -Based on context, I'll determine the best workflow: - -### Context Analysis -- Previous command executed -- Current task states -- Unfinished work from last session -- Your typical patterns - -### Smart Execution - -If last command was: -- `status` → Likely starting work → Run daily standup -- `complete` → Task finished → Find next task -- `list pending` → Planning → Suggest sprint planning -- `expand` → Breaking down work → Show complexity analysis -- `init` → New project → Show onboarding workflow - -If no recent commands: -- Morning? → Daily standup workflow -- Many pending tasks? → Sprint planning -- Tasks blocked? → Dependency resolution -- Friday? → Weekly review - -### Workflow Composition - -I'll chain appropriate commands: -1. Analyze current state -2. Execute primary workflow -3. Suggest follow-up actions -4. Prepare environment for coding - -### Learning Mode - -This command learns from your patterns: -- Track command sequences -- Note time preferences -- Remember common workflows -- Adapt to your style - -Example flows detected: -- Morning: standup → next → start -- After lunch: status → continue task -- End of day: complete → commit → status diff --git a/.claude/output/cache_comparison.md b/.claude/output/cache_comparison.md new file mode 100644 index 00000000..0b957e77 --- /dev/null +++ b/.claude/output/cache_comparison.md @@ -0,0 +1,189 @@ +# Claude CLI vs glmaude Request Comparison + +This document compares requests from Claude CLI (to Anthropic API) and glmaude (to Z.AI API) to understand prompt caching behavior. + +## Executive Summary + +| Aspect | Claude CLI (Anthropic) | glmaude (Z.AI) | +|--------|------------------------|----------------| +| **Endpoint** | `api.anthropic.com` | `api.z.ai` | +| **Request Size** | 134,770 bytes | 147,462 bytes | +| **Tools Count** | 20 | 20 | +| **System Blocks** | 3 | 2 | +| **Cache Read** | 15,883 tokens | 512 tokens | +| **Cache Creation** | 18,119 | N/A | + +**Key Finding:** Z.AI caches only ~512 tokens (fixed tool definitions) while Anthropic caches much more (~15K+ tokens including system prompt). + +--- + +## 1. HTTP Headers + +### Claude CLI → Anthropic +``` +anthropic-beta: oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,advanced-tool-use-2025-11-20 +anthropic-version: 2023-06-01 +user-agent: claude-cli/2.1.12 (external, cli) +content-type: application/json +``` + +### glmaude → Z.AI +``` +anthropic-beta: claude-code-20250219,interleaved-thinking-2025-05-14,advanced-tool-use-2025-11-20 +anthropic-version: 2023-06-01 +user-agent: claude-cli/2.1.12 (external, cli) +content-type: application/json +``` + +### Header Differences + +| Header | Claude CLI | glmaude | +|--------|-----------|---------| +| `anthropic-beta` | `oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2...` | `claude-code-20250219,interleaved-thinking-2025-05-14,advance...` | +| `user-agent` | `claude-cli/2.1.12 (external, cli)` | `claude-cli/2.1.12 (external, cli)` | +| Path | `/v1/messages?beta=true` | `/api/anthropic/v1/messages?beta=true` | + +--- + +## 2. Request Structure + +### Top-Level Keys + +| Key | Claude CLI | glmaude | +|-----|-----------|---------| +| model | `claude-opus-4-5-20251101` | `glm-4.7` | +| max_tokens | `32000` | `32000` | +| stream | `True` | `True` | +| tools | ✅ (20) | ✅ (20) | +| system | ✅ (3 blocks) | ✅ (2 blocks) | +| messages | ✅ (1) | ✅ (1) | +| metadata | `['user_id']` | `['user_id']` | + +--- + +## 3. System Prompt Structure + +### Claude CLI System Blocks + +| Block | Size | cache_control | Preview | +|-------|------|---------------|---------| +| 0 | 57 chars | ❌ | `You are Claude Code, Anthropic's official CLI for Claude....` | +| 1 | 62 chars | ✅ | `You are a Claude agent, built on Anthropic's Claude Agent SDK....` | +| 2 | 14,028 chars | ✅ | ` You are an interactive CLI tool that helps users with software engineering tasks. Use the instructi...` | + +### glmaude System Blocks + +| Block | Size | cache_control | Preview | +|-------|------|---------------|---------| +| 0 | 62 chars | ✅ | `You are a Claude agent, built on Anthropic's Claude Agent SDK....` | +| 1 | 13,900 chars | ✅ | ` You are an interactive CLI tool that helps users with software engineering tasks. Use the instructi...` | + +--- + +## 4. Tools Comparison + +### Summary + +| Category | Count | +|----------|-------| +| Common tools | 20 | +| Claude CLI only | 0 | +| glmaude only | 0 | + +### Common Tools (20) + +Both Claude CLI and glmaude share these tools: + +- `AskUserQuestion` +- `Bash` +- `Edit` +- `EnterPlanMode` +- `ExitPlanMode` +- `Glob` +- `Grep` +- `KillShell` +- `ListMcpResourcesTool` +- `MCPSearch` +- `NotebookEdit` +- `Read` +- `ReadMcpResourceTool` +- `Skill` +- `Task` +- `TaskOutput` +- `TodoWrite` +- `WebFetch` +- `WebSearch` +- `Write` + +### Claude CLI Only (0) + +(none) + +### glmaude Only (0) + +(none) + +--- + +## 5. Cache Statistics + +### Response Usage Comparison + +| Metric | Claude CLI (Anthropic) | glmaude (Z.AI) | +|--------|------------------------|----------------| +| input_tokens | 3 | 0 | +| output_tokens | 4 | 0 | +| cache_read_input_tokens | 15,883 | 512 | +| cache_creation_input_tokens | 18,119 | N/A | + +### Analysis + +**Anthropic (Claude CLI):** +- Caches **15,883 tokens** (529433.3% of total input) +- Creates **18,119** new cache tokens +- Caches significant portions of the system prompt + +**Z.AI (glmaude):** +- Caches only **512 tokens** (fixed amount) +- No cache creation reported +- Likely caches only tool definitions, not custom system prompts + +--- + +## 6. Key Differences Summary + +| Difference | Impact | +|------------|--------| +| **Cache amount** | Anthropic: ~15,883 tokens vs Z.AI: fixed 512 | +| **Cache creation** | Anthropic reports cache_creation; Z.AI doesn't | +| **Tool overlap** | 20/20 Claude tools are also in glmaude | +| **Beta header** | Different beta feature flags | + +--- + +## 7. Implications for SDK/ccproxy + +For an SDK to get caching benefits: + +1. **Tools are required** - Both APIs only cache when tools are present +2. **Z.AI caches less** - Only ~512 tokens (tool definitions), not custom prompts +3. **Anthropic caches more** - Significant system prompt caching possible + +### Recommendation for ccproxy + +To enable caching for requests routed to Z.AI: +- Include at least one tool definition in requests +- Expect ~512 token savings (fixed, regardless of prompt size) +- Consider adding a hook to inject minimal tools for Z.AI-bound requests + +### Test Verification + +To verify caching works, the request must include: +- `tools` array with at least one tool +- `?beta=true` query parameter (Z.AI requirement) +- `anthropic-beta` header with appropriate flags +- `cache_control: {"type": "ephemeral"}` on system blocks + +--- + +*Generated from MITM traces captured on 2026-01-17 17:43* diff --git a/.claude/output/failed_request.json b/.claude/output/failed_request.json new file mode 100644 index 00000000..8308b1b3 --- /dev/null +++ b/.claude/output/failed_request.json @@ -0,0 +1 @@ +{"messages": [{"role": "user", "content": [{"type": "text", "text": "<system-reminder>\nThe following skills are available for use with the Skill tool:\n\n- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: \"rebind ctrl+s\", \"add a chord shortcut\", \"change the submit key\", \"customize keybindings\".\n- text-to-image: Render text to PNG for visual perception. Converts text into image format so Claude can perceive it spatially rather than sequentially. Use when visual tokens provide better insight than text tokens.\n- claude:init-glob: Initialize multiple projects by evaluating a glob pattern and running /init in each directory\n- claude:reinit-memory: Complete re-initialization of project CLAUDE.md with verification\n- claude:tail: Print the last N turns of the conversation to a file\n- claude:new-agent: Design a new agent with interactive configuration\n- claude:new-command: Add a New Slash Command\n- claude:orchestrate: Orchestrate task execution with intelligent parallelization, model selection, and agent assignment\n- docstore:vanalyze: Analyze built docstore and recommend VectorCode collections\n- docstore:add: Modify docstore.nix to add documentation sources (repos, packages, websites, or global store content)\n- docstore:init: Initialize a project docstore with ctx entries based on user requirements\n- user:generate:text-align: Analyze and realign unicode box-drawing diagrams\n- user:generate:text-to-mmd: Convert text/ASCII diagrams to Mermaid format with visual iteration\n- user:generate:jsonschema: Generate and refine JSON schema from a JSON file using quicktype\n- user:git:commit: Create a git commit\n- user:git:merge-main: Merge main branch into current branch\n- clark:rename-exports: Export and rename agent responses with parallel haiku agents\n- planstore: Manage project plan store: save, load, and organize plans\n- handoff: Generate typed handoff document for session continuation\n</system-reminder>"}, {"type": "text", "text": "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n# claudeMd\nCodebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of /home/starbased/.claude/CLAUDE.md (user's private global instructions for all projects):\n\n# I am Kyle's Assistant, Claude\n\nYou are my well-seasoned and efficacious assistant, who diligently follows instructions and pushes back when evidence contradicts my assertions. You are proactive and anticipate my next decision and take the initiative for me, but move with discipline. You overcome uncertainty and challenge with your diligence and foresight through excessive detailed planning, generous context curation, and with your academically rigorous explanations and compelling lectures, as well as your emphasis on integrity, precision, and curiosity.\n\n- **IMPERATIVE**: ALL instructions within this document MUST BE FOLLOWED, these are not optional unless explicitly stated\n- **CRITICAL**: Follow established patterns and protocols\n- **IMPORTANT**: ASK FOR CLARIFICATION.\n- **DO NOT**: Write documentation I did not ask for.\n- **DO NOT**: Give excessive commentary in comments when writing code\n- **DO**: Push back when I am incorrect about an assumption.\n- **DO**: Preserve prior context and detail unless explicitly asked otherwise.\n\n## Speech-to-Text Input\n\nKyle communicates primarily via dictation. Input is not verbatim\u2014expect transcription artifacts.\n\n- **CRITICAL**: Silently self-correct obvious errors (spelling, homophones, minor transcription noise). Never call out\n corrections unless they affect your response.\n- **IMPORTANT**: Interpret ambiguous words by nearest phonetic match in context. Use surrounding words, topic, and \n project state to disambiguate.\n- **DO**: Ask for clarification when errors corrupt intent or create genuine ambiguity.\n- **DO NOT**: Treat dictated input as high-fidelity text. Assume reasonable transcription noise.\n\n## Core Operating Principles\n\n### Context Preservation Protocol\n\n- **IMPERATIVE**: The main thread is sacred. Every tool call, file read, and data fetch consumes irreplaceable context.\n- **CRITICAL**: Maximize session runway by offloading ALL work \u22651 unit to agents.\n- **IMPORTANT**: The main thread exists for: dialogue, decisions, synthesis, and final presentation.\n\n**Unit of Work Threshold:**\n\n```\nWork Units Action\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u22651 unit \u2192 Delegate to agent (always)\n<1 unit \u2192 Do inline (prompt overhead exceeds work)\n```\n\nA \"unit\" is any discrete task: reading a file, searching code, running a command, fetching a URL, implementing a feature, fixing a bug. If you would use a tool, it's likely \u22651 unit.\n\n**Examples of <1 unit (do inline):**\n\n- Simple file moves: `mv ~/dev/scratch/project ~/dev/projects/project`\n- Single command execution with obvious outcome\n- Creating a directory, renaming a file\n- Running a build command the user requested\n\nThe threshold is about **complexity**, not command count. Multiple simple commands chained together are still <1 unit if the outcome is predictable and requires no investigation.\n\n**Main Thread Reserved For:**\n\n- Receiving and clarifying requirements\n- Making architectural decisions with the user\n- Synthesizing agent results into responses\n- Presenting completed work\n- Quick inline operations where delegation overhead > task cost\n\n**Delegate Everything Else:**\n\n- Iterative File reading/exploration \u2192 agent\n- Haystack and needle searches (grep, glob) \u2192 agent\n- Web fetches/research \u2192 agent\n- Implementation work \u2192 agent\n- Test running/fixing \u2192 agent\n- Multi-step investigation \u2192 agent\n\n### Iterative Agent Loop\n\n- **IMPERATIVE**: Do NOT accept incomplete agent work. Iterate until the task meets specifications.\n\n```\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 1. Define success criteria clearly \u2502\n\u2502 2. Delegate to appropriate agent \u2502\n\u2502 3. Review agent output \u2502\n\u2502 4. If incomplete \u2192 re-delegate \u2502\u25c0\u2500\u2510\n\u2502 5. Repeat until criteria met \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2518\n\u2502 6. Synthesize final result for user \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n**When iterating:**\n\n- Provide feedback on what's missing or incorrect\n- Include relevant context from previous attempt\n- Adjust agent type if current one is unsuitable\n- Only stop when task is genuinely complete\n\n### Agent Selection\n\n**Priority Order:**\n\n1. **Project-level agents** (`.claude/agents/`) - project-specific, use aggressively\n2. **User-level specialist agents** (`~/.claude/agents/`) - task-specialized, use proactively\n3. **General-purpose agent** - fallback for everything else\n\n| Task Domain | Agent |\n| ----------------------- | ------------------------- |\n| GitHub research | `gh-researcher` |\n| Repo mining/docs | `git-miner` |\n| Deep research/reasoning | `perplexity` |\n| Python development | `python` |\n| Web Search | `jina` |\n| Web extraction | `firecrawl`, `jina-haiku` |\n\n### Model Selection for Agents\n\nWhen delegating via the `model` parameter:\n\n**Sonnet** (default workhorse, 90%+ of tasks):\n\n- Standard implementation work\n- Code review and analysis\n- Code exploration and debugging (tracing logic across files, root cause analysis)\n- Extended context tasks\n- Production-grade output at reasonable cost/speed\n- Maintaining and iterating on existing code infrastructure\n\n**Opus** (complex reasoning, architectural):\n\n- Building out new code infrastructure\n- Multi-step investigations\n- Novel problem-solving requiring abstract reasoning\n- Complex architectural decisions\n- Multi-component refactors\n- Self-improving or meta-cognitive tasks\n\n**Haiku** (fast, cheap, bulk):\n\n- Iterative file searches and grep operations\n- Ultra-low latency command running and environmental probing\n- Straightforward edits with clear patterns\n- Quick lookups and simple transformations\n- Fades on: multi-file refactoring, novel problems, reasoning, capable code\n\n**Decision heuristic:**\n\n- Opus is the default model inherited by task/agent tool calls. Do you have a reason to use Haiku or Sonnet instead of Opus?\n- Does it need a developer? \u2192 Sonnet\n- Does it need architect-level reasoning? \u2192 Opus\n\n### Problem Resolution & Integrity\n\n- **IMPERATIVE**: When encountering errors or roadblocks, you MUST:\n - Persist and genuinely fix the underlying issue, OR\n - Fail honestly and stop. Report the exact problem for my review. Suggest, but do not act.\n- **CRITICAL**: NEVER downgrade versions or disable a feature to progress.\n- **CRITICAL**: NEVER bypass verification steps or assume success without a full test from a user-perspective.\n- **DO NOT**: Invent or assume solution without consulting documentation\n- **DO**: Aggressively and proactively seek out a package or library's documentation\n- **DO**: Invoke the docstore agent for every non-standard library/package/tool and add to the docstore `ctx`\n\n### File Editing Principles\n\n- **CRITICAL**: When editing existing files, be surgical: insert what's needed, preserve everything else.\n- \"Minimal changes\" means minimal _diff_, not minimal _result_.\n- Never conflate conciseness in responses with reduction of existing content.\n- Removing content not explicitly requested is over-engineering, same as adding unrequested features.\n\n#### File Operations: Shell vs Token\n\n- **IMPERATIVE**: Use shell commands (`cp`, `mv`, `rm`, `mkdir`) for file system operations. NEVER read a file into context just to copy or move it.\n- **CRITICAL**: Only read files when you need to analyze, understand, or manipulate their content.\n- **DO NOT**: Read \u2192 Write to copy a file. Use `cp source dest`.\n- **DO NOT**: Read \u2192 Write \u2192 Delete to move a file. Use `mv source dest`.\n\n**Token preservation principle**: If the operation doesn't require understanding or transforming content, use shell commands. Tokens are for reasoning, not file shuffling.\n\n## Development Environment\n\n- **Primary user**: Kyle (username: `starbased`, email: `s@starbased.net`, [github](https://github.com/starbaser))\n- **OS**: Arch Linux x86_64 | Hyprland | Wayland\n- **Configuration**: Nix Home-Manager (See `~/.config/nix`, manages files SYSTEM-WIDE)\n- **Editor**: `nvim` (See `~/.config/nix/config/nvim-pome`)\n- **Terminal**: `kitty` (See `~/.config/nix/config/kitty`)\n- **Shell**: ZSH\n- **Package Managers**:\n - System: `nix` managed (preferred), `paru`/`pacman` otherwise\n - Python: `uv` (NOT `pip`)\n - Lua/Neovim: `luarocks`/`lazy.nvim`\n\n### Directory Overview\n\n- **IMPERATIVE**: When working in a project directory (i.e. `~/dev/projects/*`), the project folder acts as a namespace - everything we're currently working on goes inside it\n- **CRITICAL**: Use `~/tmp` === `/tmp/`, user-dedicated tmpfs, use for one-and-done scripts, transient data for processing, and ephemeral artifacts for analysis like source repos or downloads\n- **IMPORTANT**: Use `~/dev/scratch/` ONLY for:\n - Testing API endpoints or libraries in isolation\n - Temporary explorations unrelated to any project\n - Code snippets for answering general questions\n - Create a directory related to the work and use `git init && uv init --bare`\n\n```\n# `~notable~` entries below that have a `(~abc)` after the directory name can use that ~prefix\n# to refer to the path in ZSH. e.g. `~x == ~/.config/nix` `~p=~/dev/projects`\n/home/starbased/ # (~/) aka $HOME\n\u251c\u2500\u2500 Documents/ # (~D)\n\u251c\u2500\u2500 Downloads/ # (~W)\n\u251c\u2500\u2500 Pictures/ # (~P)\n\u251c\u2500\u2500 Videos/ # (~V)\n\u251c\u2500\u2500 Music/ # (~M)\n\u251c\u2500\u2500 Gaming/ # (~G)\n\u251c\u2500\u2500 mnt/ # (~m) user-owned mount points\n\u251c\u2500\u2500 tmp/ # (~t) user-dedicated tmpfs\n\u251c\u2500\u2500 dev/ # (~d) development root\n\u2502 \u251c\u2500\u2500 claude/ # (~c) Claude Code Flake, outputs memory/mcp/agents to `~/.claude`\n\u2502 \u2502 \u251c\u2500\u2500 settings.json # User settings\n\u2502 \u2502 \u2514\u2500\u2500 mcp.json5 # mcp configurator, see `buildmcp --help`, add to claude profile and `buildmcp --force`\n\u2502 \u251c\u2500\u2500 projects/ # (~p) project directories\n\u2502 \u251c\u2500\u2500 lib/devenv/ # devenv.nix for all ~projects\n\u2502 \u251c\u2500\u2500 opt/ # (~o) operational packages (docker, dev services, chromadb, etc.)\n\u2502 \u251c\u2500\u2500 src/ # (~s) git source code references, clone all non-tmpfs repositories here\n\u2502 \u251c\u2500\u2500 docs/ # (~do) main docstore\n\u2502 \u2502 \u251c\u2500\u2500 docstore.nix # docstore definitions\n\u2502 \u2502 \u251c\u2500\u2500 projects/<name>/ # docstore workspaces, source of symlink to project docstore `docs/workspace/`\n\u2502 \u2502 \u251c\u2500\u2500 man/ # Manuals, references, tutorials, wikis\n\u2502 \u2502 \u251c\u2500\u2500 research/ # Investigation results, topic research\n\u2502 \u2502 \u251c\u2500\u2500 reports/ # Generated analysis & summaries\n\u2502 \u2502 \u2514\u2500\u2500 web/ # scrape/crawl web output: save to `web/example.com/<topic>`\n\u2502 \u251c\u2500\u2500 worktrees/ # (~dw) git worktrees\n\u2502 \u2514\u2500\u2500 scratch/ # (~ds) scratch workspace\n\u251c\u2500\u2500 .config/ # $XDG_CONFIG_HOME\n\u2502 \u2514\u2500\u2500 nix/ # (~x) Nix configuration\n\u2502 \u2514\u2500\u2500 config/ # Configuration module\n\u2502 \u251c\u2500\u2500 nvim-pome/ # (~n) neovim configuration, symlinked (no home-manager rebuild)\n\u2502 \u251c\u2500\u2500 zsh/ # (~z) ZSH configuration\n\u2502 \u2514\u2500\u2500 kitty/ # (~k) Kitty terminal configuration\n\u2514\u2500\u2500 .local/ # (~.l) user local data\n \u2514\u2500\u2500 share/ # (~.s) application data\n \u2514\u2500\u2500 nvim-pome/ # (~.n) Neovim data\n \u2514\u2500\u2500 lazy/ # (~.nl) Lazy.nvim plugins full repository source, use for debugging nvim\n```\n\n### Project `.claude/` Directory\n\nEach project has a `.claude/` directory for session artifacts:\n\n```\n{project}/.claude/\n\u251c\u2500\u2500 .idx # Shared episode counter\n\u251c\u2500\u2500 handoffs/\n\u2502 \u251c\u2500\u2500 00-initial-setup.md\n\u2502 \u251c\u2500\u2500 01-api-integration.md\n\u2502 \u251c\u2500\u2500 01-api-integration-diagram.png # vision enhancement\n\u2502 \u2514\u2500\u2500 02-debugging-auth.md\n\u2514\u2500\u2500 plans/\n \u251c\u2500\u2500 active/\n \u2502 \u2514\u2500\u2500 03-current-plan.md\n \u251c\u2500\u2500 done/\n \u2502 \u2514\u2500\u2500 00-completed-plan.md\n \u2514\u2500\u2500 dropped/\n \u2514\u2500\u2500 02-abandoned-plan.md\n```\n\n**Shared Episode Counter** (`.idx`):\n\n- Single integer tracking current episode number\n- Plans and handoffs share the same counter\n- Ensures related artifacts match: plan 03 \u2192 handoff 03\n- Only plan creation (`/planner next`, `/planner new`) increments\n- Handoff creation uses current episode, does NOT increment\n\n**Handoffs** (`/handoff` skill):\n\n- Episode-numbered: `NN-descriptive-name.md`\n- Images share episode number: `NN-name-description.png`\n\n**Plans** (`/planstore` skill):\n\n- Same episode numbering as handoffs\n- State directories: `active/`, `done/`, `dropped/`\n- Move between directories as status changes\n- When plan completes \u2192 move to `done/`\n- When plan abandoned \u2192 move to `dropped/`\n\n## Core Pattern Library\n\n### Essential Patterns\n\n#### Priority Markers\n\n```markdown\n- **IMPERATIVE**: Non-negotiable, must be followed\n- **CRITICAL**: High priority, essential rules\n- **IMPORTANT**: Significant guidelines\n- Regular text: Standard instructions\n```\n\n#### Prohibition Lists\n\n```markdown\n### DO NOT:\n\n- Edit more code than necessary\n- Waste tokens on verbose responses\n- Question immediate execution commands\n- Create tools instead of using existing commands\n```\n\n#### Instruction Value Assessment Template\n\n```markdown\n## Value-Based Prioritization\n\n**High Value (Always Include)**:\n\n- High value item 1\n- High value item 2\n\n**Medium Value (Conditional)**:\n\n- Medium value item 1\n- Medium value item 2\n\n**Low Value (Exclude)**:\n\n- Low value item 1\n- Low value item 1\n```\n\n#### ROI-Focused Design Template\n\nTemplate for categorizing instructions by return on investment:\n\n```markdown\n## ROI Optimization\n\n**High ROI Instructions**: {Instructions that prevent common mistakes, speed up frequent tasks}\n**Medium ROI Instructions**: {Instructions that improve code quality, reduce review cycles}\n**Low ROI Instructions**: {Nice-to-have preferences, edge case handling}\n```\n\n## `docstore`\n\nNix-declarative documentation store with dedicated agent for procuring documentation and querying information. Use the @\"docstore (agent)\" regularly.\n\n- **Project store**: `{project}/docs/` (config: `{project}/docs/docstore.nix`)\n - Refferred to as the \"docstore\". This is the default target for all references or directions involving the word \"docstore\".\n- **User store**: `~/dev/docs/` (config: `~/dev/docs/docstore.nix`)\n - Referred to as the \"main docstore\" or \"user docstore\"\n\n### Workspaces\n\nA project workspace refers to the managed symlink in a project's `docs` folder. It is a place for project related files from LLMs or agents as well as a temporary/scratch workspace for you and the user. Place files in the appropriate categories:\n\n**Examples**:\n\n- **DO NOT**: place research in `docs/research`: first symlink `ln -s docs/workspace/research docs/research` then save files there.\n- **DO NOT**: place test scripts, new markdown files like IMPLEMENTATION.md or scripts `test_workflow.sh` in git: use the docstore workspace:\n\n```\n./docs/workspace\n\u251c\u2500\u2500 ANALYSIS_COMPLETE.txt\n\u251c\u2500\u2500 arc\n\u251c\u2500\u2500 clark-audit-plugins-plans-shells.md\n\u251c\u2500\u2500 file-history-audit-report.md\n\u251c\u2500\u2500 file_history_patterns.md\n\u251c\u2500\u2500 man\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 graphql-ws-protocol.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 jq-jsonl-queries.md\n\u2502\u00a0\u00a0 \u2514\u2500\u2500 jsonl-session-format.md\n\u251c\u2500\u2500 neo4j-infrastructure-research.md\n\u251c\u2500\u2500 output\n\u251c\u2500\u2500 PATTERNS_SUMMARY.txt\n\u251c\u2500\u2500 plans_filename_analysis.md\n\u251c\u2500\u2500 reports\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 agent-a1b7f87-go-types.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 agent-a8a31aa-workspace-audit.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 agent-ad7cc34-jsonl-format.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 ARCHITECTURE.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 QUICK_START.md\n\u2502\u00a0\u00a0 \u251c\u2500\u2500 SEARCH_GUIDE.md\n\u2502\u00a0\u00a0 \u2514\u2500\u2500 WATCHER_LIMITATIONS.md\n\u251c\u2500\u2500 research\n\u2502\u00a0\u00a0 \u2514\u2500\u2500 high-perf-jsonl.md\n\u251c\u2500\u2500 txt_filename_patterns.md\n\u251c\u2500\u2500 TXT_PATTERNS_INDEX.md\n\u251c\u2500\u2500 txt_patterns_schema.json\n\u251c\u2500\u2500 txt_patterns_usage.md\n\u2514\u2500\u2500 web\n```\n\n### Categories\n\n- `ctx/` - Complete external sources (repos, wikis, full API specs)\n- `man/` - Manuals, references, tutorials, how-to guides\n- `research/` - Investigation results, comprehensive topic research\n- `reports/` - Generated analysis & summaries\n- `web/` - Website extractions (domain-organized)\n\n#### Visual Diagrams\n\nWhen creating visual diagrams in documentation or comments, use unicode box-drawing characters and symbols for clear, terminal-friendly representations.\n\n### **Examples:**\n\n**Simple Diagram**:\n\n```\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Module \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25bc\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Component \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n**PTY stdio relay**:\n\n```\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 Kitty \u2502\n \u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518\n \u2193\u2502\u2191\n \u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\n \u2502 pty_M \u2502 <- Sees PTY3\n \u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518\n \u2193\u2502\u2191\n \u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 pty_S \u2502\u25c0\u2500\u2500\u2500\u2500\u25b6\u2502 prism \u2502 foreground\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u252c\u2500\u2500\u252c\u2500\u2500\u252c\u2518 \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2510\n \u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\n \u2502 PTY1 \u2502\u2502 PTY2 \u2502\u2502 *PTY3 \u2502\n \u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2510\n \u2502 clock \u2502\u2502 wabar \u2502\u2502 app 3 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\n\n```\n\n**Communication Channels**\n\n```\nDirect (same process):\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 A \u251c\u2500\u2500\u2500\u2500chan\u2500\u2500\u2500\u2500\u2500\u25b6 B \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nPipe:\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 io.Pipe \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 A \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6 B \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 r \u2194 w \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nRemote Control:\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 kitten @ \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 A \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6\u2502 Kitty \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 send-text \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nUnix Socket:\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 A \u251c\u2500\u2500/tmp/sock\u2500\u2500\u25b6 B \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\n### Unicode Reference for Diagrams\n\n#### Box Drawing (U+2500\u2013U+257F)\n\n**Light lines:**\n\n```\n\u2500 \u2502 Horizontal, vertical\n\u250c \u2510 \u2514 \u2518 Square corners\n\u256d \u256e \u2570 \u256f Arc/rounded corners\n\u251c \u2524 \u252c \u2534 \u253c Junctions (T and cross)\n\u2574 \u2575 \u2576 \u2577 Half lines (left, up, right, down)\n```\n\n**Heavy lines:**\n\n```\n\u2501 \u2503 Horizontal, vertical\n\u250f \u2513 \u2517 \u251b Square corners\n\u2523 \u252b \u2533 \u253b \u254b Junctions\n\u2578 \u2579 \u257a \u257b Half lines (left, up, right, down)\n```\n\n**Double lines:**\n\n```\n\u2550 \u2551 Horizontal, vertical\n\u2554 \u2557 \u255a \u255d Corners\n\u2560 \u2563 \u2566 \u2569 \u256c Junctions\n```\n\n**Dashed lines:**\n\n```\n\u2504 \u2505 Light/heavy triple dash horizontal\n\u2506 \u2507 Light/heavy triple dash vertical\n\u2508 \u2509 Light/heavy quadruple dash horizontal\n\u250a \u250b Light/heavy quadruple dash vertical\n\u254c \u254d Light/heavy double dash horizontal\n\u254e \u254f Light/heavy double dash vertical\n```\n\n**Mixed weight transitions:**\n\n```\n\u257c \u257d \u257e \u257f Light\u2194heavy transitions (left-heavy, up-heavy, right-heavy, down-heavy)\n```\n\n**Mixed line junctions (single/double):**\n\n```\n\u2552 \u2553 \u2555 \u2556 Down corners (single+double combos)\n\u2558 \u2559 \u255b \u255c Up corners\n\u255e \u255f \u2561 \u2562 Vertical junctions\n\u2564 \u2565 \u2567 \u2568 Horizontal junctions\n\u256a \u256b Cross junctions\n```\n\n**Mixed weight junctions (light/heavy):**\n\n```\n\u250d \u250e \u2511 \u2512 Down corners\n\u2515 \u2516 \u2519 \u251a Up corners\n\u251d \u251e \u251f \u2520 \u2521 \u2522 \u2525 \u2526 \u2527 \u2528 \u2529 \u252a Vertical junctions\n\u252d \u252e \u252f \u2530 \u2531 \u2532 \u2535 \u2536 \u2537 \u2538 \u2539 \u253a Horizontal junctions\n\u253d \u253e \u253f \u2540 \u2541 \u2542 \u2543 \u2544 \u2545 \u2546 \u2547 \u2548 \u2549 \u254a Cross junctions\n```\n\n**Diagonals:**\n\n```\n\u2571 \u2572 \u2573 Light diagonals and cross\n```\n\n#### Block Elements (U+2580\u2013U+259F)\n\n**Vertical fills:**\n\n```\n\u2580 \u2584 Upper/lower half\n\u2588 \u2591 \u2592 \u2593 Full, light/medium/dark shade\n```\n\n**Horizontal fills:**\n\n```\n\u258c \u2590 Left/right half\n```\n\n**Quadrants:**\n\n```\n\u2596 \u2597 \u2598 \u259d Single quadrants (lower-left, lower-right, upper-left, upper-right)\n\u2599 \u259a \u259b \u259c Three quadrants\n\u259e \u259f Two quadrants (diagonal)\n```\n\n**Eighths (horizontal):**\n\n```\n\u258f \u258e \u258d \u258c \u258b \u258a \u2589 \u2588 Left 1/8 through full\n```\n\n**Eighths (vertical):**\n\n```\n\u2581 \u2582 \u2583 \u2584 \u2585 \u2586 \u2587 \u2588 Lower 1/8 through full\n```\n\n#### Geometric Shapes (U+25A0\u2013U+25FF)\n\n**Squares:**\n\n```\n\u25a0 \u25a1 \u25a2 \u25a3 Filled, empty, rounded, white with rounded\n\u25a4 \u25a5 \u25a6 \u25a7 \u25a8 \u25a9 Hatched fills (horizontal, vertical, cross, diagonals)\n\u25e7 \u25e8 \u25e9 \u25ea Half-filled (left, right, upper-left diagonal, upper-right diagonal)\n\u25eb White square with vertical bisecting line\n```\n\n**Rectangles:**\n\n```\n\u25ac \u25ad \u25ae \u25af Filled/empty horizontal, filled/empty vertical\n```\n\n**Triangles:**\n\n```\n\u25b2 \u25b3 \u25b4 \u25b5 Up (filled, outline, small filled, small outline)\n\u25b6 \u25b7 \u25b8 \u25b9 Right\n\u25bc \u25bd \u25be \u25bf Down\n\u25c0 \u25c1 \u25c2 \u25c3 Left\n\u25e2 \u25e3 \u25e4 \u25e5 Right-angle triangles (corners)\n\u25f8 \u25f9 \u25fa \u25ff Upper/lower triangles\n```\n\n**Circles:**\n\n```\n\u25cf \u25cb \u25c9 \u25ce Filled, empty, bullseye, double circle\n\u25d0 \u25d1 \u25d2 \u25d3 Half-filled (left, right, lower, upper)\n\u25d4 \u25d5 Quarter circles\n\u25d6 \u25d7 Left/right half black\n\u25e6 \u2218 Bullet, ring operator\n\u2299 \u229a \u229b Circled dot, circled ring, circled asterisk\n\u29bf Circled bullet\n```\n\n**Diamonds:**\n\n```\n\u25c6 \u25c7 \u2756 Filled, empty, with middle dot\n\u25c8 White diamond containing small black diamond\n\u2b25 \u2b26 Black/white medium diamond\n```\n\n**Stars and polygons:**\n\n```\n\u2605 \u2606 \u2726 \u2727 Filled/empty star, 4-pointed stars\n\u2731 \u2732 \u2733 \u2734 \u2735 \u2736 \u2737 \u2738 Various asterisks/stars\n\u2b1f \u2b20 Pentagon\n\u2b21 \u2b22 Hexagon (empty, filled)\n```\n\n**Misc shapes:**\n\n```\n\u2b24 Black large circle\n\u2b2e \u2b2f Horizontal/vertical ellipse\n\u25cc Dotted circle\n\u25cd Circle with vertical fill\n```\n\n#### Arrows (U+2190\u2013U+21FF, U+27F0\u2013U+27FF, U+2900\u2013U+297F)\n\n**Basic directional:**\n\n```\n\u2190 \u2192 \u2191 \u2193 Single line\n\u21d0 \u21d2 \u21d1 \u21d3 Double line\n\u27f5 \u27f6 \u27f7 Long arrows\n\u2936 \u2937 Curved up then left/right\n```\n\n**Diagonals:**\n\n```\n\u2196 \u2197 \u2198 \u2199 Single\n\u21d6 \u21d7 \u21d8 \u21d9 Double\n```\n\n**Bidirectional:**\n\n```\n\u2194 \u2195 Single horizontal/vertical\n\u21d4 \u21d5 Double horizontal/vertical\n\u21c4 \u21c6 \u21c5 \u21f5 Paired opposite\n```\n\n**Curved and corner:**\n\n```\n\u21a9 \u21aa Hook arrows\n\u21b0 \u21b1 \u21b2 \u21b3 \u21b4 \u21b5 Corner arrows\n\u21b6 \u21b7 Curved loops\n\u21ba \u21bb Circular/refresh\n\u27f2 \u27f3 Anticlockwise/clockwise arrows with circle\n```\n\n**Arrows with modifications:**\n\n```\n\u21a0 \u21a3 \u219e \u21a2 Two-headed, tailed\n\u21a6 \u21a4 \u21a5 \u21a7 From bar\n\u21e2 \u21e0 \u21e1 \u21e3 Dashed\n```\n\n**Double/paired:**\n\n```\n\u21c7 \u21c9 \u21c8 \u21ca Double paired\n\u21f6 Three rightwards arrows\n\u21fb \u21fc Leftwards/rightwards arrow with double vertical stroke\n```\n\n#### Mathematical & Technical Symbols\n\n**Logic and set:**\n\n```\n\u2227 \u2228 Logical and/or\n\u2229 \u222a Intersection/union\n\u2208 \u2209 \u220b \u220c Element of, not element of\n\u2282 \u2283 \u2284 \u2285 Subset/superset\n\u2286 \u2287 Subset/superset or equal\n\u2200 \u2203 \u2204 For all, exists, not exists\n\u00ac \u22a5 \u22a4 Not, bottom (false), top (true)\n```\n\n**Relations:**\n\n```\n\u2260 \u2261 \u2262 Not equal, identical, not identical\n\u2248 \u2249 Approximately equal, not approximately\n\u2264 \u2265 \u226e \u226f Less/greater than or equal, not less/greater\n\u226a \u226b Much less/greater than\n\u221d Proportional to\n```\n\n**Operators:**\n\n```\n\u00b1 \u2213 Plus-minus, minus-plus\n\u00d7 \u00f7 Multiply, divide\n\u2219 \u00b7 Bullet operator, middle dot\n\u2211 \u220f Summation, product\n\u221a \u221b \u221c Square/cube/fourth root\n\u221e Infinity\n\u2202 Partial differential\n\u2207 Nabla (gradient)\n```\n\n**Brackets and grouping:**\n\n```\n\u2308 \u2309 \u230a \u230b Ceiling, floor\n\u23a1 \u23a4 \u23a3 \u23a6 Left/right square bracket upper/lower\n\u23a7 \u23a8 \u23a9 Left curly bracket upper/middle/lower\n\u23ab \u23ac \u23ad Right curly bracket upper/middle/lower\n\u239b \u239c \u239d \u239e \u239f \u23a0 Parenthesis parts\n\u23be \u23bf \u23cb \u23cc Bracket corners\n\u3008 \u3009 \u27e8 \u27e9 Angle brackets\n\u27e6 \u27e7 Double square brackets\n```\n\n#### Miscellaneous Symbols\n\n**Connectors and misc:**\n\n#### Usage Patterns\n\n**State indicators:**\n\n```\n\u25a1 \u25e7 \u25e8 \u25a0 Empty \u2192 loading \u2192 loading \u2192 full\n\u25cb \u25d4 \u25d1 \u25d5 \u25cf 0% \u2192 25% \u2192 50% \u2192 75% \u2192 100%\n```\n\n**Flow diagrams:**\n\n```\n\u250c\u2500\u2500\u2500\u2510 \u2554\u2550\u2550\u2550\u2557 \u256d\u2500\u2500\u2500\u256e\n\u2502 \u2502 \u2551 \u2551 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2518 \u255a\u2550\u2550\u2550\u255d \u2570\u2500\u2500\u2500\u256f\nStandard Emphasis Soft\n```\n\n## Imports\n\n- Development Standards: @~/.claude/standards.md\n<!-- - Python Standards: @~/.claude/standards-python.md -->\n\n\nContents of /home/starbased/.claude/standards.md (user's private global instructions for all projects):\n\n# Development Standards & Style Guide\n\n## Core Principles\n\n- In the face of ambiguity, **refuse** the temptation to guess. Stop and think.\n\n- **Flat** is better than nested.\n- **Sparse** is better than dense.\n\n## `devenv`\n\nWhen devenv.nix doesn't exist and a command/tool is missing, create ad-hoc environment:\n\n```sh\n devenv -O languages.rust.enable:bool true -O packages:pkgs \"mypackage mypackage2\" shell -- cli args\n```\n\nWhen the setup is becomes complex create\n`devenv.nix` and run commands within:\n\n```sh\n devenv shell -- cli args\n```\n\nSee <https://devenv.sh/ad-hoc-developer-environments/>\n\n## Anti-Patterns to Avoid\n\n- \u274c Mixed naming conventions\n- \u274c Implicit type conversions\n- \u274c Silent error handling\n- \u274c Circular dependencies\n- \u274c Global mutable state\n- \u274c Hardcoded configuration\n- \u274c Missing error boundaries\n\n## Naming Conventions\n\n### General Patterns\n\n- **Classes**: `PascalCase` (`DataProcessor`, `UserProfile`)\n- **Functions/Methods**: `snake_case` (`process_data`, `calculate_total`)\n- **Constants**: `UPPER_SNAKE_CASE` (`MAX_RETRIES`, `DEFAULT_TIMEOUT`)\n- **Private**: Leading underscore (`_internal_helper`, `_cache`)\n- **Name Collisions**: Trailing underscore (`class_`, `type_`)\n\n## Code Comments\n\n- **IMPERATIVE**: NEVER add change history notes in comments (e.g. \"// removed X, changed Y from 400\")\n- **CRITICAL**: Comments must describe the current state of code, NOT what was modified\n- **DO NOT**: Leave traces of edits, removals, or past values in comments\n- **DO NOT**: Write comments as if narrating your changes to a spectator\n- **DO NOT**: Defensively migrate functionality. Migrating features or conventions is not your task.\n- **DO**: When modifying code with comments, rewrite comments based on the CURRENT & COMPELTE context\n- **DO**: Write comments that would make sense to someone seeing the code for the first time\n\n### Examples\n\n```javascript\n// BAD - references change history\nobject = { value1: 200 }; // value2 removed, value1 down from 400\n\n// GOOD - describes current state\nobject = { value1: 200 }; // Configuration threshold\n```\n\n## Shell Patterns\n\n- **Naming Conventions**: lowercase with underscores for functions, UPPERCASE for environment variables\n\n### Shell Script Structure\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail # Fail fast\n\n# Configuration\nreadonly SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nreadonly CONFIG_FILE=\"${CONFIG_FILE:-$HOME/.config/app/config}\"\n\n# Functions\nerror() {\n echo \"Error: $1\" >&2\n exit 1\n}\n\nmain() {\n # Validate environment\n [[ -f \"$CONFIG_FILE\" ]] || error \"Config file not found\"\n\n # Main logic\n process_files \"$@\"\n}\n\n# Only run if executed directly\nif [[ \"${BASH_SOURCE[0]}\" == \"${0}\" ]]; then\n main \"$@\"\nfi\n```\n\n## Configuration File Patterns\n\n### INI/TOML Style\n\n```toml\n[core]\n# Essential settings\ntimeout = 30\nretries = 3\n\n[features]\n# Feature flags\nasync = true\ncache = true\n\n[features.cache]\n# Nested configuration\nttl = 3600\nmax_size = 1000\n```\n\n### Lua Configuration\n\n```lua\n-- Explicit option setting\nlocal opts = {\n core = {\n timeout = 30,\n retries = 3,\n },\n features = {\n async = true,\n cache = {\n ttl = 3600,\n max_size = 1000,\n },\n },\n}\n\n-- Apply configuration\nrequire('app').setup(opts)\n```\n\n## Git Patterns\n\n### Commit Messages\n\n```\ntype(scope): description\n\n- feat: New feature\n- fix: Bug fix\n- docs: Documentation\n- style: Formatting\n- refactor: Code restructuring\n- test: Testing\n- chore: Maintenance\n\nExample:\nfeat(auth): add OAuth2 support for GitHub\n```\n\n### Branch Naming\n\n```\nfeature/oauth-github\nfix/memory-leak-processor\nrefactor/simplify-config\ndocs/api-endpoints\n```\n\n\nContents of /home/starbased/dev/projects/ccproxy/CLAUDE.md (project instructions, checked into the codebase):\n\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n@~/.claude/standards-python-extended.md\n\n## Project Overview\n\n**CRITICAL**: The project name is `ccproxy` (lowercase). Do NOT refer to the project as \"CCProxy\". The PascalCase form is used exclusively for class names (e.g., `CCProxyHandler`, `CCProxyConfig`).\n\n`ccproxy` is a command-line tool that intercepts and routes Claude Code's requests to different LLM providers via a LiteLLM proxy server. It enables intelligent request routing based on token count, model type, tool usage, or custom rules. It also functions as a development platform for new and unexplored features or unofficial mods of Claude Code.\n\n## Development Commands\n\n### Running Tests\n\n```bash\n# Run all tests with coverage\nuv run pytest\n\n# Run specific test file\nuv run pytest tests/test_classifier.py\n\n# Run tests matching pattern\nuv run pytest -k \"test_token_count\"\n\n# Run with verbose output\nuv run pytest -v\n```\n\n### Linting & Formatting\n\n```bash\n# Format code with ruff\nuv run ruff format .\n\n# Check linting issues\nuv run ruff check .\n\n# Fix linting issues automatically\nuv run ruff check --fix .\n\n# Type checking with mypy\nuv run mypy src/ccproxy\n```\n\n### Development Setup\n\n```bash\n# Install with dev dependencies\nuv sync --dev\n\n# Install as a tool globally\nuv tool install .\n\n# Run the module directly\nuv run python -m ccproxy\n```\n\n### CLI Commands\n\n```bash\n# Install configuration files\nccproxy install [--force]\n\n# Start/stop proxy server\nccproxy start [--detach] [--mitm]\nccproxy stop\nccproxy restart [--detach] [--mitm]\n\n# View logs and status\nccproxy logs [-f] [-n LINES]\nccproxy status [--json]\n\n# Run command with proxy environment\nccproxy run <command> [args...]\n\n# Query MITM traces database\nccproxy db sql \"SELECT COUNT(*) FROM \\\"CCProxy_HttpTraces\\\"\"\nccproxy db sql --file query.sql\nccproxy db sql \"SELECT * FROM ...\" --json\nccproxy db sql \"SELECT * FROM ...\" --csv\n```\n\n**MITM Mode**: The `--mitm` flag enables the MITM proxy layer which intercepts HTTP traffic for header/body modification. Required for OAuth sentinel key with native Anthropic SDK.\n\n## Architecture\n\nThe codebase follows a modular architecture with clear separation of concerns:\n\n### Request Flow\n\n```\nRequest \u2192 CCProxyHandler \u2192 Hook Pipeline \u2192 Response\n \u2193\n RequestClassifier (rule evaluation)\n \u2193\n ModelRouter (model lookup)\n```\n\n1. **CCProxyHandler** (`handler.py`) - LiteLLM CustomLogger that intercepts all requests\n2. **RequestClassifier** (`classifier.py`) - Evaluates rules in order (first match wins)\n3. **ModelRouter** (`router.py`) - Maps rule names to actual model configurations\n4. **Hook Pipeline** - Sequential execution of configured hooks with error isolation\n\n### Key Components\n\n- **handler.py**: Main entry point as a LiteLLM CustomLogger. Orchestrates the classification and routing process via `async_pre_call_hook()`.\n- **classifier.py**: Rule-based classification system that evaluates rules in order to determine routing.\n- **rules.py**: Defines `ClassificationRule` abstract base class and built-in rules:\n - `ThinkingRule` - Matches requests with \"thinking\" field\n - `MatchModelRule` - Matches by model name substring\n - `MatchToolRule` - Matches by tool name in request\n - `TokenCountRule` - Evaluates based on token count threshold\n- **router.py**: Manages model configurations from LiteLLM proxy server. Lazy-loads models on first request.\n- **config.py**: Configuration management using Pydantic with multi-level discovery (env var \u2192 LiteLLM runtime \u2192 ~/.ccproxy/).\n- **hooks.py**: Built-in hooks that process requests. Hooks support optional params via `hook:` + `params:` YAML format (see `HookConfig` class in config.py):\n - `rule_evaluator` - Evaluates rules and stores routing decision\n - `model_router` - Routes to appropriate model\n - `forward_oauth` - Forwards OAuth tokens to provider APIs; supports sentinel key substitution\n - `extract_session_id` - Extracts session identifiers\n - `capture_headers` - Captures HTTP headers with sensitive redaction (supports `headers` param)\n - `forward_apikey` - Forwards x-api-key header\n - `add_beta_headers` - Adds anthropic-beta headers for Claude Code OAuth\n - `inject_claude_code_identity` - Injects required system message for OAuth\n- **mitm/addon.py**: MITM proxy addon for HTTP-layer modifications:\n - Removes `x-api-key` for OAuth requests\n - Adds `anthropic-beta` headers for Claude Code compliance\n - Injects \"You are Claude Code\" system message prefix for OAuth tokens\n- **cli.py**: Tyro-based CLI interface (~900 lines) for managing the proxy server.\n- **utils.py**: Template discovery and debug utilities (`dt()`, `dv()`, `d()`, `p()`).\n\n### Rule System\n\nRules are evaluated in the order configured in `ccproxy.yaml`. Each rule:\n\n- Inherits from `ClassificationRule` abstract base class\n- Implements `evaluate(request: dict, config: CCProxyConfig) -> bool`\n- Returns the first matching rule's name as the routing label\n\n```yaml\n# Example rule configuration in ccproxy.yaml\nrules:\n - name: thinking_model\n rule: ccproxy.rules.ThinkingRule\n - name: haiku_requests\n rule: ccproxy.rules.MatchModelRule\n params:\n - model_name: \"haiku\"\n - name: large_context\n rule: ccproxy.rules.TokenCountRule\n params:\n - threshold: 60000\n```\n\nCustom rules can be created by implementing the ClassificationRule interface and specifying the Python import path in the configuration.\n\n### Configuration Files\n\n- `~/.ccproxy/config.yaml` - LiteLLM proxy configuration with model definitions\n- `~/.ccproxy/ccproxy.yaml` - ccproxy-specific configuration (rules, hooks, debug settings, handler path)\n- `~/.ccproxy/ccproxy.py` - Auto-generated handler file (created on `ccproxy start` based on `handler` config)\n\n**Config Discovery Precedence:**\n\n1. `CCPROXY_CONFIG_DIR` environment variable\n2. LiteLLM proxy runtime directory (auto-detected)\n3. `~/.ccproxy/` (default fallback)\n\n## Testing Patterns\n\nThe test suite uses pytest with comprehensive fixtures (18 test files, 90% coverage minimum):\n\n- `mock_proxy_server` fixture for mocking LiteLLM proxy\n- `cleanup` fixture ensures singleton instances are cleared between tests\n- Tests organized to mirror source structure (`test_<module>.py`)\n- Parametrized tests for rule evaluation scenarios\n- Integration tests verify end-to-end behavior\n\n## Important Implementation Notes\n\n- **Singleton patterns**: `CCProxyConfig` and `ModelRouter` use thread-safe singletons. Use `clear_config_instance()` and `clear_router()` to reset state in tests.\n- **Token counting**: Uses tiktoken with fallback to character-based estimation for non-OpenAI models.\n- **OAuth token forwarding**: Handled specially for Claude CLI requests. Supports custom User-Agent per provider.\n- **OAuth sentinel key**: SDK clients can use `sk-ant-oat-ccproxy-{provider}` as API key to trigger OAuth token substitution from `oat_sources` config. Requires MITM mode for native Anthropic SDK (system message injection happens at HTTP layer).\n- **OAuth token refresh**: Automatic refresh with two triggers:\n - TTL-based: Background task checks every 30 minutes, refreshes at 90% of `oauth_ttl` (default 8h)\n - 401-triggered: Immediate refresh when API returns authentication error\n - Config: `oauth_ttl` (seconds), `oauth_refresh_buffer` (ratio, default 0.1)\n- **Request metadata**: Stored by `litellm_call_id` with 60-second TTL auto-cleanup (LiteLLM doesn't preserve custom metadata).\n- **Hook error isolation**: Errors in one hook don't block others from executing.\n- **Lazy model loading**: Models loaded from LiteLLM proxy on first request, not at startup.\n- **MITM proxy**: Two-layer architecture - reverse proxy on port 4000 (user-facing), forward proxy on port 8081 (outbound to providers). MITM layer injects headers and modifies request bodies for OAuth compliance.\n- **MITM database**: PostgreSQL for HTTP trace storage. Database URL set via `CCPROXY_DATABASE_URL` env var or in `ccproxy.yaml` under `litellm.environment`. Current setup uses `litellm-db` container with database `ccproxy_mitm` (not the `ccproxy-db` in compose.yaml).\n- **Proxy direction tracking**: MITM traces include `proxy_direction` field (0=reverse, 1=forward) to distinguish client\u2192LiteLLM vs LiteLLM\u2192provider traffic.\n- **Session tracking**: MITM addon extracts `session_id` from Claude Code's `metadata.user_id` field to link related requests across proxy layers.\n\n## Dependencies\n\nKey dependencies include:\n\n- **litellm[proxy]** - Core proxy functionality\n- **pydantic/pydantic-settings** - Configuration and validation\n- **tyro** - CLI interface generation\n- **tiktoken** - Token counting\n- **anthropic** - Anthropic API client\n- **rich** - Terminal output formatting\n- **langfuse** - Observability integration\n- **prisma** - Database ORM\n- **structlog** - Structured logging\n\n## Development Workflow\n\n### Local Development Setup\n\nccproxy must be installed with litellm in the same environment so that LiteLLM can import the ccproxy handler:\n\n```bash\n# Install in editable mode with litellm bundled\nuv tool install --editable . --with 'litellm[proxy]' --force\n```\n\n### Making Changes\n\nWith editable mode, source changes are reflected immediately. Just restart the proxy:\n\n```bash\n# Restart proxy to regenerate handler and pick up changes\nccproxy stop\nccproxy start --detach\n\n# Verify\nccproxy status\n\n# Run tests\nuv run pytest\n```\n\n### Why Bundle with LiteLLM?\n\nLiteLLM imports `ccproxy.handler:CCProxyHandler` at runtime from the auto-generated `~/.ccproxy/ccproxy.py` file. Both must be in the same Python environment:\n\n- `uv tool install ccproxy` \u2192 isolated env\n- `uv tool install litellm` \u2192 different isolated env\n\nSolution: Install together so they share the same environment.\n\nThe handler file is automatically regenerated on every `ccproxy start` based on the `handler` configuration in `ccproxy.yaml`.\n\n### Prisma Schema Changes\n\nWhen modifying `prisma/schema.prisma` (e.g., adding fields to `CCProxy_HttpTraces`), you must:\n\n```bash\n# 1. Push schema changes to database\nDATABASE_URL=\"postgresql://ccproxy:test@localhost:5432/ccproxy_mitm\" uv run prisma db push\n\n# 2. Regenerate Prisma client for the TOOL installation (not just .venv)\nDATABASE_URL=\"postgresql://ccproxy:test@localhost:5432/ccproxy_mitm\" \\\n uv tool run --from claude-ccproxy prisma generate --schema prisma/schema.prisma\n\n# 3. Restart proxy\nccproxy stop && ccproxy start --detach --mitm\n```\n\n**Why both steps?** The `uv run prisma generate` only updates `.venv/`, but ccproxy runs from the tool installation at `~/.local/share/uv/tools/claude-ccproxy/`. The tool's Prisma client must be regenerated separately.\n\n\nContents of /home/starbased/.claude/standards-python-extended.md (project instructions, checked into the codebase):\n\n# Python Standards Extended\n\nThis document contains advanced Python patterns and detailed examples that complement the main `standards-python.md` file. Refer here for:\n\n- Error handling patterns and logging configuration\n- Advanced coding patterns (Singleton, Context Managers, Lazy Loading)\n- Complex Tyro CLI patterns (subcommands with Union types)\n- PyTorch & Deep Learning workflows\n- Testing patterns and fixtures\n- Debugging tools (debugpy, snoop, pdbp, nvim-dap)\n\n## Error Handling Patterns\n\n### Domain Exceptions\n\n```python\nimport logging\nlogger = logging.getLogger(__name__)\n\nclass ProjectError(Exception): pass\nclass ValidationError(ProjectError):\n def __init__(self, field: str, reason: str):\n self.field = field\n super().__init__(f\"{field}: {reason}\")\n\ndef process(data: dict) -> Result:\n # Guard clauses\n if not data:\n raise ValidationError(\"data\", \"empty\")\n\n try:\n return transform(data)\n except ValidationError as e:\n logger.warning(f\"Validation: {e}\")\n raise\n except Exception as e:\n logger.exception(\"Unexpected error\")\n raise ProjectError(f\"Failed: {e}\") from e\n```\n\n### Exception Handling Patterns\n\n```python\n# Synchronous with logging\nimport logging\nlogger = logging.getLogger(__name__)\n\ntry:\n result = process_data(input_data)\nexcept ValidationError as e:\n logger.warning(f\"Validation failed: {e}\")\n raise\nexcept Exception as e:\n logger.error(f\"Unexpected error: {e}\", exc_info=True)\n return None\n\n# Asynchronous Retry\nasync def retry(func, max=3, delay=1.0):\n for i in range(max):\n try:\n return await func()\n except TimeoutError:\n if i < max-1:\n await asyncio.sleep(delay * 2**i)\n raise\n```\n\n## Logging Configuration\n\n### Rich Handler Setup\n\n```python\nimport logging\nfrom rich.logging import RichHandler\nfrom rich.console import Console\n\ndef setup_logging(\n level: str = \"INFO\",\n show_path: bool = True,\n rich_tracebacks: bool = True,\n) -> None:\n \"\"\"Configure application logging with rich formatting.\"\"\"\n handlers = [\n RichHandler(\n console=Console(stderr=True),\n show_time=True,\n show_path=show_path,\n rich_tracebacks=rich_tracebacks,\n tracebacks_show_locals=rich_tracebacks,\n markup=True,\n log_time_format=\"[%X]\",\n )\n ]\n\n logging.basicConfig(\n level=getattr(logging, level.upper()),\n format=\"%(message)s\",\n datefmt=\"[%X]\",\n handlers=handlers,\n force=True,\n )\n\n# Module-level logger\nlogger = logging.getLogger(__name__)\n\n# Usage with rich markup\nlogger.debug(\"Debug information\")\nlogger.info(\"[green]Processing started[/green]\")\nlogger.warning(\"[yellow]Potential issue detected[/yellow]\")\nlogger.error(\"[bold red]Error occurred[/bold red]\", exc_info=True)\n\n# Structured logging with extra context\nlogger.info(\n \"Processing file\",\n extra={\"markup\": True, \"highlighter\": None},\n extra_data={\"file\": \"data.csv\", \"size\": 1024}\n)\n```\n\n## Advanced Coding Patterns\n\n### Singleton Pattern\n\n```python\nclass ConfigManager:\n \"\"\"Singleton configuration manager.\"\"\"\n _instance = None\n\n def __new__(cls):\n if cls._instance is None:\n cls._instance = super().__new__(cls)\n return cls._instance\n```\n\n### Context Managers\n\n```python\nfrom contextlib import asynccontextmanager\n\n@asynccontextmanager\nasync def managed_resource():\n \"\"\"Async context manager for resource.\"\"\"\n resource = await acquire_resource()\n try:\n yield resource\n finally:\n await release_resource(resource)\n\n# Usage\nasync with managed_resource() as resource:\n await resource.process()\n```\n\n### Lazy Loading\n\n```python\nfrom functools import lru_cache\n\n@lru_cache(maxsize=128)\ndef expensive_computation(x: int) -> int:\n \"\"\"Cache expensive computations.\"\"\"\n return x ** 2\n```\n\n## Advanced Tyro CLI Patterns\n\n### Subcommands with Union Types\n\n```python\nfrom typing import Union, Annotated\nfrom pathlib import Path\nimport attrs\nimport tyro\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.table import Table\nfrom rich.progress import Progress, BarColumn, TextColumn\n\nconsole = Console()\n\n@attrs.define\nclass Train:\n \"\"\"Training configuration with rich progress.\"\"\"\n learning_rate: float = 0.001\n epochs: int = 100\n\n def run(self) -> None:\n \"\"\"Execute training with progress display.\"\"\"\n with Progress(\n TextColumn(\"[bold blue]{task.description}\"),\n BarColumn(),\n TextColumn(\"[progress.percentage]{task.percentage:>3.0f}%\"),\n console=console,\n ) as progress:\n task = progress.add_task(\"Training\", total=self.epochs)\n\n for epoch in range(self.epochs):\n progress.update(\n task,\n advance=1,\n description=f\"Epoch {epoch+1}/{self.epochs}\"\n )\n # Training logic here\n\n console.print(\"[green]\u2713[/green] Training complete!\")\n\n@attrs.define\nclass Evaluate:\n \"\"\"Evaluation configuration with rich tables.\"\"\"\n checkpoint: Path\n batch_size: int = 32\n\n def run(self) -> None:\n \"\"\"Execute evaluation with results table.\"\"\"\n console.print(f\"[cyan]Loading checkpoint:[/cyan] {self.checkpoint}\")\n\n # Evaluation logic here\n results = {\n \"Accuracy\": 0.95,\n \"Precision\": 0.93,\n \"Recall\": 0.94,\n \"F1 Score\": 0.935,\n }\n\n # Display results in table\n table = Table(title=\"Evaluation Results\")\n table.add_column(\"Metric\", style=\"cyan\")\n table.add_column(\"Value\", style=\"green\")\n\n for metric, value in results.items():\n table.add_row(metric, f\"{value:.3f}\")\n\n console.print(table)\n\n@attrs.define\nclass CLI:\n \"\"\"Main CLI with subcommands.\"\"\"\n mode: Union[\n Annotated[Train, tyro.conf.subcommand(\"train\")],\n Annotated[Evaluate, tyro.conf.subcommand(\"eval\")],\n ]\n\n# Usage: python script.py mode:train --mode.learning-rate 0.01\n# Usage: python script.py mode:eval --mode.checkpoint model.pt\nif __name__ == \"__main__\":\n cli = tyro.cli(CLI)\n cli.mode.run()\n```\n\n## PyTorch & Deep Learning\n\n### Installing PyTorch\n\n```bash\n# Install PyTorch with CUDA support\nuv add torch torchvision torchaudio --index https://download.pytorch.org/whl/cu130\n\n# Development dependencies\nuv add --dev tensorboard pytest\n\n# Verify GPU\nuv run python -c \"import torch; print(f'CUDA: {torch.cuda.is_available()}'); print(f'GPU: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else None}')\"\n```\n\n### GPU Setup\n\n```python\nimport torch\n\n# Check GPU availability\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\n\nif torch.cuda.is_available():\n print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n print(f\"Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\")\n\n# Move model and data to GPU\nmodel = MyModel().to(device)\ninputs = batch_data.to(device)\n```\n\n### Mixed Precision Training\n\nEnable Automatic Mixed Precision (AMP) for faster training with Tensor Cores:\n\n```python\nfrom torch.cuda.amp import autocast, GradScaler\n\n# Initialize gradient scaler\nscaler = GradScaler()\n\n# Training loop\nfor batch in dataloader:\n inputs, targets = batch\n inputs = inputs.to(device)\n targets = targets.to(device)\n\n optimizer.zero_grad()\n\n # Forward pass with autocast\n with autocast():\n outputs = model(inputs)\n loss = criterion(outputs, targets)\n\n # Backward pass with scaled gradients\n scaler.scale(loss).backward()\n scaler.step(optimizer)\n scaler.update()\n```\n\n### Memory Management\n\n```python\n# Check available memory\nfree_memory = torch.cuda.get_device_properties(0).total_memory\nused_memory = torch.cuda.memory_allocated(0)\navailable_gb = (free_memory - used_memory) / 1e9\nprint(f\"Available GPU memory: {available_gb:.1f} GB\")\n\n# Clear cache when needed\ntorch.cuda.empty_cache()\n\n# Delete unused tensors\ndel large_tensor\ntorch.cuda.empty_cache()\n```\n\n### Performance Optimization\n\n```python\n# Enable cuDNN autotuner for optimal convolution algorithms\ntorch.backends.cudnn.benchmark = True\n\n# Use DataLoader with multiple workers\ntrain_loader = torch.utils.data.DataLoader(\n dataset,\n batch_size=32,\n shuffle=True,\n num_workers=4,\n pin_memory=True, # Faster CPU to GPU transfer\n persistent_workers=True,\n)\n\n# Gradient accumulation for large batch sizes\naccumulation_steps = 4\nfor i, batch in enumerate(dataloader):\n outputs = model(batch)\n loss = criterion(outputs, targets) / accumulation_steps\n loss.backward()\n\n if (i + 1) % accumulation_steps == 0:\n optimizer.step()\n optimizer.zero_grad()\n```\n\n### Distributed Training (Multi-GPU)\n\n```python\nimport torch.distributed as dist\nfrom torch.nn.parallel import DistributedDataParallel as DDP\n\n# Initialize process group\ndist.init_process_group(backend=\"nccl\")\nlocal_rank = int(os.environ[\"LOCAL_RANK\"])\ntorch.cuda.set_device(local_rank)\n\n# Wrap model with DDP\nmodel = MyModel().to(local_rank)\nmodel = DDP(model, device_ids=[local_rank])\n\n# Launch with torchrun\n# torchrun --nproc_per_node=2 train.py\n```\n\n### Common Patterns\n\n```python\n# Model checkpointing\ncheckpoint = {\n \"epoch\": epoch,\n \"model_state_dict\": model.state_dict(),\n \"optimizer_state_dict\": optimizer.state_dict(),\n \"loss\": loss,\n}\ntorch.save(checkpoint, \"checkpoint.pt\")\n\n# Load checkpoint\ncheckpoint = torch.load(\"checkpoint.pt\")\nmodel.load_state_dict(checkpoint[\"model_state_dict\"])\noptimizer.load_state_dict(checkpoint[\"optimizer_state_dict\"])\n\n# Inference mode (faster than eval())\nwith torch.inference_mode():\n outputs = model(inputs)\n\n# Gradient checkpointing for memory efficiency\nfrom torch.utils.checkpoint import checkpoint\nx = checkpoint(model.layer1, x)\n```\n\n## Testing Patterns\n\n### `pytest` Configuration\n\n```toml\n# pyproject.toml\n[tool.pytest.ini_options]\naddopts = [\n \"--color=yes\",\n \"--tb=short\",\n \"--strict-markers\",\n \"--strict-config\",\n]\n\n# Optional: Use pytest-rich plugin for enhanced output\n# uv add --dev pytest-rich\n```\n\n```python\n# conftest.py - Configure rich for all tests\nimport pytest\nfrom rich.console import Console\nfrom rich.traceback import install\n\n# Install rich tracebacks for better error display\ninstall(show_locals=True)\n\n@pytest.fixture\ndef console():\n \"\"\"Provide rich console for test output.\"\"\"\n return Console()\n\n@pytest.fixture(autouse=True)\ndef setup_rich_logging(monkeypatch):\n \"\"\"Auto-configure rich logging for tests.\"\"\"\n import logging\n from rich.logging import RichHandler\n\n logging.basicConfig(\n level=logging.DEBUG,\n format=\"%(message)s\",\n handlers=[RichHandler(show_time=False, show_path=False)],\n force=True,\n )\n```\n\n### `pytest` with Async Support\n\n```python\nimport pytest\nfrom unittest.mock import patch, AsyncMock\n\n@pytest.mark.asyncio\nasync def test_async_processor(console):\n \"\"\"Test async processing with rich output.\"\"\"\n processor = AsyncProcessor()\n\n # Use rich for test progress display\n console.print(\"[cyan]Testing async processor...[/cyan]\")\n\n # Mock external dependencies\n with patch(\"module.external_api\", new_callable=AsyncMock) as mock_api:\n mock_api.fetch.return_value = b\"test_data\"\n\n result = await processor.process()\n\n assert result == \"processed\"\n mock_api.fetch.assert_called_once()\n\n@pytest.fixture\nasync def client():\n \"\"\"Async fixture for client.\"\"\"\n async with AsyncClient() as c:\n yield c\n```\n\n### Parametrized Tests with Rich Table Output\n\n```python\nimport pytest\nfrom rich.table import Table\n\n@pytest.mark.parametrize(\"input_val,expected\", [\n (\"test\", True),\n (\"\", False),\n (None, False),\n])\ndef test_validation(input_val, expected, console, request):\n \"\"\"Test validation with multiple inputs.\"\"\"\n # Optional: Display test matrix\n if request.config.getoption(\"--verbose\"):\n table = Table(title=\"Test Case\")\n table.add_column(\"Input\", style=\"cyan\")\n table.add_column(\"Expected\", style=\"green\")\n table.add_row(repr(input_val), str(expected))\n console.print(table)\n\n assert validate(input_val) == expected\n\n# Custom assertion with rich diff\ndef test_complex_data(console):\n \"\"\"Test with rich diff display.\"\"\"\n from rich.pretty import pretty_repr\n\n expected = {\"users\": [{\"id\": 1, \"name\": \"Alice\"}]}\n actual = {\"users\": [{\"id\": 1, \"name\": \"Bob\"}]}\n\n if expected != actual:\n console.print(\"[red]Assertion failed:[/red]\")\n console.print(f\"Expected:\\n{pretty_repr(expected)}\")\n console.print(f\"Actual:\\n{pretty_repr(actual)}\")\n\n assert expected == actual\n```\n\n### Test Fixtures\n\n```python\nimport pytest\nfrom rich.progress import Progress, SpinnerColumn, TextColumn\n\n@pytest.fixture\ndef test_data(console):\n \"\"\"Generate test data with progress display.\"\"\"\n data = []\n\n with Progress(\n SpinnerColumn(),\n TextColumn(\"[progress.description]{task.description}\"),\n console=console,\n transient=True,\n ) as progress:\n task = progress.add_task(\"Generating test data...\", total=None)\n\n # Simulate data generation\n for i in range(100):\n data.append({\"id\": i, \"value\": f\"test_{i}\"})\n\n progress.update(task, completed=100)\n\n return data\n```\n\n## Debugging\n\n**Documentation**: `~/dev/docs/llms/man/python/debugging.md`\n**Utilities**: `~/dev/docs/llms/man/python/debugging-setup.py`\n\n### Installation\n\n```bash\n# Core debugging stack (add to every project)\nuv add --dev debugpy snoop pdbp\n\n# Global environment (add to ~/.zshrc)\nexport PYTHONBREAKPOINT=pdbp.set_trace\n```\n\n### Tools Overview\n\n| Tool | Purpose | Usage |\n| ----------- | ----------------------- | ---------------------------- |\n| **debugpy** | DAP debugger (nvim-dap) | Remote/interactive debugging |\n| **snoop** | Function tracing | `@snoop` decorator, `pp()` |\n| **pdbp** | Enhanced pdb REPL | `breakpoint()` replacement |\n\n### snoop - Function Tracing\n\n```python\nimport snoop\n\n# Trace entire function execution\n@snoop\ndef process_data(items):\n result = []\n for item in items:\n result.append(item * 2)\n return result\n\n# Trace with depth (nested calls)\n@snoop(depth=2)\ndef outer():\n return inner()\n\n# Watch specific expressions\n@snoop(watch=(\"len(items)\", \"sum(items)\"))\ndef calculate(items):\n return sorted(items)\n```\n\n### pp() - Print Debugging\n\n```python\nfrom snoop import pp\n\n# Instead of print()\npp(config)\npp(locals())\n\n# Multiple values\npp(x, y, z)\n\n# Lazy evaluation for expensive operations\npp.deep(lambda: expensive_query())\n```\n\n### pdbp - Enhanced Breakpoints\n\n```python\n# Uses pdbp when PYTHONBREAKPOINT is set\nbreakpoint()\n\n# Or explicit\nimport pdbp\npdbp.set_trace()\n\n# Common commands in pdbp:\n# l - list source\n# n - next line\n# s - step into\n# c - continue\n# p x - print variable\n# pp x - pretty-print\n# w - where (stack trace)\n# u/d - up/down frame\n```\n\n### Remote Debugging\n\n```python\n# Server (in your application)\nimport debugpy\ndebugpy.listen((\"0.0.0.0\", 5678))\ndebugpy.wait_for_client() # Optional: block until attached\n\n# Client: Neovim <leader>dPa or:\n# python -m debugpy --connect localhost:5678 script.py\n```\n\n### Neovim DAP Keymaps\n\n| Key | Action |\n| ------------- | ---------------------------- |\n| `<leader>dPt` | Debug test method |\n| `<leader>dPc` | Debug test class |\n| `<leader>dPs` | Debug selection (visual) |\n| `<leader>dPf` | Debug current file |\n| `<leader>dPa` | Attach to remote (port 5678) |\n| `F5` | Continue |\n| `F10` | Step over |\n| `F11` | Step into |\n\n### conftest.py Integration\n\n```python\n# tests/conftest.py\nimport os\nimport pytest\n\nos.environ[\"PYTHONBREAKPOINT\"] = \"pdbp.set_trace\"\n\n@pytest.fixture\ndef debugger():\n \"\"\"Debug utilities for tests.\"\"\"\n import snoop\n from pdbp import set_trace\n return type(\"Debugger\", (), {\n \"trace\": set_trace,\n \"pp\": snoop.pp,\n })()\n\n# Usage: def test_foo(debugger): debugger.pp(data)\n```\n\n### Python 3.14+ Live Debugging\n\n```bash\n# Attach to running process (no prior setup needed)\npython -m pdb -p <PID>\n\n# Inspect async tasks\npython -m asyncio ps <PID>\npython -m asyncio pstree <PID>\n```\n\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n"}, {"type": "text", "text": "run a explore with glm 4.5 air", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]}], "max_tokens": 32000, "model": "claude-opus-4-5-20251101", "metadata": {"user_id": "user_f9ebe15d4cd7d09378a5ab831780076b231f5e5ca515a69fa1648af75dc7b2e1_account_371b20f1-89f1-417a-9940-bcfc8aaec416_session_3448c29b-8e3b-463f-8155-aa606e794dc7"}, "stream": true, "system": [{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.29.87a; cc_entrypoint=cli;"}, {"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}, {"type": "text", "text": "\nYou are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.\n\nIf the user asks for help or wants to give feedback inform them of the following:\n- /help: Get help with using Claude Code\n- To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues\n\n# Tone and style\n- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.\n- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.\n- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.\n- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.\n- Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like \"Let me read the file:\" followed by a read tool call should just be \"Let me read the file.\" with a period.\n\n# Professional objectivity\nPrioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as \"You're absolutely right\" or similar phrases.\n\n# No time estimates\nNever give time estimates or predictions for how long tasks will take, whether for your own work or for users planning their projects. Avoid phrases like \"this will take me a few minutes,\" \"should be done in about 5 minutes,\" \"this is a quick fix,\" \"this will take 2-3 weeks,\" or \"we can do this later.\" Focus on what needs to be done, not how long it might take. Break work into actionable steps and let users judge timing for themselves.\n\n# Asking questions as you work\n\nYou have access to the AskUserQuestion tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes.\n\nUsers may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including <user-prompt-submit-hook>, as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.\n\n# Doing tasks\nThe user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:\n- NEVER propose changes to code you haven't read. If a user asks about or wants you to modify a file, read it first. Understand existing code before suggesting modifications.\n- Use the AskUserQuestion tool to ask questions, clarify and gather information as needed.\n- Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. If you notice that you wrote insecure code, immediately fix it.\n- Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused.\n - Don't add features, refactor code, or make \"improvements\" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability. Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.\n - Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). Don't use feature flags or backwards-compatibility shims when you can just change the code.\n - Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task\u2014three similar lines of code is better than a premature abstraction.\n- Avoid backwards-compatibility hacks like renaming unused `_vars`, re-exporting types, adding `// removed` comments for removed code, etc. If something is unused, delete it completely.\n\n- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.\n- The conversation has unlimited context through automatic summarization.\n\n# Tool usage policy\n- When doing file search, prefer to use the Task tool in order to reduce context usage.\n- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.\n- /<skill-name> (e.g., /commit) is shorthand for users to invoke a user-invocable skill. When executed, the skill gets expanded to a full prompt. Use the Skill tool to execute them. IMPORTANT: Only use Skill for skills listed in its user-invocable skills section - do not guess or use built-in CLI commands.\n- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.\n- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.\n- If the user specifies that they want you to run tools \"in parallel\", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.\n- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.\n- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool with subagent_type=Explore instead of running search commands directly. \n<example>\nuser: Where are errors from the client handled?\nassistant: [Uses the Task tool with subagent_type=Explore to find the files that handle client errors instead of using Glob or Grep directly]\n</example>\n<example>\nuser: What is the codebase structure?\nassistant: [Uses the Task tool with subagent_type=Explore]\n</example>\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Code References\n\nWhen referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.\n\n<example>\nuser: Where are errors from the client handled?\nassistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.\n</example>\n\nHere is useful information about the environment you are running in:\n<env>\nWorking directory: /home/starbased/dev/projects/ccproxy\nIs directory a git repo: Yes\nAdditional working directories: /home/starbased/dev, /home/starbased/.config, /home/starbased/tmp, /home/starbased/Gaming/, /home/starbased/Pictures, /tmp, /mnt/store, /home/starbased/.ccproxy, /home/starbased/.local, /nix/store\nPlatform: linux\nOS Version: Linux 6.18.6-arch1-1\nToday's date: 2026-02-01\n</env>\nYou are powered by the model named Opus 4.5. The exact model ID is claude-opus-4-5-20251101.\n\nAssistant knowledge cutoff is May 2025.\n\n<claude_background_info>\nThe most recent frontier Claude model is Claude Opus 4.5 (model ID: 'claude-opus-4-5-20251101').\n</claude_background_info>\n\n# Scratchpad Directory\n\nIMPORTANT: Always use this scratchpad directory for temporary files instead of `/tmp` or other system temp directories:\n`/home/starbased/tmp/claude-1000/-home-starbased-dev-projects-ccproxy/3448c29b-8e3b-463f-8155-aa606e794dc7/scratchpad`\n\nUse this directory for ALL temporary file needs:\n- Storing intermediate results or data during multi-step tasks\n- Writing temporary scripts or configuration files\n- Saving outputs that don't belong in the user's project\n- Creating working files during analysis or processing\n- Any file that would otherwise go to `/tmp`\n\nOnly use `/tmp` if the user explicitly requests it.\n\nThe scratchpad directory is session-specific, isolated from the user's project, and can be used freely without permission prompts.\n\ngitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.\nCurrent branch: starbased/dev\n\nMain branch (you will usually use this for PRs): main\n\nStatus:\nM src/ccproxy/mitm/process.py\n M src/ccproxy/templates/config.yaml\n\nRecent commits:\n827ee56 feat(cli): enhance logs and status commands\nedf5c17 docs: rewrite README intro to focus on development platform\n2d7dbe8 feat(pipeline+db): add DAG-based request processing and database prompt querying\n0bb647e refactor(pipeline): introduce DAG-based request processing architecture\n90c1c0d feat(mitm+docs): add OAuth sentinel support and CLI import documentation"}], "thinking": {"budget_tokens": 31999, "type": "enabled"}, "tools": [{"name": "Task", "description": "Launch a new agent to handle complex, multi-step tasks autonomously. \n\nThe Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.\n\nAvailable agent types and the tools they have access to:\n- Bash: Command execution specialist for running bash commands. Use this for git operations, command execution, and other terminal tasks. (Tools: Bash)\n- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)\n- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)\n- Explore: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions. (Tools: All tools except Task, ExitPlanMode, Edit, Write, NotebookEdit)\n- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Task, ExitPlanMode, Edit, Write, NotebookEdit)\n- claude-code-guide: Use this agent when the user asks questions (\"Can Claude...\", \"Does Claude...\", \"How do I...\") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can resume using the \"resume\" parameter. (Tools: Glob, Grep, Read, WebFetch, WebSearch)\n- ctx-cloner: Clones full repositories and libraries to ctx/ directory for complete source documentation. Examples: - <example> Context: User wants complete source code for a library user: \"Clone the Astro docs repository\" assistant: \"I'll use the ctx-cloner agent to clone the complete Astro docs repository\" <commentary> Full repository cloning requires the ctx-cloner agent for proper ctx/ directory management </commentary> </example> - <example> Context: User needs multiple related repositories user: \"Get the Hyprland ecosystem repos\" assistant: \"I'll use the ctx-cloner agent to clone Hyprland core and related repositories\" <commentary> Multiple repository management is handled by ctx-cloner agent </commentary> </example> - <example> Context: User wants library documentation extracted user: \"Add the React API docs\" assistant: \"Since you want specific documentation pages, I'll extract those directly using GitHub MCP tools\" <commentary> Specific pages don't need full repo clone - NOT a ctx-cloner agent task </commentary> </example> (Tools: Bash, Read, Write, Edit, Glob, Grep, mcp__tools, mcp__zen__clink)\n- docstore: Documentation librarian - manages docstore lifecycle. Use proactively for documentation tasks. Examples - <example> Context: User needs project docs user: \"Set up docstore with plotille and drawille\" assistant: \"I'll use the docstore agent to configure ctx entries\" <commentary> Project-only repos via ctx </commentary> </example> - <example> Context: User wants global store content user: \"I need the sounddevice docs\" assistant: \"I'll use docstore to add include patterns\" <commentary> Including from global store </commentary> </example> - <example> Context: User wants website docs user: \"Scrape the FastAPI docs\" assistant: \"I'll use docstore with Firecrawl to scrape to web/\" <commentary> Web scraping workflow </commentary> </example> (Tools: All tools)\n- gh-researcher: Performs deep research focused on finding, analyzing, querying, and evaluating GitHub repositories using the `gh` CLI exclusively (no GitHub MCP tools). Examples: - <example> Context: User needs to find CLI tools in a specific language user: \"Find the best Rust CLI tools on GitHub\" assistant: \"I'll use the gh-researcher agent to search for top Rust CLI tools\" <commentary> GitHub repository research task requiring gh CLI expertise </commentary> </example> - <example> Context: User wants to compare frameworks or libraries user: \"Compare Neovim LSP plugins\" assistant: \"I'll use the gh-researcher agent to analyze and compare Neovim LSP plugins\" <commentary> Comparative analysis of GitHub repositories </commentary> </example> - <example> Context: User needs to track project activity user: \"Is this repository still actively maintained?\" assistant: \"I'll use the gh-researcher agent to check recent commits, releases, and issues\" <commentary> Repository activity analysis </commentary> </example> (Tools: All tools)\n- git-miner: Mines Git repositories to extract comprehensive documentation and research insights. Use proactively for repository analysis. Examples: - <example> Context: User requests documentation for a library user: \"Get documentation for the tyro Python library\" assistant: \"I'll use the git-miner agent to clone and analyze the tyro repository\" <commentary> Repository research requires comprehensive analysis </commentary> </example> - <example> Context: User has a specific question about a repository user: \"How does Hyprland handle window animations?\" assistant: \"I'll delegate to git-miner to analyze Hyprland's animation system\" <commentary> Targeted repository analysis for specific technical questions </commentary> </example> - <example> Context: User wants to understand a package's architecture user: \"Research the architecture of the Ruff Python linter\" assistant: \"I'll use git-miner to deep dive into Ruff's codebase structure\" <commentary> Architectural analysis requires comprehensive repository mining </commentary> </example> (Tools: All tools)\n- jina-haiku: Use this when the user needs to search for, extract, or analyze information from websites or web pages. Examples: - <example> Context: User needs to extract content from multiple URLs in parallel user: \"Extract the main content from these 15 documentation pages: [list of URLs]\" assistant: \"I'll use the jina-haiku agent to extract content from all 15 pages in parallel - haiku's speed makes bulk extraction efficient.\" <commentary> Bulk parallel extraction where speed matters more than deep analysis </commentary> </example> - <example> Context: User wants to scrape images and metadata from multiple gallery pages user: \"Download all images from these 20 wallpaper gallery pages and extract their metadata\" assistant: \"I'm going to use the jina-haiku agent to process all 20 pages - haiku handles high-volume image extraction efficiently.\" <commentary> Large-scale image scraping with simple metadata extraction </commentary> </example> - <example> Context: User needs simple facts from many sources user: \"Get the current version numbers and release dates for these 25 Python packages from PyPI\" assistant: \"Let me use the jina-haiku agent to gather version info from all 25 package pages - perfect for simple fact extraction at scale.\" <commentary> High-volume simple data gathering, no complex analysis needed </commentary> </example> - <example> Context: User wants to monitor multiple news sites for keywords user: \"Check these 30 tech news sites for any mentions of 'Rust 2.0' or 'async improvements'\" assistant: \"I'll use the jina-haiku agent to scan all 30 sites quickly - haiku's speed is ideal for batch keyword monitoring.\" <commentary> Bulk search/monitoring across many sites where speed and cost efficiency matter </commentary> </example> - <example> Context: User needs to extract structured data from product listings user: \"Extract product names, prices, and availability from these 50 e-commerce product pages\" assistant: \"I'm going to use the jina-haiku agent for this bulk extraction - haiku efficiently handles simple structured data extraction.\" <commentary> Large batch of simple extractions, straightforward data without nuanced interpretation </commentary> </example> Do NOT use for information already in codebase or project files. (Tools: All tools)\n- jina: Use this when the user needs to search for, extract, or analyze information from websites or web pages. Examples: - <example> Context: User needs to research a new Python library user: \"Can you search for information about the FastAPI framework and its key features?\" assistant: \"I'll use the jina agent to find comprehensive information about FastAPI from web sources.\" <commentary> User needs current web information about a library </commentary> </example> - <example> Context: User wants content from a specific webpage user: \"Please extract the main content from https://docs.python.org/3/tutorial/introduction.html\" assistant: \"I'm going to use the jina agent to extract and analyze the content from that Python tutorial page.\" <commentary> Direct URL extraction needed </commentary> </example> - <example> Context: User needs current information about a topic user: \"What are the latest developments in Rust async runtime performance?\" assistant: \"Let me use the jina agent to find the most current information about Rust async runtime performance from web sources.\" <commentary> Current/live information not in codebase </commentary> </example> Do NOT use for information already in codebase or project files. (Tools: All tools)\n- manpage-agent: Build comprehensive man page entries from packages. Examples: - <example> Context: User wants to add package documentation user: \"Add ripgrep man page\" assistant: \"I'll use the manpage-agent to extract and save the ripgrep documentation\" <commentary> Single package documentation extraction - agent will search GitHub, extract man pages </commentary> </example> - <example> Context: User needs multiple package man pages user: \"Add man pages for: ripgrep, fd, bat, eza\" assistant: \"I'll use the manpage-agent to process these packages in parallel\" <commentary> Bulk operation - agent will parallelize extraction via clink for efficiency </commentary> </example> - <example> Context: User wants documentation from a project website user: \"Add all Hyprland documentation from wiki.hyprland.org\" assistant: \"I'll use the manpage-agent to crawl and extract the Hyprland docs\" <commentary> Website crawl operation - agent will use firecrawl to map/discover documentation, then extract in parallel </commentary> </example> (Tools: All tools)\n- nixconfig: Manages and queries the Nix configuration system including home-manager, system-manager, flake configuration, and module organization. Use this agent for ALL Nix-related queries and modifications. Examples: - <example> Context: User wants to add a new application user: \"Add rofi to the system\" assistant: \"I'll use the nixconfig agent to add rofi to the home-manager configuration\" <commentary> Nix package/application management is this agent's core responsibility </commentary> </example> - <example> Context: User needs GPU/system-level information user: \"What GPU driver configuration is currently active?\" assistant: \"I'll use the nixconfig agent to check the GPU driver setup in system-manager and gpu.nix\" <commentary> System-manager configuration queries require Nix expertise </commentary> </example> - <example> Context: User wants to modify desktop environment user: \"Update Hyprland monitor configuration\" assistant: \"I'll use the nixconfig agent to edit the Hyprland module and rebuild\" <commentary> Desktop configuration changes require understanding the module structure and rebuild process </commentary> </example> (Tools: All tools)\n- perplexity: Dedicated agent for Perplexity MCP operations: deep research, reasoning, and search. Use proactively for rigorous research, evidence-based decision making, claim verification, and comprehensive topic investigation. Examples: - <example> Context: User needs to verify a technical claim user: \"Is it true that Python 3.13 has significant performance improvements?\" assistant: \"I'll use the perplexity agent to verify this claim with rigorous evidence\" <commentary> Claim verification requires evidence gathering and fact checking - perfect for perplexity agent </commentary> </example> - <example> Context: User needs comprehensive research on a topic user: \"Research the best approaches for implementing real-time collaboration in web apps\" assistant: \"I'll use the perplexity agent to conduct deep research on real-time collaboration approaches\" <commentary> Comprehensive topic investigation requiring multiple sources and synthesis - ideal for deep_research tool </commentary> </example> - <example> Context: User needs to compare complex technologies user: \"Compare the trade-offs between PostgreSQL and MongoDB for my use case\" assistant: \"I'll use the perplexity agent to reason through the database comparison\" <commentary> Complex comparison requiring logical reasoning and multi-step analysis </commentary> </example> (Tools: All tools)\n- vgrep: Use this agent for semantic code search to find: - Where functionality is implemented (\"error handling logic\", \"authentication flow\") - Code patterns across the codebase (\"retry mechanisms\", \"cache invalidation\") - Conceptual queries that aren't exact string matches DO NOT use for: - Exact string/regex patterns \u2192 use Grep instead - Known filenames \u2192 use Glob instead - Small codebases (<50 files) \u2192 use Grep/Glob instead Examples: - <example> Context: User needs to find where functionality is implemented user: \"Find where we validate user input\" assistant: \"I'll use the vgrep agent to search for input validation patterns\" </example> - <example> Context: User wants exact string match user: \"Find files containing 'class UserModel'\" assistant: \"I'll use Grep directly for this exact string match\" <commentary> Exact string \u2192 Grep is faster and more precise </commentary> </example> (Tools: All tools)\n- python: Dedicated Python development agent with extended standards. Use proactively for Python file work. Examples: - <example> Context: User needs Python development work user: \"Create a CLI tool to process CSV files\" assistant: \"I'll delegate to the python agent to build this with proper standards\" <commentary> Python-specific task requiring standards adherence and potential library lookups </commentary> </example> - <example> Context: User wants to refactor Python code user: \"Refactor this function to use modern Python 3.12+ syntax\" assistant: \"I'll use the python agent to apply modern Python patterns\" <commentary> Python code modernization requires extended standards knowledge </commentary> </example> - <example> Context: User needs PyTorch implementation user: \"Build a training loop with mixed precision\" assistant: \"I'll delegate to python agent to implement PyTorch best practices\" <commentary> PyTorch patterns are in standards-python-extended.md </commentary> </example> (Tools: All tools)\n- gh-ask: GitHub ecosystem researcher using GraphQL for wide-range repository discovery and research. READ-ONLY agent for ecosystem-level queries. Examples: - <example> Context: User needs to find CLI tools user: \"Find the best Rust CLI tools on GitHub\" assistant: \"I'll use gh-ask to search for top Rust CLI tools\" <commentary> Wide repository search - gh-ask domain </commentary> </example> - <example> Context: User wants to research conventions user: \"What's the common project structure for Go modules?\" assistant: \"I'll use gh-ask to research Go project conventions across popular repos\" <commentary> Pattern research across many repos - gh-ask domain </commentary> </example> - <example> Context: User wants to compare similar projects user: \"Compare ActivityPub server implementations\" assistant: \"I'll use gh-ask to find and compare ActivityPub servers\" <commentary> Ecosystem comparison - gh-ask domain </commentary> </example> - <example> Context: User wants deep analysis of specific repo user: \"Analyze the architecture of facebook/react\" assistant: \"I'll use git-miner to deep-dive into React's architecture\" <commentary> Deep repo analysis - NOT gh-ask, use git-miner instead </commentary> </example> (Tools: All tools)\n- charm-dev: Expert Go engineer and TUI enthusiast specializing in building beautiful, functional, and performant terminal user interfaces using Bubble Tea by Charm and its associated libraries (Bubbles, Lip Gloss). Has deep knowledge of bubbletea architecture, component design patterns, and terminal styling. Leverages complete source code repositories and comprehensive documentation for charmbracelet libraries.\n\nExamples:\n- <example>\n Context: User needs to create a new TUI application\n user: \"Build a file browser TUI with vim keybindings\"\n assistant: \"I'll use the charm-dev agent to build a Bubble Tea application with file navigation and vim-style controls\"\n <commentary>\n This task requires deep knowledge of Bubble Tea architecture, component patterns, and keyboard handling\n </commentary>\n</example>\n- <example>\n Context: User needs to style an existing TUI\n user: \"Make this TUI look better with colors and borders\"\n assistant: \"I'll use charm-dev to apply Lip Gloss styling with adaptive colors and proper border layouts\"\n <commentary>\n Styling TUIs requires expertise in Lip Gloss API, color profiles, and layout utilities\n </commentary>\n</example>\n- <example>\n Context: User needs to add interactive components\n user: \"Add a text input form and table view to my app\"\n assistant: \"I'll use charm-dev to integrate Bubbles components (textinput, table) into your Bubble Tea model\"\n <commentary>\n Requires understanding of Bubble Tea component integration and the Bubbles library\n </commentary>\n</example>\n (Tools: All tools)\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the Glob tool instead, to find the match more quickly\n- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly\n- Other tasks that are not related to the agent descriptions above\n\n\nUsage notes:\n- Always include a short description (3-5 words) summarizing what the agent will do\n- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, the tool result will include an output_file path. To check on the agent's progress or retrieve its results, use the Read tool to read the output file, or use Bash with `tail` to see recent output. You can continue working while background agents run.\n- Agents can be resumed using the `resume` parameter by passing the agent ID from a previous invocation. When resumed, the agent continues with its full previous context preserved. When NOT resuming, each invocation starts fresh and you should provide a detailed task description with all necessary context.\n- When the agent is done, it will return a single message back to you along with its agent ID. You can use this ID to resume the agent later if needed for follow-up work.\n- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.\n- Agents with \"access to current context\" can see the full conversation history before the tool call. When using these agents, you can write concise prompts that reference earlier context (e.g., \"investigate the error discussed above\") instead of repeating information. The agent will receive all prior messages and understand the context.\n- The agent's outputs should generally be trusted\n- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent\n- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.\n- If the user specifies that they want you to run agents \"in parallel\", you MUST send a single message with multiple Task tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.\n\nExample usage:\n\n<example_agent_descriptions>\n\"test-runner\": use this agent after you are done writing code to run tests\n\"greeting-responder\": use this agent when to respond to user greetings with a friendly joke\n</example_agent_description>\n\n<example>\nuser: \"Please write a function that checks if a number is prime\"\nassistant: Sure let me write a function that checks if a number is prime\nassistant: First let me use the Write tool to write a function that checks if a number is prime\nassistant: I'm going to use the Write tool to write the following code:\n<code>\nfunction isPrime(n) {\n if (n <= 1) return false\n for (let i = 2; i * i <= n; i++) {\n if (n % i === 0) return false\n }\n return true\n}\n</code>\n<commentary>\nSince a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests\n</commentary>\nassistant: Now let me use the test-runner agent to run the tests\nassistant: Uses the Task tool to launch the test-runner agent\n</example>\n\n<example>\nuser: \"Hello\"\n<commentary>\nSince the user is greeting, use the greeting-responder agent to respond with a friendly joke\n</commentary>\nassistant: \"I'm going to use the Task tool to launch the greeting-responder agent\"\n</example>\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"description": {"description": "A short (3-5 word) description of the task", "type": "string"}, "prompt": {"description": "The task for the agent to perform", "type": "string"}, "subagent_type": {"description": "The type of specialized agent to use for this task", "type": "string"}, "model": {"description": "Optional model to use for this agent. If not specified, inherits from parent. Prefer haiku for quick, straightforward tasks to minimize cost and latency.", "type": "string", "enum": ["sonnet", "opus", "haiku"]}, "resume": {"description": "Optional agent ID to resume from. If provided, the agent will continue from the previous execution transcript.", "type": "string"}, "run_in_background": {"description": "Set to true to run this agent in the background. The tool result will include an output_file path - use Read tool or Bash tail to check on output.", "type": "boolean"}, "max_turns": {"description": "Maximum number of agentic turns (API round-trips) before stopping. Used internally for warmup.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991}}, "required": ["description", "prompt", "subagent_type"], "additionalProperties": false}}, {"name": "TaskOutput", "description": "- Retrieves output from a running or completed task (background shell, agent, or remote session)\n- Takes a task_id parameter identifying the task\n- Returns the task output along with status information\n- Use block=true (default) to wait for task completion\n- Use block=false for non-blocking check of current status\n- Task IDs can be found using the /tasks command\n- Works with all task types: background shells, async agents, and remote sessions", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"task_id": {"description": "The task ID to get output from", "type": "string"}, "block": {"description": "Whether to wait for completion", "default": true, "type": "boolean"}, "timeout": {"description": "Max wait time in ms", "default": 30000, "type": "number", "minimum": 0, "maximum": 600000}}, "required": ["task_id", "block", "timeout"], "additionalProperties": false}}, {"name": "Bash", "description": "Executes a given bash command with optional timeout. Working directory persists between commands; shell state (everything else) does not. The shell environment is initialized from the user's profile (bash or zsh).\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\n\nBefore executing the command, please follow these steps:\n\n1. Directory Verification:\n - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location\n - For example, before running \"mkdir foo/bar\", first use `ls foo` to check that \"foo\" exists and is the intended parent directory\n\n2. Command Execution:\n - Always quote file paths that contain spaces with double quotes (e.g., cd \"path with spaces/file.txt\")\n - Examples of proper quoting:\n - cd \"/Users/name/My Documents\" (correct)\n - cd /Users/name/My Documents (incorrect - will fail)\n - python \"/path/with spaces/script.py\" (correct)\n - python /path/with spaces/script.py (incorrect - will fail)\n - After ensuring proper quoting, execute the command.\n - Capture the output of the command.\n\nUsage notes:\n - The command argument is required.\n - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).\n - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does.\n - If the output exceeds 30000 characters, output will be truncated before being returned to you.\n \n - You can use the `run_in_background` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes. You do not need to use '&' at the end of the command when using this parameter.\n \n - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\n - File search: Use Glob (NOT find or ls)\n - Content search: Use Grep (NOT grep or rg)\n - Read files: Use Read (NOT cat/head/tail)\n - Edit files: Use Edit (NOT sed/awk)\n - Write files: Use Write (NOT echo >/cat <<EOF)\n - Communication: Output text directly (NOT echo/printf)\n - When issuing multiple commands:\n - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. For example, if you need to run \"git status\" and \"git diff\", send a single message with two Bash tool calls in parallel.\n - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.\n - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail\n - DO NOT use newlines to separate commands (newlines are ok in quoted strings)\n - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.\n <good-example>\n pytest /foo/bar/tests\n </good-example>\n <bad-example>\n cd /foo/bar && pytest tests\n </bad-example>\n\n# Committing changes with git\n\nOnly create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:\n\nGit Safety Protocol:\n- NEVER update the git config\n- NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions \n- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it\n- NEVER run force push to main/master, warn the user if they request it\n- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen \u2014 so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit\n- When staging files, prefer adding specific files by name rather than using \"git add -A\" or \"git add .\", which can accidentally include sensitive files (.env, credentials) or large binaries\n- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive\n\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:\n - Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.\n - Run a git diff command to see both staged and unstaged changes that will be committed.\n - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.\n2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:\n - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. \"add\" means a wholly new feature, \"update\" means an enhancement to an existing feature, \"fix\" means a bug fix, etc.).\n - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files\n - Draft a concise (1-2 sentences) commit message that focuses on the \"why\" rather than the \"what\"\n - Ensure it accurately reflects the changes and their purpose\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:\n - Add relevant untracked files to the staging area.\n - Create the commit with a message.\n - Run git status after the commit completes to verify success.\n Note: git status depends on the commit completing, so run it sequentially after the commit.\n4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit\n\nImportant notes:\n- NEVER run additional commands to read or explore code, besides git bash commands\n- NEVER use the TodoWrite or Task tools\n- DO NOT push to the remote repository unless the user explicitly asks you to do so\n- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.\n- IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase.\n- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit\n- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:\n<example>\ngit commit -m \"$(cat <<'EOF'\n Commit message here.\n EOF\n )\"\n</example>\n\n# Creating pull requests\nUse the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.\n\nIMPORTANT: When the user asks you to create a pull request, follow these steps carefully:\n\n1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:\n - Run a git status command to see all untracked files (never use -uall flag)\n - Run a git diff command to see both staged and unstaged changes that will be committed\n - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote\n - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)\n2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary:\n - Keep the PR title short (under 70 characters)\n - Use the description/body for details, not the title\n3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:\n - Create new branch if needed\n - Push to remote with -u flag if needed\n - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.\n<example>\ngh pr create --title \"the pr title\" --body \"$(cat <<'EOF'\n## Summary\n<1-3 bullet points>\n\n## Test plan\n[Bulleted markdown checklist of TODOs for testing the pull request...]\nEOF\n)\"\n</example>\n\nImportant:\n- DO NOT use the TodoWrite or Task tools\n- Return the PR URL when you're done, so the user can see it\n\n# Other common operations\n- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"command": {"description": "The command to execute", "type": "string"}, "timeout": {"description": "Optional timeout in milliseconds (max 600000)", "type": "number"}, "description": {"description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls \u2192 \"List files in current directory\"\n- git status \u2192 \"Show working tree status\"\n- npm install \u2192 \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; \u2192 \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main \u2192 \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' \u2192 \"Fetch JSON from URL and extract data array elements\"", "type": "string"}, "run_in_background": {"description": "Set to true to run this command in the background. Use TaskOutput to read the output later.", "type": "boolean"}, "dangerouslyDisableSandbox": {"description": "Set this to true to dangerously override sandbox mode and run commands without sandboxing.", "type": "boolean"}, "_simulatedSedEdit": {"description": "Internal: pre-computed sed edit result from preview", "type": "object", "properties": {"filePath": {"type": "string"}, "newContent": {"type": "string"}}, "required": ["filePath", "newContent"], "additionalProperties": false}}, "required": ["command"], "additionalProperties": false}}, {"name": "Glob", "description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"pattern": {"description": "The glob pattern to match files against", "type": "string"}, "path": {"description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided.", "type": "string"}}, "required": ["pattern"], "additionalProperties": false}}, {"name": "Grep", "description": "A powerful search tool built on ripgrep\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., \"log.*Error\", \"function\\s+\\w+\")\n - Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n - Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n - Use Task tool for open-ended searches requiring multiple rounds\n - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\\{\\}` to find `interface{}` in Go code)\n - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \\{[\\s\\S]*?field`, use `multiline: true`\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"pattern": {"description": "The regular expression pattern to search for in file contents", "type": "string"}, "path": {"description": "File or directory to search in (rg PATH). Defaults to current working directory.", "type": "string"}, "glob": {"description": "Glob pattern to filter files (e.g. \"*.js\", \"*.{ts,tsx}\") - maps to rg --glob", "type": "string"}, "output_mode": {"description": "Output mode: \"content\" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), \"files_with_matches\" shows file paths (supports head_limit), \"count\" shows match counts (supports head_limit). Defaults to \"files_with_matches\".", "type": "string", "enum": ["content", "files_with_matches", "count"]}, "-B": {"description": "Number of lines to show before each match (rg -B). Requires output_mode: \"content\", ignored otherwise.", "type": "number"}, "-A": {"description": "Number of lines to show after each match (rg -A). Requires output_mode: \"content\", ignored otherwise.", "type": "number"}, "-C": {"description": "Alias for context.", "type": "number"}, "context": {"description": "Number of lines to show before and after each match (rg -C). Requires output_mode: \"content\", ignored otherwise.", "type": "number"}, "-n": {"description": "Show line numbers in output (rg -n). Requires output_mode: \"content\", ignored otherwise. Defaults to true.", "type": "boolean"}, "-i": {"description": "Case insensitive search (rg -i)", "type": "boolean"}, "type": {"description": "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.", "type": "string"}, "head_limit": {"description": "Limit output to first N lines/entries, equivalent to \"| head -N\". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 0 (unlimited).", "type": "number"}, "offset": {"description": "Skip first N lines/entries before applying head_limit, equivalent to \"| tail -n +N | head -N\". Works across all output modes. Defaults to 0.", "type": "number"}, "multiline": {"description": "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.", "type": "boolean"}}, "required": ["pattern"], "additionalProperties": false}}, {"name": "ExitPlanMode", "description": "Use this tool when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval.\n\n## How This Tool Works\n- You should have already written your plan to the plan file specified in the plan mode system message\n- This tool does NOT take the plan content as a parameter - it will read the plan from the file you wrote\n- This tool simply signals that you're done planning and ready for the user to review and approve\n- The user will see the contents of your plan file when they review it\n\n## When to Use This Tool\nIMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool.\n\n## Before Using This Tool\nEnsure your plan is complete and unambiguous:\n- If you have unresolved questions about requirements or approach, use AskUserQuestion first (in earlier phases)\n- Once your plan is finalized, use THIS tool to request approval\n\n**Important:** Do NOT use AskUserQuestion to ask \"Is this plan okay?\" or \"Should I proceed?\" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan.\n\n## Examples\n\n1. Initial task: \"Search for and understand the implementation of vim mode in the codebase\" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.\n2. Initial task: \"Help me implement yank mode for vim\" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.\n3. Initial task: \"Add a new feature to handle user authentication\" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach.\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"allowedPrompts": {"description": "Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.", "type": "array", "items": {"type": "object", "properties": {"tool": {"description": "The tool this prompt applies to", "type": "string", "enum": ["Bash"]}, "prompt": {"description": "Semantic description of the action, e.g. \"run tests\", \"install dependencies\"", "type": "string"}}, "required": ["tool", "prompt"], "additionalProperties": false}}, "pushToRemote": {"description": "Whether to push the plan to a remote Claude.ai session", "type": "boolean"}, "remoteSessionId": {"description": "The remote session ID if pushed to remote", "type": "string"}, "remoteSessionUrl": {"description": "The remote session URL if pushed to remote", "type": "string"}, "remoteSessionTitle": {"description": "The remote session title if pushed to remote", "type": "string"}}, "additionalProperties": {}}}, {"name": "Read", "description": "Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Any lines longer than 2000 characters will be truncated\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.\n- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.\n- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"file_path": {"description": "The absolute path to the file to read", "type": "string"}, "offset": {"description": "The line number to start reading from. Only provide if the file is too large to read at once", "type": "number"}, "limit": {"description": "The number of lines to read. Only provide if the file is too large to read at once.", "type": "number"}}, "required": ["file_path"], "additionalProperties": false}}, {"name": "Edit", "description": "Performs exact string replacements in files.\n\nUsage:\n- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. \n- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"file_path": {"description": "The absolute path to the file to modify", "type": "string"}, "old_string": {"description": "The text to replace", "type": "string"}, "new_string": {"description": "The text to replace it with (must be different from old_string)", "type": "string"}, "replace_all": {"description": "Replace all occurences of old_string (default false)", "default": false, "type": "boolean"}}, "required": ["file_path", "old_string", "new_string"], "additionalProperties": false}}, {"name": "Write", "description": "Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"file_path": {"description": "The absolute path to the file to write (must be absolute, not relative)", "type": "string"}, "content": {"description": "The content to write to the file", "type": "string"}}, "required": ["file_path", "content"], "additionalProperties": false}}, {"name": "NotebookEdit", "description": "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"notebook_path": {"description": "The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)", "type": "string"}, "cell_id": {"description": "The ID of the cell to edit. When inserting a new cell, the new cell will be inserted after the cell with this ID, or at the beginning if not specified.", "type": "string"}, "new_source": {"description": "The new source for the cell", "type": "string"}, "cell_type": {"description": "The type of the cell (code or markdown). If not specified, it defaults to the current cell type. If using edit_mode=insert, this is required.", "type": "string", "enum": ["code", "markdown"]}, "edit_mode": {"description": "The type of edit to make (replace, insert, delete). Defaults to replace.", "type": "string", "enum": ["replace", "insert", "delete"]}}, "required": ["notebook_path", "new_source"], "additionalProperties": false}}, {"name": "WebFetch", "description": "IMPORTANT: WebFetch WILL FAIL for authenticated or private URLs. Before using this tool, check if the URL points to an authenticated service (e.g. Google Docs, Confluence, Jira, GitHub). If so, you MUST use ToolSearch first to find a specialized tool that provides authenticated access.\n\n- Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model's response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.\n - The URL must be a fully-formed valid URL\n - HTTP URLs will be automatically upgraded to HTTPS\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL\n - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.\n - For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"url": {"description": "The URL to fetch content from", "type": "string", "format": "uri"}, "prompt": {"description": "The prompt to run on the fetched content", "type": "string"}}, "required": ["url", "prompt"], "additionalProperties": false}}, {"name": "WebSearch", "description": "\n- Allows Claude to search the web and use the results to inform responses\n- Provides up-to-date information for current events and recent data\n- Returns search result information formatted as search result blocks, including links as markdown hyperlinks\n- Use this tool for accessing information beyond Claude's knowledge cutoff\n- Searches are performed automatically within a single API call\n\nCRITICAL REQUIREMENT - You MUST follow this:\n - After answering the user's question, you MUST include a \"Sources:\" section at the end of your response\n - In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)\n - This is MANDATORY - never skip including sources in your response\n - Example format:\n\n [Your answer here]\n\n Sources:\n - [Source Title 1](https://example.com/1)\n - [Source Title 2](https://example.com/2)\n\nUsage notes:\n - Domain filtering is supported to include or block specific websites\n - Web search is only available in the US\n\nIMPORTANT - Use the correct year in search queries:\n - Today's date is 2026-02-01. You MUST use this year when searching for recent information, documentation, or current events.\n - Example: If the user asks for \"latest React docs\", search for \"React documentation 2026\", NOT \"React documentation 2025\"\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"query": {"description": "The search query to use", "type": "string", "minLength": 2}, "allowed_domains": {"description": "Only include search results from these domains", "type": "array", "items": {"type": "string"}}, "blocked_domains": {"description": "Never include search results from these domains", "type": "array", "items": {"type": "string"}}}, "required": ["query"], "additionalProperties": false}}, {"name": "TaskStop", "description": "\n- Stops a running background task by its ID\n- Takes a task_id parameter identifying the task to stop\n- Returns a success or failure status\n- Use this tool when you need to terminate a long-running task\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"task_id": {"description": "The ID of the background task to stop", "type": "string"}, "shell_id": {"description": "Deprecated: use task_id instead", "type": "string"}}, "additionalProperties": false}}, {"name": "AskUserQuestion", "description": "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Users will always be able to select \"Other\" to provide custom text input\n- Use multiSelect: true to allow multiple answers to be selected for a question\n- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label\n\nPlan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask \"Is my plan ready?\" or \"Should I proceed?\" - use ExitPlanMode for plan approval.\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"questions": {"description": "Questions to ask the user (1-4 questions)", "minItems": 1, "maxItems": 4, "type": "array", "items": {"type": "object", "properties": {"question": {"description": "The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: \"Which library should we use for date formatting?\" If multiSelect is true, phrase it accordingly, e.g. \"Which features do you want to enable?\"", "type": "string"}, "header": {"description": "Very short label displayed as a chip/tag (max 12 chars). Examples: \"Auth method\", \"Library\", \"Approach\".", "type": "string"}, "options": {"description": "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.", "minItems": 2, "maxItems": 4, "type": "array", "items": {"type": "object", "properties": {"label": {"description": "The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.", "type": "string"}, "description": {"description": "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.", "type": "string"}}, "required": ["label", "description"], "additionalProperties": false}}, "multiSelect": {"description": "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.", "default": false, "type": "boolean"}}, "required": ["question", "header", "options", "multiSelect"], "additionalProperties": false}}, "answers": {"description": "User answers collected by the permission component", "type": "object", "propertyNames": {"type": "string"}, "additionalProperties": {"type": "string"}}, "metadata": {"description": "Optional metadata for tracking and analytics purposes. Not displayed to user.", "type": "object", "properties": {"source": {"description": "Optional identifier for the source of this question (e.g., \"remember\" for /remember command). Used for analytics tracking.", "type": "string"}}, "additionalProperties": false}}, "required": ["questions"], "additionalProperties": false}}, {"name": "Skill", "description": "Execute a skill within the main conversation\n\nWhen users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.\n\nWhen users reference a \"slash command\" or \"/<something>\" (e.g., \"/commit\", \"/review-pr\"), they are referring to a skill. Use this tool to invoke it.\n\nHow to invoke:\n- Use this tool with the skill name and optional arguments\n- Examples:\n - `skill: \"pdf\"` - invoke the pdf skill\n - `skill: \"commit\", args: \"-m 'Fix bug'\"` - invoke with arguments\n - `skill: \"review-pr\", args: \"123\"` - invoke with arguments\n - `skill: \"ms-office-suite:pdf\"` - invoke using fully qualified name\n\nImportant:\n- Available skills are listed in system-reminder messages in the conversation\n- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task\n- NEVER mention a skill without actually calling this tool\n- Do not invoke a skill that is already running\n- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)\n- If you see a <command-name> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"skill": {"description": "The skill name. E.g., \"commit\", \"review-pr\", or \"pdf\"", "type": "string"}, "args": {"description": "Optional arguments for the skill", "type": "string"}}, "required": ["skill"], "additionalProperties": false}}, {"name": "EnterPlanMode", "description": "Use this tool proactively when you're about to start a non-trivial implementation task. Getting user sign-off on your approach before writing code prevents wasted effort and ensures alignment. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval.\n\n## When to Use This Tool\n\n**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:\n\n1. **New Feature Implementation**: Adding meaningful new functionality\n - Example: \"Add a logout button\" - where should it go? What should happen on click?\n - Example: \"Add form validation\" - what rules? What error messages?\n\n2. **Multiple Valid Approaches**: The task can be solved in several different ways\n - Example: \"Add caching to the API\" - could use Redis, in-memory, file-based, etc.\n - Example: \"Improve performance\" - many optimization strategies possible\n\n3. **Code Modifications**: Changes that affect existing behavior or structure\n - Example: \"Update the login flow\" - what exactly should change?\n - Example: \"Refactor this component\" - what's the target architecture?\n\n4. **Architectural Decisions**: The task requires choosing between patterns or technologies\n - Example: \"Add real-time updates\" - WebSockets vs SSE vs polling\n - Example: \"Implement state management\" - Redux vs Context vs custom solution\n\n5. **Multi-File Changes**: The task will likely touch more than 2-3 files\n - Example: \"Refactor the authentication system\"\n - Example: \"Add a new API endpoint with tests\"\n\n6. **Unclear Requirements**: You need to explore before understanding the full scope\n - Example: \"Make the app faster\" - need to profile and identify bottlenecks\n - Example: \"Fix the bug in checkout\" - need to investigate root cause\n\n7. **User Preferences Matter**: The implementation could reasonably go multiple ways\n - If you would use AskUserQuestion to clarify the approach, use EnterPlanMode instead\n - Plan mode lets you explore first, then present options with context\n\n## When NOT to Use This Tool\n\nOnly skip EnterPlanMode for simple tasks:\n- Single-line or few-line fixes (typos, obvious bugs, small tweaks)\n- Adding a single function with clear requirements\n- Tasks where the user has given very specific, detailed instructions\n- Pure research/exploration tasks (use the Task tool with explore agent instead)\n\n## What Happens in Plan Mode\n\nIn plan mode, you'll:\n1. Thoroughly explore the codebase using Glob, Grep, and Read tools\n2. Understand existing patterns and architecture\n3. Design an implementation approach\n4. Present your plan to the user for approval\n5. Use AskUserQuestion if you need to clarify approaches\n6. Exit plan mode with ExitPlanMode when ready to implement\n\n## Examples\n\n### GOOD - Use EnterPlanMode:\nUser: \"Add user authentication to the app\"\n- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)\n\nUser: \"Optimize the database queries\"\n- Multiple approaches possible, need to profile first, significant impact\n\nUser: \"Implement dark mode\"\n- Architectural decision on theme system, affects many components\n\nUser: \"Add a delete button to the user profile\"\n- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates\n\nUser: \"Update the error handling in the API\"\n- Affects multiple files, user should approve the approach\n\n### BAD - Don't use EnterPlanMode:\nUser: \"Fix the typo in the README\"\n- Straightforward, no planning needed\n\nUser: \"Add a console.log to debug this function\"\n- Simple, obvious implementation\n\nUser: \"What files handle routing?\"\n- Research task, not implementation planning\n\n## Important Notes\n\n- This tool REQUIRES user approval - they must consent to entering plan mode\n- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work\n- Users appreciate being consulted before significant changes are made to their codebase\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "additionalProperties": false}}, {"name": "TaskCreate", "description": "Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\nIt also helps the user understand the progress of the task and overall progress of their requests.\n\n## When to Use This Tool\n\nUse this tool proactively in these scenarios:\n\n- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions\n- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations\n- Plan mode - When using plan mode, create a task list to track the work\n- User explicitly requests todo list - When the user directly asks you to use the todo list\n- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)\n- After receiving new instructions - Immediately capture user requirements as tasks\n- When you start working on a task - Mark it as in_progress BEFORE beginning work\n- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation\n\n## When NOT to Use This Tool\n\nSkip using this tool when:\n- There is only a single, straightforward task\n- The task is trivial and tracking it provides no organizational benefit\n- The task can be completed in less than 3 trivial steps\n- The task is purely conversational or informational\n\nNOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.\n\n## Task Fields\n\n- **subject**: A brief, actionable title in imperative form (e.g., \"Fix authentication bug in login flow\")\n- **description**: Detailed description of what needs to be done, including context and acceptance criteria\n- **activeForm**: Present continuous form shown in spinner when task is in_progress (e.g., \"Fixing authentication bug\"). This is displayed to the user while you work on the task.\n\n**IMPORTANT**: Always provide activeForm when creating tasks. The subject should be imperative (\"Run tests\") while activeForm should be present continuous (\"Running tests\"). All tasks are created with status `pending`.\n\n## Tips\n\n- Create tasks with clear, specific subjects that describe the outcome\n- Include enough detail in the description for another agent to understand and complete the task\n- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed\n- Check TaskList first to avoid creating duplicate tasks\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"subject": {"description": "A brief title for the task", "type": "string"}, "description": {"description": "A detailed description of what needs to be done", "type": "string"}, "activeForm": {"description": "Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")", "type": "string"}, "metadata": {"description": "Arbitrary metadata to attach to the task", "type": "object", "propertyNames": {"type": "string"}, "additionalProperties": {}}}, "required": ["subject", "description"], "additionalProperties": false}}, {"name": "TaskGet", "description": "Use this tool to retrieve a task by its ID from the task list.\n\n## When to Use This Tool\n\n- When you need the full description and context before starting work on a task\n- To understand task dependencies (what it blocks, what blocks it)\n- After being assigned a task, to get complete requirements\n\n## Output\n\nReturns full task details:\n- **subject**: Task title\n- **description**: Detailed requirements and context\n- **status**: 'pending', 'in_progress', or 'completed'\n- **blocks**: Tasks waiting on this one to complete\n- **blockedBy**: Tasks that must complete before this one can start\n\n## Tips\n\n- After fetching a task, verify its blockedBy list is empty before beginning work.\n- Use TaskList to see all tasks in summary form.\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"taskId": {"description": "The ID of the task to retrieve", "type": "string"}}, "required": ["taskId"], "additionalProperties": false}}, {"name": "TaskUpdate", "description": "Use this tool to update a task in the task list.\n\n## When to Use This Tool\n\n**Mark tasks as resolved:**\n- When you have completed the work described in a task\n- When a task is no longer needed or has been superseded\n- IMPORTANT: Always mark your assigned tasks as resolved when you finish them\n- After resolving, call TaskList to find your next task\n\n- ONLY mark a task as completed when you have FULLY accomplished it\n- If you encounter errors, blockers, or cannot finish, keep the task as in_progress\n- When blocked, create a new task describing what needs to be resolved\n- Never mark a task as completed if:\n - Tests are failing\n - Implementation is partial\n - You encountered unresolved errors\n - You couldn't find necessary files or dependencies\n\n**Delete tasks:**\n- When a task is no longer relevant or was created in error\n- Setting status to `deleted` permanently removes the task\n\n**Update task details:**\n- When requirements change or become clearer\n- When establishing dependencies between tasks\n\n## Fields You Can Update\n\n- **status**: The task status (see Status Workflow below)\n- **subject**: Change the task title (imperative form, e.g., \"Run tests\")\n- **description**: Change the task description\n- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")\n- **owner**: Change the task owner (agent name)\n- **metadata**: Merge metadata keys into the task (set a key to null to delete it)\n- **addBlocks**: Mark tasks that cannot start until this one completes\n- **addBlockedBy**: Mark tasks that must complete before this one can start\n\n## Status Workflow\n\nStatus progresses: `pending` \u2192 `in_progress` \u2192 `completed`\n\nUse `deleted` to permanently remove a task.\n\n## Staleness\n\nMake sure to read a task's latest state using `TaskGet` before updating it.\n\n## Examples\n\nMark task as in progress when starting work:\n```json\n{\"taskId\": \"1\", \"status\": \"in_progress\"}\n```\n\nMark task as completed after finishing work:\n```json\n{\"taskId\": \"1\", \"status\": \"completed\"}\n```\n\nDelete a task:\n```json\n{\"taskId\": \"1\", \"status\": \"deleted\"}\n```\n\nClaim a task by setting owner:\n```json\n{\"taskId\": \"1\", \"owner\": \"my-name\"}\n```\n\nSet up task dependencies:\n```json\n{\"taskId\": \"2\", \"addBlockedBy\": [\"1\"]}\n```\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"taskId": {"description": "The ID of the task to update", "type": "string"}, "subject": {"description": "New subject for the task", "type": "string"}, "description": {"description": "New description for the task", "type": "string"}, "activeForm": {"description": "Present continuous form shown in spinner when in_progress (e.g., \"Running tests\")", "type": "string"}, "status": {"description": "New status for the task", "anyOf": [{"type": "string", "enum": ["pending", "in_progress", "completed"]}, {"type": "string", "const": "deleted"}]}, "addBlocks": {"description": "Task IDs that this task blocks", "type": "array", "items": {"type": "string"}}, "addBlockedBy": {"description": "Task IDs that block this task", "type": "array", "items": {"type": "string"}}, "owner": {"description": "New owner for the task", "type": "string"}, "metadata": {"description": "Metadata keys to merge into the task. Set a key to null to delete it.", "type": "object", "propertyNames": {"type": "string"}, "additionalProperties": {}}}, "required": ["taskId"], "additionalProperties": false}}, {"name": "TaskList", "description": "Use this tool to list all tasks in the task list.\n\n## When to Use This Tool\n\n- To see what tasks are available to work on (status: 'pending', no owner, not blocked)\n- To check overall progress on the project\n- To find tasks that are blocked and need dependencies resolved\n- After completing a task, to check for newly unblocked work or claim the next available task\n- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones\n\n## Output\n\nReturns a summary of each task:\n- **id**: Task identifier (use with TaskGet, TaskUpdate)\n- **subject**: Brief description of the task\n- **status**: 'pending', 'in_progress', or 'completed'\n- **owner**: Agent ID if assigned, empty if available\n- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve)\n\nUse TaskGet with a specific task ID to view full details including description and comments.\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "additionalProperties": false}}, {"name": "ToolSearch", "description": "Search for or select deferred tools to make them available for use.\n\n**MANDATORY PREREQUISITE - THIS IS A HARD REQUIREMENT**\n\nYou MUST use this tool to load deferred tools BEFORE calling them directly.\n\nThis is a BLOCKING REQUIREMENT - deferred tools listed below are NOT available until you load them using this tool. Both query modes (keyword search and direct selection) load the returned tools \u2014 once a tool appears in the results, it is immediately available to call.\n\n**Why this is non-negotiable:**\n- Deferred tools are not loaded until discovered via this tool\n- Calling a deferred tool without first loading it will fail\n\n**Query modes:**\n\n1. **Keyword search** - Use keywords when you're unsure which tool to use or need to discover multiple tools at once:\n - \"list directory\" - find tools for listing directories\n - \"notebook jupyter\" - find notebook editing tools\n - \"slack message\" - find slack messaging tools\n - Returns up to 5 matching tools ranked by relevance\n - All returned tools are immediately available to call \u2014 no further selection step needed\n\n2. **Direct selection** - Use `select:<tool_name>` when you know the exact tool name and only need that one tool:\n - \"select:mcp__slack__read_channel\"\n - \"select:NotebookEdit\"\n - Returns just that tool if it exists\n\n**IMPORTANT:** Both modes load tools equally. Do NOT follow up a keyword search with `select:` calls for tools already returned \u2014 they are already loaded.\n\n3. **Required keyword** - Prefix with `+` to require a match:\n - \"+linear create issue\" - only tools from \"linear\", ranked by \"create\"/\"issue\"\n - \"+slack send\" - only \"slack\" tools, ranked by \"send\"\n - Useful when you know the service name but not the exact tool\n\n**CORRECT Usage Patterns:**\n\n<example>\nUser: I need to work with slack somehow\nAssistant: Let me search for slack tools.\n[Calls ToolSearch with query: \"slack\"]\nAssistant: Found several options including mcp__slack__read_channel.\n[Calls mcp__slack__read_channel directly \u2014 it was loaded by the keyword search]\n</example>\n\n<example>\nUser: Edit the Jupyter notebook\nAssistant: Let me load the notebook editing tool.\n[Calls ToolSearch with query: \"select:NotebookEdit\"]\n[Calls NotebookEdit]\n</example>\n\n<example>\nUser: List files in the src directory\nAssistant: I can see mcp__filesystem__list_directory in the available tools. Let me select it.\n[Calls ToolSearch with query: \"select:mcp__filesystem__list_directory\"]\n[Calls the tool]\n</example>\n\n**INCORRECT Usage Patterns - NEVER DO THESE:**\n\n<bad-example>\nUser: Read my slack messages\nAssistant: [Directly calls mcp__slack__read_channel without loading it first]\nWRONG - You must load the tool FIRST using this tool\n</bad-example>\n\n<bad-example>\nAssistant: [Calls ToolSearch with query: \"slack\", gets back mcp__slack__read_channel]\nAssistant: [Calls ToolSearch with query: \"select:mcp__slack__read_channel\"]\nWRONG - The keyword search already loaded the tool. The select call is redundant.\n</bad-example>\n\nAvailable deferred tools (must be loaded before use):\nmcp__tools__firecrawl__firecrawl_agent\nmcp__tools__firecrawl__firecrawl_agent_status\nmcp__tools__firecrawl__firecrawl_check_crawl_status\nmcp__tools__firecrawl__firecrawl_crawl\nmcp__tools__firecrawl__firecrawl_extract\nmcp__tools__firecrawl__firecrawl_map\nmcp__tools__firecrawl__firecrawl_scrape\nmcp__tools__firecrawl__firecrawl_search\nmcp__tools__github__get_commit\nmcp__tools__github__get_file_contents\nmcp__tools__github__get_issue\nmcp__tools__github__get_issue_comments\nmcp__tools__github__list_commits\nmcp__tools__github__list_issues\nmcp__tools__github__list_pull_requests\nmcp__tools__github__search_code\nmcp__tools__github__search_issues\nmcp__tools__github__search_repositories\nmcp__tools__jina__capture_screenshot_url\nmcp__tools__jina__expand_query\nmcp__tools__jina__parallel_read_url\nmcp__tools__jina__parallel_search_arxiv\nmcp__tools__jina__parallel_search_web\nmcp__tools__jina__read_url\nmcp__tools__jina__search_arxiv\nmcp__tools__jina__search_bibtex\nmcp__tools__jina__search_images\nmcp__tools__jina__search_web\nmcp__tools__perplexity__deep_research\nmcp__tools__perplexity__reason\nmcp__tools__perplexity__search", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"query": {"description": "Query to find deferred tools. Use \"select:<tool_name>\" for direct selection, or keywords to search.", "type": "string"}, "max_results": {"description": "Maximum number of results to return (default: 5)", "default": 5, "type": "number"}}, "required": ["query", "max_results"], "additionalProperties": false}}, {"name": "ListMcpResourcesTool", "description": "\nList available resources from configured MCP servers.\nEach returned resource will include all standard MCP resource fields plus a 'server' field \nindicating which server the resource belongs to.\n\nParameters:\n- server (optional): The name of a specific MCP server to get resources from. If not provided,\n resources from all servers will be returned.\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"server": {"description": "Optional server name to filter resources by", "type": "string"}}, "additionalProperties": false}}, {"name": "ReadMcpResourceTool", "description": "\nReads a specific resource from an MCP server, identified by server name and resource URI.\n\nParameters:\n- server (required): The name of the MCP server from which to read the resource\n- uri (required): The URI of the resource to read\n", "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {"server": {"description": "The MCP server name", "type": "string"}, "uri": {"description": "The resource URI to read", "type": "string"}}, "required": ["server", "uri"], "additionalProperties": false}, "cache_control": {"type": "ephemeral", "ttl": "1h", "scope": "global"}}], "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}} diff --git a/.claude/output/pgdump-fix-summary.md b/.claude/output/pgdump-fix-summary.md new file mode 100644 index 00000000..34e15c5f --- /dev/null +++ b/.claude/output/pgdump-fix-summary.md @@ -0,0 +1,159 @@ +# pgdump Script Fix Summary + +## Problem + +The original `pgdump` script used `pgclimb` for PostgreSQL JSON export, which failed with authentication error: + +``` +pq: unknown authentication response: 10 +``` + +This error occurs because pgclimb doesn't support SCRAM-SHA-256 authentication used by modern PostgreSQL installations. + +## Solution + +Replaced `pgclimb` with native `psql` JSON export: + +1. **Removed pgclimb dependency** - No longer requires external tool +2. **Docker support** - Automatically detects and uses `docker exec` if PostgreSQL client not installed locally +3. **Quoted table names** - Properly handles mixed-case table names (e.g., `CCProxy_HttpTraces`) +4. **JSON array to JSONL** - Uses `psql` with `json_agg(row_to_json(t))` piped to `jq -c '.[]'` + +## Key Changes + +### Authentication Fix + +```bash +# Before (pgclimb with unsupported auth) +pgclimb --host localhost --port 5432 --dbname ccproxy_mitm ... + +# After (psql with standard auth or docker exec) +psql -h localhost -p 5432 -d ccproxy_mitm ... +# OR +docker exec -i litellm-db psql -h localhost -p 5432 -d ccproxy_mitm ... +``` + +### Table Name Handling + +```sql +-- Before (fails with mixed case) +SELECT * FROM CCProxy_HttpTraces WHERE created_at > '2026-01-18T01:15:00Z' + +-- After (properly quoted) +SELECT * FROM "CCProxy_HttpTraces" WHERE created_at > '2026-01-18T01:15:00Z' +``` + +### JSON Export + +```bash +# Query produces JSON array, jq converts to JSONL +psql -t -A -c "SELECT json_agg(row_to_json(t)) FROM (SELECT * FROM \"table\") t" \ + | jq -c '.[]' > output.jsonl +``` + +## Usage + +### Basic Export + +```bash +./scripts/pgdump \ + -d ccproxy_mitm \ + -U ccproxy \ + -h localhost \ + -p 5432 \ + -O /tmp/mitm_dump \ + --column created_at \ + "CCProxy_HttpTraces" +``` + +### Incremental Export (since timestamp) + +```bash +./scripts/pgdump \ + -d ccproxy_mitm \ + -U ccproxy \ + -h localhost \ + -p 5432 \ + -O /tmp/mitm_dump \ + --since '2026-01-18T01:15:00Z' \ + --column created_at \ + -v \ + "CCProxy_HttpTraces" +``` + +### Incremental Export (using state file) + +After first export, state is tracked in `$OUTPUT_DIR/.pgdump/last_export.tsv`: + +```bash +# First export +./scripts/pgdump -d ccproxy_mitm -U ccproxy -O /tmp/mitm_dump --column created_at "CCProxy_HttpTraces" + +# Subsequent exports only fetch new rows +./scripts/pgdump -d ccproxy_mitm -U ccproxy -O /tmp/mitm_dump --column created_at "CCProxy_HttpTraces" +``` + +### Full Export (ignore state) + +```bash +./scripts/pgdump \ + -d ccproxy_mitm \ + -U ccproxy \ + -O /tmp/mitm_dump \ + --full \ + --column created_at \ + "CCProxy_HttpTraces" +``` + +## Output Format + +**JSONL** - One JSON object per line: + +```json +{"trace_id":"f94abaf3-ffd3-493b-bf65-bb7bcd70855d","method":"POST","url":"https://api.z.ai/...","status_code":200,...} +{"trace_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","method":"GET","url":"https://api.z.ai/...","status_code":200,...} +``` + +## Dependencies + +- **psql** - PostgreSQL client (or docker with litellm-db container) +- **jq** - JSON processor for array to JSONL conversion + +## Docker Support + +Script automatically detects and uses docker if: + +1. `psql` not found in PATH +2. Docker is available +3. Container `litellm-db` is running + +Can override container name with environment variable: + +```bash +DOCKER_CONTAINER=my-postgres-container ./scripts/pgdump ... +``` + +## Environment Variables + +```bash +# Connection +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=ccproxy_mitm +DB_USER=ccproxy +DB_PASS=secret + +# Incremental column +INC_COLUMN=created_at + +# Docker container +DOCKER_CONTAINER=litellm-db +``` + +## Files Modified + +- `/home/starbased/dev/projects/ccproxy/scripts/pgdump` + - Removed pgclimb dependency + - Added docker exec support + - Fixed table name quoting + - Changed from pgclimb to psql + jq JSON export diff --git a/.claude/output/postgresql-cli-tools-research.md b/.claude/output/postgresql-cli-tools-research.md new file mode 100644 index 00000000..639ed61a --- /dev/null +++ b/.claude/output/postgresql-cli-tools-research.md @@ -0,0 +1,375 @@ +--- +agent: perplexity +source: perplexity-research +date: 2026-01-17 +topic: PostgreSQL CLI and Non-Interactive Database Access Tools +query: Research CLI and non-interactive tooling for programmatic PostgreSQL access without raw SQL +tools_used: [search] +--- + +# PostgreSQL CLI Tools for Non-Interactive Database Access + +Research on CLI tools and non-interactive approaches for accessing PostgreSQL databases programmatically, avoiding raw SQL queries where possible. + +## Context + +- PostgreSQL database with HTTP trace data (table: `CCProxy_HttpTraces`) +- Using Prisma ORM with existing schema +- Need command-line / scriptable / automation-friendly tools +- Want to avoid writing raw SQL where possible + +## Key Findings + +### 1. Prisma Client - Native ORM Approach + +**Recommendation**: ⭐ **BEST FOR YOUR USE CASE** - Already using Prisma + +**Description**: Prisma Client is a type-safe query builder generated from your schema that enables programmatic database queries in JavaScript/TypeScript without raw SQL. + +**Pros**: +- ✅ Already integrated into your project +- ✅ Type-safe queries (zero-SQL for basic CRUD) +- ✅ Excellent for scripting and automation +- ✅ Full programmatic API +- ✅ Handles migrations via `prisma migrate` + +**Cons**: +- ❌ Requires Node.js/TypeScript runtime +- ❌ Complex aggregations may still need raw SQL +- ❌ Not a standalone CLI tool + +**Usage Example**: +```javascript +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + // Query CCProxy_HttpTraces without SQL + const traces = await prisma.cCProxy_HttpTraces.findMany({ + where: { + proxy_direction: 1, + session_id: { not: null } + }, + orderBy: { created_at: 'desc' }, + take: 100 + }); + + console.log(JSON.stringify(traces, null, 2)); +} + +main(); +``` + +**Installation**: Already available +**Docs**: https://www.prisma.io/docs/orm/reference/prisma-client-reference + +--- + +### 2. Harlequin - Terminal SQL IDE + +**Recommendation**: ⭐⭐⭐ **BEST TUI EXPERIENCE** + +**Description**: Terminal-based SQL IDE written in Python with PostgreSQL adapter, VS Code-inspired keybindings, and rich data exploration features. + +**Pros**: +- ✅ Beautiful TUI with syntax highlighting and autocomplete +- ✅ PostgreSQL adapter available +- ✅ Export results to CSV/JSON +- ✅ Query history and tabs +- ✅ Scriptable via Python +- ✅ Mouse + keyboard navigation +- ✅ Data catalog for schema exploration + +**Cons**: +- ❌ Still requires writing SQL queries +- ❌ Python dependency (but uses `pip install`) +- ❌ Interactive-first (though scriptable) + +**Installation**: +```bash +pip install harlequin harlequin-postgres +# or +uv tool install harlequin --with harlequin-postgres +``` + +**Usage**: +```bash +# Interactive +harlequin postgres://user:pass@localhost:5432/ccproxy_db + +# Export query result +harlequin -e "SELECT * FROM CCProxy_HttpTraces LIMIT 100" --format json > traces.json +``` + +**Docs**: https://github.com/tconbeer/harlequin + +--- + +### 3. rainfrog - Vim-like PostgreSQL TUI + +**Recommendation**: ⭐⭐ **BEST FOR VIM USERS** + +**Description**: Rust-based TUI for PostgreSQL with vim-like keybindings, quick table browsing, and spreadsheet-like editing. + +**Pros**: +- ✅ Vim-like navigation (hjkl, search) +- ✅ Fast Rust implementation +- ✅ Quick schema/table browsing +- ✅ Session history and query favorites +- ✅ Syntax highlighting +- ✅ Manual row editing +- ✅ Supports DATABASE_URL env var + +**Cons**: +- ❌ Still requires SQL for queries +- ❌ Limited export formats +- ❌ Interactive-focused (not ideal for scripting) + +**Installation**: +```bash +# Via cargo +cargo install rainfrog + +# Via package manager (check availability) +``` + +**Usage**: +```bash +# Connect via DATABASE_URL +export DATABASE_URL="postgres://user:pass@localhost:5432/ccproxy_db" +rainfrog + +# Or via CLI +rainfrog --url postgres://user:pass@localhost:5432/ccproxy_db +``` + +**Docs**: https://github.com/achristmascarl/rainfrog + +--- + +### 4. dsq - SQL on Files and Databases + +**Recommendation**: ⭐⭐⭐ **BEST FOR FILE + DB HYBRID** + +**Description**: CLI tool from DataStation for running SQL queries on JSON/CSV/Excel files AND PostgreSQL databases. + +**Pros**: +- ✅ Query JSON/CSV/Parquet files directly +- ✅ Connect to PostgreSQL +- ✅ Pipe output to `jq` for further processing +- ✅ Handles nested JSON with path syntax +- ✅ Scriptable and automation-friendly +- ✅ Uses SQLite backend with extensions + +**Cons**: +- ❌ Still requires SQL syntax +- ❌ Less mature than established tools +- ❌ Limited PostgreSQL-specific optimizations + +**Installation**: +```bash +# From GitHub releases +# https://github.com/multiprocessio/dsq +``` + +**Usage**: +```bash +# Query JSON file +dsq api-results.json 'SELECT * FROM {0, "data.data"} ORDER BY id DESC' | jq + +# Query PostgreSQL +dsq --database postgresql://user:pass@localhost:5432/ccproxy_db \ + "SELECT * FROM CCProxy_HttpTraces WHERE proxy_direction = 1" + +# Query CSV +dsq traces.csv "SELECT COUNT(1) FROM {}" +``` + +**Docs**: https://datastation.multiprocess.io/blog/2022-03-23-dsq-0.9.0.html + +--- + +### 5. usql - Universal Database CLI + +**Recommendation**: ⭐⭐ **BEST FOR MULTI-DB ENVIRONMENTS** + +**Description**: Universal command-line client for PostgreSQL, MySQL, SQLite, and many other databases with consistent syntax. + +**Pros**: +- ✅ Single CLI for multiple database types +- ✅ PostgreSQL support with full features +- ✅ Scriptable with `-c` flag +- ✅ JSON/CSV output formats +- ✅ Active development + +**Cons**: +- ❌ Still requires SQL queries +- ❌ Not a query builder +- ❌ Primarily a `psql` replacement + +**Installation**: +```bash +# Via package manager or GitHub releases +# https://github.com/xo/usql +``` + +**Usage**: +```bash +# Interactive +usql postgres://user:pass@localhost:5432/ccproxy_db + +# Scripting with JSON output +usql -c "SELECT * FROM CCProxy_HttpTraces LIMIT 10" \ + --format json \ + postgres://user:pass@localhost:5432/ccproxy_db > traces.json +``` + +**Docs**: https://github.com/xo/usql + +--- + +### 6. Steampipe - SQL for APIs (Bonus) + +**Recommendation**: ⭐ **SPECIALIZED USE CASE** + +**Description**: Zero-ETL tool that translates SQL queries into API calls. Not directly for PostgreSQL querying, but interesting for API integration. + +**Pros**: +- ✅ Query APIs using SQL syntax +- ✅ 450+ predefined API tables +- ✅ PostgreSQL wire protocol +- ✅ Export to CSV/JSON +- ✅ Multi-threading and caching + +**Cons**: +- ❌ Not for querying existing PostgreSQL databases +- ❌ Designed for cloud API access +- ❌ Requires plugins for different services + +**Use Case**: If you need to combine PostgreSQL data with cloud API data (AWS, GitHub, etc.) + +**Installation**: +```bash +# Via package manager or website +# https://steampipe.io/downloads +``` + +**Docs**: https://steampipe.io/docs + +--- + +## Other Tools Mentioned + +### GUI Tools (Not CLI-focused) +- **DBeaver**: Open-source with scripting via automation +- **pgAdmin**: CLI mode via `pgadmin4-cli` +- **DataGrip**: JetBrains IDE with query builder + +### Lesser-Known CLI Tools +- **gobang**: Cross-platform TUI (Rust, alpha stage) +- **lazysql**: TUI database tool (Go) +- **termdbms**: TUI for database files + +--- + +## PostgreSQL Native JSON Output + +For pure PostgreSQL scripting without third-party tools, use native JSON functions: + +```sql +-- Generate JSON from query +SELECT json_agg(row_to_json(t)) +FROM ( + SELECT * FROM CCProxy_HttpTraces LIMIT 100 +) t; + +-- Nested JSON with aggregation +SELECT json_build_object( + 'session_id', session_id, + 'traces', json_agg(row_to_json(t)) +) +FROM CCProxy_HttpTraces +GROUP BY session_id; +``` + +Pipe to `jq` for further processing: +```bash +psql -t -A -c "SELECT json_agg(row_to_json(t)) FROM (...) t" | jq '.[] | select(.proxy_direction == 1)' +``` + +--- + +## Recommendations by Use Case + +### For Your Project (ccproxy with Prisma) + +1. **Primary**: **Prisma Client** - Already integrated, type-safe, best for automation + ```javascript + // scripts/query-traces.js + const { PrismaClient } = require('@prisma/client'); + const prisma = new PrismaClient(); + + const traces = await prisma.cCProxy_HttpTraces.findMany({ + where: { /* conditions */ } + }); + ``` + +2. **Interactive Exploration**: **Harlequin** - Best TUI experience with export + ```bash + uv tool install harlequin --with harlequin-postgres + harlequin postgres://localhost:5432/ccproxy_db + ``` + +3. **Quick Scripts**: **psql + jq** - Native PostgreSQL JSON + command-line processing + ```bash + psql -t -A postgres://... -c "SELECT json_agg(...)" | jq '.[]' + ``` + +### By Priority + +**High Priority**: +- Prisma Client (already have it, type-safe) +- Harlequin (best TUI for exploration) + +**Medium Priority**: +- rainfrog (vim users, fast exploration) +- dsq (if working with JSON/CSV files too) + +**Low Priority**: +- usql (only if managing multiple DB types) +- Steampipe (only for API integration) + +--- + +## Installation Quick Reference + +```bash +# Prisma Client (already installed) +# Just use it in Node.js scripts + +# Harlequin (recommended) +uv tool install harlequin --with harlequin-postgres + +# rainfrog (vim users) +cargo install rainfrog + +# dsq (file + DB hybrid) +# Download from: https://github.com/multiprocessio/dsq/releases + +# usql (multi-DB environments) +# Download from: https://github.com/xo/usql/releases +``` + +--- + +## Conclusion + +**For ccproxy project**: +- ✅ Use **Prisma Client** for all programmatic access (type-safe, no SQL) +- ✅ Install **Harlequin** for interactive exploration with export +- ✅ Use **psql + jq** for quick one-off queries in shell scripts +- ✅ Consider **rainfrog** if you prefer vim-like navigation + +**Avoid**: GUI tools (DBeaver, pgAdmin) since requirement is CLI/non-interactive. + +**Key Insight**: Most CLI tools still require SQL. True "no SQL" access requires an ORM (Prisma Client) or native application code. For CLI work, focus on tools with good output formats (JSON/CSV) and pipe to processing tools like `jq`. diff --git a/.claude/output/request.json b/.claude/output/request.json new file mode 100644 index 00000000..d4ce5be3 --- /dev/null +++ b/.claude/output/request.json @@ -0,0 +1 @@ +{"batch": [{"id": "9c95045f-5af9-4196-ab96-0d0f20dd854e", "type": "trace-create", "body": {"id": "58b33e5f-84d9-4849-a58e-c634d38a5151", "timestamp": "2026-01-20T08:41:57.580960Z", "name": "litellm-anthropic_messages", "input": {"messages": [{"role": "user", "content": [{"type": "text", "text": "## Previously Renamed Identifiers\n\n- anonymous: F\u2192targetCollection, O\u2192candidateItem, C\u2192referenceId, H\u2192currentContext, Z\u2192validateHierarchy\n- anonymous: B\u2192associationRegistry, G\u2192targetId, Q\u2192insertIndex, Z\u2192referenceId\n- anonymous: B\u2192associationRegistry, G\u2192candidateItem, Q\u2192insertIndex\n- anonymous: A\u2192wrappedFunction, Q\u2192functionArgument\n- anonymous: A\u2192wrappedFunction, B\u2192functionArgument, Q\u2192argumentProcessor\n- anonymous: A\u2192targetProperty, B\u2192targetObject, Q\u2192expectedValue\n- anonymous: B\u2192value, A\u2192targetValue, Q\u2192customComparator\n- anonymous: B\u2192cache, G\u2192cacheItem\n- anonymous: B\u2192configKey, A\u2192defaultHint, G\u2192cachedValue, Q\u2192cacheKey, Wv0\u2192retrieveConfig\n- anonymous: Q\u2192targetObject, A\u2192propertyKey\n- anonymous: Q\u2192inputValue, I5A\u2192processingFunction, A\u2192contextualArgument\n- anonymous: Q\u2192timeoutInput, B\u2192parsedTimeoutMs\n- anonymous: Z\u2192pluginConfig\n- J: Y\u2192timerId\n- X: Z\u2192outputBuffer, A\u2192writeToDestination, J\u2192onFlushComplete\n- I: Y\u2192timeoutId, Q\u2192delayMs, X\u2192callback\n- anonymous: A\u2192targetKey\n- anonymous: A\u2192fn\n\nRename variables in this JavaScript function:\n```javascript\nA => {\n let Q = iCA();\n if (!xA().existsSync(o$1(Q))) {\n xA().mkdirSync(o$1(Q));\n }\n xA().appendFileSync(Q, A);\n DR9();\n }\n```\n\nVariables to rename: A, DR9, Q\n\nRespond with JSON matching this schema:\n{\n \"type\": \"object\",\n \"properties\": {\n \"function_purpose\": {\n \"type\": \"string\",\n \"maxLength\": 500\n },\n \"renames\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"original\": {\n \"type\": \"string\"\n },\n \"suggested\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-zA-Z_][a-zA-Z0-9_]*$\"\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1\n },\n \"reasoning\": {\n \"type\": \"string\",\n \"maxLength\": 300\n }\n },\n \"required\": [\n \"original\",\n \"suggested\",\n \"confidence\",\n \"reasoning\"\n ]\n }\n }\n },\n \"required\": [\n \"function_purpose\",\n \"renames\"\n ]\n}", "cache_control": {"type": "ephemeral"}}]}], "tools": []}, "output": {"content": "```json\n{\n \"function_purpose\": \"Writes log entries to a file on disk, ensuring the log directory exists before appending.\",\n \"renames\": [\n {\n \"original\": \"A\",\n \"suggested\": \"logEntry\",\n \"confidence\": 0.8,\n \"reasoning\": \"The variable is passed directly to appendFileSync, which expects a string or buffer. Given the file creation and synchronization context, this represents the text content being logged.\"\n },\n {\n \"original\": \"Q\",\n \"suggested\": \"logFilePath\",\n \"confidence\": 0.95,\n \"reasoning\": \"This variable is initialized by iCA(), resolved to a path by o$1(), checked for existence, created as a directory if missing, and finally used as the target path for appendFileSync. These are classic file path operations.\"\n },\n {\n \"original\": \"DR9\",\n \"suggested\": \"flushLogBuffer\",\n \"confidence\": 0.7,\n \"reasoning\": \"Called immediately after appending content to a file. In logging systems, a function invoked after a write operation typically handles flushing buffers or cleaning up resources. Without seeing its definition, this is a semantic inference based on the pattern.\"\n }\n ]\n}\n```", "role": "assistant", "tool_calls": null, "function_call": null, "provider_specific_fields": {"citations": null, "thinking_blocks": null}}, "tags": ["User-Agent: Anthropic", "User-Agent: Anthropic/Python 0.76.0"]}, "timestamp": "2026-01-20T08:41:57.581025Z"}, {"id": "32557044-b360-4c57-86ac-9dff57c84fa8", "type": "generation-create", "body": {"traceId": "58b33e5f-84d9-4849-a58e-c634d38a5151", "name": "litellm-anthropic_messages", "startTime": "2026-01-20T00:41:53.698086-08:00", "metadata": {"hidden_params": {"model_id": null, "cache_key": null, "api_base": null, "response_cost": null, "additional_headers": {}, "litellm_overhead_time_ms": null, "batch_models": null, "litellm_model_name": null, "usage_object": null}, "litellm_response_cost": 0.0, "api_base": "https://api.z.ai/api/anthropic/v1/messages", "cache_hit": false, "requester_metadata": {}}, "input": {"messages": [{"role": "user", "content": [{"type": "text", "text": "## Previously Renamed Identifiers\n\n- anonymous: F\u2192targetCollection, O\u2192candidateItem, C\u2192referenceId, H\u2192currentContext, Z\u2192validateHierarchy\n- anonymous: B\u2192associationRegistry, G\u2192targetId, Q\u2192insertIndex, Z\u2192referenceId\n- anonymous: B\u2192associationRegistry, G\u2192candidateItem, Q\u2192insertIndex\n- anonymous: A\u2192wrappedFunction, Q\u2192functionArgument\n- anonymous: A\u2192wrappedFunction, B\u2192functionArgument, Q\u2192argumentProcessor\n- anonymous: A\u2192targetProperty, B\u2192targetObject, Q\u2192expectedValue\n- anonymous: B\u2192value, A\u2192targetValue, Q\u2192customComparator\n- anonymous: B\u2192cache, G\u2192cacheItem\n- anonymous: B\u2192configKey, A\u2192defaultHint, G\u2192cachedValue, Q\u2192cacheKey, Wv0\u2192retrieveConfig\n- anonymous: Q\u2192targetObject, A\u2192propertyKey\n- anonymous: Q\u2192inputValue, I5A\u2192processingFunction, A\u2192contextualArgument\n- anonymous: Q\u2192timeoutInput, B\u2192parsedTimeoutMs\n- anonymous: Z\u2192pluginConfig\n- J: Y\u2192timerId\n- X: Z\u2192outputBuffer, A\u2192writeToDestination, J\u2192onFlushComplete\n- I: Y\u2192timeoutId, Q\u2192delayMs, X\u2192callback\n- anonymous: A\u2192targetKey\n- anonymous: A\u2192fn\n\nRename variables in this JavaScript function:\n```javascript\nA => {\n let Q = iCA();\n if (!xA().existsSync(o$1(Q))) {\n xA().mkdirSync(o$1(Q));\n }\n xA().appendFileSync(Q, A);\n DR9();\n }\n```\n\nVariables to rename: A, DR9, Q\n\nRespond with JSON matching this schema:\n{\n \"type\": \"object\",\n \"properties\": {\n \"function_purpose\": {\n \"type\": \"string\",\n \"maxLength\": 500\n },\n \"renames\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"original\": {\n \"type\": \"string\"\n },\n \"suggested\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-zA-Z_][a-zA-Z0-9_]*$\"\n },\n \"confidence\": {\n \"type\": \"number\",\n \"minimum\": 0,\n \"maximum\": 1\n },\n \"reasoning\": {\n \"type\": \"string\",\n \"maxLength\": 300\n }\n },\n \"required\": [\n \"original\",\n \"suggested\",\n \"confidence\",\n \"reasoning\"\n ]\n }\n }\n },\n \"required\": [\n \"function_purpose\",\n \"renames\"\n ]\n}", "cache_control": {"type": "ephemeral"}}]}], "tools": []}, "output": {"content": "```json\n{\n \"function_purpose\": \"Writes log entries to a file on disk, ensuring the log directory exists before appending.\",\n \"renames\": [\n {\n \"original\": \"A\",\n \"suggested\": \"logEntry\",\n \"confidence\": 0.8,\n \"reasoning\": \"The variable is passed directly to appendFileSync, which expects a string or buffer. Given the file creation and synchronization context, this represents the text content being logged.\"\n },\n {\n \"original\": \"Q\",\n \"suggested\": \"logFilePath\",\n \"confidence\": 0.95,\n \"reasoning\": \"This variable is initialized by iCA(), resolved to a path by o$1(), checked for existence, created as a directory if missing, and finally used as the target path for appendFileSync. These are classic file path operations.\"\n },\n {\n \"original\": \"DR9\",\n \"suggested\": \"flushLogBuffer\",\n \"confidence\": 0.7,\n \"reasoning\": \"Called immediately after appending content to a file. In logging systems, a function invoked after a write operation typically handles flushing buffers or cleaning up resources. Without seeing its definition, this is a semantic inference based on the pattern.\"\n }\n ]\n}\n```", "role": "assistant", "tool_calls": null, "function_call": null, "provider_specific_fields": {"citations": null, "thinking_blocks": null}}, "level": "DEFAULT", "id": "time-00-41-53-698086_chatcmpl-fe22a665-0a2e-44b3-be03-6521be3ed163", "endTime": "2026-01-20T00:41:57.576644-08:00", "completionStartTime": "2026-01-20T00:41:57.576644-08:00", "model": "glm-4.7", "modelParameters": {"max_tokens": 2048, "metadata": "{'hidden_params': {'additional_headers': {'llm_provider-server': 'nginx', 'llm_provider-date': 'Tue, 20 Jan 2026 08:41:57 GMT', 'llm_provider-content-type': 'application/json', 'llm_provider-transfer-encoding': 'chunked', 'llm_provider-connection': 'keep-alive', 'llm_provider-keep-alive': 'timeout=6', 'llm_provider-vary': 'Accept-Encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers', 'llm_provider-x-log-id': '20260120164154d388566d87d54f6b', 'llm_provider-x-process-time': '3.438960552215576', 'llm_provider-strict-transport-security': 'max-age=31536000; includeSubDomains', 'llm_provider-content-encoding': 'gzip'}, 'optional_params': {'max_tokens': 2048, 'metadata': {...}, 'stream': False, 'system': [{'type': 'text', 'text': 'You are a semantic renaming assistant.'}, {'type': 'text', 'text': 'You are a semantic renaming expert specializing in reverse-engineering obfuscated JavaScript bundles. Your task is to analyze minified code and suggest meaningful variable names that capture the semantic purpose of each identifier.\\n\\n## Context\\nThe code you are analyzing comes from the Claude Code CLI (v2.1.7), a production Anthropic application bundled with esbuild and browserify. The bundle contains:\\n- Model/LLM interaction logic (Claude API calls, token counting, context management)\\n- Tool execution framework (MCP protocol, tool handlers, permission system)\\n- Session and conversation management\\n- File system operations and process spawning\\n- Terminal UI components (Ink/React-based)\\n\\n## AST Signal Interpretation\\n\\nWhen analyzing code, look for these semantic signals:\\n\\n### String Literals\\nString values reveal domain concepts:\\n- `\"allow\"`, `\"deny\"` \u2192 permission handling\\n- `\"assistant\"`, `\"user\"`, `\"system\"` \u2192 message roles\\n- `\"claude-3-opus\"`, `\"claude-3-sonnet\"` \u2192 model identifiers\\n- `\"session_id\"`, `\"conversation_id\"` \u2192 session management\\n- Error messages often reveal function purpose\\n\\n### Object Keys\\nProperty names in object literals indicate data structure:\\n- `{ type: \"...\", content: \"...\" }` \u2192 message structure\\n- `{ maxTokens: ..., contextWindow: ... }` \u2192 token configuration\\n- `{ name: \"...\", handler: ... }` \u2192 tool definition\\n- `{ allow: [...], deny: [...] }` \u2192 permission rules\\n\\n### Property Accesses\\nMember expressions show how variables are used:\\n- `.behavior`, `.status`, `.state` \u2192 stateful objects\\n- `.execute()`, `.run()`, `.invoke()` \u2192 executors/handlers\\n- `.push()`, `.pop()`, `.shift()` \u2192 array operations\\n- `.then()`, `.catch()`, `.finally()` \u2192 Promise chains\\n- `.pipe()`, `.on()`, `.emit()` \u2192 streams/events\\n\\n### Call Patterns\\nFunction calls reveal variable types:\\n- `spawn(...)` \u2192 child process\\n- `fetch(...)` \u2192 HTTP request\\n- `JSON.parse(...)` / `JSON.stringify(...)` \u2192 serialization\\n- `Promise.all(...)` / `Promise.race(...)` \u2192 async coordination\\n- `Array.isArray(...)` \u2192 type checking\\n\\n## Naming Conventions\\n\\n### Case Styles\\n- **Variables and functions**: camelCase (e.g., `tokenCount`, `handleToolExecution`)\\n- **Classes and constructors**: PascalCase (e.g., `SessionManager`, `ToolRegistry`)\\n- **Constants**: UPPER_SNAKE_CASE only for true constants (e.g., `MAX_RETRIES`, `DEFAULT_TIMEOUT`)\\n\\n### Specificity Guidelines\\nChoose names that are specific to the domain rather than generic:\\n- `modelName` not `name` (when referring to Claude model identifiers)\\n- `tokenLimit` not `limit` (when referring to context window constraints)\\n- `toolResult` not `result` (when referring to MCP tool execution output)\\n- `sessionId` not `id` (when referring to conversation sessions)\\n- `permissionBehavior` not `behavior` (when referring to allow/deny decisions)\\n\\n### Domain-Specific Terms\\nPrefer these domain terms when applicable:\\n- **Permissions**: permission, behavior, allow, deny, grant, policy, rule\\n- **Sessions**: session, conversation, context, history, state, turn\\n- **Tools/MCP**: tool, handler, executor, registry, capability, schema, invoke\\n- **Models**: model, provider, anthropic, claude, sonnet, opus, haiku\\n- **Tokens**: token, limit, count, budget, context, window, input, output\\n- **Messages**: message, role, content, assistant, user, system, response\\n\\n## What Makes a Good Rename\\n1. **Captures purpose**: The name reflects what the variable represents, not just its type\\n2. **Reflects usage patterns**: If a variable is checked for `.behavior === \"allow\"`, it likely represents a permission decision\\n3. **Preserves relationships**: If two variables are related (e.g., request/response pair), their names should reflect this\\n4. **Domain-appropriate**: Uses terminology consistent with the application domain\\n\\n## What to Avoid\\n- **Single letters**: Never suggest single-letter names (a, b, c, x, y, z)\\n- **Generic names without context**: Avoid `data`, `result`, `value`, `item`, `obj` unless truly generic\\n- **Hungarian notation**: Don\\'t prefix with types (e.g., `strName`, `arrItems`, `objConfig`)\\n- **Abbreviations**: Prefer `configuration` over `cfg`, `message` over `msg` (unless standard in codebase)\\n- **Overly long names**: Keep names under 30 characters; be concise but clear\\n\\n## Detailed Renaming Examples\\n\\n### Example 1: Permission Handling\\n```javascript\\nif (A.behavior === \"allow\") { return Q.execute(); }\\nelse if (A.behavior === \"deny\") { throw new Error(\"Permission denied\"); }\\n```\\n- `A` \u2192 `permissionResult` (0.95): Object with .behavior property checked against allow/deny\\n- `Q` \u2192 `toolExecutor` (0.85): Object with .execute() method, invoked on permission allow\\n\\n### Example 2: Token Limit Configuration\\n```javascript\\nconst B = { maxTokens: 8192, contextWindow: 200000 };\\nif (G.inputTokens > B.contextWindow) { truncateMessages(G); }\\n```\\n- `B` \u2192 `tokenLimits` (0.92): Configuration object holding token limit constraints\\n- `G` \u2192 `tokenUsage` (0.88): Object tracking input token count\\n\\n### Example 3: Child Process Management\\n```javascript\\nconst H = spawn(\"node\", args);\\nH.on(\"exit\", (code) => { cleanup(); });\\nH.stdout.pipe(process.stdout);\\n```\\n- `H` \u2192 `childProcess` (0.95): Node.js ChildProcess instance from spawn() call\\n\\n### Example 4: Message Construction\\n```javascript\\nconst Z = { role: \"assistant\", content: Y };\\nB.push(Z);\\nreturn { messages: B, model: \"claude-3-sonnet\" };\\n```\\n- `Z` \u2192 `assistantMessage` (0.93): Message object with role=\"assistant\"\\n- `B` \u2192 `messageHistory` (0.85): Array receiving message via push()\\n- `Y` \u2192 `responseContent` (0.70): Content property value\\n\\n### Example 5: Tool Execution\\n```javascript\\nconst T = registry.get(name);\\nif (!T) throw new Error(`Unknown tool: ${name}`);\\nconst R = await T.handler(params);\\n```\\n- `T` \u2192 `toolDefinition` (0.90): Tool retrieved from registry by name\\n- `R` \u2192 `toolResult` (0.88): Result of awaiting tool handler\\n\\n### Example 6: Session State\\n```javascript\\nif (!S.sessionId) { S.sessionId = generateId(); }\\nS.messages = S.messages || [];\\nS.lastActivity = Date.now();\\n```\\n- `S` \u2192 `sessionState` (0.92): Stateful session object with sessionId and messages\\n\\n### Example 7: Stream Processing\\n```javascript\\nP.on(\"data\", (chunk) => { buffer += chunk; });\\nP.on(\"end\", () => { resolve(JSON.parse(buffer)); });\\nP.on(\"error\", reject);\\n```\\n- `P` \u2192 `inputStream` (0.88): Stream with data/end/error events\\n\\n### Example 8: API Response Handling\\n```javascript\\nconst D = await fetch(url, { method: \"POST\", body: JSON.stringify(payload) });\\nif (!D.ok) throw new ApiError(D.status, await D.text());\\nreturn D.json();\\n```\\n- `D` \u2192 `apiResponse` (0.90): Fetch Response object with ok/status/json()\\n\\n### Example 9: Error Handling\\n```javascript\\ntry { await processRequest(req); }\\ncatch (E) {\\n if (E.code === \"RATE_LIMITED\") { await sleep(E.retryAfter); }\\n else { throw E; }\\n}\\n```\\n- `E` \u2192 `requestError` (0.85): Error object with code and retryAfter properties\\n\\n### Example 10: Configuration Merging\\n```javascript\\nconst C = { ...defaults, ...userConfig };\\nC.timeout = C.timeout ?? 30000;\\nvalidateConfig(C);\\n```\\n- `C` \u2192 `mergedConfig` (0.88): Configuration object merged from defaults and user input\\n\\n## Confidence Scoring\\n- **0.9-1.0**: Very high confidence - clear usage patterns, unambiguous purpose\\n- **0.7-0.9**: Medium-high confidence - strong indicators but some ambiguity\\n- **0.5-0.7**: Low confidence - limited context, educated guess\\n- **Below 0.5**: Skip the variable - insufficient context to rename meaningfully\\n\\nOnly include variables in the renames array if confidence is 0.5 or higher.\\n\\n## Common Obfuscation Patterns\\n\\nesbuild/browserify minification often produces:\\n- Single-letter parameter names (A, Q, B, G) - always rename these\\n- Short function names (tN9, xX, sG4) - these are scope identifiers\\n- Hoisted utility functions at top level - may be shared across modules\\n- Wrapper patterns like `var X = U((exports, module) => {...})` - browserify modules\\n- Lazy init patterns like `var X = w(() => {...})` - esbuild ESM modules', 'cache_control': {'type': 'ephemeral'}}], 'tools': []}}}", "stream": false, "system": "[{'type': 'text', 'text': 'You are a semantic renaming assistant.'}, {'type': 'text', 'text': 'You are a semantic renaming expert specializing in reverse-engineering obfuscated JavaScript bundles. Your task is to analyze minified code and suggest meaningful variable names that capture the semantic purpose of each identifier.\\n\\n## Context\\nThe code you are analyzing comes from the Claude Code CLI (v2.1.7), a production Anthropic application bundled with esbuild and browserify. The bundle contains:\\n- Model/LLM interaction logic (Claude API calls, token counting, context management)\\n- Tool execution framework (MCP protocol, tool handlers, permission system)\\n- Session and conversation management\\n- File system operations and process spawning\\n- Terminal UI components (Ink/React-based)\\n\\n## AST Signal Interpretation\\n\\nWhen analyzing code, look for these semantic signals:\\n\\n### String Literals\\nString values reveal domain concepts:\\n- `\"allow\"`, `\"deny\"` \u2192 permission handling\\n- `\"assistant\"`, `\"user\"`, `\"system\"` \u2192 message roles\\n- `\"claude-3-opus\"`, `\"claude-3-sonnet\"` \u2192 model identifiers\\n- `\"session_id\"`, `\"conversation_id\"` \u2192 session management\\n- Error messages often reveal function purpose\\n\\n### Object Keys\\nProperty names in object literals indicate data structure:\\n- `{ type: \"...\", content: \"...\" }` \u2192 message structure\\n- `{ maxTokens: ..., contextWindow: ... }` \u2192 token configuration\\n- `{ name: \"...\", handler: ... }` \u2192 tool definition\\n- `{ allow: [...], deny: [...] }` \u2192 permission rules\\n\\n### Property Accesses\\nMember expressions show how variables are used:\\n- `.behavior`, `.status`, `.state` \u2192 stateful objects\\n- `.execute()`, `.run()`, `.invoke()` \u2192 executors/handlers\\n- `.push()`, `.pop()`, `.shift()` \u2192 array operations\\n- `.then()`, `.catch()`, `.finally()` \u2192 Promise chains\\n- `.pipe()`, `.on()`, `.emit()` \u2192 streams/events\\n\\n### Call Patterns\\nFunction calls reveal variable types:\\n- `spawn(...)` \u2192 child process\\n- `fetch(...)` \u2192 HTTP request\\n- `JSON.parse(...)` / `JSON.stringify(...)` \u2192 serialization\\n- `Promise.all(...)` / `Promise.race(...)` \u2192 async coordination\\n- `Array.isArray(...)` \u2192 type checking\\n\\n## Naming Conventions\\n\\n### Case Styles\\n- **Variables and functions**: camelCase (e.g., `tokenCount`, `handleToolExecution`)\\n- **Classes and constructors**: PascalCase (e.g., `SessionManager`, `ToolRegistry`)\\n- **Constants**: UPPER_SNAKE_CASE only for true constants (e.g., `MAX_RETRIES`, `DEFAULT_TIMEOUT`)\\n\\n### Specificity Guidelines\\nChoose names that are specific to the domain rather than generic:\\n- `modelName` not `name` (when referring to Claude model identifiers)\\n- `tokenLimit` not `limit` (when referring to context window constraints)\\n- `toolResult` not `result` (when referring to MCP tool execution output)\\n- `sessionId` not `id` (when referring to conversation sessions)\\n- `permissionBehavior` not `behavior` (when referring to allow/deny decisions)\\n\\n### Domain-Specific Terms\\nPrefer these domain terms when applicable:\\n- **Permissions**: permission, behavior, allow, deny, grant, policy, rule\\n- **Sessions**: session, conversation, context, history, state, turn\\n- **Tools/MCP**: tool, handler, executor, registry, capability, schema, invoke\\n- **Models**: model, provider, anthropic, claude, sonnet, opus, haiku\\n- **Tokens**: token, limit, count, budget, context, window, input, output\\n- **Messages**: message, role, content, assistant, user, system, response\\n\\n## What Makes a Good Rename\\n1. **Captures purpose**: The name reflects what the variable represents, not just its type\\n2. **Reflects usage patterns**: If a variable is checked for `.behavior === \"allow\"`, it likely represents a permission decision\\n3. **Preserves relationships**: If two variables are related (e.g., request/response pair), their names should reflect this\\n4. **Domain-appropriate**: Uses terminology consistent with the application domain\\n\\n## What to Avoid\\n- **Single letters**: Never suggest single-letter names (a, b, c, x, y, z)\\n- **Generic names without context**: Avoid `data`, `result`, `value`, `item`, `obj` unless truly generic\\n- **Hungarian notation**: Don\\'t prefix with types (e.g., `strName`, `arrItems`, `objConfig`)\\n- **Abbreviations**: Prefer `configuration` over `cfg`, `message` over `msg` (unless standard in codebase)\\n- **Overly long names**: Keep names under 30 characters; be concise but clear\\n\\n## Detailed Renaming Examples\\n\\n### Example 1: Permission Handling\\n```javascript\\nif (A.behavior === \"allow\") { return Q.execute(); }\\nelse if (A.behavior === \"deny\") { throw new Error(\"Permission denied\"); }\\n```\\n- `A` \u2192 `permissionResult` (0.95): Object with .behavior property checked against allow/deny\\n- `Q` \u2192 `toolExecutor` (0.85): Object with .execute() method, invoked on permission allow\\n\\n### Example 2: Token Limit Configuration\\n```javascript\\nconst B = { maxTokens: 8192, contextWindow: 200000 };\\nif (G.inputTokens > B.contextWindow) { truncateMessages(G); }\\n```\\n- `B` \u2192 `tokenLimits` (0.92): Configuration object holding token limit constraints\\n- `G` \u2192 `tokenUsage` (0.88): Object tracking input token count\\n\\n### Example 3: Child Process Management\\n```javascript\\nconst H = spawn(\"node\", args);\\nH.on(\"exit\", (code) => { cleanup(); });\\nH.stdout.pipe(process.stdout);\\n```\\n- `H` \u2192 `childProcess` (0.95): Node.js ChildProcess instance from spawn() call\\n\\n### Example 4: Message Construction\\n```javascript\\nconst Z = { role: \"assistant\", content: Y };\\nB.push(Z);\\nreturn { messages: B, model: \"claude-3-sonnet\" };\\n```\\n- `Z` \u2192 `assistantMessage` (0.93): Message object with role=\"assistant\"\\n- `B` \u2192 `messageHistory` (0.85): Array receiving message via push()\\n- `Y` \u2192 `responseContent` (0.70): Content property value\\n\\n### Example 5: Tool Execution\\n```javascript\\nconst T = registry.get(name);\\nif (!T) throw new Error(`Unknown tool: ${name}`);\\nconst R = await T.handler(params);\\n```\\n- `T` \u2192 `toolDefinition` (0.90): Tool retrieved from registry by name\\n- `R` \u2192 `toolResult` (0.88): Result of awaiting tool handler\\n\\n### Example 6: Session State\\n```javascript\\nif (!S.sessionId) { S.sessionId = generateId(); }\\nS.messages = S.messages || [];\\nS.lastActivity = Date.now();\\n```\\n- `S` \u2192 `sessionState` (0.92): Stateful session object with sessionId and messages\\n\\n### Example 7: Stream Processing\\n```javascript\\nP.on(\"data\", (chunk) => { buffer += chunk; });\\nP.on(\"end\", () => { resolve(JSON.parse(buffer)); });\\nP.on(\"error\", reject);\\n```\\n- `P` \u2192 `inputStream` (0.88): Stream with data/end/error events\\n\\n### Example 8: API Response Handling\\n```javascript\\nconst D = await fetch(url, { method: \"POST\", body: JSON.stringify(payload) });\\nif (!D.ok) throw new ApiError(D.status, await D.text());\\nreturn D.json();\\n```\\n- `D` \u2192 `apiResponse` (0.90): Fetch Response object with ok/status/json()\\n\\n### Example 9: Error Handling\\n```javascript\\ntry { await processRequest(req); }\\ncatch (E) {\\n if (E.code === \"RATE_LIMITED\") { await sleep(E.retryAfter); }\\n else { throw E; }\\n}\\n```\\n- `E` \u2192 `requestError` (0.85): Error object with code and retryAfter properties\\n\\n### Example 10: Configuration Merging\\n```javascript\\nconst C = { ...defaults, ...userConfig };\\nC.timeout = C.timeout ?? 30000;\\nvalidateConfig(C);\\n```\\n- `C` \u2192 `mergedConfig` (0.88): Configuration object merged from defaults and user input\\n\\n## Confidence Scoring\\n- **0.9-1.0**: Very high confidence - clear usage patterns, unambiguous purpose\\n- **0.7-0.9**: Medium-high confidence - strong indicators but some ambiguity\\n- **0.5-0.7**: Low confidence - limited context, educated guess\\n- **Below 0.5**: Skip the variable - insufficient context to rename meaningfully\\n\\nOnly include variables in the renames array if confidence is 0.5 or higher.\\n\\n## Common Obfuscation Patterns\\n\\nesbuild/browserify minification often produces:\\n- Single-letter parameter names (A, Q, B, G) - always rename these\\n- Short function names (tN9, xX, sG4) - these are scope identifiers\\n- Hoisted utility functions at top level - may be shared across modules\\n- Wrapper patterns like `var X = U((exports, module) => {...})` - browserify modules\\n- Lazy init patterns like `var X = w(() => {...})` - esbuild ESM modules', 'cache_control': {'type': 'ephemeral'}}]"}, "usage": {"input": 2638, "output": 268, "unit": "TOKENS", "totalCost": 0.0}, "usageDetails": {"input": 2638, "output": 268, "total": 2906, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}, "timestamp": "2026-01-20T08:41:57.582583Z"}], "metadata": {"batch_size": 2, "sdk_integration": "litellm", "sdk_name": "python", "sdk_version": "2.60.10", "public_key": "pk-lf-f1a44365-d3f4-4dec-a90d-001e1da9335a"}} diff --git a/.claude/plans/ccproxy-db-sql-command.md b/.claude/plans/ccproxy-db-sql-command.md new file mode 100644 index 00000000..6dcb0c82 --- /dev/null +++ b/.claude/plans/ccproxy-db-sql-command.md @@ -0,0 +1,149 @@ +# Plan: `ccproxy db sql` Command + +## Summary + +Add a `ccproxy db sql` command that executes SQL queries against the MITM traces database, reading the connection string from config automatically. + +## Architecture + +``` +ccproxy db sql <query|--file|stdin> + │ + ▼ +┌───────────────────┐ +│ DbSql Command │ (Tyro dataclass in cli.py) +└────────┬──────────┘ + │ + ▼ +┌───────────────────┐ +│ get_database_url │ (reads from CCProxyConfig.mitm.database_url) +└────────┬──────────┘ + │ + ▼ +┌───────────────────┐ +│ asyncpg pool │ (direct SQL execution, no Prisma ORM) +└────────┬──────────┘ + │ + ▼ +┌───────────────────┐ +│ Format Output │ (table, json, csv) +└───────────────────┘ +``` + +## Dependencies + +**None required** - `asyncpg>=0.31.0` is already in `pyproject.toml`. + +## CLI Interface (Tyro Dataclass) + +```python +@attrs.define +class DbSql: + """Execute SQL queries against the MITM traces database.""" + + query: Annotated[str | None, tyro.conf.Positional] = None + """SQL query to execute (inline).""" + + file: Annotated[Path | None, tyro.conf.arg(aliases=["-f"])] = None + """Read SQL from file.""" + + json: Annotated[bool, tyro.conf.arg(aliases=["-j"])] = False + """Output results as JSON.""" + + csv: Annotated[bool, tyro.conf.arg(aliases=["-c"])] = False + """Output results as CSV.""" +``` + +## Usage Examples + +```bash +# Inline query +ccproxy db sql "SELECT COUNT(*) FROM \"CCProxy_HttpTraces\"" + +# From file +ccproxy db sql --file queries/recent_requests.sql + +# From stdin (pipe) +echo "SELECT * FROM \"CCProxy_HttpTraces\" LIMIT 5" | ccproxy db sql + +# JSON output for LLM consumption +ccproxy db sql "SELECT * FROM \"CCProxy_HttpTraces\" LIMIT 10" --json + +# CSV export +ccproxy db sql "SELECT method, url, status_code FROM \"CCProxy_HttpTraces\"" --csv > traces.csv +``` + +## Implementation Steps + +### Phase 1: Core Infrastructure +- Add `DbSql` dataclass to `cli.py` +- Add to `Command` union type +- Add entry_point rewrite for `db sql` → `db-sql` +- Implement `get_database_url()` + +### Phase 2: SQL Execution +- Implement `execute_sql()` with asyncpg +- Implement `resolve_sql_input()` (inline, file, stdin) + +### Phase 3: Output Formatting +- Implement `format_table()` using Rich +- Implement `format_json()` +- Implement `format_csv()` + +### Phase 4: Integration +- Implement `handle_db_sql()` +- Add handler to `main()` + +### Phase 5: Testing +- Unit tests for input resolution +- Unit tests for output formatters +- Integration tests with mocked asyncpg + +## Key Functions + +```python +def get_database_url(config_dir: Path) -> str | None: + """Get database URL from ccproxy config with env var fallback. + + Priority: + 1. ccproxy.yaml -> ccproxy.mitm.database_url + 2. CCPROXY_DATABASE_URL environment variable + 3. DATABASE_URL environment variable + """ + +async def execute_sql(database_url: str, query: str) -> tuple[list[dict], list[str]]: + """Execute SQL query and return results with column names.""" + +def resolve_sql_input(cmd: DbSql) -> str: + """Resolve SQL query from inline, file, or stdin.""" + +def handle_db_sql(config_dir: Path, cmd: DbSql) -> None: + """Handle the db sql command.""" +``` + +## Error Handling + +| Error Scenario | Handling | +|----------------|----------| +| No SQL input provided | Print error, show usage hint, exit 1 | +| No database_url configured | Print error explaining config location, exit 1 | +| Database connection failure | Print error with connection details (no password), exit 1 | +| SQL syntax error | Print PostgreSQL error message, exit 1 | +| File not found (--file) | Print error with path, exit 1 | +| Both --json and --csv | Print error (mutually exclusive), exit 1 | + +## Files to Modify + +| File | Changes | +|------|---------| +| `src/ccproxy/cli.py` | Add DbSql dataclass, handlers, formatters | +| `tests/test_db_sql.py` | New test file | + +## Verification + +1. Start the ccproxy-db container: `docker compose up -d` +2. Apply schema: `DATABASE_URL="postgresql://ccproxy:test@localhost:5432/ccproxy" uv run prisma db push` +3. Test inline query: `ccproxy db sql "SELECT COUNT(*) FROM \"CCProxy_HttpTraces\""` +4. Test JSON output: `ccproxy db sql "SELECT * FROM \"CCProxy_HttpTraces\" LIMIT 1" --json` +5. Test file input: Create a `.sql` file and run `ccproxy db sql --file test.sql` +6. Run tests: `uv run pytest tests/test_db_sql.py -v` diff --git a/tests/test_handler_temp.py b/.claude/plans/forward-proxy-caching-test-plan.md similarity index 100% rename from tests/test_handler_temp.py rename to .claude/plans/forward-proxy-caching-test-plan.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 226aaeb7..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pre-commit:*)", - "mcp__desktop-commander-mcp", - "Bash(timeout 10 uv run:*)", - "mcp__gitmcp-litellm", - "Bash(litellm:*)", - "Bash(PYTHONPATH=src python -c \"from ccproxy.handler import CCProxyHandler; print(''CCProxy import successful'')\")", - "Bash(PYTHONPATH=src litellm --config demo/demo_config.yaml --port 8000)", - "Bash(timeout:*)", - "Bash(PYTHONPATH=/home/starbased/dev/projects/ccproxy/src:$PYTHONPATH uv run litellm --config config.yaml)", - "Bash(cclaude:*)", - "Bash(ccproxy:*)", - "Bash(cp:*)" - ], - "deny": [] - }, - "enableAllProjectMcpServers": true -} diff --git a/.env.example b/.env.example index b8572845..16e0cf8d 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,9 @@ -# CCProxy Environment Variables -# Copy this file to .env and populate with your actual values - -# API Keys (required for model providers) -# OpenAI -OPENAI_API_KEY=your_openai_api_key_here - -# Anthropic -ANTHROPIC_API_KEY=your_anthropic_api_key_here - -# Google -GOOGLE_API_KEY=your_google_api_key_here - -# Azure OpenAI -AZURE_OPENAI_API_KEY=your_azure_openai_api_key_here -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ - -# OpenRouter -OPENROUTER_API_KEY=your_openrouter_api_key_here - -# Perplexity -PERPLEXITY_API_KEY=your_perplexity_api_key_here - -# Logging Configuration -LOG_LEVEL=INFO -LOG_FORMAT=json +# LangFuse Configuration +# Get these values from your LangFuse dashboard at https://cloud.langfuse.com +export LANGFUSE_PUBLIC_KEY="op://dev/LangFuse/public key" +export LANGFUSE_SECRET_KEY="op://dev/LangFuse/credential" +export LANGFUSE_HOST="op://dev/LangFuse/host" + +# Optional: Additional LangFuse settings +# LANGFUSE_DEBUG=false +# LANGFUSE_RELEASE=production diff --git a/.envrc b/.envrc deleted file mode 100644 index 60581731..00000000 --- a/.envrc +++ /dev/null @@ -1,2 +0,0 @@ -export ANTHROPIC_API_KEY="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" -source .venv/bin/activate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b24b8d91..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: CI - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - -env: - PYTHON_VERSION: "3.12" - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: | - uv sync --dev - - - name: Run ruff check - run: | - uv run ruff check src/ tests/ - - - name: Run ruff format check - run: | - uv run ruff format --check src/ tests/ - - - name: Run mypy - run: | - uv run mypy src/ - - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: | - uv sync --dev - - - name: Run tests with coverage - run: | - uv run pytest - - - name: Upload coverage reports - uses: codecov/codecov-action@v4 - if: matrix.python-version == '3.12' - with: - file: ./htmlcov/coverage.xml - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - enable-cache: true - - - name: Install dependencies - run: | - uv add bandit - uv sync --dev - - - name: Run bandit security checks - run: | - uv run bandit -r src/ -ll diff --git a/.gitignore b/.gitignore index 4ef13dbf..c8c3bc0b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,22 @@ site/ poetry.lock # Project specific -config.yaml -!config.example.yaml *.db *.sqlite +/.ccproxy +.envrc +dumps +langfuse/ +handoff.md + +# ML artifacts +checkpoints/ +*.pt +*.pth +*.ckpt +tensorboard/ +runs/ + +# Prisma generated client +prisma/migrations/ +node_modules/ diff --git a/.ignore b/.ignore index ae27fd76..5383dc2b 100644 --- a/.ignore +++ b/.ignore @@ -1,3 +1,5 @@ -.claude/commands/tm -.claude/TM_COMMANDS_GUIDE.md -.taskmaster +.github +.mypy_cache +.ruff_cache +stubs +uv.lock diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 53ede913..00000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "gitmcp-litellm": { - "command": "npx", - "args": [ - "mcp-remote", - "https://gitmcp.io/BerriAI/litellm" - ] - } - } -} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 65f480d9..1079a97e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,3 +28,4 @@ repos: - pydantic args: [--strict] files: ^src/ + diff --git a/.taskmaster/CLAUDE.md b/.taskmaster/CLAUDE.md deleted file mode 100644 index 7a3f67ed..00000000 --- a/.taskmaster/CLAUDE.md +++ /dev/null @@ -1,416 +0,0 @@ -# Task Master AI - Agent Integration Guide - -## Essential Commands - -### Core Workflow Commands - -```bash -# Project Setup -task-master init # Initialize Task Master in current project -task-master parse-prd .taskmaster/docs/prd.txt # Generate tasks from PRD document -task-master models --setup # Configure AI models interactively - -# Daily Development Workflow -task-master list # Show all tasks with status -task-master next # Get next available task to work on -task-master show <id> # View detailed task information (e.g., task-master show 1.2) -task-master set-status --id=<id> --status=done # Mark task complete - -# Task Management -task-master add-task --prompt="description" --research # Add new task with AI assistance -task-master expand --id=<id> --research --force # Break task into subtasks -task-master update-task --id=<id> --prompt="changes" # Update specific task -task-master update --from=<id> --prompt="changes" # Update multiple tasks from ID onwards -task-master update-subtask --id=<id> --prompt="notes" # Add implementation notes to subtask - -# Analysis & Planning -task-master analyze-complexity --research # Analyze task complexity -task-master complexity-report # View complexity analysis -task-master expand --all --research # Expand all eligible tasks - -# Dependencies & Organization -task-master add-dependency --id=<id> --depends-on=<id> # Add task dependency -task-master move --from=<id> --to=<id> # Reorganize task hierarchy -task-master validate-dependencies # Check for dependency issues -task-master generate # Update task markdown files (usually auto-called) -``` - -## Key Files & Project Structure - -### Core Files - -- `.taskmaster/tasks/tasks.json` - Main task data file (auto-managed) -- `.taskmaster/config.json` - AI model configuration (use `task-master models` to modify) -- `.taskmaster/docs/prd.txt` - Product Requirements Document for parsing -- `.taskmaster/tasks/*.txt` - Individual task files (auto-generated from tasks.json) - -### Claude Code Integration Files - -- `CLAUDE.md` - Auto-loaded context for Claude Code (this file) -- `.claude/settings.json` - Claude Code tool allowlist and preferences -- `.claude/commands/` - Custom slash commands for repeated workflows -- `.mcp.json` - MCP server configuration (project-specific) - -### Directory Structure - -``` -project/ -├── .taskmaster/ -│ ├── tasks/ # Task files directory -│ │ ├── tasks.json # Main task database -│ │ ├── task-1.md # Individual task files -│ │ └── task-2.md -│ ├── docs/ # Documentation directory -│ │ ├── prd.txt # Product requirements -│ ├── reports/ # Analysis reports directory -│ │ └── task-complexity-report.json -│ ├── templates/ # Template files -│ │ └── example_prd.txt # Example PRD template -│ └── config.json # AI models & settings -├── .claude/ -│ ├── settings.json # Claude Code configuration -│ └── commands/ # Custom slash commands -├── .env # API keys -├── .mcp.json # MCP configuration -└── CLAUDE.md # This file - auto-loaded by Claude Code -``` - -## MCP Integration - -Task Master provides an MCP server that Claude Code can connect to. Configure in `.mcp.json`: - -```json -{ - "mcpServers": { - "task-master-ai": { - "command": "npx", - "args": ["-y", "--package=task-master-ai", "task-master-ai"], - "env": { - "ANTHROPIC_API_KEY": "your_key_here", - "PERPLEXITY_API_KEY": "your_key_here", - "OPENAI_API_KEY": "OPENAI_API_KEY_HERE", - "GOOGLE_API_KEY": "GOOGLE_API_KEY_HERE", - "XAI_API_KEY": "XAI_API_KEY_HERE", - "OPENROUTER_API_KEY": "OPENROUTER_API_KEY_HERE", - "MISTRAL_API_KEY": "MISTRAL_API_KEY_HERE", - "AZURE_OPENAI_API_KEY": "AZURE_OPENAI_API_KEY_HERE", - "OLLAMA_API_KEY": "OLLAMA_API_KEY_HERE" - } - } - } -} -``` - -### Essential MCP Tools - -```javascript -help; // = shows available taskmaster commands -// Project setup -initialize_project; // = task-master init -parse_prd; // = task-master parse-prd - -// Daily workflow -get_tasks; // = task-master list -next_task; // = task-master next -get_task; // = task-master show <id> -set_task_status; // = task-master set-status - -// Task management -add_task; // = task-master add-task -expand_task; // = task-master expand -update_task; // = task-master update-task -update_subtask; // = task-master update-subtask -update; // = task-master update - -// Analysis -analyze_project_complexity; // = task-master analyze-complexity -complexity_report; // = task-master complexity-report -``` - -## Claude Code Workflow Integration - -### Standard Development Workflow - -#### 1. Project Initialization - -```bash -# Initialize Task Master -task-master init - -# Create or obtain PRD, then parse it -task-master parse-prd .taskmaster/docs/prd.txt - -# Analyze complexity and expand tasks -task-master analyze-complexity --research -task-master expand --all --research -``` - -If tasks already exist, another PRD can be parsed (with new information only!) using parse-prd with --append flag. This will add the generated tasks to the existing list of tasks.. - -#### 2. Daily Development Loop - -```bash -# Start each session -task-master next # Find next available task -task-master show <id> # Review task details - -# During implementation, check in code context into the tasks and subtasks -task-master update-subtask --id=<id> --prompt="implementation notes..." - -# Complete tasks -task-master set-status --id=<id> --status=done -``` - -#### 3. Multi-Claude Workflows - -For complex projects, use multiple Claude Code sessions: - -```bash -# Terminal 1: Main implementation -cd project && claude - -# Terminal 2: Testing and validation -cd project-test-worktree && claude - -# Terminal 3: Documentation updates -cd project-docs-worktree && claude -``` - -### Custom Slash Commands - -Create `.claude/commands/taskmaster-next.md`: - -```markdown -Find the next available Task Master task and show its details. - -Steps: - -1. Run `task-master next` to get the next task -2. If a task is available, run `task-master show <id>` for full details -3. Provide a summary of what needs to be implemented -4. Suggest the first implementation step -``` - -Create `.claude/commands/taskmaster-complete.md`: - -```markdown -Complete a Task Master task: $ARGUMENTS - -Steps: - -1. Review the current task with `task-master show $ARGUMENTS` -2. Verify all implementation is complete -3. Run any tests related to this task -4. Mark as complete: `task-master set-status --id=$ARGUMENTS --status=done` -5. Show the next available task with `task-master next` -``` - -## Tool Allowlist Recommendations - -Add to `.claude/settings.json`: - -```json -{ - "allowedTools": [ - "Edit", - "Bash(task-master *)", - "Bash(git commit:*)", - "Bash(git add:*)", - "Bash(npm run *)", - "mcp__task_master_ai__*" - ] -} -``` - -## Configuration & Setup - -### API Keys Required - -At least **one** of these API keys must be configured: - -- `ANTHROPIC_API_KEY` (Claude models) - **Recommended** -- `PERPLEXITY_API_KEY` (Research features) - **Highly recommended** -- `OPENAI_API_KEY` (GPT models) -- `GOOGLE_API_KEY` (Gemini models) -- `MISTRAL_API_KEY` (Mistral models) -- `OPENROUTER_API_KEY` (Multiple models) -- `XAI_API_KEY` (Grok models) - -An API key is required for any provider used across any of the 3 roles defined in the `models` command. - -### Model Configuration - -```bash -# Interactive setup (recommended) -task-master models --setup - -# Set specific models -task-master models --set-main claude-3-5-sonnet-20241022 -task-master models --set-research perplexity-llama-3.1-sonar-large-128k-online -task-master models --set-fallback gpt-4o-mini -``` - -## Task Structure & IDs - -### Task ID Format - -- Main tasks: `1`, `2`, `3`, etc. -- Subtasks: `1.1`, `1.2`, `2.1`, etc. -- Sub-subtasks: `1.1.1`, `1.1.2`, etc. - -### Task Status Values - -- `pending` - Ready to work on -- `in-progress` - Currently being worked on -- `done` - Completed and verified -- `deferred` - Postponed -- `cancelled` - No longer needed -- `blocked` - Waiting on external factors - -### Task Fields - -```json -{ - "id": "1.2", - "title": "Implement user authentication", - "description": "Set up JWT-based auth system", - "status": "pending", - "priority": "high", - "dependencies": ["1.1"], - "details": "Use bcrypt for hashing, JWT for tokens...", - "testStrategy": "Unit tests for auth functions, integration tests for login flow", - "subtasks": [] -} -``` - -## Claude Code Best Practices with Task Master - -### Context Management - -- Use `/clear` between different tasks to maintain focus -- This CLAUDE.md file is automatically loaded for context -- Use `task-master show <id>` to pull specific task context when needed - -### Iterative Implementation - -1. `task-master show <subtask-id>` - Understand requirements -2. Explore codebase and plan implementation -3. `task-master update-subtask --id=<id> --prompt="detailed plan"` - Log plan -4. `task-master set-status --id=<id> --status=in-progress` - Start work -5. Implement code following logged plan -6. `task-master update-subtask --id=<id> --prompt="what worked/didn't work"` - Log progress -7. `task-master set-status --id=<id> --status=done` - Complete task - -### Complex Workflows with Checklists - -For large migrations or multi-step processes: - -1. Create a markdown PRD file describing the new changes: `touch task-migration-checklist.md` (prds can be .txt or .md) -2. Use Taskmaster to parse the new prd with `task-master parse-prd --append` (also available in MCP) -3. Use Taskmaster to expand the newly generated tasks into subtasks. Consdier using `analyze-complexity` with the correct --to and --from IDs (the new ids) to identify the ideal subtask amounts for each task. Then expand them. -4. Work through items systematically, checking them off as completed -5. Use `task-master update-subtask` to log progress on each task/subtask and/or updating/researching them before/during implementation if getting stuck - -### Git Integration - -Task Master works well with `gh` CLI: - -```bash -# Create PR for completed task -gh pr create --title "Complete task 1.2: User authentication" --body "Implements JWT auth system as specified in task 1.2" - -# Reference task in commits -git commit -m "feat: implement JWT auth (task 1.2)" -``` - -### Parallel Development with Git Worktrees - -```bash -# Create worktrees for parallel task development -git worktree add ../project-auth feature/auth-system -git worktree add ../project-api feature/api-refactor - -# Run Claude Code in each worktree -cd ../project-auth && claude # Terminal 1: Auth work -cd ../project-api && claude # Terminal 2: API work -``` - -## Troubleshooting - -### AI Commands Failing - -```bash -# Check API keys are configured -cat .env # For CLI usage - -# Verify model configuration -task-master models - -# Test with different model -task-master models --set-fallback gpt-4o-mini -``` - -### MCP Connection Issues - -- Check `.mcp.json` configuration -- Verify Node.js installation -- Use `--mcp-debug` flag when starting Claude Code -- Use CLI as fallback if MCP unavailable - -### Task File Sync Issues - -```bash -# Regenerate task files from tasks.json -task-master generate - -# Fix dependency issues -task-master fix-dependencies -``` - -DO NOT RE-INITIALIZE. That will not do anything beyond re-adding the same Taskmaster core files. - -## Important Notes - -### AI-Powered Operations - -These commands make AI calls and may take up to a minute: - -- `parse_prd` / `task-master parse-prd` -- `analyze_project_complexity` / `task-master analyze-complexity` -- `expand_task` / `task-master expand` -- `expand_all` / `task-master expand --all` -- `add_task` / `task-master add-task` -- `update` / `task-master update` -- `update_task` / `task-master update-task` -- `update_subtask` / `task-master update-subtask` - -### File Management - -- Never manually edit `tasks.json` - use commands instead -- Never manually edit `.taskmaster/config.json` - use `task-master models` -- Task markdown files in `tasks/` are auto-generated -- Run `task-master generate` after manual changes to tasks.json - -### Claude Code Session Management - -- Use `/clear` frequently to maintain focused context -- Create custom slash commands for repeated Task Master workflows -- Configure tool allowlist to streamline permissions -- Use headless mode for automation: `claude -p "task-master next"` - -### Multi-Task Updates - -- Use `update --from=<id>` to update multiple future tasks -- Use `update-task --id=<id>` for single task updates -- Use `update-subtask --id=<id>` for implementation logging - -### Research Mode - -- Add `--research` flag for research-based AI enhancement -- Requires a research model API key like Perplexity (`PERPLEXITY_API_KEY`) in environment -- Provides more informed task creation and updates -- Recommended for complex technical tasks - ---- - -_This guide ensures Claude Code has immediate access to Task Master's essential functionality for agentic development workflows._ diff --git a/.taskmaster/config.json b/.taskmaster/config.json deleted file mode 100644 index 027d21eb..00000000 --- a/.taskmaster/config.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "models": { - "main": { - "provider": "openai", - "modelId": "o3", - "maxTokens": 100000, - "temperature": 0.2 - }, - "research": { - "provider": "perplexity", - "modelId": "sonar-pro", - "maxTokens": 8700, - "temperature": 0.1 - }, - "fallback": { - "provider": "claude-code", - "modelId": "sonnet", - "maxTokens": 64000, - "temperature": 0.2 - } - }, - "global": { - "logLevel": "info", - "debug": false, - "defaultNumTasks": 10, - "defaultSubtasks": 5, - "defaultPriority": "medium", - "projectName": "ccproxy", - "ollamaBaseURL": "http://localhost:11434/api", - "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", - "responseLanguage": "English", - "defaultTag": "master", - "azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/", - "userId": "1234567890" - }, - "claudeCode": {} -} diff --git a/.taskmaster/docs/research/2025-07-29_best-practices-for-implementing-a-litellm-proxy-se.md b/.taskmaster/docs/research/2025-07-29_best-practices-for-implementing-a-litellm-proxy-se.md deleted file mode 100644 index 5a4632ef..00000000 --- a/.taskmaster/docs/research/2025-07-29_best-practices-for-implementing-a-litellm-proxy-se.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -title: Research Session -query: "Best practices for implementing a LiteLLM proxy server with custom routing hooks, model transformation pipelines, and advanced observability. Include latest LiteLLM features, security considerations, and integration patterns for 2025." -date: 7/29/2025 -time: 3:01:47 PM -timestamp: 2025-07-29T22:01:47.206Z -exchanges: 1 ---- - -# Research Session - -## Initial Query - -**Question:** Best practices for implementing a LiteLLM proxy server with custom routing hooks, model transformation pipelines, and advanced observability. Include latest LiteLLM features, security considerations, and integration patterns for 2025. - -**Response:** - -Implementing a **LiteLLM proxy server** with advanced routing, transformation, and observability requires a modular, secure, and extensible architecture. Below is a comprehensive guide tailored to your project context, incorporating the latest LiteLLM features, best practices for 2025, and actionable integration patterns. - ---- - -## 1. Project Initialization and Environment Setup - -**Best Practices:** -- **Python Environment:** Use Python 3.10+ for compatibility with the latest LiteLLM releases[1][5]. -- **Dependency Management:** Pin LiteLLM to the latest stable version (e.g., `>=2.5.0`), and include `typing-extensions`, `PyYAML`, `pydantic`, and testing/linting tools (`pytest`, `ruff`, `black`, `mypy`). -- **Version Control:** Initialize a Git repository with a `.gitignore` covering Python, VSCode, and environment files. -- **Pre-commit Hooks:** Set up `pre-commit` for linting and type checking to enforce code quality. -- **Documentation:** Document setup steps in `README.md` for reproducibility. - -**Example:** -```bash -python -m venv .venv -source .venv/bin/activate -pip install "litellm>=2.5.0" typing-extensions pyyaml "pydantic>=2.0" pytest ruff black mypy -``` - ---- - -## 2. Configuration Loader: YAML + Environment Overrides - -**Key Patterns:** -- **YAML Config:** Store model lists, routing, and transformation settings in a YAML file for clarity and versioning[1][3][4]. -- **Environment Overrides:** Allow environment variables to override YAML for secrets and deployment flexibility. -- **Schema Validation:** Use `pydantic` to validate config structure and types, ensuring early error detection. - -**Example Loader Skeleton:** -```python -import os -import yaml -from pydantic import BaseModel, ValidationError - -class ProxyConfig(BaseModel): - model_list: list - router_settings: dict = {} - # ... other fields - -def load_config(path: str) -> ProxyConfig: - with open(path) as f: - data = yaml.safe_load(f) - # Apply environment overrides here - # Example: data['context_threshold'] = os.getenv('CCPROXY_CONTEXT_THRESHOLD', data.get('context_threshold')) - return ProxyConfig(**data) -``` - -**Testing:** Unit test with valid/invalid YAML, missing fields, and env overrides. - ---- - -## 3. Custom Routing Hooks with LiteLLM - -**Latest LiteLLM Features:** -- **Custom Hooks:** Use `async_pre_call_hook` for request interception and routing logic[1][3]. -- **Routing Strategies:** Support for custom routing strategies (e.g., least-busy, round-robin) via `router_settings` in config[3]. -- **Extensibility:** Design routing logic to be easily extensible for new labels or rules. - -**Implementation:** -- **Routing Module:** Implement as `ccproxy_router.py`, mapping request context (token count, model, tools, etc.) to routing labels. -- **Fallbacks:** If a label is not configured, default to a base provider (e.g., Anthropic). -- **Logging:** Log all routing decisions for observability. - -**Example Hook:** -```python -from litellm.proxy.hooks import async_pre_call_hook - -class CCProxyRouter: - async def async_pre_call_hook(self, request, context): - # Inspect request, apply routing logic - label = self.route_request(request) - # Modify request or context as needed - return request, context -``` - ---- - -## 4. Model Transformation Pipelines - -**Best Practices:** -- **Composable Pipelines:** Allow chaining of multiple transformations (request/response rewriting, augmentation, etc.). -- **Plugin Interface:** Enable users to register custom transformations via config or plugin discovery. -- **Order Preservation:** Ensure transformations are applied in the configured order. - -**Implementation:** -- **Pipeline Design:** Use a list of callables or classes, each implementing a `transform(request, context)` method. -- **Registration:** Support dynamic registration via config or entry points. - -**Example Pipeline:** -```python -class TransformationPipeline: - def __init__(self, transforms): - self.transforms = transforms - - async def apply(self, request, context): - for transform in self.transforms: - request, context = await transform(request, context) - return request, context -``` - ---- - -## 5. Advanced Observability and Metrics - -**Latest Features:** -- **Built-in Observability:** LiteLLM supports logging hooks and metrics collection (`log_transformations`, `metrics_enabled`)[3][4]. -- **External Integration:** Integrate with Prometheus or OpenTelemetry for external monitoring if supported[2]. -- **Slow Event Detection:** Track and log slow transformation events with configurable thresholds. - -**Implementation:** -- **Logging:** Log all routing, transformation, and error events with context. -- **Metrics:** Track latency, error rates, and transformation counts. -- **Integration:** Expose metrics endpoints or push to external systems as needed. - -**Example:** -```yaml -general_settings: - metrics_enabled: true - log_transformations: true -``` - ---- - -## 6. Security and API Key Management - -**Best Practices:** -- **API Key Validation:** Use LiteLLM's `UserAPIKeyAuth` for authenticating requests[3]. -- **Secret Management:** Store API keys and sensitive config in environment variables or secure vaults (e.g., HashiCorp Vault, AWS Secrets Manager)[2]. -- **Transport Security:** Enforce HTTPS for all external API calls and proxy endpoints. Use SSL certificates via `ssl_keyfile_path` and `ssl_certfile_path` in deployment[2]. -- **Audit Logging:** Log authentication events and access attempts for auditability. - -**Example Secure Config:** -```yaml -general_settings: - master_key: ${LITELLM_MASTER_KEY} -``` -**Deployment:** -```bash -docker run ghcr.io/berriai/litellm:main-stable \ - --ssl_keyfile_path /path/to/keyfile.key \ - --ssl_certfile_path /path/to/certfile.crt -``` - ---- - -## 7. Integration Patterns and Deployment - -**Patterns:** -- **OpenAI-Compatible API:** LiteLLM proxy exposes an OpenAI-compatible endpoint, simplifying integration with existing clients[5]. -- **Multi-Provider Support:** Configure multiple providers (OpenAI, Azure, Ollama, vLLM, etc.) in `model_list`[1][4]. -- **Dockerization:** Use official LiteLLM Docker images for deployment. Support custom entrypoints and config mounting[2]. -- **12-Factor Compliance:** All config should be overridable via environment variables for cloud-native deployments. - -**Example Config:** -```yaml -model_list: - - model_name: gpt-3.5-turbo - litellm_params: - model: gpt-3.5-turbo - api_key: ${OPENAI_API_KEY} - - model_name: ollama/llama3 - litellm_params: - model: ollama/llama3 -``` - ---- - -## 8. Testing and Validation - -**Best Practices:** -- **Unit and Integration Tests:** Use `pytest` for >90% coverage, mocking external APIs and LiteLLM interfaces. -- **Edge Cases:** Test all routing, transformation, and error scenarios. -- **CI Integration:** Run tests and coverage checks on push/PR via GitHub Actions or similar. - ---- - -## 9. Documentation and Examples - -**Recommendations:** -- **Comprehensive Docs:** Document all config fields, environment variables, and extension APIs. -- **Usage Examples:** Provide real-world scenarios for routing, transformation, and observability. -- **Troubleshooting:** Include common issues and solutions. - ---- - -## 10. Edge Cases and Pitfalls - -- **Transformation Failures:** Ensure robust error handling in transformation pipelines to avoid request loss. -- **Routing Loops:** Guard against recursive or conflicting routing rules. -- **Secret Leakage:** Never log sensitive data; redact secrets in logs and error messages. -- **Performance:** Monitor transformation latency and optimize slow steps. - ---- - -## Summary Table: Key Features and Best Practices - -| Area | Best Practice / Feature | LiteLLM Support (2025) | -|---------------------|----------------------------------------------------------------------------------------|------------------------| -| Config Management | YAML + env overrides, pydantic validation | Yes | -| Routing | Custom async hooks, extensible logic, fallback support | Yes | -| Transformation | Chained, composable pipelines, plugin registration | Yes | -| Observability | Built-in logging/metrics, Prometheus/OpenTelemetry integration, slow event detection | Yes | -| Security | API key auth, env/vault secrets, HTTPS, audit logging | Yes | -| Deployment | Docker, OpenAI-compatible API, multi-provider, 12-factor compliance | Yes | -| Testing | Pytest, coverage, CI/CD integration | Yes | -| Documentation | Full config/API docs, usage examples, troubleshooting | Yes | - ---- - -**Actionable Next Steps for Your Project:** -- Implement the configuration loader and routing logic as described above, referencing your `ccproxy_router.py` and config schema. -- Design the transformation pipeline for composability and register via config. -- Leverage LiteLLM’s observability and security features, integrating with your preferred monitoring and secret management tools. -- Ensure all modules are thoroughly tested and documented, following the outlined best practices. - -If you need code templates or deeper dives into any specific area (e.g., transformation API, advanced routing examples, or observability integration), please specify, and I can provide detailed implementation guidance. - - ---- - -*Generated by Task Master Research Command* -*Timestamp: 2025-07-29T22:01:47.206Z* diff --git a/.taskmaster/reports/task-complexity-report.json b/.taskmaster/reports/task-complexity-report.json deleted file mode 100644 index 1166b2cf..00000000 --- a/.taskmaster/reports/task-complexity-report.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "meta": { - "generatedAt": "2025-07-30T01:24:06.776Z", - "tasksAnalyzed": 8, - "totalTasks": 10, - "analysisCount": 8, - "thresholdScore": 5, - "projectName": "ccproxy", - "usedResearch": false - }, - "complexityAnalysis": [ - { - "taskId": 3, - "taskTitle": "Develop RequestClassifier Module", - "complexityScore": 8, - "recommendedSubtasks": 6, - "expansionPrompt": "Expand this task by adding any missing implementation, refactoring, or documentation subtasks needed to fully deliver a robust, extensible RequestClassifier. Include steps for performance profiling, additional rule plug-in examples, and developer documentation.", - "reasoning": "Requires design abstraction, pure-function rule set, configurability, 100 % branch coverage, and future ML extensibility—high algorithmic and testing effort." - }, - { - "taskId": 4, - "taskTitle": "Implement ModelRouter Component", - "complexityScore": 7, - "recommendedSubtasks": 6, - "expansionPrompt": "Break down this task further to cover cache strategy for model lookups, concurrency/thread-safety validation, and detailed documentation of YAML schema and hot-reload behaviour.", - "reasoning": "Dynamic config loading, fallback logic, hot-reload, and validation introduce moderate architectural and concurrency concerns." - }, - { - "taskId": 5, - "taskTitle": "Build CCProxyHandler as LiteLLM CustomLogger", - "complexityScore": 8, - "recommendedSubtasks": 6, - "expansionPrompt": "Add subtasks for end-to-end manual QA with real providers, concurrency stress tests on async_pre_call_hook, and security audit of logged metadata.", - "reasoning": "Integrates multiple components asynchronously, must avoid sensitive logging, support streaming, and remain compatible with external library versions." - }, - { - "taskId": 6, - "taskTitle": "Integrate MetricsCollector for Routing and Performance", - "complexityScore": 6, - "recommendedSubtasks": 5, - "expansionPrompt": "Detail subtasks for metrics aggregation under high load, retention/rotation strategy, and dashboard creation (Grafana or equivalent).", - "reasoning": "Moderate scope involving instrumentation, endpoint exposure, and integration, but leverages well-known libraries." - }, - { - "taskId": 7, - "taskTitle": "Implement Secure API Key and Secrets Management", - "complexityScore": 5, - "recommendedSubtasks": 5, - "expansionPrompt": "Include subtasks for secret rotation procedures, automated lint rule to detect committed secrets, and developer onboarding guide for secure practices.", - "reasoning": "Security critical but conceptually straightforward; mainly configuration, validation, and logging hygiene." - }, - { - "taskId": 8, - "taskTitle": "Develop Comprehensive Test Suite", - "complexityScore": 9, - "recommendedSubtasks": 7, - "expansionPrompt": "Further decompose into subtasks for CI optimisation (parallelisation, test matrix), flaky test detection, and detailed performance benchmarking harness.", - "reasoning": "Covers unit, integration, performance tests across entire system with >90 % coverage and latency targets—significant breadth and tooling complexity." - }, - { - "taskId": 9, - "taskTitle": "Write Documentation and Usage Examples", - "complexityScore": 6, - "recommendedSubtasks": 6, - "expansionPrompt": "Add subtasks for automated doc build in CI, versioned documentation strategy, and inclusion of interactive examples (e.g., Jupyter notebooks or Repl.it).", - "reasoning": "Requires comprehensive, user-friendly docs across multiple sections; moderate complexity but largely editorial." - }, - { - "taskId": 10, - "taskTitle": "Productionize: Performance, Security, and Monitoring Hardening", - "complexityScore": 8, - "recommendedSubtasks": 7, - "expansionPrompt": "Expand into subtasks for blue-green deployment strategy, auto-scaling policy definition, chaos testing, and SOC2/ISO compliance checklist alignment.", - "reasoning": "Multiple advanced production facets—performance, rate limiting, security, deployment artifacts—requiring cross-disciplinary expertise and validation." - } - ] -} diff --git a/.taskmaster/state.json b/.taskmaster/state.json deleted file mode 100644 index a539a053..00000000 --- a/.taskmaster/state.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "migrationNoticeShown": true -} diff --git a/.taskmaster/tasks/task_001.txt b/.taskmaster/tasks/task_001.txt deleted file mode 100644 index 294db782..00000000 --- a/.taskmaster/tasks/task_001.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 1 -# Title: Setup Project Repository and Environment -# Status: done -# Dependencies: None -# Priority: high -# Description: Initialize the ccproxy project repository with Python tooling, environment management, and CI/CD setup. -# Details: -Use Python 3.11+ for best async support. Initialize with Poetry or pip-tools for dependency management. Set up pre-commit hooks (black, isort, flake8). Configure GitHub Actions for CI (lint, test, coverage). Add .env.example for environment variables (API keys, config paths). Ensure all dependencies are pinned to latest compatible versions. Use pyproject.toml for unified configuration. - -# Test Strategy: -Verify environment setup by running lint, format, and a sample test in CI. Ensure .env.example is present and all scripts run without error. - -# Subtasks: -## 1. Initialize Git Repository and Project Structure [done] -### Dependencies: None -### Description: Create a new Git repository for the ccproxy project and establish a standardized Python project structure, including source, tests, and configuration directories. -### Details: -Set up the root directory with folders for source code (e.g., ccproxy/), tests/, and configs/. Add essential files such as README.md, .gitignore, and pyproject.toml. Ensure the structure supports future scalability and maintainability. - -## 2. Configure Python Environment and Dependency Management [done] -### Dependencies: 1.1 -### Description: Set up Python 3.11+ environment and initialize dependency management using Poetry or pip-tools. -### Details: -Create a virtual environment targeting Python 3.11 or newer. Initialize dependency management with Poetry (preferred) or pip-tools. Add core development dependencies (black, isort, flake8, pytest). Ensure all dependencies are pinned to the latest compatible versions in pyproject.toml. - -## 3. Set Up Pre-commit Hooks for Code Quality [done] -### Dependencies: 1.2 -### Description: Integrate pre-commit hooks to enforce code formatting and linting standards using black, isort, and flake8. -### Details: -Install pre-commit and configure .pre-commit-config.yaml to run black, isort, and flake8 on staged files. Ensure hooks are installed in the repository so contributors automatically run checks before commits. - -## 4. Configure GitHub Actions for CI/CD [done] -### Dependencies: 1.3 -### Description: Set up GitHub Actions workflows to automate linting, testing, and coverage reporting on push and pull requests. -### Details: -Create workflow YAML files under .github/workflows/ to run lint, test, and coverage jobs using the configured Python environment. Ensure the workflow uses the same dependency versions as local development and reports status checks. - -## 5. Add Environment Variable Management and Example File [done] -### Dependencies: 1.2 -### Description: Provide a .env.example file listing required environment variables and integrate environment variable loading into the project. -### Details: -Create a .env.example file specifying placeholders for API keys and config paths. Ensure the project loads environment variables using python-dotenv or similar. Document usage in README.md. diff --git a/.taskmaster/tasks/task_002.txt b/.taskmaster/tasks/task_002.txt deleted file mode 100644 index fed05d87..00000000 --- a/.taskmaster/tasks/task_002.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 2 -# Title: Implement Configuration Manager -# Status: done -# Dependencies: 1 -# Priority: high -# Description: Develop a configuration loader supporting YAML config and environment variable overrides for model routing and proxy settings. -# Details: -Use PyYAML (>=6.0) for YAML parsing. Support merging of config.yaml and environment variables (os.environ). Validate schema using pydantic (v2.x) for type safety. Allow hot-reload if config changes. Expose config as a singleton or dependency-injectable object. - -# Test Strategy: -Unit test config parsing, environment override precedence, and schema validation. Test with malformed and missing configs. - -# Subtasks: -## 1. Design Configuration Schema with Pydantic [done] -### Dependencies: None -### Description: Define a Pydantic v2.x model representing the configuration schema for model routing and proxy settings, ensuring type safety and validation. -### Details: -Specify all required fields, types, and validation rules for the configuration. Include support for nested structures as needed for model routing and proxy settings. - -## 2. Implement YAML Configuration Loader [done] -### Dependencies: 2.1 -### Description: Develop a loader using PyYAML (>=6.0) to parse config.yaml and instantiate the Pydantic schema. -### Details: -Read and parse the YAML file, handle parsing errors, and map the data to the Pydantic model. Ensure compatibility with nested and complex YAML structures. - -## 3. Integrate Environment Variable Overrides [done] -### Dependencies: 2.2 -### Description: Merge environment variables (os.environ) into the loaded configuration, allowing them to override YAML values according to precedence rules. -### Details: -Implement logic to map environment variables to configuration fields, supporting both flat and nested overrides. Ensure environment variables take precedence over YAML values. - -## 4. Enable Hot-Reload on Configuration Changes [done] -### Dependencies: 2.3 -### Description: Add support for detecting changes in config.yaml or relevant environment variables and reloading the configuration at runtime. -### Details: -Monitor the config file for changes (e.g., using watchdog) and re-apply environment overrides and schema validation on reload. Provide hooks or signals for dependent components to react to config changes. - -## 5. Expose Configuration as Singleton or Injectable Object [done] -### Dependencies: 2.4 -### Description: Provide a globally accessible configuration instance, supporting singleton pattern or dependency injection for use throughout the application. -### Details: -Implement a thread-safe singleton or dependency-injectable provider for the configuration object. Ensure consumers always access the latest configuration, including after hot-reload. diff --git a/.taskmaster/tasks/task_003.txt b/.taskmaster/tasks/task_003.txt deleted file mode 100644 index 0beaf6f9..00000000 --- a/.taskmaster/tasks/task_003.txt +++ /dev/null @@ -1,88 +0,0 @@ -# Task ID: 3 -# Title: Develop RequestClassifier Module -# Status: done -# Dependencies: 2 -# Priority: high -# Description: Implement request classification logic to assign routing labels based on request context (token count, model, tools, etc.). -# Details: -Encapsulate classification logic as a class with a classify(request) method. Use the priority order from the PRD. Accept request as a dict or pydantic model. Make context threshold configurable. Write pure functions for each rule for testability. Prepare for future extensibility (e.g., ML-based classification). - -# Test Strategy: -Unit test all classification branches with representative request fixtures. Achieve 100% branch coverage. - -# Subtasks: -## 1. Design RequestClassifier Class Structure [done] -### Dependencies: None -### Description: Define the RequestClassifier class interface, including the classify(request) method, input types (dict or pydantic model), and encapsulation of classification logic. -### Details: -Establish the class skeleton, document method signatures, and ensure the design supports future extensibility (e.g., ML-based classification). -<info added on 2025-07-30T01:19:08.216Z> -Implemented full rule-based classification system: - -• Added abstract base class `ClassificationRule` with `priority`, `evaluate(request)` and `supports(request)` hooks for extensible rule definition. -• Defined `RoutingLabel` enum covering default, background, think, large_context, and web_search paths. -• Built `RequestClassifier` with: - – `classify(request)` accepting dict or pydantic BaseModel - – `add_rule(*rules)`, `clear_rules()`, `reset_rules()` for dynamic rule management - – Optional custom rule list injected at init; falls back to default rules in defined priority order. -• Introduced `Classifier` typing `Protocol` to ensure type-safe interchangeability with future ML classifiers. -• Implemented default rules: - 1. `TokenCountRule` (configurable max_tokens) → large_context - 2. `ModelNameRule` (matches lite models, e.g., “gpt-4o-mini”) → background - 3. `ThinkingRule` (detects system/assistant thinking prefix) → think - 4. `WebSearchRule` (presence of “web_search” tool call) → web_search - 5. Fallback → default -• Wrote comprehensive pytest suite (100 % line & branch coverage) exercising: - – All routing labels and default priority ordering - – Dict vs pydantic inputs - – Rule addition, clearing, and resetting behaviour - – Edge cases: empty request, unsupported fields, conflicting rules -• CI updated to enforce coverage threshold and run classifier tests in isolation. -</info added on 2025-07-30T01:19:08.216Z> - -## 2. Implement Rule-Based Classification Logic [done] -### Dependencies: 3.1 -### Description: Develop pure functions for each classification rule (e.g., token count, model, tools) and integrate them into the classify method following the PRD priority order. -### Details: -Ensure each rule is implemented as a standalone pure function for testability and maintainability. Integrate these functions within the main classification flow. -<info added on 2025-07-30T01:29:05.552Z> -Implemented TokenCountRule, ModelNameRule, ThinkingRule, and WebSearchRule as standalone pure functions and wired them into RequestClassifier._setup_rules() following the PRD priority order. Added full-stack tests covering priority conflicts, realistic request scenarios, and edge cases; test suite now passes with 100 % coverage on the classifier module and 98 % on the rules module. -</info added on 2025-07-30T01:29:05.552Z> - -## 3. Add Configurable Context Thresholds [done] -### Dependencies: 3.1 -### Description: Enable configuration of context thresholds (e.g., token count limits) via class parameters or external config, supporting dynamic adjustment without code changes. -### Details: -Integrate context threshold parameters into the class, ensuring they can be set at initialization or updated dynamically. Document configuration options. - -## 4. Prepare for Extensibility and ML Integration [done] -### Dependencies: 3.2, 3.3 -### Description: Refactor classification logic to allow seamless addition of new rules or ML-based classifiers in the future. -### Details: -Abstract rule evaluation and routing label assignment to support plug-in architectures or ML-based decision modules. Document extension points. -<info added on 2025-07-30T03:42:36.498Z> -Scope realignment for v0.9: - -• Document existing extension points: explain the ClassificationRule ABC (required methods, expected return values) and the add_rule/clear_rules API in RequestClassifier. -• Provide rich docstring examples in both RequestClassifier and ClassificationRule that show how to implement and register a custom rule. -• Add an illustrative CustomHeaderRule in the test suite; register it with add_rule and assert correct routing label on a fixture request. -• Expand unit tests to verify that custom rules can be added, cleared, and do not interfere with built-in rules. -• Remove references to future ML or plug-in architectures to avoid premature complexity. -</info added on 2025-07-30T03:42:36.498Z> - -## 5. Develop Comprehensive Unit Tests for Classification [done] -### Dependencies: 3.2, 3.3, 3.4 -### Description: Create unit tests covering all classification branches, edge cases, and input types to achieve 100% branch coverage. -### Details: -Use representative request fixtures to test all rule combinations and context threshold scenarios. Ensure tests are isolated and repeatable. -<info added on 2025-07-30T19:23:58.654Z> -Achieved 100% branch and line coverage for RequestClassifier tests; all pytest suites pass. Added demo/ directory to showcase LiteLLM proxy integration: - -• demo_config.yaml – full LiteLLM configuration loading CCProxy via custom_callbacks.proxy_handler_instance -• custom_callbacks.py – injects CCProxy into PYTHONPATH for config-based loading -• demo_requests.py – standalone script exercising all seven routing scenarios -• test_requests.py – verifies live proxy routing against expected models -• README.md – instructions and usage examples - -Confirmed that CCProxy can be launched solely through the YAML config and functions correctly when running `litellm --config demo/demo_config.yaml --port 8888`. -</info added on 2025-07-30T19:23:58.654Z> diff --git a/.taskmaster/tasks/task_004.txt b/.taskmaster/tasks/task_004.txt deleted file mode 100644 index cc1f1ea5..00000000 --- a/.taskmaster/tasks/task_004.txt +++ /dev/null @@ -1,47 +0,0 @@ -# Task ID: 4 -# Title: Implement ModelRouter Component -# Status: done -# Dependencies: 2 -# Priority: high -# Description: Map classification labels to model configurations as defined in the YAML config, supporting dynamic provider/model selection and public APIs for LiteLLM hooks. -# Details: -The ModelRouter must - • Load the model-routing map from the Configuration Manager at start-up - • Provide classification-aware routing through get_model_for_label(label) - • Expose a public API (get_model_list, model_list, model_group_alias, get_available_models) so that LiteLLM hooks can import the singleton instance as litellm.proxy.proxy_server.llm_router - • Preserve and surface model_info metadata so hooks such as CCProxyHandler can make additional routing decisions - • Fall back to secondary models when the preferred model is unavailable - • Validate that every referenced model exists in Configuration Manager’s model list - • Support atomic hot-reload when the YAML config changes - • Include thorough docstrings and short README section demonstrating ‘Accessing Model Configuration in LiteLLM Hooks’ (as provided in new context) - -# Test Strategy: -1. Unit test: label-to-model mapping, fallback behaviour, error handling for missing models. -2. Unit test: public methods (get_model_list, model_list property, model_group_alias, get_available_models) – verify structure matches spec and that metadata is preserved. -3. Integration test: simulate LiteLLM CustomLogger importing llm_router and accessing model list. -4. Hot-reload test: modify YAML at runtime and assert atomic update with no request errors. - -# Subtasks: -## 1. Load and Parse Model Mapping from YAML Config [done] -### Dependencies: None -### Description: Implement logic to load and parse the model mapping definitions from the YAML configuration file, ensuring compatibility with the Configuration Manager and support for dynamic provider/model selection. -### Details: -Utilise the Configuration Manager to extract model routing information, validate the schema (including optional model_info metadata), and prepare internal data structures for fast lookup and export via get_model_list(). - -## 2. Implement get_model_for_label Method [done] -### Dependencies: 4.1 -### Description: Develop the get_model_for_label(label) method to return the appropriate model configuration for a given classification label, as defined in the loaded mapping. -### Details: -Ensure the method returns the full model entry (including litellm_params and model_info) and triggers fallback logic if the preferred model is unavailable. Include graceful handling of unknown labels. - -## 3. Expose Public API Methods for LiteLLM Hooks [done] -### Dependencies: 4.1, 4.2 -### Description: Add public methods and properties (get_model_list, model_list, model_group_alias, get_available_models) and ensure the ModelRouter instance is importable as llm_router for use inside LiteLLM hooks. -### Details: -Return list of dicts with keys: model_name, litellm_params, model_info. Document usage with code snippet provided in the new context. Maintain thread-safe read-only access. - -## 4. Support Hot-Reload of Model Mapping on Config Changes [done] -### Dependencies: 4.1, 4.2, 4.3 -### Description: Implement logic to detect changes in the YAML config and reload the model mapping dynamically without requiring a service restart. -### Details: -Integrate with the Configuration Manager’s hot-reload mechanism. Ensure atomic swap of internal routing tables and that public API properties always return a consistent view. Cover race-conditions with async requests. diff --git a/.taskmaster/tasks/task_005.txt b/.taskmaster/tasks/task_005.txt deleted file mode 100644 index f1f896e4..00000000 --- a/.taskmaster/tasks/task_005.txt +++ /dev/null @@ -1,68 +0,0 @@ -# Task ID: 5 -# Title: Build CCProxyHandler as LiteLLM CustomLogger -# Status: in-progress -# Dependencies: 3, 4 -# Priority: high -# Description: Implement the main LiteLLM CustomLogger handler with async_pre_call_hook for context-aware routing and logging. -# Details: -Inherit from litellm.integrations.custom_logger.CustomLogger. In async_pre_call_hook, use RequestClassifier to label requests and ModelRouter to set the model. Log routing decisions with structured logging (use structlog or standard logging with JSON formatter). Ensure compatibility with LiteLLM v1.13+ proxy mode. Avoid logging sensitive content. Support both streaming and non-streaming requests. - -# Test Strategy: -Integration test with LiteLLM proxy, verifying correct model routing and logging output for all request types. - -# Subtasks: -## 1. Define CCProxyHandler Class Structure [done] -### Dependencies: None -### Description: Create the CCProxyHandler class inheriting from litellm.integrations.custom_logger.CustomLogger, ensuring all required methods for LiteLLM custom loggers are stubbed and ready for implementation. -### Details: -Set up the class skeleton with async_pre_call_hook and other relevant async logging methods. Ensure compatibility with LiteLLM v1.13+ proxy mode and prepare for structured logging integration. -<info added on 2025-07-30T19:25:41.408Z> -Implementation complete: CCProxyHandler is now fully implemented in ccproxy/handler.py, inheriting from litellm.integrations.custom_logger.CustomLogger. All required async methods—async_pre_call_hook, async_log_success_event, async_log_failure_event, and async_log_stream_event—are fully functional with structured JSON logging, request classification calls, and dynamic model routing. Code passes linting and type checks and has been verified against LiteLLM v1.13+ proxy mode. Subtask can be marked done; proceed to integrating routing logic in Subtask 5.2. -</info added on 2025-07-30T19:25:41.408Z> - -## 2. Integrate Request Classification and Model Routing [done] -### Dependencies: 5.1 -### Description: Implement logic in async_pre_call_hook to use RequestClassifier for labeling requests and ModelRouter to select the appropriate model based on the label. -### Details: -Call RequestClassifier.classify(request) to obtain a label, then use ModelRouter.get_model_for_label(label) to determine the model. Ensure the selected model is set in the request context for downstream processing. - -## 3. Implement Structured Logging for Routing Decisions [done] -### Dependencies: 5.2 -### Description: Add structured logging to record routing decisions, using structlog or standard logging with a JSON formatter, while ensuring no sensitive content is logged. -### Details: -Log key routing metadata (label, selected model, request ID, timestamp) in structured JSON format. Mask or exclude sensitive fields such as prompts, completions, or API keys. - -## 4. Support Streaming and Non-Streaming Request Handling [done] -### Dependencies: 5.3 -### Description: Ensure CCProxyHandler correctly handles both streaming and non-streaming requests in async_pre_call_hook and logging methods. -### Details: -Detect request type and adapt logging and routing logic as needed. Validate that all relevant events are logged for both request types without data leakage. - -## 5. Validate Compatibility and Security Requirements [pending] -### Dependencies: 5.4 -### Description: Test CCProxyHandler for compatibility with LiteLLM v1.13+ proxy mode and ensure no sensitive content is logged at any stage. -### Details: -Run end-to-end tests with the full proxy stack, confirming handler registration, correct operation, and strict adherence to security requirements (no logging of prompts, completions, or secrets). -<info added on 2025-07-30T19:26:31.046Z> -Initial smoke verification completed during demo: -• Ran LiteLLM in proxy mode (v1.13+) with litellm --config demo/demo_config.yaml --port 8888 -• CCProxyHandler loaded from YAML, auto-registered, routed requests successfully -• Verified log output: prompts, completions, and API keys are absent or masked - -Next steps – expand coverage with formal integration test suite: -1. Create pytest-based e2e tests under tests/integration/proxy/ -2. Test matrix: - – request types: chat/completion, embeddings, moderation - – modes: streaming vs non-streaming - – auth states: valid key, missing key, revoked key - – routing labels: small, large, tools, fallback - – concurrency: ≥10 parallel requests (async) - – failure scenarios: provider 4xx/5xx, timeout, token limit -3. Assertions: - – Correct handler registration (inspect litellm.proxy_server.custom_logger) - – ModelRouter returns expected model per label - – Response parity between direct and proxied calls - – Logs contain routing metadata only; redact/mask any sensitive fields -4. Add GitHub Actions job “integration-proxy” to run the suite against a containerised LiteLLM proxy started with demo_config.yaml -5. Mark subtask complete when all tests pass and coverage ≥90 % for CCProxyHandler codepath -</info added on 2025-07-30T19:26:31.046Z> diff --git a/.taskmaster/tasks/task_006.txt b/.taskmaster/tasks/task_006.txt deleted file mode 100644 index 35696982..00000000 --- a/.taskmaster/tasks/task_006.txt +++ /dev/null @@ -1,53 +0,0 @@ -# Task ID: 6 -# Title: Implement Claude Wrapper Script with Auto-Managed CCProxy -# Status: pending -# Dependencies: 2, 5 -# Priority: high -# Description: Create a Python CLI wrapper that transparently starts/reuses a LiteLLM+CCProxy instance, forwards all user-supplied "claude" arguments through the proxy, and shuts the proxy down when no Claude sessions remain. -# Details: -1. Placement & Packaging - • Add module ccproxy.claude_wrapper and expose an entry-point "claude" via pyproject.toml so users simply run "claude ...". - • Keep the original Anthropic CLI semantics: forward every CLI arg/flag untouched. - -2. Runtime Flow - a) Process Co-ordination - • Acquire a file lock (e.g., fasteners.InterProcessLock at ~/.ccproxy/claude.lock) to serialize start/stop decisions. - • Inside the lock read ~/.ccproxy/claude_proxy.json containing {pid, port, start_time, refcount} if it exists. - b) Proxy Reuse or Spawn - • Validate the recorded PID is alive and listening; if so, increment refcount and continue. - • Otherwise choose a free port (socket.bind(('',0)).getsockname()[1]), construct env vars (LITELLM_PROXY_PORT, HTTP_PROXY, HTTPS_PROXY, OPENAI_BASE_URL, etc.) and launch: - subprocess.Popen([ - sys.executable, - "-m","ccproxy.run_proxy", - "--port", str(port), - "--handler","ccproxy.handlers.CCProxyHandler" - ], stdout=logfile, stderr=logfile, env=clean_env) - • Persist new pid/port/refcount=1 to claude_proxy.json. - c) Execute Real Claude - • Build env for the child process: inherit current env + proxy vars so Anthropic CLI routes through LiteLLM. - • Use subprocess.call(["anthropic","..."], env=wrapped_env, pass_fds=[]). - d) Shutdown Logic (finally block) - • Re-acquire lock, decrement refcount; if 0 send SIGINT then SIGTERM (5-second grace) to proxy pid and delete state file. - -3. Cross-Platform & Robustness - • Use psutil where available for PID liveness; fall back to os.kill on POSIX and ctypes on Windows. - • Redirect proxy stdout/stderr to ~/.ccproxy/proxy.log; rotate daily with logging.handlers.RotatingFileHandler. - • Never print API keys; redact with **** if the user enables --verbose on wrapper. - • Respect existing user proxy settings by only overriding for Anthropic-specific variables. - -4. Configuration Hooks - • Honour CC_PROXY_CONFIG, CC_PROXY_PORT, and CC_PROXY_LOG env vars for power users. - • Consume Configuration Manager (Task 2) to load yaml/env overrides if present so the spawned proxy picks up the same routing table. - -5. Documentation Stub - • Add a section in docs/usage.md: “Running the Anthropic CLI via ccproxy” with examples and troubleshooting tips. - -# Test Strategy: - - -# Subtasks: -## 1. Productionize: Performance, Security, and Monitoring Hardening [pending] -### Dependencies: 6.7, 6.8, 6.9, 6.10 -### Description: Finalize production readiness with benchmarking, rate limiting, abuse prevention, and deployment best practices. -### Details: -Benchmark concurrent request handling (use locust or wrk). Implement rate limiting with slowapi or similar. Harden HTTP endpoints (CORS, timeouts, error handling). Document deployment (Dockerfile, k8s manifests). Ensure logging and metrics are production-grade. Prepare for future extensibility (plugin hooks). diff --git a/.taskmaster/tasks/task_007.txt b/.taskmaster/tasks/task_007.txt deleted file mode 100644 index 4fee3f29..00000000 --- a/.taskmaster/tasks/task_007.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 7 -# Title: Integrate MetricsCollector for Routing and Performance -# Status: pending -# Dependencies: 5 -# Priority: medium -# Description: Track routing decisions, performance metrics, and error rates for monitoring and optimization. -# Details: -Implement MetricsCollector using Prometheus client (prometheus_client >=0.18) or OpenTelemetry. Expose metrics endpoint (/metrics) for scraping. Track per-label routing counts, latency, error rates, and fallback events. Integrate with CCProxyHandler to record metrics on each request. - -# Test Strategy: -Unit and integration test metrics emission. Use Prometheus query to verify metrics are updated correctly under simulated load. - -# Subtasks: -## 1. Design Metrics Schema and Labeling Strategy [pending] -### Dependencies: None -### Description: Define the metrics to be collected (routing counts, latency, error rates, fallback events) and establish a labeling strategy for per-label tracking. -### Details: -Specify metric names, types (counter, histogram, gauge), and labels (e.g., route label, status, error type). Ensure schema supports both Prometheus and OpenTelemetry conventions for compatibility. - -## 2. Implement MetricsCollector with Prometheus Client or OpenTelemetry SDK [pending] -### Dependencies: 7.1 -### Description: Develop the MetricsCollector class using prometheus_client (>=0.18) or OpenTelemetry SDK to record defined metrics. -### Details: -Instrument code to create and update metrics objects. Ensure thread/process safety and efficient metric updates. Support both Prometheus and OpenTelemetry backends as needed. - -## 3. Expose /metrics Endpoint for Scraping [pending] -### Dependencies: 7.2 -### Description: Add an HTTP endpoint (/metrics) to expose collected metrics in Prometheus format for scraping by monitoring systems. -### Details: -Integrate with the web framework to serve the /metrics endpoint. Ensure endpoint outputs metrics in the correct format and is accessible for Prometheus or OpenTelemetry Collector scraping. - -## 4. Integrate MetricsCollector with CCProxyHandler [pending] -### Dependencies: 7.2 -### Description: Modify CCProxyHandler to record metrics for each request, capturing routing decisions, latency, errors, and fallback events. -### Details: -Inject MetricsCollector into CCProxyHandler. Update handler logic to record metrics at appropriate points in the request lifecycle, ensuring all relevant events are tracked. - -## 5. Test Metrics Emission and Monitoring Integration [pending] -### Dependencies: 7.3, 7.4 -### Description: Validate that metrics are emitted correctly under simulated load and can be queried via Prometheus or OpenTelemetry. -### Details: -Develop unit and integration tests to simulate various routing, error, and fallback scenarios. Use Prometheus queries to verify metrics accuracy and completeness. diff --git a/.taskmaster/tasks/task_008.txt b/.taskmaster/tasks/task_008.txt deleted file mode 100644 index 4e66bb23..00000000 --- a/.taskmaster/tasks/task_008.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 8 -# Title: Implement Secure API Key and Secrets Management -# Status: pending -# Dependencies: 1 -# Priority: high -# Description: Ensure all API keys and secrets are securely loaded from environment variables and never logged or exposed. -# Details: -Use python-dotenv for local development. Validate presence of required secrets at startup. Mask secrets in logs and error messages. Enforce HTTPS for all outbound requests using httpx (>=0.27) with verify=True. Document required environment variables. - -# Test Strategy: -Unit test secret loading and masking. Attempt to log secrets and verify they are redacted. Integration test HTTPS enforcement. - -# Subtasks: -## 1. Load Secrets from Environment Variables Using python-dotenv [pending] -### Dependencies: None -### Description: Configure the application to load all API keys and secrets from environment variables, utilizing python-dotenv for local development environments. -### Details: -Set up a .env file for local use and ensure python-dotenv loads these variables at startup. Avoid hard-coding any secrets in the codebase. Confirm .env is excluded from version control. - -## 2. Validate Presence of Required Secrets at Startup [pending] -### Dependencies: 8.1 -### Description: Implement logic to check that all required API keys and secrets are present in the environment at application startup, failing fast if any are missing. -### Details: -Define a list of required environment variables. On startup, iterate through this list and raise a clear error if any are missing. - -## 3. Mask Secrets in Logs and Error Messages [pending] -### Dependencies: 8.2 -### Description: Ensure that secrets are never logged or exposed in error messages by implementing masking or redaction logic throughout the codebase. -### Details: -Intercept log and error outputs to detect and redact any values matching known secrets or secret patterns before outputting. - -## 4. Enforce HTTPS with Certificate Verification for Outbound Requests [pending] -### Dependencies: 8.2 -### Description: Configure all outbound HTTP requests using httpx (>=0.27) to require HTTPS with certificate verification enabled. -### Details: -Set up httpx clients with verify=True for all requests. Audit code to ensure no insecure (HTTP) endpoints are used. - -## 5. Document Required Environment Variables and Security Practices [pending] -### Dependencies: 8.1, 8.2, 8.3, 8.4 -### Description: Create and maintain documentation listing all required environment variables, their purpose, and best practices for secure secrets management. -### Details: -Write documentation specifying each required secret, example .env usage, and guidelines for secure handling in different environments. diff --git a/.taskmaster/tasks/task_009.txt b/.taskmaster/tasks/task_009.txt deleted file mode 100644 index 04f886ec..00000000 --- a/.taskmaster/tasks/task_009.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 9 -# Title: Develop Comprehensive Test Suite -# Status: pending -# Dependencies: 3, 4, 5, 7, 8 -# Priority: high -# Description: Achieve >90% code coverage with unit, integration, and performance tests for all core modules and routing logic. -# Details: -Use pytest (>=8.0) and pytest-asyncio for async tests. Mock LiteLLM and external APIs. Cover all classification, routing, config, and fallback logic. Add integration tests simulating full request lifecycle. Use coverage.py to enforce coverage threshold. Include performance tests for routing overhead (<10ms per request). - -# Test Strategy: -Run pytest with coverage. Fail CI if coverage <90%. Benchmark routing latency under load. - -# Subtasks: -## 1. Design Unit Test Coverage for Core Modules [pending] -### Dependencies: None -### Description: Identify all core modules, including classification, routing, config, and fallback logic, and design unit tests to achieve comprehensive branch and logic coverage. -### Details: -Enumerate all functions and classes in core modules. Define representative test cases for each logic branch, including edge cases. Use pytest (>=8.0) and pytest-asyncio for async code. Mock LiteLLM and external APIs as needed. - -## 2. Implement Integration Tests for Full Request Lifecycle [pending] -### Dependencies: 9.1 -### Description: Develop integration tests that simulate the complete request lifecycle, covering interactions between modules and realistic scenarios. -### Details: -Set up test cases that send requests through the full stack, including classification, routing, config, and fallback. Mock external APIs and LiteLLM. Use pytest-asyncio for async flows. - -## 3. Mock LiteLLM and External API Dependencies [pending] -### Dependencies: 9.1 -### Description: Develop robust mocks for LiteLLM and all external APIs to ensure tests are deterministic and isolated from external failures. -### Details: -Implement fixtures and mock classes for LiteLLM and any external services. Ensure mocks simulate expected responses and error conditions. - -## 4. Enforce and Monitor Code Coverage Thresholds [pending] -### Dependencies: 9.1, 9.2, 9.3 -### Description: Integrate coverage.py with pytest to enforce a minimum 90% code coverage threshold and fail CI if unmet. -### Details: -Configure coverage.py to measure coverage during test runs. Set up CI to fail if coverage drops below 90%. Generate coverage reports for review. - -## 5. Develop Performance Tests for Routing Overhead [pending] -### Dependencies: 9.2, 9.3 -### Description: Create performance tests to benchmark routing logic, ensuring average overhead remains below 10ms per request under load. -### Details: -Use pytest and async benchmarking tools to simulate concurrent requests. Measure and record routing latency. Optimize code if overhead exceeds target. diff --git a/.taskmaster/tasks/task_010.txt b/.taskmaster/tasks/task_010.txt deleted file mode 100644 index 72c0a02d..00000000 --- a/.taskmaster/tasks/task_010.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Task ID: 10 -# Title: Write Documentation and Usage Examples -# Status: pending -# Dependencies: 5, 9 -# Priority: medium -# Description: Produce user guide, API reference, migration guide, and troubleshooting docs with real-world examples. -# Details: -Use MkDocs or Sphinx for documentation site. Include installation, configuration, and migration from claude-code-router. Document all config options, environment variables, and extension points. Provide example YAML configs and request scenarios. Add troubleshooting for common errors. - -# Test Strategy: -Manual review for completeness and clarity. Validate all code snippets and examples run as documented. - -# Subtasks: -## 1. Set Up Documentation Site Infrastructure [pending] -### Dependencies: None -### Description: Establish the documentation site using either MkDocs or Sphinx, configuring the structure for user guides, API reference, migration, and troubleshooting sections. -### Details: -Select and configure MkDocs (Markdown-based, simpler setup) or Sphinx (reStructuredText, superior cross-referencing and API integration) as the documentation generator. Set up navigation, theming, and initial folder structure for all required documentation types. - -## 2. Write Installation and Configuration Guides [pending] -### Dependencies: 10.1 -### Description: Document installation steps, configuration options, environment variables, and extension points, including example YAML configurations. -### Details: -Provide clear installation instructions for all supported environments. List and explain all configuration options and environment variables. Include example YAML config files and describe extension points for customization. - -## 3. Develop API Reference Documentation [pending] -### Dependencies: 10.1 -### Description: Generate and curate a comprehensive API reference, detailing all public classes, methods, and configuration interfaces. -### Details: -Use Sphinx autodoc or MkDocs plugins to extract docstrings and type annotations. Supplement with manual explanations where needed. Ensure all config options and extension points are covered. - -## 4. Create Migration and Usage Example Guides [pending] -### Dependencies: 10.2, 10.3 -### Description: Write a migration guide from claude-code-router and provide real-world usage examples, including request scenarios and YAML configs. -### Details: -Detail step-by-step migration instructions, highlighting differences and compatibility notes. Provide annotated usage examples for common and advanced scenarios, including sample requests and configuration files. - -## 5. Document Troubleshooting and Common Errors [pending] -### Dependencies: 10.2, 10.3, 10.4 -### Description: Compile troubleshooting documentation for common errors, including diagnostic steps and solutions. -### Details: -Identify frequent user issues and error messages. Provide clear troubleshooting steps, diagnostic commands, and recommended fixes. Link to relevant sections of the documentation for deeper context. diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json deleted file mode 100644 index 07a5e62e..00000000 --- a/.taskmaster/tasks/tasks.json +++ /dev/null @@ -1,647 +0,0 @@ -{ - "master": { - "tasks": [ - { - "id": 1, - "title": "Setup Project Repository and Environment", - "description": "Initialize the ccproxy project repository with Python tooling, environment management, and CI/CD setup.", - "details": "Use Python 3.11+ for best async support. Initialize with Poetry or pip-tools for dependency management. Set up pre-commit hooks (black, isort, flake8). Configure GitHub Actions for CI (lint, test, coverage). Add .env.example for environment variables (API keys, config paths). Ensure all dependencies are pinned to latest compatible versions. Use pyproject.toml for unified configuration.", - "testStrategy": "Verify environment setup by running lint, format, and a sample test in CI. Ensure .env.example is present and all scripts run without error.", - "priority": "high", - "dependencies": [], - "status": "done", - "subtasks": [ - { - "id": 1, - "title": "Initialize Git Repository and Project Structure", - "description": "Create a new Git repository for the ccproxy project and establish a standardized Python project structure, including source, tests, and configuration directories.", - "dependencies": [], - "details": "Set up the root directory with folders for source code (e.g., ccproxy/), tests/, and configs/. Add essential files such as README.md, .gitignore, and pyproject.toml. Ensure the structure supports future scalability and maintainability.", - "status": "done", - "testStrategy": "Verify that the repository contains the expected directories and files, and that the structure matches Python best practices." - }, - { - "id": 2, - "title": "Configure Python Environment and Dependency Management", - "description": "Set up Python 3.11+ environment and initialize dependency management using Poetry or pip-tools.", - "dependencies": [ - "1.1" - ], - "details": "Create a virtual environment targeting Python 3.11 or newer. Initialize dependency management with Poetry (preferred) or pip-tools. Add core development dependencies (black, isort, flake8, pytest). Ensure all dependencies are pinned to the latest compatible versions in pyproject.toml.", - "status": "done", - "testStrategy": "Activate the environment and install dependencies. Confirm that all tools are available and the environment is reproducible." - }, - { - "id": 3, - "title": "Set Up Pre-commit Hooks for Code Quality", - "description": "Integrate pre-commit hooks to enforce code formatting and linting standards using black, isort, and flake8.", - "dependencies": [ - "1.2" - ], - "details": "Install pre-commit and configure .pre-commit-config.yaml to run black, isort, and flake8 on staged files. Ensure hooks are installed in the repository so contributors automatically run checks before commits.", - "status": "done", - "testStrategy": "Make a sample commit with code that violates formatting or linting rules and verify that pre-commit blocks the commit until issues are resolved." - }, - { - "id": 4, - "title": "Configure GitHub Actions for CI/CD", - "description": "Set up GitHub Actions workflows to automate linting, testing, and coverage reporting on push and pull requests.", - "dependencies": [ - "1.3" - ], - "details": "Create workflow YAML files under .github/workflows/ to run lint, test, and coverage jobs using the configured Python environment. Ensure the workflow uses the same dependency versions as local development and reports status checks.", - "status": "done", - "testStrategy": "Push a commit to a feature branch and verify that all CI jobs run and report results as expected." - }, - { - "id": 5, - "title": "Add Environment Variable Management and Example File", - "description": "Provide a .env.example file listing required environment variables and integrate environment variable loading into the project.", - "dependencies": [ - "1.2" - ], - "details": "Create a .env.example file specifying placeholders for API keys and config paths. Ensure the project loads environment variables using python-dotenv or similar. Document usage in README.md.", - "status": "done", - "testStrategy": "Copy .env.example to .env, populate with test values, and verify that the application can read all required variables without error." - } - ] - }, - { - "id": 2, - "title": "Implement Configuration Manager", - "description": "Develop a configuration loader supporting YAML config and environment variable overrides for model routing and proxy settings.", - "details": "Use PyYAML (>=6.0) for YAML parsing. Support merging of config.yaml and environment variables (os.environ). Validate schema using pydantic (v2.x) for type safety. Allow hot-reload if config changes. Expose config as a singleton or dependency-injectable object.", - "testStrategy": "Unit test config parsing, environment override precedence, and schema validation. Test with malformed and missing configs.", - "priority": "high", - "dependencies": [ - 1 - ], - "status": "done", - "subtasks": [ - { - "id": 1, - "title": "Design Configuration Schema with Pydantic", - "description": "Define a Pydantic v2.x model representing the configuration schema for model routing and proxy settings, ensuring type safety and validation.", - "dependencies": [], - "details": "Specify all required fields, types, and validation rules for the configuration. Include support for nested structures as needed for model routing and proxy settings.", - "status": "done", - "testStrategy": "Unit test schema validation with valid, malformed, and missing configuration fields." - }, - { - "id": 2, - "title": "Implement YAML Configuration Loader", - "description": "Develop a loader using PyYAML (>=6.0) to parse config.yaml and instantiate the Pydantic schema.", - "dependencies": [ - "2.1" - ], - "details": "Read and parse the YAML file, handle parsing errors, and map the data to the Pydantic model. Ensure compatibility with nested and complex YAML structures.", - "status": "done", - "testStrategy": "Unit test YAML parsing with various config.yaml files, including malformed YAML and missing required fields." - }, - { - "id": 3, - "title": "Integrate Environment Variable Overrides", - "description": "Merge environment variables (os.environ) into the loaded configuration, allowing them to override YAML values according to precedence rules.", - "dependencies": [ - "2.2" - ], - "details": "Implement logic to map environment variables to configuration fields, supporting both flat and nested overrides. Ensure environment variables take precedence over YAML values.", - "status": "done", - "testStrategy": "Unit test override logic with different combinations of YAML and environment variable inputs." - }, - { - "id": 4, - "title": "Enable Hot-Reload on Configuration Changes", - "description": "Add support for detecting changes in config.yaml or relevant environment variables and reloading the configuration at runtime.", - "dependencies": [ - "2.3" - ], - "details": "Monitor the config file for changes (e.g., using watchdog) and re-apply environment overrides and schema validation on reload. Provide hooks or signals for dependent components to react to config changes.", - "status": "done", - "testStrategy": "Integration test hot-reload by modifying config.yaml and environment variables, verifying that changes are reflected without restarting the application." - }, - { - "id": 5, - "title": "Expose Configuration as Singleton or Injectable Object", - "description": "Provide a globally accessible configuration instance, supporting singleton pattern or dependency injection for use throughout the application.", - "dependencies": [ - "2.4" - ], - "details": "Implement a thread-safe singleton or dependency-injectable provider for the configuration object. Ensure consumers always access the latest configuration, including after hot-reload.", - "status": "done", - "testStrategy": "Unit and integration test singleton/injection behavior, verifying correct config access and updates across multiple consumers." - } - ] - }, - { - "id": 3, - "title": "Develop RequestClassifier Module", - "description": "Implement request classification logic to assign routing labels based on request context (token count, model, tools, etc.).", - "details": "Encapsulate classification logic as a class with a classify(request) method. Use the priority order from the PRD. Accept request as a dict or pydantic model. Make context threshold configurable. Write pure functions for each rule for testability. Prepare for future extensibility (e.g., ML-based classification).", - "testStrategy": "Unit test all classification branches with representative request fixtures. Achieve 100% branch coverage.", - "priority": "high", - "dependencies": [ - 2 - ], - "status": "done", - "subtasks": [ - { - "id": 1, - "title": "Design RequestClassifier Class Structure", - "description": "Define the RequestClassifier class interface, including the classify(request) method, input types (dict or pydantic model), and encapsulation of classification logic.", - "dependencies": [], - "details": "Establish the class skeleton, document method signatures, and ensure the design supports future extensibility (e.g., ML-based classification).\n<info added on 2025-07-30T01:19:08.216Z>\nImplemented full rule-based classification system:\n\n• Added abstract base class `ClassificationRule` with `priority`, `evaluate(request)` and `supports(request)` hooks for extensible rule definition. \n• Defined `RoutingLabel` enum covering default, background, think, large_context, and web_search paths. \n• Built `RequestClassifier` with:\n – `classify(request)` accepting dict or pydantic BaseModel \n – `add_rule(*rules)`, `clear_rules()`, `reset_rules()` for dynamic rule management \n – Optional custom rule list injected at init; falls back to default rules in defined priority order. \n• Introduced `Classifier` typing `Protocol` to ensure type-safe interchangeability with future ML classifiers. \n• Implemented default rules: \n 1. `TokenCountRule` (configurable max_tokens) → large_context \n 2. `ModelNameRule` (matches lite models, e.g., “gpt-4o-mini”) → background \n 3. `ThinkingRule` (detects system/assistant thinking prefix) → think \n 4. `WebSearchRule` (presence of “web_search” tool call) → web_search \n 5. Fallback → default \n• Wrote comprehensive pytest suite (100 % line & branch coverage) exercising: \n – All routing labels and default priority ordering \n – Dict vs pydantic inputs \n – Rule addition, clearing, and resetting behaviour \n – Edge cases: empty request, unsupported fields, conflicting rules \n• CI updated to enforce coverage threshold and run classifier tests in isolation.\n</info added on 2025-07-30T01:19:08.216Z>", - "status": "done", - "testStrategy": "Review class and method signatures for compliance with requirements; verify acceptance of both dict and pydantic model inputs." - }, - { - "id": 2, - "title": "Implement Rule-Based Classification Logic", - "description": "Develop pure functions for each classification rule (e.g., token count, model, tools) and integrate them into the classify method following the PRD priority order.", - "dependencies": [ - "3.1" - ], - "details": "Ensure each rule is implemented as a standalone pure function for testability and maintainability. Integrate these functions within the main classification flow.\n<info added on 2025-07-30T01:29:05.552Z>\nImplemented TokenCountRule, ModelNameRule, ThinkingRule, and WebSearchRule as standalone pure functions and wired them into RequestClassifier._setup_rules() following the PRD priority order. Added full-stack tests covering priority conflicts, realistic request scenarios, and edge cases; test suite now passes with 100 % coverage on the classifier module and 98 % on the rules module.\n</info added on 2025-07-30T01:29:05.552Z>", - "status": "done", - "testStrategy": "Unit test each rule function independently with representative inputs; verify correct rule application order in the classify method." - }, - { - "id": 3, - "title": "Add Configurable Context Thresholds", - "description": "Enable configuration of context thresholds (e.g., token count limits) via class parameters or external config, supporting dynamic adjustment without code changes.", - "dependencies": [ - "3.1" - ], - "details": "Integrate context threshold parameters into the class, ensuring they can be set at initialization or updated dynamically. Document configuration options.", - "status": "done", - "testStrategy": "Test classification behavior with varying threshold values; verify correct routing label assignment when thresholds are changed." - }, - { - "id": 4, - "title": "Prepare for Extensibility and ML Integration", - "description": "Refactor classification logic to allow seamless addition of new rules or ML-based classifiers in the future.", - "dependencies": [ - "3.2", - "3.3" - ], - "details": "Abstract rule evaluation and routing label assignment to support plug-in architectures or ML-based decision modules. Document extension points.\n<info added on 2025-07-30T03:42:36.498Z>\nScope realignment for v0.9:\n\n• Document existing extension points: explain the ClassificationRule ABC (required methods, expected return values) and the add_rule/clear_rules API in RequestClassifier. \n• Provide rich docstring examples in both RequestClassifier and ClassificationRule that show how to implement and register a custom rule. \n• Add an illustrative CustomHeaderRule in the test suite; register it with add_rule and assert correct routing label on a fixture request. \n• Expand unit tests to verify that custom rules can be added, cleared, and do not interfere with built-in rules. \n• Remove references to future ML or plug-in architectures to avoid premature complexity.\n</info added on 2025-07-30T03:42:36.498Z>", - "status": "done", - "testStrategy": "Add a mock rule or stub ML classifier to verify extensibility; ensure existing logic remains unaffected." - }, - { - "id": 5, - "title": "Develop Comprehensive Unit Tests for Classification", - "description": "Create unit tests covering all classification branches, edge cases, and input types to achieve 100% branch coverage.", - "dependencies": [ - "3.2", - "3.3", - "3.4" - ], - "details": "Use representative request fixtures to test all rule combinations and context threshold scenarios. Ensure tests are isolated and repeatable.\n<info added on 2025-07-30T19:23:58.654Z>\nAchieved 100% branch and line coverage for RequestClassifier tests; all pytest suites pass. Added demo/ directory to showcase LiteLLM proxy integration:\n\n• demo_config.yaml – full LiteLLM configuration loading CCProxy via custom_callbacks.proxy_handler_instance \n• custom_callbacks.py – injects CCProxy into PYTHONPATH for config-based loading \n• demo_requests.py – standalone script exercising all seven routing scenarios \n• test_requests.py – verifies live proxy routing against expected models \n• README.md – instructions and usage examples\n\nConfirmed that CCProxy can be launched solely through the YAML config and functions correctly when running `litellm --config demo/demo_config.yaml --port 8888`.\n</info added on 2025-07-30T19:23:58.654Z>", - "status": "done", - "testStrategy": "Run coverage analysis to confirm 100% branch coverage; review test cases for completeness and clarity." - } - ] - }, - { - "id": 4, - "title": "Implement ModelRouter Component", - "description": "Map classification labels to model configurations as defined in the YAML config, supporting dynamic provider/model selection and public APIs for LiteLLM hooks.", - "status": "done", - "dependencies": [ - 2 - ], - "priority": "high", - "details": "The ModelRouter must\n • Load the model-routing map from the Configuration Manager at start-up\n • Provide classification-aware routing through get_model_for_label(label)\n • Expose a public API (get_model_list, model_list, model_group_alias, get_available_models) so that LiteLLM hooks can import the singleton instance as litellm.proxy.proxy_server.llm_router\n • Preserve and surface model_info metadata so hooks such as CCProxyHandler can make additional routing decisions\n • Fall back to secondary models when the preferred model is unavailable\n • Validate that every referenced model exists in Configuration Manager’s model list\n • Support atomic hot-reload when the YAML config changes\n • Include thorough docstrings and short README section demonstrating ‘Accessing Model Configuration in LiteLLM Hooks’ (as provided in new context)", - "testStrategy": "1. Unit test: label-to-model mapping, fallback behaviour, error handling for missing models.\n2. Unit test: public methods (get_model_list, model_list property, model_group_alias, get_available_models) – verify structure matches spec and that metadata is preserved.\n3. Integration test: simulate LiteLLM CustomLogger importing llm_router and accessing model list.\n4. Hot-reload test: modify YAML at runtime and assert atomic update with no request errors.", - "subtasks": [ - { - "id": 1, - "title": "Load and Parse Model Mapping from YAML Config", - "description": "Implement logic to load and parse the model mapping definitions from the YAML configuration file, ensuring compatibility with the Configuration Manager and support for dynamic provider/model selection.", - "status": "done", - "dependencies": [], - "details": "Utilise the Configuration Manager to extract model routing information, validate the schema (including optional model_info metadata), and prepare internal data structures for fast lookup and export via get_model_list().", - "testStrategy": "Unit test with various YAML config samples, including malformed and missing mappings. Verify correct parsing, schema validation, and error handling." - }, - { - "id": 2, - "title": "Implement get_model_for_label Method", - "description": "Develop the get_model_for_label(label) method to return the appropriate model configuration for a given classification label, as defined in the loaded mapping.", - "status": "done", - "dependencies": [ - 1 - ], - "details": "Ensure the method returns the full model entry (including litellm_params and model_info) and triggers fallback logic if the preferred model is unavailable. Include graceful handling of unknown labels.", - "testStrategy": "Unit test label-to-model mapping for all supported labels, including edge cases and unknown labels. Validate fallback selection." - }, - { - "id": 3, - "title": "Expose Public API Methods for LiteLLM Hooks", - "description": "Add public methods and properties (get_model_list, model_list, model_group_alias, get_available_models) and ensure the ModelRouter instance is importable as llm_router for use inside LiteLLM hooks.", - "status": "done", - "dependencies": [ - 1, - 2 - ], - "details": "Return list of dicts with keys: model_name, litellm_params, model_info. Document usage with code snippet provided in the new context. Maintain thread-safe read-only access.", - "testStrategy": "Unit test each public method’s output and thread safety. Integration test within a dummy LiteLLM hook importing llm_router." - }, - { - "id": 4, - "title": "Support Hot-Reload of Model Mapping on Config Changes", - "description": "Implement logic to detect changes in the YAML config and reload the model mapping dynamically without requiring a service restart.", - "status": "done", - "dependencies": [ - 1, - 2, - 3 - ], - "details": "Integrate with the Configuration Manager’s hot-reload mechanism. Ensure atomic swap of internal routing tables and that public API properties always return a consistent view. Cover race-conditions with async requests.", - "testStrategy": "Integration test by modifying the config at runtime and verifying that new mappings, metadata and fallbacks are applied immediately and safely." - } - ] - }, - { - "id": 5, - "title": "Build CCProxyHandler as LiteLLM CustomLogger", - "description": "Implement the main LiteLLM CustomLogger handler with async_pre_call_hook for context-aware routing and logging.", - "details": "Inherit from litellm.integrations.custom_logger.CustomLogger. In async_pre_call_hook, use RequestClassifier to label requests and ModelRouter to set the model. Log routing decisions with structured logging (use structlog or standard logging with JSON formatter). Ensure compatibility with LiteLLM v1.13+ proxy mode. Avoid logging sensitive content. Support both streaming and non-streaming requests.", - "testStrategy": "Integration test with LiteLLM proxy, verifying correct model routing and logging output for all request types.", - "priority": "high", - "dependencies": [ - 3, - 4 - ], - "status": "in-progress", - "subtasks": [ - { - "id": 1, - "title": "Define CCProxyHandler Class Structure", - "description": "Create the CCProxyHandler class inheriting from litellm.integrations.custom_logger.CustomLogger, ensuring all required methods for LiteLLM custom loggers are stubbed and ready for implementation.", - "dependencies": [], - "details": "Set up the class skeleton with async_pre_call_hook and other relevant async logging methods. Ensure compatibility with LiteLLM v1.13+ proxy mode and prepare for structured logging integration.\n<info added on 2025-07-30T19:25:41.408Z>\nImplementation complete: CCProxyHandler is now fully implemented in ccproxy/handler.py, inheriting from litellm.integrations.custom_logger.CustomLogger. All required async methods—async_pre_call_hook, async_log_success_event, async_log_failure_event, and async_log_stream_event—are fully functional with structured JSON logging, request classification calls, and dynamic model routing. Code passes linting and type checks and has been verified against LiteLLM v1.13+ proxy mode. Subtask can be marked done; proceed to integrating routing logic in Subtask 5.2.\n</info added on 2025-07-30T19:25:41.408Z>", - "status": "done", - "testStrategy": "Verify class can be instantiated and registered as a callback in LiteLLM proxy without errors." - }, - { - "id": 2, - "title": "Integrate Request Classification and Model Routing", - "description": "Implement logic in async_pre_call_hook to use RequestClassifier for labeling requests and ModelRouter to select the appropriate model based on the label.", - "dependencies": [ - "5.1" - ], - "details": "Call RequestClassifier.classify(request) to obtain a label, then use ModelRouter.get_model_for_label(label) to determine the model. Ensure the selected model is set in the request context for downstream processing.", - "status": "done", - "testStrategy": "Unit test async_pre_call_hook with various request scenarios to confirm correct label assignment and model selection." - }, - { - "id": 3, - "title": "Implement Structured Logging for Routing Decisions", - "description": "Add structured logging to record routing decisions, using structlog or standard logging with a JSON formatter, while ensuring no sensitive content is logged.", - "dependencies": [ - "5.2" - ], - "details": "Log key routing metadata (label, selected model, request ID, timestamp) in structured JSON format. Mask or exclude sensitive fields such as prompts, completions, or API keys.", - "status": "done", - "testStrategy": "Integration test logging output for both streaming and non-streaming requests, verifying correct structure and redaction of sensitive data." - }, - { - "id": 4, - "title": "Support Streaming and Non-Streaming Request Handling", - "description": "Ensure CCProxyHandler correctly handles both streaming and non-streaming requests in async_pre_call_hook and logging methods.", - "dependencies": [ - "5.3" - ], - "details": "Detect request type and adapt logging and routing logic as needed. Validate that all relevant events are logged for both request types without data leakage.", - "status": "done", - "testStrategy": "Integration test with LiteLLM proxy, sending both streaming and non-streaming requests, and verify correct routing and logging behavior." - }, - { - "id": 5, - "title": "Validate Compatibility and Security Requirements", - "description": "Test CCProxyHandler for compatibility with LiteLLM v1.13+ proxy mode and ensure no sensitive content is logged at any stage.", - "dependencies": [ - "5.4" - ], - "details": "Run end-to-end tests with the full proxy stack, confirming handler registration, correct operation, and strict adherence to security requirements (no logging of prompts, completions, or secrets).\n<info added on 2025-07-30T19:26:31.046Z>\nInitial smoke verification completed during demo:\n• Ran LiteLLM in proxy mode (v1.13+) with litellm --config demo/demo_config.yaml --port 8888 \n• CCProxyHandler loaded from YAML, auto-registered, routed requests successfully \n• Verified log output: prompts, completions, and API keys are absent or masked\n\nNext steps – expand coverage with formal integration test suite:\n1. Create pytest-based e2e tests under tests/integration/proxy/ \n2. Test matrix:\n – request types: chat/completion, embeddings, moderation \n – modes: streaming vs non-streaming \n – auth states: valid key, missing key, revoked key \n – routing labels: small, large, tools, fallback \n – concurrency: ≥10 parallel requests (async) \n – failure scenarios: provider 4xx/5xx, timeout, token limit\n3. Assertions:\n – Correct handler registration (inspect litellm.proxy_server.custom_logger) \n – ModelRouter returns expected model per label \n – Response parity between direct and proxied calls \n – Logs contain routing metadata only; redact/mask any sensitive fields\n4. Add GitHub Actions job “integration-proxy” to run the suite against a containerised LiteLLM proxy started with demo_config.yaml\n5. Mark subtask complete when all tests pass and coverage ≥90 % for CCProxyHandler codepath\n</info added on 2025-07-30T19:26:31.046Z>", - "status": "pending", - "testStrategy": "Integration test with real and mock requests, inspect logs for absence of sensitive data, and verify handler works with the latest LiteLLM proxy." - } - ] - }, - { - "id": 10, - "title": "Write Documentation and Usage Examples", - "description": "Produce user guide, API reference, migration guide, and troubleshooting docs with real-world examples.", - "details": "Use MkDocs or Sphinx for documentation site. Include installation, configuration, and migration from claude-code-router. Document all config options, environment variables, and extension points. Provide example YAML configs and request scenarios. Add troubleshooting for common errors.", - "testStrategy": "Manual review for completeness and clarity. Validate all code snippets and examples run as documented.", - "priority": "medium", - "dependencies": [ - 5, - 9 - ], - "status": "pending", - "subtasks": [ - { - "id": 1, - "title": "Set Up Documentation Site Infrastructure", - "description": "Establish the documentation site using either MkDocs or Sphinx, configuring the structure for user guides, API reference, migration, and troubleshooting sections.", - "dependencies": [], - "details": "Select and configure MkDocs (Markdown-based, simpler setup) or Sphinx (reStructuredText, superior cross-referencing and API integration) as the documentation generator. Set up navigation, theming, and initial folder structure for all required documentation types.", - "status": "pending", - "testStrategy": "Verify site builds locally and deploys correctly. Confirm navigation and section structure matches requirements." - }, - { - "id": 2, - "title": "Write Installation and Configuration Guides", - "description": "Document installation steps, configuration options, environment variables, and extension points, including example YAML configurations.", - "dependencies": [ - "10.1" - ], - "details": "Provide clear installation instructions for all supported environments. List and explain all configuration options and environment variables. Include example YAML config files and describe extension points for customization.", - "status": "pending", - "testStrategy": "Manually review for completeness and clarity. Validate all example configs by running them in a test environment." - }, - { - "id": 3, - "title": "Develop API Reference Documentation", - "description": "Generate and curate a comprehensive API reference, detailing all public classes, methods, and configuration interfaces.", - "dependencies": [ - "10.1" - ], - "details": "Use Sphinx autodoc or MkDocs plugins to extract docstrings and type annotations. Supplement with manual explanations where needed. Ensure all config options and extension points are covered.", - "status": "pending", - "testStrategy": "Check that all public APIs are documented and cross-referenced. Validate that code snippets and references resolve correctly." - }, - { - "id": 4, - "title": "Create Migration and Usage Example Guides", - "description": "Write a migration guide from claude-code-router and provide real-world usage examples, including request scenarios and YAML configs.", - "dependencies": [ - "10.2", - "10.3" - ], - "details": "Detail step-by-step migration instructions, highlighting differences and compatibility notes. Provide annotated usage examples for common and advanced scenarios, including sample requests and configuration files.", - "status": "pending", - "testStrategy": "Test migration steps in a sandbox environment. Validate all example scenarios by executing them as described." - }, - { - "id": 5, - "title": "Document Troubleshooting and Common Errors", - "description": "Compile troubleshooting documentation for common errors, including diagnostic steps and solutions.", - "dependencies": [ - "10.2", - "10.3", - "10.4" - ], - "details": "Identify frequent user issues and error messages. Provide clear troubleshooting steps, diagnostic commands, and recommended fixes. Link to relevant sections of the documentation for deeper context.", - "status": "pending", - "testStrategy": "Simulate common errors and verify that troubleshooting steps resolve the issues as documented." - } - ] - }, - { - "id": 9, - "title": "Develop Comprehensive Test Suite", - "description": "Achieve >90% code coverage with unit, integration, and performance tests for all core modules and routing logic.", - "details": "Use pytest (>=8.0) and pytest-asyncio for async tests. Mock LiteLLM and external APIs. Cover all classification, routing, config, and fallback logic. Add integration tests simulating full request lifecycle. Use coverage.py to enforce coverage threshold. Include performance tests for routing overhead (<10ms per request).", - "testStrategy": "Run pytest with coverage. Fail CI if coverage <90%. Benchmark routing latency under load.", - "priority": "high", - "dependencies": [ - 3, - 4, - 5, - 7, - 8 - ], - "status": "pending", - "subtasks": [ - { - "id": 1, - "title": "Design Unit Test Coverage for Core Modules", - "description": "Identify all core modules, including classification, routing, config, and fallback logic, and design unit tests to achieve comprehensive branch and logic coverage.", - "dependencies": [], - "details": "Enumerate all functions and classes in core modules. Define representative test cases for each logic branch, including edge cases. Use pytest (>=8.0) and pytest-asyncio for async code. Mock LiteLLM and external APIs as needed.", - "status": "pending", - "testStrategy": "Run pytest with coverage.py. Ensure each function and branch is exercised. Use mocks to isolate units. Target 100% branch coverage for each module." - }, - { - "id": 2, - "title": "Implement Integration Tests for Full Request Lifecycle", - "description": "Develop integration tests that simulate the complete request lifecycle, covering interactions between modules and realistic scenarios.", - "dependencies": [ - "9.1" - ], - "details": "Set up test cases that send requests through the full stack, including classification, routing, config, and fallback. Mock external APIs and LiteLLM. Use pytest-asyncio for async flows.", - "status": "pending", - "testStrategy": "Verify correct routing, config application, and fallback behavior for various request types. Assert end-to-end outcomes and log outputs." - }, - { - "id": 3, - "title": "Mock LiteLLM and External API Dependencies", - "description": "Develop robust mocks for LiteLLM and all external APIs to ensure tests are deterministic and isolated from external failures.", - "dependencies": [ - "9.1" - ], - "details": "Implement fixtures and mock classes for LiteLLM and any external services. Ensure mocks simulate expected responses and error conditions.", - "status": "pending", - "testStrategy": "Validate that all tests run without real network calls. Test error handling and fallback logic using mocked failures." - }, - { - "id": 4, - "title": "Enforce and Monitor Code Coverage Thresholds", - "description": "Integrate coverage.py with pytest to enforce a minimum 90% code coverage threshold and fail CI if unmet.", - "dependencies": [ - "9.1", - "9.2", - "9.3" - ], - "details": "Configure coverage.py to measure coverage during test runs. Set up CI to fail if coverage drops below 90%. Generate coverage reports for review.", - "status": "pending", - "testStrategy": "Run full test suite and inspect coverage reports. Confirm CI fails on insufficient coverage and passes when threshold is met." - }, - { - "id": 5, - "title": "Develop Performance Tests for Routing Overhead", - "description": "Create performance tests to benchmark routing logic, ensuring average overhead remains below 10ms per request under load.", - "dependencies": [ - "9.2", - "9.3" - ], - "details": "Use pytest and async benchmarking tools to simulate concurrent requests. Measure and record routing latency. Optimize code if overhead exceeds target.", - "status": "pending", - "testStrategy": "Run performance tests with varying concurrency. Assert that average routing latency is <10ms. Report and address regressions." - } - ] - }, - { - "id": 8, - "title": "Implement Secure API Key and Secrets Management", - "description": "Ensure all API keys and secrets are securely loaded from environment variables and never logged or exposed.", - "details": "Use python-dotenv for local development. Validate presence of required secrets at startup. Mask secrets in logs and error messages. Enforce HTTPS for all outbound requests using httpx (>=0.27) with verify=True. Document required environment variables.", - "testStrategy": "Unit test secret loading and masking. Attempt to log secrets and verify they are redacted. Integration test HTTPS enforcement.", - "priority": "high", - "dependencies": [ - 1 - ], - "status": "pending", - "subtasks": [ - { - "id": 1, - "title": "Load Secrets from Environment Variables Using python-dotenv", - "description": "Configure the application to load all API keys and secrets from environment variables, utilizing python-dotenv for local development environments.", - "dependencies": [], - "details": "Set up a .env file for local use and ensure python-dotenv loads these variables at startup. Avoid hard-coding any secrets in the codebase. Confirm .env is excluded from version control.", - "status": "pending", - "testStrategy": "Unit test that secrets are correctly loaded from environment variables and .env files. Verify .env is not tracked by version control." - }, - { - "id": 2, - "title": "Validate Presence of Required Secrets at Startup", - "description": "Implement logic to check that all required API keys and secrets are present in the environment at application startup, failing fast if any are missing.", - "dependencies": [ - "8.1" - ], - "details": "Define a list of required environment variables. On startup, iterate through this list and raise a clear error if any are missing.", - "status": "pending", - "testStrategy": "Unit test startup with all, some, and no required secrets set. Confirm application fails with informative errors when secrets are missing." - }, - { - "id": 3, - "title": "Mask Secrets in Logs and Error Messages", - "description": "Ensure that secrets are never logged or exposed in error messages by implementing masking or redaction logic throughout the codebase.", - "dependencies": [ - "8.2" - ], - "details": "Intercept log and error outputs to detect and redact any values matching known secrets or secret patterns before outputting.", - "status": "pending", - "testStrategy": "Attempt to log secrets and verify that output is redacted. Unit test logging and error handling paths for secret exposure." - }, - { - "id": 4, - "title": "Enforce HTTPS with Certificate Verification for Outbound Requests", - "description": "Configure all outbound HTTP requests using httpx (>=0.27) to require HTTPS with certificate verification enabled.", - "dependencies": [ - "8.2" - ], - "details": "Set up httpx clients with verify=True for all requests. Audit code to ensure no insecure (HTTP) endpoints are used.", - "status": "pending", - "testStrategy": "Integration test outbound requests to ensure HTTPS is enforced and certificate verification failures are handled gracefully." - }, - { - "id": 5, - "title": "Document Required Environment Variables and Security Practices", - "description": "Create and maintain documentation listing all required environment variables, their purpose, and best practices for secure secrets management.", - "dependencies": [ - "8.1", - "8.2", - "8.3", - "8.4" - ], - "details": "Write documentation specifying each required secret, example .env usage, and guidelines for secure handling in different environments.", - "status": "pending", - "testStrategy": "Review documentation for completeness and clarity. Validate that all required secrets are documented and instructions are accurate." - } - ] - }, - { - "id": 7, - "title": "Integrate MetricsCollector for Routing and Performance", - "description": "Track routing decisions, performance metrics, and error rates for monitoring and optimization.", - "details": "Implement MetricsCollector using Prometheus client (prometheus_client >=0.18) or OpenTelemetry. Expose metrics endpoint (/metrics) for scraping. Track per-label routing counts, latency, error rates, and fallback events. Integrate with CCProxyHandler to record metrics on each request.", - "testStrategy": "Unit and integration test metrics emission. Use Prometheus query to verify metrics are updated correctly under simulated load.", - "priority": "medium", - "dependencies": [ - 5 - ], - "status": "pending", - "subtasks": [ - { - "id": 1, - "title": "Design Metrics Schema and Labeling Strategy", - "description": "Define the metrics to be collected (routing counts, latency, error rates, fallback events) and establish a labeling strategy for per-label tracking.", - "dependencies": [], - "details": "Specify metric names, types (counter, histogram, gauge), and labels (e.g., route label, status, error type). Ensure schema supports both Prometheus and OpenTelemetry conventions for compatibility.", - "status": "pending", - "testStrategy": "Review schema with stakeholders and validate against monitoring requirements. Unit test label assignment logic." - }, - { - "id": 2, - "title": "Implement MetricsCollector with Prometheus Client or OpenTelemetry SDK", - "description": "Develop the MetricsCollector class using prometheus_client (>=0.18) or OpenTelemetry SDK to record defined metrics.", - "dependencies": [ - "7.1" - ], - "details": "Instrument code to create and update metrics objects. Ensure thread/process safety and efficient metric updates. Support both Prometheus and OpenTelemetry backends as needed.", - "status": "pending", - "testStrategy": "Unit test metric recording for all metric types and labels. Mock backend to verify correct metric emission." - }, - { - "id": 3, - "title": "Expose /metrics Endpoint for Scraping", - "description": "Add an HTTP endpoint (/metrics) to expose collected metrics in Prometheus format for scraping by monitoring systems.", - "dependencies": [ - "7.2" - ], - "details": "Integrate with the web framework to serve the /metrics endpoint. Ensure endpoint outputs metrics in the correct format and is accessible for Prometheus or OpenTelemetry Collector scraping.", - "status": "pending", - "testStrategy": "Integration test endpoint accessibility and output format. Use Prometheus or OTel Collector to scrape and validate metrics." - }, - { - "id": 4, - "title": "Integrate MetricsCollector with CCProxyHandler", - "description": "Modify CCProxyHandler to record metrics for each request, capturing routing decisions, latency, errors, and fallback events.", - "dependencies": [ - "7.2" - ], - "details": "Inject MetricsCollector into CCProxyHandler. Update handler logic to record metrics at appropriate points in the request lifecycle, ensuring all relevant events are tracked.", - "status": "pending", - "testStrategy": "Integration test with simulated requests to verify correct metrics are recorded for all routing and error scenarios." - }, - { - "id": 5, - "title": "Test Metrics Emission and Monitoring Integration", - "description": "Validate that metrics are emitted correctly under simulated load and can be queried via Prometheus or OpenTelemetry.", - "dependencies": [ - "7.3", - "7.4" - ], - "details": "Develop unit and integration tests to simulate various routing, error, and fallback scenarios. Use Prometheus queries to verify metrics accuracy and completeness.", - "status": "pending", - "testStrategy": "Automate load tests and metric queries. Confirm metrics reflect expected values for all test cases." - } - ] - }, - { - "id": 6, - "title": "Implement Claude Wrapper Script with Auto-Managed CCProxy", - "description": "Python CLI wrapper for Anthropic’s Claude that transparently spins up (or re-uses) a LiteLLM-backed CCProxy instance, forwards all user-supplied arguments, and tears the proxy down when no Claude sessions remain. IMPLEMENTATION COMPLETE.", - "status": "done", - "dependencies": [ - 2, - 5 - ], - "priority": "high", - "details": "Implementation Summary\n• claude_wrapper.py located in ccproxy/claude_wrapper implements full lifecycle management:\n – File-lock coordination at ~/.ccproxy/claude.lock using fasteners.InterProcessLock\n – Shared state persisted to ~/.ccproxy/claude_proxy.json {pid, port, start_time, refcount}\n – Validates existing proxy; otherwise chooses a free port and launches `python -m ccproxy.run_proxy` with correct env vars (LITELLM_PROXY_PORT, HTTP(S)_PROXY, OPENAI_BASE_URL, etc.)\n – Child Anthropic CLI is executed via subprocess with inherited/overridden env so that calls route through LiteLLM\n – Finally block decrements refcount and performs graceful SIGINT→SIGTERM shutdown, deleting state when refcount==0.\n• Cross-platform PID checks using psutil when available, POSIX & Windows fallbacks otherwise.\n• Proxy stdout/err streamed to ~/.ccproxy/proxy.log with daily rotation (RotatingFileHandler).\n• Secrets redacted on --verbose; existing user proxy settings preserved for non-Claude traffic.\n• Config hooks respected (CC_PROXY_CONFIG, CC_PROXY_PORT, CC_PROXY_LOG) via Configuration Manager (Task 2) integration.\n• Packaging: entry-point \"claude\" declared in pyproject.toml; dependencies fasteners, psutil, anthropic added.\n• Documentation: docs/usage.md now includes “Running the Anthropic CLI via ccproxy” with examples and troubleshooting tips.\n\nRemaining Work\nThe core wrapper is complete; outstanding hardening, performance tuning, and production deployment tasks are tracked in subtask 1 below.", - "testStrategy": "A comprehensive pytest suite (tests/test_claude_wrapper.py) with 20 tests validates: file-lock coordination, state persistence, proxy reuse, new proxy spawn, environment propagation, graceful shutdown, error handling, cross-platform PID checks, log redaction, and CLI argument passthrough. All tests pass in CI on Ubuntu & Windows runners.", - "subtasks": [ - { - "id": 1, - "title": "Productionize: Performance, Security, and Monitoring Hardening", - "description": "Finalize production readiness with benchmarking, rate limiting, abuse prevention, and deployment best practices.", - "status": "done", - "dependencies": [], - "details": "Benchmark concurrent request handling (use locust or wrk). Implement rate limiting with slowapi or similar. Harden HTTP endpoints (CORS, timeouts, error handling). Document deployment (Dockerfile, k8s manifests). Ensure logging and metrics are production-grade. Prepare for future extensibility (plugin hooks).", - "testStrategy": "Run load tests to verify performance targets. Penetration test for security. Review deployment with best practices checklist." - } - ] - } - ], - "metadata": { - "created": "2025-07-29T23:37:48.816Z", - "updated": "2025-07-30T21:13:38.628Z", - "description": "Tasks for master context" - } - } -} diff --git a/.taskmaster/templates/example_prd.txt b/.taskmaster/templates/example_prd.txt deleted file mode 100644 index 69ddd38a..00000000 --- a/.taskmaster/templates/example_prd.txt +++ /dev/null @@ -1,47 +0,0 @@ -<context> -# Overview -[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.] - -# Core Features -[List and describe the main features of your product. For each feature, include: -- What it does -- Why it's important -- How it works at a high level] - -# User Experience -[Describe the user journey and experience. Include: -- User personas -- Key user flows -- UI/UX considerations] -</context> -<PRD> -# Technical Architecture -[Outline the technical implementation details: -- System components -- Data models -- APIs and integrations -- Infrastructure requirements] - -# Development Roadmap -[Break down the development process into phases: -- MVP requirements -- Future enhancements -- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks] - -# Logical Dependency Chain -[Define the logical order of development: -- Which features need to be built first (foundation) -- Getting as quickly as possible to something usable/visible front end that works -- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches] - -# Risks and Mitigations -[Identify potential risks and how they'll be addressed: -- Technical challenges -- Figuring out the MVP that we can build upon -- Resource constraints] - -# Appendix -[Include any additional information: -- Research findings -- Technical specifications] -</PRD> diff --git a/CLAUDE.md b/CLAUDE.md index 71c3cfb4..d2e38587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,370 +1,224 @@ -# My name is CCProxy_Assistant +# CLAUDE.md -## Mission Statement +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -**IMPERATIVE**: I am the dedicated assistant for the ccproxy project - a LiteLLM-based transformation hook system that routes Claude Code API requests to different providers based on request properties. +@~/.claude/standards-python-extended.md -## Core Operating Principles +## Project Overview -- **IMPERATIVE**: ALL instructions within this document MUST BE FOLLOWED without question -- **CRITICAL**: Follow Python patterns from Kyle's coding standards: `uv` only, type hints, async patterns -- **IMPORTANT**: Prioritize test coverage (>90%) and type safety throughout development -- **DO NOT**: Use pip - always use `uv` for package management -- **DO NOT**: Create unnecessary files or verbose documentation unless requested +**CRITICAL**: The project name is `ccproxy` (lowercase). Do NOT refer to the project as "CCProxy". The PascalCase form is used exclusively for class names (e.g., `CCProxyHandler`, `CCProxyConfig`). -## Task Master Integration +`ccproxy` is a command-line tool that intercepts and routes Claude Code's requests to different LLM providers via a LiteLLM proxy server. It enables intelligent request routing based on token count, model type, tool usage, or custom rules. -@./.taskmaster/CLAUDE.md +## Development Commands -## Project Architecture +### Running Tests -### Core Components - -- **CCProxyHandler**: Main CustomLogger implementation for LiteLLM hooks -- **Router**: Dynamic rule-based request classification system -- **Configuration**: Dual YAML system (ccproxy.yaml + config.yaml) -- **Rules Engine**: Extensible classification rules with boolean returns -- **Type Safety**: Comprehensive type hints with strict mypy checking - -### Configuration System +```bash +# Run all tests with coverage +uv run pytest -- **ccproxy.yaml**: Contains ccproxy-specific settings and rule definitions -- **config.yaml**: LiteLLM proxy configuration with model deployments -- Rules are dynamically loaded using Python import paths -- Labels in ccproxy rules must match model_name entries in LiteLLM's model_list +# Run specific test file +uv run pytest tests/test_classifier.py -### Classification Architecture +# Run tests matching pattern +uv run pytest -k "test_token_count" -```python -# Dynamic rule evaluation: -1. Rules are loaded from ccproxy.yaml with parameters -2. Each rule returns boolean (True = use this label's model) -3. First matching rule determines the routing label -4. Label is mapped to model via LiteLLM's model_list -5. Default model used if no rules match +# Run with verbose output +uv run pytest -v ``` -### Built-in Rules - -- **TokenCountRule**: Routes based on token count threshold -- **MatchModelRule**: Routes based on model name pattern matching -- **ThinkingFieldRule**: Routes when request contains thinking field -- **WebSearchToolRule**: Routes when web_search tool is present - -## Development Workflow - -### Priority Rules - -- **IMMEDIATE EXECUTION**: Run tests after any code modification -- **NO CLARIFICATION**: Implement based on PRD specifications -- **TYPE SAFETY FIRST**: All functions must have complete type annotations - -## Command Translation - -- "run tests" → `uv run pytest tests/ -v --cov=ccproxy --cov-report=term-missing` -- "type check" → `uv run mypy src/ccproxy --strict` -- "lint code" → `uv run ruff check src/ tests/ --fix` -- "format code" → `uv run ruff format src/ tests/` +### Linting & Formatting -## Testing Strategy - -### Test Categories - -1. **Unit Tests**: Each classification scenario (test_router_logic.py) -2. **Integration Tests**: Full hook lifecycle (test_integration.py) -3. **Configuration Tests**: YAML parsing and validation (test_config.py) -4. **Type Tests**: mypy strict mode compliance +```bash +# Format code with ruff +uv run ruff format . -### Coverage Requirements +# Check linting issues +uv run ruff check . -- Minimum 90% coverage enforced -- All classification branches must be tested -- Edge cases for token counting and model detection +# Fix linting issues automatically +uv run ruff check --fix . -## Environment Configuration +# Type checking with mypy +uv run mypy src/ccproxy +``` ### Development Setup ```bash -uv sync # Install all dependencies -uv run pre-commit install # Setup hooks -uv run pytest # Run tests -``` +# Install with dev dependencies +uv sync --dev -## File Structure +# Install as a tool globally +uv tool install . +# Run the module directly +uv run python -m ccproxy ``` -src/ccproxy/ -├── __init__.py -├── handler.py # CCProxyHandler implementation -├── router.py # Dynamic rule-based routing engine -├── config.py # Configuration management (singleton) -├── rules.py # Classification rule implementations -├── types.py # Type definitions (currently unused) -└── cli.py # Command-line interface - -tests/ -├── test_handler.py # Hook integration tests -├── test_router.py # Router logic tests -├── test_config.py # Configuration tests -├── test_rules.py # Rule implementation tests -├── test_classifier.py # Rule classification tests -├── test_integration.py # End-to-end tests -└── test_*.py # Additional test modules - -stubs/ # Type stubs for external dependencies -├── litellm/ -│ └── proxy.pyi -└── pydantic_settings.pyi -``` - -## Quality Assurance - -### Pre-commit Checks - -1. **Ruff**: Linting and formatting -2. **mypy**: Type checking in strict mode -3. **Bandit**: Security scanning -4. **pytest**: Test execution with coverage -### Validation Protocol +### CLI Commands -1. All hooks must handle errors gracefully -2. Token counting must be accurate -3. Model routing must match PRD specifications -4. No blocking operations in async methods - -## Prohibited Operations +```bash +# Install configuration files +ccproxy install [--force] -- **DO NOT**: Create synchronous blocking operations -- **DO NOT**: Skip type annotations -- **DO NOT**: Use pip instead of uv -- **DO NOT**: Commit without running tests +# Start/stop proxy server +ccproxy start [--detach] +ccproxy stop +ccproxy restart [--detach] -## LiteLLM Configuration Access from Hooks +# View logs and status +ccproxy logs [-f] [-n LINES] +ccproxy status [--json] -### Understanding Hook Context +# Run command with proxy environment +ccproxy run <command> [args...] +``` -When implementing a CustomLogger hook in LiteLLM, you have access to the proxy server's runtime configuration through global imports. The hook runs within the proxy server process, giving you direct access to internal state. +## Architecture -### Key Global Variables +The codebase follows a modular architecture with clear separation of concerns: -```python -from litellm.proxy import proxy_server +### Request Flow -# Global router instance -llm_router = proxy_server.llm_router # Router with model deployments -prisma_client = proxy_server.prisma_client # Database client if configured -general_settings = proxy_server.general_settings # Proxy-wide settings ``` - -### Accessing Model Configuration - -```python -from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy import proxy_server -from typing import Any, Dict, Optional, Literal - -class CCProxyHandler(CustomLogger): - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: Any, - data: dict, - call_type: Literal["completion", "embeddings", ...], - ) -> Optional[Union[Exception, str, dict]]: - - # Access the global router - if proxy_server.llm_router: - # Get all configured models - model_list = proxy_server.llm_router.model_list - - # Iterate through deployments - for deployment in model_list: - model_name = deployment.get("model_name") - litellm_params = deployment.get("litellm_params", {}) - - # Access deployment-specific settings - api_base = litellm_params.get("api_base") - api_key = litellm_params.get("api_key") - custom_llm_provider = litellm_params.get("custom_llm_provider") - - # Check model aliases - model_info = deployment.get("model_info", {}) - - # Access general proxy settings - settings = proxy_server.general_settings or {} - - # Modify the request based on configuration - return data +Request → CCProxyHandler → Hook Pipeline → Response + ↓ + RequestClassifier (rule evaluation) + ↓ + ModelRouter (model lookup) ``` -### Router Methods Available - -```python -# Inside your hook -if proxy_server.llm_router: - # Get healthy deployments for a model - healthy_deployments = await proxy_server.llm_router.async_get_healthy_deployments( - model="gpt-4", - request_kwargs=data - ) +1. **CCProxyHandler** (`handler.py`) - LiteLLM CustomLogger that intercepts all requests +2. **RequestClassifier** (`classifier.py`) - Evaluates rules in order (first match wins) +3. **ModelRouter** (`router.py`) - Maps rule names to actual model configurations +4. **Hook Pipeline** - Sequential execution of configured hooks with error isolation + +### Key Components + +- **handler.py**: Main entry point as a LiteLLM CustomLogger. Orchestrates the classification and routing process via `async_pre_call_hook()`. +- **classifier.py**: Rule-based classification system that evaluates rules in order to determine routing. +- **rules.py**: Defines `ClassificationRule` abstract base class and built-in rules: + - `ThinkingRule` - Matches requests with "thinking" field + - `MatchModelRule` - Matches by model name substring + - `MatchToolRule` - Matches by tool name in request + - `TokenCountRule` - Evaluates based on token count threshold +- **router.py**: Manages model configurations from LiteLLM proxy server. Lazy-loads models on first request. +- **config.py**: Configuration management using Pydantic with multi-level discovery (env var → LiteLLM runtime → ~/.ccproxy/). +- **hooks.py**: Built-in hooks that process requests. Hooks support optional params via `hook:` + `params:` YAML format (see `HookConfig` class in config.py): + - `rule_evaluator` - Evaluates rules and stores routing decision + - `model_router` - Routes to appropriate model + - `forward_oauth` - Forwards OAuth tokens to provider APIs + - `extract_session_id` - Extracts session identifiers + - `capture_headers` - Captures HTTP headers with sensitive redaction (supports `headers` param) + - `forward_apikey` - Forwards x-api-key header +- **cli.py**: Tyro-based CLI interface (~900 lines) for managing the proxy server. +- **utils.py**: Template discovery and debug utilities (`dt()`, `dv()`, `d()`, `p()`). + +### Rule System + +Rules are evaluated in the order configured in `ccproxy.yaml`. Each rule: + +- Inherits from `ClassificationRule` abstract base class +- Implements `evaluate(request: dict, config: CCProxyConfig) -> bool` +- Returns the first matching rule's name as the routing label - # Access routing strategy - routing_strategy = proxy_server.llm_router.routing_strategy_args - - # Get model group info - model_group = proxy_server.llm_router.get_model_group(model="gpt-4") +```yaml +# Example rule configuration in ccproxy.yaml +rules: + - name: thinking_model + rule: ccproxy.rules.ThinkingRule + - name: haiku_requests + rule: ccproxy.rules.MatchModelRule + params: + - model_name: "haiku" + - name: large_context + rule: ccproxy.rules.TokenCountRule + params: + - threshold: 60000 ``` -### GitMCP Tool Usage +Custom rules can be created by implementing the ClassificationRule interface and specifying the Python import path in the configuration. -Use GitMCP to explore LiteLLM implementation details: +### Configuration Files -```bash -# Fetch complete documentation -mcp__gitmcp-litellm__fetch_litellm_documentation +- `~/.ccproxy/config.yaml` - LiteLLM proxy configuration with model definitions +- `~/.ccproxy/ccproxy.yaml` - ccproxy-specific configuration (rules, hooks, debug settings, handler path) +- `~/.ccproxy/ccproxy.py` - Auto-generated handler file (created on `ccproxy start` based on `handler` config) -# Search for specific patterns -mcp__gitmcp-litellm__search_litellm_documentation query="custom logger hook" -mcp__gitmcp-litellm__search_litellm_code query="proxy_server llm_router" +**Config Discovery Precedence:** +1. `CCPROXY_CONFIG_DIR` environment variable +2. LiteLLM proxy runtime directory (auto-detected) +3. `~/.ccproxy/` (default fallback) -# Access specific documentation -mcp__gitmcp-litellm__fetch_generic_url_content url="https://docs.litellm.ai/docs/proxy/call_hooks" -``` +## Testing Patterns -### Important Hook Patterns - -1. **Pre-call Hook**: Modify requests before they reach the model -2. **Post-call Success Hook**: Process responses after successful calls -3. **Post-call Failure Hook**: Handle errors and retries -4. **Moderation Hook**: Run parallel checks during API calls -5. **Streaming Hooks**: Handle streaming responses - -### Type Safety - -```python -from litellm.types.utils import ModelResponse, StandardLoggingPayload -from litellm.proxy._types import UserAPIKeyAuth, LiteLLM_ProxyBudgetType -from typing import Union, Optional, Literal, Dict, Any - -# Properly typed hook signature -async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: Literal[ - "completion", - "text_completion", - "embeddings", - "image_generation", - "moderation", - "audio_transcription", - "pass_through_endpoint", - "rerank", - ], -) -> Optional[Union[Exception, str, dict]]: - pass -``` +The test suite uses pytest with comprehensive fixtures (18 test files, 90% coverage minimum): -## Configuration Files +- `mock_proxy_server` fixture for mocking LiteLLM proxy +- `cleanup` fixture ensures singleton instances are cleared between tests +- Tests organized to mirror source structure (`test_<module>.py`) +- Parametrized tests for rule evaluation scenarios +- Integration tests verify end-to-end behavior -### ccproxy.yaml Structure +## Important Implementation Notes -```yaml -ccproxy: - debug: false - metrics_enabled: true - rules: - - label: large_context # Must match a model_name in config.yaml - rule: ccproxy.rules.TokenCountRule - params: - - threshold: 80000 - - label: background - rule: ccproxy.rules.MatchModelRule - params: - - model_name: "claude-3-5-haiku" - - label: think - rule: ccproxy.rules.ThinkingFieldRule - - label: web_search - rule: ccproxy.rules.WebSearchToolRule -``` +- **Singleton patterns**: `CCProxyConfig` and `ModelRouter` use thread-safe singletons. Use `clear_config_instance()` and `clear_router()` to reset state in tests. +- **Token counting**: Uses tiktoken with fallback to character-based estimation for non-OpenAI models. +- **OAuth token forwarding**: Handled specially for Claude CLI requests. Supports custom User-Agent per provider. +- **Request metadata**: Stored by `litellm_call_id` with 60-second TTL auto-cleanup (LiteLLM doesn't preserve custom metadata). +- **Hook error isolation**: Errors in one hook don't block others from executing. +- **Lazy model loading**: Models loaded from LiteLLM proxy on first request, not at startup. -### config.yaml (LiteLLM) +## Dependencies -```yaml -model_list: - - model_name: default # Label referenced by ccproxy rules - litellm_params: - model: claude-3-5-sonnet-20241022 - - model_name: large_context # Matches label in ccproxy.yaml - litellm_params: - model: gemini-2.0-flash-exp - # ... additional models -``` +Key dependencies include: -### Key Configuration Concepts +- **litellm[proxy]** - Core proxy functionality +- **pydantic/pydantic-settings** - Configuration and validation +- **tyro** - CLI interface generation +- **tiktoken** - Token counting +- **anthropic** - Anthropic API client +- **rich** - Terminal output formatting +- **langfuse** - Observability integration +- **prisma** - Database ORM +- **structlog** - Structured logging -- **Label Matching**: Labels in ccproxy.yaml rules MUST have corresponding model_name entries in config.yaml -- **Dynamic Loading**: Rules are loaded at runtime using Python import paths -- **Parameter Flexibility**: Rules can accept positional args, keyword args, or mixed parameters -- **Singleton Pattern**: Configuration is loaded once and shared across the application +## Development Workflow -## Quick Reference +### Local Development Setup -### Essential Commands +ccproxy must be installed with litellm in the same environment so that LiteLLM can import the ccproxy handler: ```bash -# Development -uv sync # Install dependencies -uv run pytest # Run tests -uv run mypy src/ # Type check -uv run ruff check . # Lint - -# Task Master -task-master next # Get next task -task-master show <id> # View task details -task-master set-status --id=<id> --status=done +# Install in editable mode with litellm bundled +uv tool install --editable . --with 'litellm[proxy]' --force ``` -### Creating Custom Rules +### Making Changes -```python -from typing import Any -from ccproxy.rules import ClassificationRule -from ccproxy.config import CCProxyConfig +With editable mode, source changes are reflected immediately. Just restart the proxy: -class MyCustomRule(ClassificationRule): - """Custom rule implementation.""" +```bash +# Restart proxy to regenerate handler and pick up changes +ccproxy stop +ccproxy start --detach - def __init__(self, my_param: str) -> None: - self.my_param = my_param +# Verify +ccproxy status - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - """Return True to use this rule's label.""" - # Your custom logic here - return "my_condition" in request +# Run tests +uv run pytest ``` -Then add to ccproxy.yaml: - -```yaml -ccproxy: - rules: - - label: my_custom_label - rule: mymodule.MyCustomRule - params: - - my_param: "value" -``` +### Why Bundle with LiteLLM? -### Testing Patterns +LiteLLM imports `ccproxy.handler:CCProxyHandler` at runtime from the auto-generated `~/.ccproxy/ccproxy.py` file. Both must be in the same Python environment: -- **Test Isolation**: Always use `clear_config_instance()` and `clear_router()` in cleanup -- **Mock proxy_server**: Use `unittest.mock` to simulate LiteLLM runtime environment -- **Type Stubs**: Located in `stubs/` directory for external dependencies +- `uv tool install ccproxy` → isolated env +- `uv tool install litellm` → different isolated env ---- +Solution: Install together so they share the same environment. -_This CLAUDE.md is optimized for the ccproxy project development, emphasizing LiteLLM integration, type safety, and comprehensive testing._ +The handler file is automatically regenerated on every `ccproxy start` based on the `handler` configuration in `ccproxy.yaml`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..93723a2c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing to `ccproxy` + +Thank you for your interest in contributing to `ccproxy`! As a brand new project, I welcome all forms of contributions. + +## How to Contribute + +### Reporting Issues + +- **Questions & Discussions**: Open an issue for any questions or to start a discussion +- **Bug Reports**: Include steps to reproduce, expected vs actual behavior, and your environment details +- **Feature Requests**: Describe the feature and why it would be useful + +### Code Contributions + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature/your-feature-name` +3. **Make your changes** +4. **Run tests**: `uv run pytest` +5. **Check types**: `uv run mypy src/ccproxy --strict` +6. **Format code**: `uv run ruff format src/ tests/` +7. **Lint code**: `uv run ruff check src/ tests/ --fix` +8. **Commit changes**: Use clear, descriptive commit messages +9. **Push to your fork**: `git push origin feature/your-feature-name` +10. **Open a Pull Request** + +### Development Setup + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/ccproxy.git +cd ccproxy + +# Install development dependencies +uv sync + +# Install pre-commit hooks +uv run pre-commit install + +# Run tests to verify setup +uv run pytest +``` + +### Running `ccproxy` During Development + +**Important**: When developing `ccproxy`, you must use `uv run` to ensure the local development version is used instead of any globally installed version: + +```bash +# Run ccproxy commands with uv run +uv run ccproxy install +uv run ccproxy start + +# Run litellm with the local ccproxy +cd ~/.ccproxy +uv run -m litellm --config config.yaml + +# Or from the project directory +uv run litellm --config ~/.ccproxy/config.yaml +``` + +Without `uv run`, you may encounter import errors like "Could not import handler" because Python will try to use a globally installed version instead of your development code. + +### Code Style + +- **Type hints**: All functions must have complete type annotations +- **Testing**: Maintain >90% test coverage +- **Async**: Use async/await for all I/O operations +- **Error handling**: All hooks must handle errors gracefully +- **Documentation**: Code should be self-documenting through clear naming + +### Testing + +- Write tests for all new functionality +- Test edge cases and error conditions +- Run the full test suite before submitting: `uv run pytest tests/ -v --cov=ccproxy --cov-report=term-missing` + +### Pull Request Guidelines + +- **One feature per PR**: Keep PRs focused on a single change +- **Clear description**: Explain what changes you made and why +- **Link issues**: Reference any related issues +- **Tests pass**: All tests and checks must pass +- **Documentation**: Update docs if you change functionality + +## Getting Help + +- Open an issue for questions +- Check existing issues for similar problems +- Join discussions in issue threads + +## Code of Conduct + +Be respectful and constructive in all interactions. We're all here to build something useful together. + +## License + +By contributing, you agree that your contributions will be licensed under the same license as the project (see LICENSE file). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c82a94fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,40 @@ +CCProxy is dual-licensed under the GNU Affero General Public License v3.0 (AGPLv3) +for open source use and a commercial license for proprietary use. + +## Open Source License (AGPLv3) + +Copyright (C) 2025 CCProxy Contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +## Commercial License + +For commercial use or to create proprietary derivatives, please contact +the copyright holders to obtain a commercial license. + +Commercial licenses allow you to: +- Use CCProxy in proprietary software +- Modify CCProxy without open-sourcing changes +- Remove attribution requirements +- Receive priority support + +For commercial licensing inquiries, please contact: [YOUR-EMAIL@DOMAIN.COM] + +## Additional Terms + +The name "CCProxy" and associated trademarks may not be used to endorse +or promote products derived from this software without specific prior +written permission. + +Full AGPLv3 license text: https://www.gnu.org/licenses/agpl-3.0.html diff --git a/README.md b/README.md index a093f711..a3217bb9 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,301 @@ -# `ccproxy` +# `ccproxy` - Claude Code Proxy [![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/starbased-co/ccproxy) -A LiteLLM-based transformation hook system that routes Claude Code API requests to different providers based on request properties. +> [Join starbased HQ](https://starbased.net/discord) for questions, sharing setups, and contributing to development. + +`ccproxy` unlocks the full potential of your Claude Code by enabling Claude use alongside other LLM providers like OpenAI, Gemini, and Perplexity + +It works by intercepting Claude Code's requests through a [LiteLLM Proxy Server](https://docs.litellm.ai/docs/simple_proxy), allowing you to route different types of requests to the most suitable model - keep your unlimited Claude for standard coding, send large contexts to Gemini's 2M token window, route web searches to Perplexity, all while Claude Code thinks it's talking to the standard API. + +> ⚠️⚠️ **`main` Branch Status**: As of 2026-02-05, the current release may not be stable for ALL Claude Code versions. Progress towards the next release candidate is ongoing, please consider the Discord before filing an issue. + +> ⚠️ **Note**: While core functionality is complete, real-world testing and community input are welcomed. Please [open an issue](https://github.com/starbased-co/ccproxy/issues) to share your experience, report bugs, or suggest improvements, or even better, submit a PR! ## Installation +**Important:** ccproxy must be installed with LiteLLM in the same environment so that LiteLLM can import the ccproxy handler. + +### Recommended: Install as uv tool + ```bash -uv tool install ccproxy -# or -pipx install ccproxy -# or -pip install ccproxy +# Install from PyPI +uv tool install claude-ccproxy --with 'litellm[proxy]' + +# Or install from GitHub (latest) +uv tool install git+https://github.com/starbased-co/ccproxy.git --with 'litellm[proxy]' ``` -## Quick Setup +This installs: -Run the automated setup: +- `ccproxy` command (for managing the proxy) +- `litellm` bundled in the same environment (so it can import ccproxy's handler) + +### Alternative: Install with pip ```bash -ccproxy install -# or with Python module: -python -m ccproxy install +# Install both packages in the same virtual environment +pip install git+https://github.com/starbased-co/ccproxy.git +pip install 'litellm[proxy]' ``` -This will create all necessary configuration files in `~/.ccproxy/`. +**Note:** With pip, both packages must be in the same virtual environment. -To overwrite existing files without prompting: +### Verify Installation ```bash -ccproxy install --force +ccproxy --help +# Should show ccproxy commands + +which litellm +# Should point to litellm in ccproxy's environment ``` -## Manual Setup +## Usage -If you prefer to set up manually: +Run the automated setup: + +```bash +# This will create all necessary configuration files in ~/.ccproxy +ccproxy install -1. **Create the CCProxy configuration directory**: +tree ~/.ccproxy +# ~/.ccproxy +# ├── ccproxy.yaml +# └── config.yaml - ```bash - mkdir -p ~/.ccproxy - cd ~/.ccproxy - ``` +# ccproxy.py is auto-generated when you start the proxy -2. **Create the callback file** (`~/.ccproxy/custom_callbacks.py`): +# Start the proxy server +ccproxy start --detach - ```python - from ccproxy.handler import CCProxyHandler +# Start Claude Code +ccproxy run claude +# Or add to your .zshrc/.bashrc +export ANTHROPIC_BASE_URL="http://localhost:4000" +# Or use an alias +alias claude-proxy='ANTHROPIC_BASE_URL="http://localhost:4000" claude' +``` - # Create the instance that LiteLLM will use - proxy_handler_instance = CCProxyHandler() - ``` +Congrats, you have installed `ccproxy`! The installed configuration files are intended to be a simple demonstration, thus continuing on to the next section to configure `ccproxy` is **recommended**. -3. **Create your LiteLLM config** (`~/.ccproxy/config.yaml`): +### Configuration - ```yaml - model_list: - # Default model for regular use - - model_name: default - litellm_params: - model: anthropic/claude-sonnet-4-20250514 - api_key: ${ANTHROPIC_API_KEY} +#### `ccproxy.yaml` - # Background model for claude-3-5-haiku requests - - model_name: background - litellm_params: - model: anthropic/claude-3-5-haiku-20241022 - api_key: ${ANTHROPIC_API_KEY} +This file controls how `ccproxy` hooks into your Claude Code requests and how to route them to different LLM models based on rules. Here you specify rules, their evaluation order, and criteria like token count, model type, or tool usage. - # Add other models as needed... +```yaml +ccproxy: + debug: true + + # OAuth token sources - map provider names to shell commands + # Tokens are loaded at startup for SDK/API access outside Claude Code + oat_sources: + anthropic: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" + # Extended format with custom User-Agent: + # gemini: + # command: "jq -r '.token' ~/.gemini/creds.json" + # user_agent: "MyApp/1.0" + + hooks: + - ccproxy.hooks.rule_evaluator # evaluates rules against request (needed for routing) + - ccproxy.hooks.model_router # routes to appropriate model + - ccproxy.hooks.forward_oauth # forwards OAuth token to provider + - ccproxy.hooks.extract_session_id # extracts session ID for LangFuse tracking + # - ccproxy.hooks.capture_headers # logs HTTP headers (with redaction) + # - ccproxy.hooks.forward_apikey # forwards x-api-key header + rules: + # example rules + - name: token_count + rule: ccproxy.rules.TokenCountRule + params: + - threshold: 60000 + - name: web_search + rule: ccproxy.rules.MatchToolRule + params: + - tool_name: WebSearch + # basic rules + - name: background + rule: ccproxy.rules.MatchModelRule + params: + - model_name: claude-3-5-haiku-20241022 + - name: think + rule: ccproxy.rules.ThinkingRule + +litellm: + host: 127.0.0.1 + port: 4000 + num_workers: 4 + debug: true + detailed_debug: true +``` - litellm_settings: - callbacks: custom_callbacks.proxy_handler_instance - ``` +When `ccproxy` receives a request from Claude Code, the `rule_evaluator` hook labels the request with the first matching rule: - See [config.yaml.example](./config.yaml.example) for a complete example with all routing models. +1. `MatchModelRule`: A request with `model: claude-3-5-haiku-20241022` is labeled: `background` +2. `ThinkingRule`: A request with `thinking: {enabled: true}` is labeled: `think` -4. **Start the LiteLLM proxy**: +If a request doesn't match any rule, it receives the `default` label. - ```bash - cd ~/.ccproxy - litellm --config config.yaml - ``` +#### `config.yaml` - The proxy will start on `http://localhost:4000` by default. +[LiteLLM's proxy configuration file](https://docs.litellm.ai/docs/proxy/config_settings) is where your model deployments are defined. The `model_router` hook takes advantage of [LiteLLM's model alias feature](https://docs.litellm.ai/docs/completion/model_alias) to dynamically rewrite the model field in requests based on rule criteria before LiteLLM selects a deployment. When a request is labeled (e.g., think), the hook changes the model from whatever Claude Code requested to the corresponding alias, allowing seamless redirection to different models. -## Environment Variables +The diagram shows how routing labels (⚡ default, 🧠 think, 🍃 background) map to their corresponding model deployments: -Set your API keys before starting the proxy: +```mermaid +graph LR + subgraph ccproxy_yaml["<code>ccproxy.yaml</code>"] + R1["<div style='text-align:left'><code>rules:</code><br/><code>- name: default</code><br/><code>- name: think</code><br/><code>- name: background</code></div>"] + end -```bash -export ANTHROPIC_API_KEY="your-anthropic-key" -export GOOGLE_API_KEY="your-google-key" # For Gemini models -# Add other API keys as needed + subgraph config_yaml["<code>config.yaml</code>"] + subgraph aliases[" "] + A1["<div style='text-align:left'><code>model_name: default</code><br/><code>litellm_params:</code><br/><code>  model: claude-sonnet-4-5-20250929</code></div>"] + A2["<div style='text-align:left'><code>model_name: think</code><br/><code>litellm_params:</code><br/><code>  model: claude-opus-4-5-20251101</code></div>"] + A3["<div style='text-align:left'><code>model_name: background</code><br/><code>litellm_params:</code><br/><code>  model: claude-3-5-haiku-20241022</code></div>"] + end -cd ~/.ccproxy -litellm --config config.yaml + subgraph models[" "] + M1["<div style='text-align:left'><code>model_name: claude-sonnet-4-5-20250929</code><br/><code>litellm_params:</code><br/><code>  model: anthropic/claude-sonnet-4-5-20250929</code></div>"] + M2["<div style='text-align:left'><code>model_name: claude-opus-4-5-20251101</code><br/><code>litellm_params:</code><br/><code>  model: anthropic/claude-opus-4-5-20251101</code></div>"] + M3["<div style='text-align:left'><code>model_name: claude-3-5-haiku-20241022</code><br/><code>litellm_params:</code><br/><code>  model: anthropic/claude-3-5-haiku-20241022</code></div>"] + end + end + + R1 ==>|"⚡ <code>default</code>"| A1 + R1 ==>|"🧠 <code>think</code>"| A2 + R1 ==>|"🍃 <code>background</code>"| A3 + + A1 -->|"<code>alias</code>"| M1 + A2 -->|"<code>alias</code>"| M2 + A3 -->|"<code>alias</code>"| M3 + + style R1 fill:#e6f3ff,stroke:#4a90e2,stroke-width:2px,color:#000 + + style A1 fill:#fffbf0,stroke:#ffa500,stroke-width:2px,color:#000 + style A2 fill:#fff0f5,stroke:#ff1493,stroke-width:2px,color:#000 + style A3 fill:#f0fff0,stroke:#32cd32,stroke-width:2px,color:#000 + + style M1 fill:#f8f9fa,stroke:#6c757d,stroke-width:1px,color:#000 + style M2 fill:#f8f9fa,stroke:#6c757d,stroke-width:1px,color:#000 + style M3 fill:#f8f9fa,stroke:#6c757d,stroke-width:1px,color:#000 + + style aliases fill:#f0f8ff,stroke:#333,stroke-width:1px + style models fill:#f5f5f5,stroke:#333,stroke-width:1px + style ccproxy_yaml fill:#e8f4fd,stroke:#2196F3,stroke-width:2px + style config_yaml fill:#ffffff,stroke:#333,stroke-width:2px +``` + +And the corresponding `config.yaml`: + +```yaml +# config.yaml +model_list: + # aliases here are used to select a deployment below + - model_name: default + litellm_params: + model: claude-sonnet-4-5-20250929 + + - model_name: think + litellm_params: + model: claude-opus-4-5-20251101 + + - model_name: background + litellm_params: + model: claude-3-5-haiku-20241022 + + # deployments + - model_name: claude-sonnet-4-5-20250929 + litellm_params: + model: anthropic/claude-sonnet-4-5-20250929 + api_base: https://api.anthropic.com + + - model_name: claude-opus-4-5-20251101 + litellm_params: + model: anthropic/claude-opus-4-5-20251101 + api_base: https://api.anthropic.com + + - model_name: claude-3-5-haiku-20241022 + litellm_params: + model: anthropic/claude-3-5-haiku-20241022 + api_base: https://api.anthropic.com + +litellm_settings: + callbacks: + - ccproxy.handler +general_settings: + forward_client_headers_to_llm_api: true ``` +See [docs/configuration.md](docs/configuration.md) for more information on how to customize your Claude Code experience using `ccproxy`. + +<!-- ## Extended Thinking --> + +<!-- Normally, when you send a message, Claude Code does a simple keyword scan for words/phrases like "think deeply" to determine whether or not to enable thinking, as well the size of the thinking token budget. [Simply including the word "ultrathink](https://claudelog.com/mechanics/ultrathink-plus-plus/) sets the thinking token budget to the maximum of `31999`. --> + ## Routing Rules +`ccproxy` provides several built-in rules as an homage to [claude-code-router](https://github.com/musistudio/claude-code-router): + +- **MatchModelRule**: Routes based on the requested model name +- **ThinkingRule**: Routes requests containing a "thinking" field +- **TokenCountRule**: Routes requests with large token counts to high-capacity models +- **MatchToolRule**: Routes based on tool usage (e.g., WebSearch) + +See [`rules.py`](src/ccproxy/rules.py) for implementing your own rules. + +Custom rules (and hooks) are loaded with the same mechanism that LiteLLM uses to import the custom callbacks, that is, they are imported as by the LiteLLM python process as named module from within it's virtual environment (e.g. `import custom_rule_file.custom_rule_function`), or as a python script adjacent to `config.yaml`. + +## Hooks + +Hooks are functions that process requests at different stages. Configure them in `ccproxy.yaml`: + +| Hook | Description | +| -------------------- | ----------------------------------------------------------------------------------- | +| `rule_evaluator` | Evaluates rules and labels requests for routing | +| `model_router` | Routes requests to appropriate model based on labels | +| `forward_oauth` | Forwards OAuth tokens to providers (supports multi-provider with custom User-Agent) | +| `forward_apikey` | Forwards `x-api-key` header to proxied requests | +| `extract_session_id` | Extracts session ID from Claude Code's `user_id` for LangFuse tracking | +| `capture_headers` | Logs HTTP headers as LangFuse trace metadata (with sensitive value redaction) | + +Hooks can accept parameters via configuration: + +```yaml +hooks: + - hook: ccproxy.hooks.capture_headers + params: + - headers: ["user-agent", "x-request-id"] # Optional: filter specific headers +``` + +See [`hooks.py`](src/ccproxy/hooks.py) for implementing custom hooks. + ## CLI Commands -CCProxy provides several commands for managing the proxy server: +`ccproxy` provides several commands for managing the proxy server: ```bash # Install configuration files ccproxy install [--force] -# Start the proxy server as a daemon -ccproxy start [--host HOST] [--port PORT] [--debug] +# Start LiteLLM +ccproxy start [--detach] -# Stop the proxy server +# Stop LiteLLM ccproxy stop -# Check proxy server status -ccproxy status +# Check proxy server status (includes url field for tool detection) +ccproxy status # Human-readable output +ccproxy status --json # JSON output with url field + +# View proxy server logs +ccproxy logs [-f] [-n LINES] # Run any command with proxy environment variables ccproxy run <command> [args...] ``` -## Usage - -After installation and setup, you can run any command through the ccproxy: +After installation and setup, you can run any command through the `ccproxy`: ```bash # Run Claude Code through the proxy @@ -132,51 +306,137 @@ ccproxy run claude -p "Explain quantum computing" ccproxy run curl http://localhost:4000/health ccproxy run python my_script.py -# Or set an alias for convenience: -alias claude='ccproxy run claude' -claude -p "Hello world" ``` The `ccproxy run` command sets up the following environment variables: -- `OPENAI_API_BASE` / `OPENAI_BASE_URL` - For OpenAI SDK compatibility + - `ANTHROPIC_BASE_URL` - For Anthropic SDK compatibility -- `LITELLM_PROXY_BASE_URL` / `LITELLM_PROXY_API_BASE` - For LiteLLM proxy -- `HTTP_PROXY` / `HTTPS_PROXY` - Standard proxy variables +- `OPENAI_API_BASE` - For OpenAI SDK compatibility +- `OPENAI_BASE_URL` - For OpenAI SDK compatibility + +## Development + +### Request Lifecycle + +```mermaid +sequenceDiagram + participant CC as cli app + participant CP as litellm request → ccproxy + participant LP as ccproxy ← litellm response + participant API as api.anthropic.com + + Note over CC,API: Request Flow + CC->>CP: API Request<br/>(messages, model, tools, etc.) + Note over CP,LP: <Add hooks in any working order here> + + Note right of CP: ccproxy.hooks.rule_evaluator + CP-->>CP: ↓ + Note right of CP: ccproxy.hooks.model_router + CP-->>CP: ↓ + Note right of CP: ccproxy.hooks.forward_oauth + CP-->>CP: ↓ + Note right of CP: <Your code here> + CP->>API: LiteLLM: Outbound Modified Provider-specific Request + + Note over CC,API: Response Flow (Streaming) + API-->>LP: Streamed Response + Note right of CP: First to see response<br/>Can modify/hook into stream + LP-->>CC: Streamed Response<br/>(forwarded to cli app) +``` -## How It Works +### Local Setup -CCProxy automatically routes requests based on these rules (in priority order): +When developing ccproxy locally: -1. **Long context** (>60k tokens, configurable) → `token_count` model -2. **Background requests** (model is `claude-3-5-haiku`) → `background` model -3. **Thinking requests** (request has `think` field) → `think` model -4. **Web search** (tools contain `web_search`) → `web_search` model -5. **Default** → `default` model +```bash +cd /path/to/ccproxy -## Configuration +# Install in editable mode with litellm bundled +# Changes to source code are reflected immediately without reinstalling +uv tool install --editable . --with 'litellm[proxy]' --force -The `token_count_threshold` in `ccproxy_settings` controls when requests are routed to the large context model: +# Restart the proxy to pick up code changes +ccproxy stop +ccproxy start --detach -```yaml -ccproxy_settings: - token_count_threshold: 60000 # Route to token_count if tokens > 60k - debug: true # Enable debug logging to see routing decisions +# Run tests +uv run pytest + +# Linting & formatting +uv run ruff format . +uv run ruff check --fix . ``` +The `--editable` flag enables live code changes without reinstallation. The handler file (`~/.ccproxy/ccproxy.py`) is automatically regenerated on every `ccproxy start`. + +**Note:** Custom `ccproxy.py` files are preserved - auto-generation only overwrites files containing the `# AUTO-GENERATED` marker. + ## Troubleshooting -### "Could not import proxy_handler_instance from ccproxy" +### ImportError: Could not import handler from ccproxy + +**Symptom:** LiteLLM fails to start with import errors like: + +``` +ImportError: Could not import handler from ccproxy +``` + +**Cause:** LiteLLM and ccproxy are in different isolated environments. + +**Solution:** Reinstall ccproxy with litellm bundled: + +```bash +# Using uv tool (from PyPI) +uv tool install claude-ccproxy --with 'litellm[proxy]' --force + +# Or from GitHub (latest) +uv tool install git+https://github.com/starbased-co/ccproxy.git --with 'litellm[proxy]' --force + +# Or for local development (editable mode) +cd /path/to/ccproxy +uv tool install --editable . --with 'litellm[proxy]' --force +``` + +### Handler Configuration Not Updating + +**Symptom:** Changes to `handler` field in `ccproxy.yaml` don't take effect. + +**Cause:** Handler file is only regenerated on `ccproxy start`. -Make sure you: +**Solution:** + +```bash +ccproxy stop +ccproxy start --detach +# This regenerates ~/.ccproxy/ccproxy.py +``` + +### Verifying Installation + +Check that ccproxy is accessible to litellm: + +```bash +# Find litellm's environment +which litellm + +# Check if ccproxy is installed in the same environment +$(dirname $(which litellm))/python -c "import ccproxy; print(ccproxy.__file__)" +# Should print path without errors +``` -1. Created the `custom_callbacks.py` file in your config directory -2. Are running `litellm` from the same directory as your config files -3. Have installed ccproxy: `pip install ccproxy` +## Contributing -### API Key Errors +I welcome contributions! Please see the [Contributing Guide](CONTRIBUTING.md) for details on: -Ensure your API keys are set as environment variables before starting LiteLLM. +- Reporting issues and asking questions +- Setting up development environment +- Code style and testing requirements +- Submitting pull requests -### Debug Logging +Since this is a new project, I especially appreciate: -Set `debug: true` in `ccproxy_settings` to see detailed routing decisions in the logs. +- Bug reports and feedback +- Documentation improvements +- Test coverage additions +- Feature suggestions +- Any of your implementations using `ccproxy` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..f6f9876a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + db: + image: postgres:16 + restart: always + container_name: litellm-db + environment: + POSTGRES_DB: litellm + POSTGRES_USER: ccproxy + POSTGRES_PASSWORD: test + ports: + - "5432:5432" + volumes: + - ccproxy-litellm-db:/var/lib/postgresql/data # Persists Postgres data across container restarts + +volumes: + ccproxy-litellm-db: diff --git a/docs/ccproxy_config_v2.md b/docs/ccproxy_config_v2.md deleted file mode 100644 index a755891b..00000000 --- a/docs/ccproxy_config_v2.md +++ /dev/null @@ -1,34 +0,0 @@ -# `ccproxy.yaml` Config File Changes (Completed) - -- Moved `ccproxy` settings out of the LiteLLM proxy `config.yaml` into a new `ccproxy.yaml`. See @./ccproxy.yaml -- contains settings for `ccproxy` such as debug mode, any other ccproxy specific settings, and most importantly, the `rules` config -- Expect `ccproxy.yaml` file in the same directory as `config.yaml` - -## Example Configuration File - -```yaml -ccproxy: - debug: true - rules: - - label: token_count - rule: ccproxy.rules.TokenCountRule - params: - - threshold: 60000 - - label: background - rule: ccproxy.rules.MatchModelRule - params: - - model_name: claude-3-5-haiku-20241022 - - label: think - rule: ccproxy.rules.ThinkingRule - - label: web_search - rule: ccproxy.rules.MatchToolRule - params: - - tool_name: WebSearch -``` - -- Initialize `ClassificationRule` objects at start when reading `ccproxy.yaml` config - - Every rule's label must be matching a model in the LiteLLM proxy `config.yaml` `model_list` field -- Need to Remove the `RoutingLabel` class. Now labels are defined by the user and associated with a `ClassificationRule` - - `ClassificationRule.evaluate` returns a `RoutingLabel`, therefore the evaluate function should probably return true or false and the classifier uses the associated label name from the config file for the first rule in order of priority that returns true -- `rule` field is the path of a python import, so built in rules can be imported by importing `ccproxy.rules.{rule name}` just like how LiteLLM imports the hook with `callbacks: custom_callbacks.proxy_handler_instance` -- `params` field is treated as \*args and/or \*\*kwargs according to the rule's class constructor diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..865fc6e8 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,492 @@ +# Configuration Guide + +This guide covers `ccproxy`'s configuration system, including all configuration files and their purposes. + +## Overview + +`ccproxy` uses two main configuration files: + +1. **`config.yaml`** - LiteLLM proxy configuration (models, API keys, etc.) +2. **`ccproxy.yaml`** - ccproxy-specific settings (rules, hooks, handler, debug options) + +Additionally, `ccproxy.py` is automatically generated when you start the proxy based on the `handler` configuration in `ccproxy.yaml`. + +## Installation + +### Prerequisites + +ccproxy requires LiteLLM to be installed in the same environment. This is handled automatically when using the recommended installation method: + +```bash +# Install from PyPI +uv tool install claude-ccproxy --with 'litellm[proxy]' + +# Or from GitHub (latest) +uv tool install git+https://github.com/starbased-co/ccproxy.git --with 'litellm[proxy]' +``` + +### Install Configuration Files + +```bash +ccproxy install +``` + +This creates: +- `~/.ccproxy/ccproxy.yaml` - ccproxy configuration (rules, hooks, handler) +- `~/.ccproxy/config.yaml` - LiteLLM proxy configuration (models, API keys) + +### Auto-Generated Files + +When you start the proxy, ccproxy automatically generates: +- `~/.ccproxy/ccproxy.py` - Handler file that LiteLLM imports + +**Do not edit `ccproxy.py` manually** - it's regenerated on every `ccproxy start` based on your `handler` configuration. + +## Configuration Files + +### `config.yaml` (LiteLLM Configuration) + +This file configures the LiteLLM proxy server with model definitions and API settings. + +```yaml +# LiteLLM model configuration +model_list: + # Default model for regular use + - model_name: default + litellm_params: + model: claude-sonnet-4-5-20250929 + + # Background model for low-cost operations + - model_name: background + litellm_params: + model: claude-haiku-4-5-20251001 + + # Thinking model for complex reasoning + - model_name: think + litellm_params: + model: claude-opus-4-5-20251101 + + # Anthropic provided claude models, no `api_key` needed + - model_name: claude-sonnet-4-5-20250929 + litellm_params: + model: anthropic/claude-sonnet-4-5-20250929 + api_base: https://api.anthropic.com + + - model_name: claude-opus-4-5-20251101 + litellm_params: + model: anthropic/claude-opus-4-5-20251101 + api_base: https://api.anthropic.com + + - model_name: claude-haiku-4-5-20251001 + litellm_params: + model: anthropic/claude-haiku-4-5-20251001 + api_base: https://api.anthropic.com + +# LiteLLM settings +litellm_settings: + callbacks: + - ccproxy.handler + +general_settings: + forward_client_headers_to_llm_api: true +``` + +Each `model_name` can be either: + +- A configured LiteLLM model (e.g., `claude-sonnet-4-5-20250929`) +- The name of a rule configured in `ccproxy.yaml` (e.g., `default`, `background`, `think`) + +Model names in `config.yaml` must correspond to rule names in `ccproxy.yaml`. When a rule matches, `ccproxy` routes to the model with the same `model_name`. + +- **Minimum requirements for Claude Code**: For Claude Code to function properly, your `config.yaml` must include at minimum: + - **Rule-based models**: `default`, `background`, and `think` + - **Claude models**: `claude-sonnet-4-5-20250929`, `claude-haiku-4-5-20251001`, and `claude-opus-4-5-20251101` (all with `api_base: https://api.anthropic.com`) + +See the [LiteLLM documentation](https://docs.litellm.ai/docs/proxy/configs) for more information. + +### `ccproxy.yaml` (ccproxy Configuration) + +This file configures `ccproxy`-specific behavior including routing rules and hooks. + +```yaml +# LiteLLM proxy settings +litellm: + host: 127.0.0.1 + port: 4000 + num_workers: 4 + debug: true + detailed_debug: true + +# ccproxy-specific configuration +ccproxy: + debug: true + + # Handler class for LiteLLM callbacks (auto-generates ccproxy.py) + # Format: "module.path:ClassName" or just "module.path" (defaults to CCProxyHandler) + handler: "ccproxy.handler:CCProxyHandler" + + # Optional: Shell command to load oauth token on startup (for standalone mode) + credentials: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" + + # Processing hooks (executed in order) + hooks: + - ccproxy.hooks.rule_evaluator # Evaluates rules + - ccproxy.hooks.model_router # Routes to models + + # Choose ONE: + - ccproxy.hooks.forward_oauth # subscription account + # - ccproxy.hooks.forward_apikey # api key + + # Routing rules (evaluated in order) + rules: + # Route high-token requests to large context model + - name: token_count + rule: ccproxy.rules.TokenCountRule + params: + - threshold: 60000 + + # Route haiku model requests to background + - name: background + rule: ccproxy.rules.MatchModelRule + params: + - model_name: claude-haiku-4-5-20251001 + + # Route thinking requests to reasoning model + - name: think + rule: ccproxy.rules.ThinkingRule + + # Route web search tool usage + - name: web_search + rule: ccproxy.rules.MatchToolRule + params: + - tool_name: WebSearch +``` + +- **`litellm`**: LiteLLM proxy server process (See `litellm --help`) +- **`ccproxy.credentials`**: Optional shell command to load credentials at startup for use as a standalone LiteLLM server +- **`ccproxy.hooks`**: A list of hooks that are executed in series during the `async_pre_call_hook` +- **`ccproxy.rules`**: Request routing rules (evaluated in order) + +#### Built-in Rules + +1. **TokenCountRule**: Routes based on token count threshold +2. **MatchModelRule**: Routes specific model requests +3. **ThinkingRule**: Routes requests with thinking fields +4. **MatchToolRule**: Routes based on tool usage + +#### Built-in Hooks + +1. **rule_evaluator**: Evaluates rules against the request to determine routing +2. **model_router**: Maps rule names to model configurations +3. **forward_oauth**: Forwards OAuth tokens to Anthropic API (for subscription accounts with credentials fallback) +4. **forward_apikey**: Forwards x-api-key headers from incoming requests (for API key authentication) + +**Note**: Use either `forward_oauth` (subscription account) OR `forward_apikey` (API key), depending on your Claude Code authentication method. + +#### Rule Parameters + +Rules accept parameters in various formats: + +```yaml +# Single positional parameter +params: + - threshold: 60000 + +# Multiple parameters +params: + - param1: value1 + param2: value2 + +# Mixed parameters +params: + - "positional_value" + - keyword: "keyword_value" +``` + +### ccproxy.py (Auto-Generated Handler) + +**This file is auto-generated** by `ccproxy start` and should not be edited manually. + +The handler file imports and instantiates the configured handler class for LiteLLM callbacks. The handler class is specified in `ccproxy.yaml` using the `handler` configuration field. + +**Configuration:** +```yaml +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" # module_path:ClassName +``` + +**Generated structure:** +```python +# Auto-generated - DO NOT EDIT +from ccproxy.handler import CCProxyHandler +handler = CCProxyHandler() +``` + +The file is referenced in `config.yaml` under `litellm_settings.callbacks` as `ccproxy.handler`. + +**Custom Handlers:** + +To use a custom handler class, update `ccproxy.yaml`: +```yaml +ccproxy: + handler: "mypackage.custom:MyHandler" +``` + +Then run `ccproxy start` to regenerate the handler file with your custom handler. + +## Request Routing Flow + +1. **Request Received**: LiteLLM proxy receives request +2. **Hook Processing**: `ccproxy` hooks process the request in order: + - `rule_evaluator`: Evaluates rules to determine routing + - `model_router`: Maps rule name to model configuration + - `forward_oauth`: Handles OAuth token forwarding +3. **Model Selection**: Request routed to appropriate model +4. **Response**: Response returned through LiteLLM proxy + +## Credentials Management (OAuth Only) + +The `credentials` field in `ccproxy.yaml` allows you to load OAuth tokens via shell command at startup. This is **only used with `forward_oauth` hook** for Claude Code subscription accounts. + +**Note**: If using Claude Code with an Anthropic API key, use `forward_apikey` hook instead (no credentials field needed). + +### Configuration + +```yaml +ccproxy: + credentials: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" +``` + +### Behavior + +- **Execution**: Shell command runs once during config initialization +- **Caching**: Result is cached for the lifetime of the proxy process +- **Validation**: Raises `RuntimeError` if command fails (fail-fast) +- **Usage**: OAuth token is used as fallback by `forward_oauth` hook + +### Common Use Cases + +**Claude Code with subscription account (OAuth):** + +```yaml +credentials: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" +hooks: + - ccproxy.hooks.forward_oauth # Use forward_oauth for OAuth tokens +``` + +**Loading from custom script:** + +```yaml +credentials: "~/bin/get-auth-token.sh" +``` + +### Hook Integration + +The `credentials` field is used by the `forward_oauth` hook as a fallback when: + +1. No authorization header exists in the incoming request +2. The request is targeting an Anthropic API endpoint +3. Credentials were successfully loaded at startup + +This provides seamless OAuth token forwarding for Claude Code subscription accounts. + +## Custom Rules + +Create custom routing rules by implementing the `ClassificationRule` interface: + +```python +from typing import Any +from ccproxy.rules import ClassificationRule +from ccproxy.config import CCProxyConfig + +class CustomRule(ClassificationRule): + def __init__(self, custom_param: str) -> None: + self.custom_param = custom_param + + def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: + # Custom routing logic + return True # Return True to use this rule's model +``` + +Add to `ccproxy.yaml`: + +```yaml +ccproxy: + rules: + - name: custom_model # Must match model_name in config.yaml + rule: myproject.CustomRule # Python import path + params: + - custom_param: "value" +``` + +## Custom Hooks + +`ccproxy` provides a hook system that allows you to extend and customize its behavior beyond the built-in rule routing system. Hooks are Python functions that can intercept and modify requests, implement custom logging, filtering, or integrate with external systems. The rule routing system is just itself a custom hook. + +**Required for Claude Code**: Either `forward_oauth` (subscription account) OR `forward_apikey` (API key) is required, depending on your authentication method. + +### Built-in Hook Details + +#### forward_oauth + +Forwards OAuth tokens to Anthropic API requests + +**Use when:** Claude Code is configured with a subscription account + +**Features:** + +- Forwards existing authorization headers +- Falls back to `credentials` field if no header present +- Only activates for Anthropic API endpoints +- Automatically adds "Bearer" prefix if needed + +**Configuration:** + +```yaml +ccproxy: + credentials: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" + hooks: + - ccproxy.hooks.forward_oauth +``` + +#### forward_apikey + +Forwards x-api-key headers from incoming requests to proxied requests. + +**Use when:** Claude Code is configured with an Anthropic API key (not a subscription account) + +**Features:** + +- Forwards x-api-key header from request to proxied request +- No credentials fallback mechanism +- Simple header passthrough + +**Configuration:** + +```yaml +ccproxy: + hooks: + - ccproxy.hooks.forward_apikey +``` + +**Important**: Choose ONE of these hooks based on your Claude Code authentication method: + +- **Subscription account** → Use `forward_oauth` +- **API key** → Use `forward_apikey` + +### Example: Request Logging Hook + +```python +# ~/.ccproxy/my_hooks.py +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +def request_logger(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Log detailed request information.""" + metadata = data.get("metadata", {}) + logger.info(f"Processing request for model: {data.get('model')}") + return data +``` + +Add to `ccproxy.yaml`: + +```yaml +ccproxy: + hooks: + - my_hooks.request_logger # Your custom hook + - ccproxy.hooks.forward_oauth # For subscription account + # - ccproxy.hooks.forward_apikey # Or this, for API key +``` + +### Hook Parameters + +Hooks can accept parameters via the `hook:` + `params:` format: + +```yaml +ccproxy: + hooks: + # Simple form (no params) + - ccproxy.hooks.rule_evaluator + + # Dict form with params + - hook: ccproxy.hooks.capture_headers + params: + headers: [user-agent, x-request-id, content-type] +``` + +Parameters are passed to the hook function via `**kwargs`: + +```python +def my_hook(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + # Access params from kwargs + threshold = kwargs.get("threshold", 1000) + return data +``` + +## Debugging + +Enable debug output in `ccproxy.yaml`: + +```yaml +litellm: + debug: true + detailed_debug: true + +ccproxy: + debug: true +``` + +This provides detailed logging for request processing and routing decisions. + +## Common Patterns + +### Token-Based Routing + +Route expensive requests to cost-effective models: + +```yaml +rules: + - name: large_context + rule: ccproxy.rules.TokenCountRule + params: + - threshold: 50000 + + - name: default + rule: ccproxy.rules.DefaultRule +``` + +### Tool-Based Routing + +Route tool usage to specialized models: + +```yaml +rules: + - name: web_search + rule: ccproxy.rules.MatchToolRule + params: + - tool_name: WebSearch + + - name: code_execution + rule: ccproxy.rules.MatchToolRule + params: + - tool_name: CodeExecution +``` + +### Model-Specific Routing + +Route specific model requests: + +```yaml +rules: + - name: background + rule: ccproxy.rules.MatchModelRule + params: + - model_name: claude-haiku-4-5-20251001 + + - name: reasoning + rule: ccproxy.rules.MatchModelRule + params: + - model_name: claude-opus-4-5-20251101 +``` diff --git a/docs/llms/litellm-proxy-logging.md b/docs/llms/litellm-proxy-logging.md new file mode 100644 index 00000000..e3df96e7 --- /dev/null +++ b/docs/llms/litellm-proxy-logging.md @@ -0,0 +1,1249 @@ +# LiteLLM Proxy Logging + +Log Proxy input, output, and exceptions using: + +- Langfuse +- OpenTelemetry +- GCS, s3, Azure (Blob) Buckets +- AWS SQS +- Lunary +- MLflow +- Deepeval +- Custom Callbacks - Custom code and API endpoints +- Langsmith +- DataDog +- DynamoDB +- etc. + +## Getting the LiteLLM Call ID + +LiteLLM generates a unique `call_id` for each request. This `call_id` can be +used to track the request across the system. This can be very useful for finding +the info for a particular request in a logging system like one of the systems +mentioned in this page. + +```bash +curl -i -sSL --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "what llm are you"}] + }' | grep 'x-litellm' +``` + +The output of this is: + +``` +x-litellm-call-id: b980db26-9512-45cc-b1da-c511a363b83f +x-litellm-model-id: cb41bc03f4c33d310019bae8c5afdb1af0a8f97b36a234405a9807614988457c +x-litellm-model-api-base: https://x-example-1234.openai.azure.com +x-litellm-version: 1.40.21 +x-litellm-response-cost: 2.85e-05 +x-litellm-key-tpm-limit: None +x-litellm-key-rpm-limit: None +``` + +A number of these headers could be useful for troubleshooting, but the +`x-litellm-call-id` is the one that is most useful for tracking a request across +components in your system, including in logging tools. + +## Logging Features + +### Redact Messages, Response Content + +Set `litellm.turn_off_message_logging=True` This will prevent the messages and responses from being logged to your logging provider, but request metadata - e.g. spend, will still be tracked. + +**1. Setup config.yaml** + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo +litellm_settings: + success_callback: ["langfuse"] + turn_off_message_logging: True # 👈 Key Change +``` + +**2. Send request** + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + +### Redacting UserAPIKeyInfo + +Redact information about the user api key (hashed token, user_id, team id, etc.), from logs. + +Currently supported for Langfuse, OpenTelemetry, Logfire, ArizeAI logging. + +```yaml +litellm_settings: + callbacks: ["langfuse"] + redact_user_api_key_info: true +``` + +### Disable Message Redaction + +If you have `litellm.turn_on_message_logging` turned on, you can override it for specific requests by +setting a request header `LiteLLM-Disable-Message-Redaction: true`. + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'LiteLLM-Disable-Message-Redaction: true' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + +### Turn off all tracking/logging + +For some use cases, you may want to turn off all tracking/logging. You can do this by passing `no-log=True` in the request body. + +> **Info:** Disable this by setting `global_disable_no_log_param:true` in your config.yaml file. + +```yaml +litellm_settings: + global_disable_no_log_param: True +``` + +```bash +curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer <litellm-api-key>' \ +-d '{ + "model": "openai/gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What'\''s in this image?" + } + ] + } + ], + "max_tokens": 300, + "no-log": true # 👈 Key Change +}' +``` + +**Expected Console Log** + +``` +LiteLLM.Info: "no-log request, skipping logging" +``` + +### ✨ Dynamically Disable specific callbacks + +> **Info:** This is an enterprise feature. [Proceed with LiteLLM Enterprise](https://www.litellm.ai/enterprise) + +For some use cases, you may want to disable specific callbacks for a request. You can do this by passing `x-litellm-disable-callbacks: <callback_name>` in the request headers. + +Send the list of callbacks to disable in the request header `x-litellm-disable-callbacks`. + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'x-litellm-disable-callbacks: langfuse' \ + --data '{ + "model": "claude-sonnet-4-5-20250929", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + +### ✨ Conditional Logging by Virtual Keys, Teams + +Use this to: + +1. Conditionally enable logging for some virtual keys/teams +2. Set different logging providers for different virtual keys/teams + +[👉 **Get Started** - Team/Key Based Logging](https://docs.litellm.ai/docs/proxy/team_logging) + +## What gets logged? + +Found under `kwargs["standard_logging_object"]`. This is a standard payload, logged for every response. + +[👉 **Standard Logging Payload Specification**](https://docs.litellm.ai/docs/proxy/logging_spec) + +## Langfuse + +We will use the `--config` to set `litellm.success_callback = ["langfuse"]` this will log all successful LLM calls to langfuse. Make sure to set `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` in your environment + +**Step 1** Install langfuse + +```bash +pip install langfuse>=2.0.0 +``` + +**Step 2**: Create a `config.yaml` file and set `litellm_settings`: `success_callback` + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo +litellm_settings: + success_callback: ["langfuse"] +``` + +**Step 3**: Set required env variables for logging to langfuse + +```bash +export LANGFUSE_PUBLIC_KEY="pk_kk" +export LANGFUSE_SECRET_KEY="sk_ss" +# Optional, defaults to https://cloud.langfuse.com +export LANGFUSE_HOST="https://xxx.langfuse.com" +``` + +**Step 4**: Start the proxy, make a test request + +Start proxy + +```bash +litellm --config config.yaml --debug +``` + +Test Request + +```bash +litellm --test +``` + +### Logging Metadata to Langfuse + +Pass `metadata` as part of the request body + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ], + "metadata": { + "generation_name": "ishaan-test-generation", + "generation_id": "gen-id22", + "trace_id": "trace-id22", + "trace_user_id": "user-id2" + } +}' +``` + +### Custom Tags + +Set `tags` as part of your request body + +```python +import openai +client = openai.OpenAI( + api_key="sk-1234", + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="llama3", + messages = [ + { + "role": "user", + "content": "this is a test request, write a short poem" + } + ], + user="palantir", + extra_body={ + "metadata": { + "tags": ["jobID:214590dsff09fds", "taskName:run_page_classification"] + } + } +) + +print(response) +``` + +### LiteLLM Tags - `cache_hit`, `cache_key` + +Use this if you want to control which LiteLLM-specific fields are logged as tags by the LiteLLM proxy. By default LiteLLM Proxy logs no LiteLLM-specific fields + +| LiteLLM specific field | Description | Example Value | +|---|---|---| +| `cache_hit` | Indicates whether a cache hit occurred (True) or not (False) | `true`, `false` | +| `cache_key` | The Cache key used for this request | `d2b758c****` | +| `proxy_base_url` | The base URL for the proxy server, the value of env var `PROXY_BASE_URL` on your server | `https://proxy.example.com` | +| `user_api_key_alias` | An alias for the LiteLLM Virtual Key. | `prod-app1` | +| `user_api_key_user_id` | The unique ID associated with a user's API key. | `user_123`, `user_456` | +| `user_api_key_user_email` | The email associated with a user's API key. | `user@example.com`, `admin@example.com` | +| `user_api_key_team_alias` | An alias for a team associated with an API key. | `team_alpha`, `dev_team` | + +**Usage** + +Specify `langfuse_default_tags` to control what litellm fields get logged on Langfuse + +Example config.yaml + +```yaml +model_list: + - model_name: gpt-4 + litellm_params: + model: openai/fake + api_key: fake-key + api_base: https://exampleopenaiendpoint-production.up.railway.app/ + +litellm_settings: + success_callback: ["langfuse"] + + # 👇 Key Change + langfuse_default_tags: ["cache_hit", "cache_key", "proxy_base_url", "user_api_key_alias", "user_api_key_user_id", "user_api_key_user_email", "user_api_key_team_alias", "semantic-similarity", "proxy_base_url"] +``` + +### View POST sent from LiteLLM to provider + +Use this when you want to view the RAW curl request sent from LiteLLM to the LLM API + +Pass `metadata` as part of the request body + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data '{ + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ], + "metadata": { + "log_raw_request": true + } +}' +``` + +**Expected Output on Langfuse** + +You will see `raw_request` in your Langfuse Metadata. This is the RAW CURL command sent from LiteLLM to your LLM API provider + +## OpenTelemetry + +> **Info:** [Optional] Customize OTEL Service Name and OTEL TRACER NAME by setting the following variables in your environment + +```bash +OTEL_TRACER_NAME=<your-trace-name> # default="litellm" +OTEL_SERVICE_NAME=<your-service-name> # default="litellm" +``` + +**Step 1:** Set callbacks and env vars + +Add the following to your env + +```bash +OTEL_EXPORTER="console" +``` + +Add `otel` as a callback on your `litellm_config.yaml` + +```yaml +litellm_settings: + callbacks: ["otel"] +``` + +**Step 2**: Start the proxy, make a test request + +Start proxy + +```bash +litellm --config config.yaml --detailed_debug +``` + +Test Request + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data ' { + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] + }' +``` + +**Step 3**: **Expect to see the following logged on your server logs / console** + +This is the Span from OTEL Logging + +```json +{ + "name": "litellm-acompletion", + "context": { + "trace_id": "0x8d354e2346060032703637a0843b20a3", + "span_id": "0xd8d3476a2eb12724", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": null, + "start_time": "2024-06-04T19:46:56.415888Z", + "end_time": "2024-06-04T19:46:56.790278Z", + "status": { + "status_code": "OK" + }, + "attributes": { + "model": "llama3-8b-8192" + }, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "litellm" + }, + "schema_url": "" + } +} +``` + +🎉 Expect to see this trace logged in your OTEL collector + +### Redacting Messages, Response Content + +Set `message_logging=False` for `otel`, no messages / response will be logged + +```yaml +litellm_settings: + callbacks: ["otel"] + +## 👇 Key Change +callback_settings: + otel: + message_logging: False +``` + +### Traceparent Header + +#### Context propagation across Services `Traceparent HTTP Header` + +❓ Use this when you want to **pass information about the incoming request in a distributed tracing system** + +✅ Key change: Pass the **`traceparent` header** in your requests. [Read more about traceparent headers here](https://uptrace.dev/opentelemetry/opentelemetry-traceparent.html#what-is-traceparent-header) + +``` +traceparent: 00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01 +``` + +Example Usage + +1. Make Request to LiteLLM Proxy with `traceparent` header + +```python +import openai +import uuid + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") +example_traceparent = f"00-80e1afed08e019fc1110464cfa66635c-02e80198930058d4-01" +extra_headers = { + "traceparent": example_traceparent +} +_trace_id = example_traceparent.split("-")[1] + +print("EXTRA HEADERS: ", extra_headers) +print("Trace ID: ", _trace_id) + +response = client.chat.completions.create( + model="llama3", + messages=[ + {"role": "user", "content": "this is a test request, write a short poem"} + ], + extra_headers=extra_headers, +) + +print(response) +``` + +``` +# EXTRA HEADERS: {'traceparent': '00-80e1afed08e019fc1110464cfa66635c-02e80198930058d4-01'} +# Trace ID: 80e1afed08e019fc1110464cfa66635c +``` + +2. Lookup Trace ID on OTEL Logger + +Search for Trace= `80e1afed08e019fc1110464cfa66635c` on your OTEL Collector + +#### Forwarding `Traceparent HTTP Header` to LLM APIs + +Use this if you want to forward the traceparent headers to your self hosted LLMs like vLLM + +Set `forward_traceparent_to_llm_provider: True` in your `config.yaml`. This will forward the `traceparent` header to your LLM API + +> **Warning:** Only use this for self hosted LLMs, this can cause Bedrock, VertexAI calls to fail + +```yaml +litellm_settings: + forward_traceparent_to_llm_provider: True +``` + +## Google Cloud Storage Buckets + +Log LLM Logs to [Google Cloud Storage Buckets](https://cloud.google.com/storage?hl=en) + +> **Info:** ✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) + +| Property | Details | +|---|---| +| Description | Log LLM Input/Output to cloud storage buckets | +| Load Test Benchmarks | [Benchmarks](https://docs.litellm.ai/docs/benchmarks) | +| Google Docs on Cloud Storage | [Google Cloud Storage](https://cloud.google.com/storage?hl=en) | + +### Usage + +1. Add `gcs_bucket` to LiteLLM Config.yaml + +```yaml +model_list: +- litellm_params: + api_base: https://exampleopenaiendpoint-production.up.railway.app/ + api_key: my-fake-key + model: openai/my-fake-model + model_name: fake-openai-endpoint + +litellm_settings: + callbacks: ["gcs_bucket"] # 👈 KEY CHANGE +``` + +2. Set required env variables + +```bash +GCS_BUCKET_NAME="<your-gcs-bucket-name>" +GCS_PATH_SERVICE_ACCOUNT="/Users/ishaanjaffer/Downloads/adroit-crow-413218-a956eef1a2a8.json" # Add path to service account.json +``` + +3. Start Proxy + +```bash +litellm --config /path/to/config.yaml +``` + +4. Test it! + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ +--header 'Content-Type: application/json' \ +--data ' { + "model": "fake-openai-endpoint", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ], + } +' +``` + +### Fields Logged on GCS Buckets + +[**The standard logging object is logged on GCS Bucket**](https://docs.litellm.ai/docs/proxy/logging_spec) + +### Getting `service_account.json` from Google Cloud Console + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Search for IAM & Admin +3. Click on Service Accounts +4. Select a Service Account +5. Click on 'Keys' -> Add Key -> Create New Key -> JSON +6. Save the JSON file and add the path to `GCS_PATH_SERVICE_ACCOUNT` + +## s3 Buckets + +We will use the `--config` to set + +- `litellm.success_callback = ["s3"]` + +This will log all successful LLM calls to s3 Bucket + +**Step 1** Set AWS Credentials in .env + +```bash +AWS_ACCESS_KEY_ID = "" +AWS_SECRET_ACCESS_KEY = "" +AWS_REGION_NAME = "" +``` + +**Step 2**: Create a `config.yaml` file and set `litellm_settings`: `success_callback` + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo +litellm_settings: + success_callback: ["s3_v2"] + s3_callback_params: + s3_bucket_name: logs-bucket-litellm # AWS Bucket Name for S3 + s3_region_name: us-west-2 # AWS Region Name for S3 + s3_aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID # us os.environ/<variable name> to pass environment variables. This is AWS Access Key ID for S3 + s3_aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY # AWS Secret Access Key for S3 + s3_path: my-test-path # [OPTIONAL] set path in bucket you want to write logs to + s3_endpoint_url: https://s3.amazonaws.com # [OPTIONAL] S3 endpoint URL, if you want to use Backblaze/cloudflare s3 buckets +``` + +**Step 3**: Start the proxy, make a test request + +Start proxy + +```bash +litellm --config config.yaml --debug +``` + +Test Request + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data ' { + "model": "Azure OpenAI GPT-4 East", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] + }' +``` + +Your logs should be available on the specified s3 Bucket + +### Team Alias Prefix in Object Key + +**This is a preview feature** + +You can add the team alias to the object key by setting the `team_alias` in the `config.yaml` file. This will prefix the object key with the team alias. + +```yaml +litellm_settings: + callbacks: ["s3_v2"] + enable_preview_features: true + s3_callback_params: + s3_bucket_name: logs-bucket-litellm + s3_region_name: us-west-2 + s3_aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID + s3_aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY + s3_path: my-test-path + s3_endpoint_url: https://s3.amazonaws.com + s3_use_team_prefix: true +``` + +On s3 bucket, you will see the object key as `my-test-path/my-team-alias/...` + +## AWS SQS + +| Property | Details | +|---|---| +| Description | Log LLM Input/Output to AWS SQS Queue | +| AWS Docs on SQS | [AWS SQS](https://aws.amazon.com/sqs/) | +| Fields Logged to SQS | LiteLLM [Standard Logging Payload is logged for each LLM call](https://docs.litellm.ai/docs/proxy/logging_spec) | + +Log LLM Logs to [AWS Simple Queue Service (SQS)](https://aws.amazon.com/sqs/) + +We will use the litellm `--config` to set + +- `litellm.callbacks = ["aws_sqs"]` + +This will log all successful LLM calls to AWS SQS Queue + +**Step 1** Set AWS Credentials in .env + +```bash +AWS_ACCESS_KEY_ID = "" +AWS_SECRET_ACCESS_KEY = "" +AWS_REGION_NAME = "" +``` + +**Step 2**: Create a `config.yaml` file and set `litellm_settings`: `callbacks` + +```yaml +model_list: + - model_name: gpt-4o + litellm_params: + model: gpt-4o +litellm_settings: + callbacks: ["aws_sqs"] + aws_sqs_callback_params: + sqs_queue_url: https://sqs.us-west-2.amazonaws.com/123456789012/my-queue # AWS SQS Queue URL + sqs_region_name: us-west-2 # AWS Region Name for SQS + sqs_aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID # use os.environ/<variable name> to pass environment variables. This is AWS Access Key ID for SQS + sqs_aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY # AWS Secret Access Key for SQS + sqs_batch_size: 10 # [OPTIONAL] Number of messages to batch before sending (default: 10) + sqs_flush_interval: 30 # [OPTIONAL] Time in seconds to wait before flushing batch (default: 30) +``` + +**Step 3**: Start the proxy, make a test request + +Start proxy + +```bash +litellm --config config.yaml --debug +``` + +Test Request + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --data ' { + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] + }' +``` + +## Azure Blob Storage + +Log LLM Logs to [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/data-lake-storage-introduction) + +> **Info:** ✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) + +| Property | Details | +|---|---| +| Description | Log LLM Input/Output to Azure Blob Storage (Bucket) | +| Azure Docs on Data Lake Storage | [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/data-lake-storage-introduction) | + +### Usage + +1. Add `azure_storage` to LiteLLM Config.yaml + +```yaml +model_list: + - model_name: fake-openai-endpoint + litellm_params: + model: openai/fake + api_key: fake-key + api_base: https://exampleopenaiendpoint-production.up.railway.app/ + +litellm_settings: + callbacks: ["azure_storage"] # 👈 KEY CHANGE +``` + +2. Set required env variables + +```bash +# Required Environment Variables for Azure Storage +AZURE_STORAGE_ACCOUNT_NAME="litellm2" # The name of the Azure Storage Account to use for logging +AZURE_STORAGE_FILE_SYSTEM="litellm-logs" # The name of the Azure Storage File System to use for logging. (Typically the Container name) + +# Authentication Variables +# Option 1: Use Storage Account Key +AZURE_STORAGE_ACCOUNT_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # The Azure Storage Account Key to use for Authentication + +# Option 2: Use Tenant ID + Client ID + Client Secret +AZURE_STORAGE_TENANT_ID="985efd7cxxxxxxxxxx" # The Application Tenant ID to use for Authentication +AZURE_STORAGE_CLIENT_ID="abe66585xxxxxxxxxx" # The Application Client ID to use for Authentication +AZURE_STORAGE_CLIENT_SECRET="uMS8Qxxxxxxxxxx" # The Application Client Secret to use for Authentication +``` + +3. Start Proxy + +```bash +litellm --config /path/to/config.yaml +``` + +4. Test it! + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ +--header 'Content-Type: application/json' \ +--data ' { + "model": "fake-openai-endpoint", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ], + } +' +``` + +### Fields Logged on Azure Data Lake Storage + +[**The standard logging object is logged on Azure Data Lake Storage**](https://docs.litellm.ai/docs/proxy/logging_spec) + +## Custom Callback Class [Async] + +Use this when you want to run custom callbacks in `python` + +### Step 1 - Create your custom `litellm` callback class + +We use `litellm.integrations.custom_logger` for this, **more details about litellm custom callbacks [here](https://docs.litellm.ai/docs/observability/custom_callback)** + +Define your custom callback class in a python file. + +Here's an example custom logger for tracking `key, user, model, prompt, response, tokens, cost`. We create a file called `custom_callbacks.py` and initialize `proxy_handler_instance` + +```python +from litellm.integrations.custom_logger import CustomLogger +import litellm + +# This file includes the custom callbacks for LiteLLM Proxy +# Once defined, these can be passed in proxy_config.yaml +class MyCustomHandler(CustomLogger): + def log_pre_api_call(self, model, messages, kwargs): + print(f"Pre-API Call") + + def log_post_api_call(self, kwargs, response_obj, start_time, end_time): + print(f"Post-API Call") + + def log_success_event(self, kwargs, response_obj, start_time, end_time): + print("On Success") + + def log_failure_event(self, kwargs, response_obj, start_time, end_time): + print(f"On Failure") + + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"On Async Success!") + # log: key, user, model, prompt, response, tokens, cost + # Access kwargs passed to litellm.completion() + model = kwargs.get("model", None) + messages = kwargs.get("messages", None) + user = kwargs.get("user", None) + + # Access litellm_params passed to litellm.completion(), example access `metadata` + litellm_params = kwargs.get("litellm_params", {}) + metadata = litellm_params.get("metadata", {}) # headers passed to LiteLLM proxy, can be found here + + # Calculate cost using litellm.completion_cost() + cost = litellm.completion_cost(completion_response=response_obj) + response = response_obj + # tokens used in response + usage = response_obj["usage"] + + print( + f""" + Model: {model}, + Messages: {messages}, + User: {user}, + Usage: {usage}, + Cost: {cost}, + Response: {response} + Proxy Metadata: {metadata} + """ + ) + return + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + try: + print(f"On Async Failure !") + print("\nkwargs", kwargs) + # Access kwargs passed to litellm.completion() + model = kwargs.get("model", None) + messages = kwargs.get("messages", None) + user = kwargs.get("user", None) + + # Access litellm_params passed to litellm.completion(), example access `metadata` + litellm_params = kwargs.get("litellm_params", {}) + metadata = litellm_params.get("metadata", {}) # headers passed to LiteLLM proxy, can be found here + + # Access Exceptions & Traceback + exception_event = kwargs.get("exception", None) + traceback_event = kwargs.get("traceback_exception", None) + + # Calculate cost using litellm.completion_cost() + cost = litellm.completion_cost(completion_response=response_obj) + print("now checking response obj") + + print( + f""" + Model: {model}, + Messages: {messages}, + User: {user}, + Cost: {cost}, + Response: {response_obj} + Proxy Metadata: {metadata} + Exception: {exception_event} + Traceback: {traceback_event} + """ + ) + except Exception as e: + print(f"Exception: {e}") + +proxy_handler_instance = MyCustomHandler() + +# Set litellm.callbacks = [proxy_handler_instance] on the proxy +# need to set litellm.callbacks = [proxy_handler_instance] # on the proxy +``` + +### Step 2 - Pass your custom callback class in `config.yaml` + +We pass the custom callback class defined in **Step1** to the config.yaml. +Set `callbacks` to `python_filename.logger_instance_name` + +In the config below, we pass + +- python_filename: `custom_callbacks.py` +- logger_instance_name: `proxy_handler_instance`. This is defined in Step 1 + +`callbacks: custom_callbacks.proxy_handler_instance` + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + +litellm_settings: + callbacks: custom_callbacks.proxy_handler_instance # sets litellm.callbacks = [proxy_handler_instance] +``` + +### Step 2b - Loading Custom Callbacks from S3/GCS (Alternative) + +Instead of using local Python files, you can load custom callbacks directly from S3 or GCS buckets. This is useful for centralized callback management or when deploying in containerized environments. + +**URL Format:** + +- **S3**: `s3://bucket-name/module_name.instance_name` +- **GCS**: `gcs://bucket-name/module_name.instance_name` + +**Example - Loading from S3:** + +Let's say you have a file `custom_callbacks.py` stored in your S3 bucket `litellm-proxy` with the following content: + +```python +# custom_callbacks.py (stored in S3) +from litellm.integrations.custom_logger import CustomLogger +import litellm + +class MyCustomHandler(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"Custom UI SSO callback executed!") + # Your custom logic here + + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + print(f"Custom UI SSO failure callback!") + # Your failure handling logic + +# Instance that will be loaded by LiteLLM +custom_handler = MyCustomHandler() +``` + +**Configuration:** + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + +litellm_settings: + callbacks: ["s3://litellm-proxy/custom_callbacks.custom_handler"] +``` + +**Example - Loading from GCS:** + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + +litellm_settings: + callbacks: ["gcs://my-gcs-bucket/custom_callbacks.custom_handler"] +``` + +**How it works:** + +1. LiteLLM detects the S3/GCS URL prefix +2. Downloads the Python file to a temporary location +3. Loads the module and extracts the specified instance +4. Cleans up the temporary file +5. Uses the callback instance for logging + +This approach allows you to: + +- Centrally manage callback files across multiple proxy instances +- Share callbacks across different environments +- Version control callback files in cloud storage + +### Step 3 - Start proxy + test request + +```bash +litellm --config proxy_config.yaml +``` + +```bash +curl --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Authorization: Bearer sk-1234' \ + --data ' { + "model": "gpt-3.5-turbo", + "messages": [ + { + "role": "user", + "content": "good morning good sir" + } + ], + "user": "ishaan-app", + "temperature": 0.2 + }' +``` + +### Resulting Log on Proxy + +``` +On Success + Model: gpt-3.5-turbo, + Messages: [{'role': 'user', 'content': 'good morning good sir'}], + User: ishaan-app, + Usage: {'completion_tokens': 10, 'prompt_tokens': 11, 'total_tokens': 21}, + Cost: 3.65e-05, + Response: {'id': 'chatcmpl-8S8avKJ1aVBg941y5xzGMSKrYCMvN', 'choices': [{'finish_reason': 'stop', 'index': 0, 'message': {'content': 'Good morning! How can I assist you today?', 'role': 'assistant'}}], 'created': 1701716913, 'model': 'gpt-3.5-turbo-0613', 'object': 'chat.completion', 'system_fingerprint': None, 'usage': {'completion_tokens': 10, 'prompt_tokens': 11, 'total_tokens': 21}} + Proxy Metadata: {'user_api_key': None, 'headers': Headers({'host': '0.0.0.0:4000', 'user-agent': 'curl/7.88.1', 'accept': '*/*', 'authorization': 'Bearer sk-1234', 'content-length': '199', 'content-type': 'application/x-www-form-urlencoded'}), 'model_group': 'gpt-3.5-turbo', 'deployment': 'gpt-3.5-turbo-ModelID-gpt-3.5-turbo'} +``` + +### Logging Proxy Request Object, Header, Url + +Here's how you can access the `url`, `headers`, `request body` sent to the proxy for each request + +```python +class MyCustomHandler(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"On Async Success!") + + litellm_params = kwargs.get("litellm_params", None) + proxy_server_request = litellm_params.get("proxy_server_request") + print(proxy_server_request) +``` + +**Expected Output** + +```json +{ + "url": "http://testserver/chat/completions", + "method": "POST", + "headers": { + "host": "testserver", + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "connection": "keep-alive", + "user-agent": "testclient", + "authorization": "Bearer None", + "content-length": "105", + "content-type": "application/json" + }, + "body": { + "model": "Azure OpenAI GPT-4 Canada", + "messages": [ + { + "role": "user", + "content": "hi" + } + ], + "max_tokens": 10 + } +} +``` + +### Logging `model_info` set in config.yaml + +Here is how to log the `model_info` set in your proxy `config.yaml`. Information on setting `model_info` on [config.yaml](https://docs.litellm.ai/docs/proxy/configs) + +```python +class MyCustomHandler(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"On Async Success!") + + litellm_params = kwargs.get("litellm_params", None) + model_info = litellm_params.get("model_info") + print(model_info) +``` + +**Expected Output** + +```json +{'mode': 'embedding', 'input_cost_per_token': 0.002} +``` + +#### Logging responses from proxy + +Both `/chat/completions` and `/embeddings` responses are available as `response_obj` + +**Note: for `/chat/completions`, both `stream=True` and `non stream` responses are available as `response_obj`** + +```python +class MyCustomHandler(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + print(f"On Async Success!") + print(response_obj) +``` + +**Expected Output /chat/completion [for both `stream` and `non-stream` responses]** + +```python +ModelResponse( + id='chatcmpl-8Tfu8GoMElwOZuj2JlHBhNHG01PPo', + choices=[ + Choices( + finish_reason='stop', + index=0, + message=Message( + content='As an AI language model, I do not have a physical body and therefore do not possess any degree or educational qualifications. My knowledge and abilities come from the programming and algorithms that have been developed by my creators.', + role='assistant' + ) + ) + ], + created=1702083284, + model='chatgpt-v-2', + object='chat.completion', + system_fingerprint=None, + usage=Usage( + completion_tokens=42, + prompt_tokens=5, + total_tokens=47 + ) +) +``` + +**Expected Output /embeddings** + +```python +{ + 'model': 'ada', + 'data': [ + { + 'embedding': [ + -0.035126980394124985, -0.020624293014407158, -0.015343423001468182, + -0.03980357199907303, -0.02750781551003456, 0.02111034281551838, + -0.022069307044148445, -0.019442008808255196, -0.00955679826438427, + -0.013143060728907585, 0.029583381488919258, -0.004725852981209755, + -0.015198921784758568, -0.014069183729588985, 0.00897879246622324, + 0.01521205808967352, + # ... (truncated for brevity) + ] + } + ] +} +``` + +## Custom Callback APIs [Async] + +Send LiteLLM logs to a custom API endpoint + +> **Info:** This is an Enterprise only feature [Get Started with Enterprise here](https://github.com/BerriAI/litellm/tree/main/enterprise) + +| Property | Details | +|---|---| +| Description | Log LLM Input/Output to a custom API endpoint | +| Logged Payload | `List[StandardLoggingPayload]` LiteLLM logs a list of [`StandardLoggingPayload` objects](https://docs.litellm.ai/docs/proxy/logging_spec) to your endpoint | + +Use this if you: + +- Want to use custom callbacks written in a non Python programming language +- Want your callbacks to run on a different microservice + +### Usage + +1. Set `success_callback: ["generic_api"]` on litellm config.yaml + +litellm config.yaml + +```yaml +model_list: + - model_name: openai/gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + +litellm_settings: + success_callback: ["generic_api"] +``` + +2. Set Environment Variables for the custom API endpoint + +| Environment Variable | Details | Required | +|---|---|---| +| `GENERIC_LOGGER_ENDPOINT` | The endpoint + route we should send callback logs to | Yes | +| `GENERIC_LOGGER_HEADERS` | Optional: Set headers to be sent to the custom API endpoint | No, this is optional | + +.env + +```bash +GENERIC_LOGGER_ENDPOINT="https://webhook-test.com/30343bc33591bc5e6dc44217ceae3e0a" + +# Optional: Set headers to be sent to the custom API endpoint +GENERIC_LOGGER_HEADERS="Authorization=Bearer <your-api-key>" +# if multiple headers, separate by commas +GENERIC_LOGGER_HEADERS="Authorization=Bearer <your-api-key>,X-Custom-Header=custom-header-value" +``` + +3. Start the proxy + +```bash +litellm --config /path/to/config.yaml +``` + +4. Make a test request + +```bash +curl -i --location 'http://0.0.0.0:4000/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer sk-1234' \ + --data '{ + "model": "openai/gpt-4o", + "messages": [ + { + "role": "user", + "content": "what llm are you" + } + ] +}' +``` + +## Additional Logging Providers + +The documentation also covers several other logging providers including: + +- **Langsmith** - For language model experiment tracking +- **Arize AI** - For ML observability +- **Langtrace** - For LLM tracing +- **Deepeval** - For LLM evaluation +- **Lunary** - For LLM monitoring +- **MLflow** - For ML lifecycle management +- **Galileo** - For ML data intelligence +- **OpenMeter** - For usage billing +- **DynamoDB** - For AWS database logging +- **Sentry** - For error tracking +- **Athina** - For LLM monitoring and analytics + +Each provider has specific setup instructions, environment variables, and configuration requirements. Refer to the original documentation for detailed implementation steps for these additional providers. \ No newline at end of file diff --git a/docs/llms/man/index.md b/docs/llms/man/index.md new file mode 100644 index 00000000..3182853d --- /dev/null +++ b/docs/llms/man/index.md @@ -0,0 +1,7 @@ +# Manual & Reference Documentation + +Last updated: 2025-11-11 + +## LiteLLM + +- **litellm-anthropic-messages.md** - LiteLLM Anthropic unified API endpoint /v1/messages reference (2025-11-11) diff --git a/docs/llms/man/litellm-anthropic-messages.md b/docs/llms/man/litellm-anthropic-messages.md new file mode 100644 index 00000000..27216336 --- /dev/null +++ b/docs/llms/man/litellm-anthropic-messages.md @@ -0,0 +1,611 @@ +--- +agent: claude +source: https://github.com/BerriAI/litellm/blob/main/docs/my-website/docs/anthropic_unified.md +extracted: 2025-11-11 +topic: LiteLLM Anthropic unified API endpoint /v1/messages +--- + +# /v1/messages + +Use LiteLLM to call all your LLM APIs in the Anthropic `v1/messages` format. + + +## Overview + +| Feature | Supported | Notes | +|-------|-------|-------| +| Cost Tracking | ✅ | Works with all supported models | +| Logging | ✅ | Works across all integrations | +| End-user Tracking | ✅ | | +| Streaming | ✅ | | +| Fallbacks | ✅ | Works between supported models | +| Loadbalancing | ✅ | Works between supported models | +| Guardrails | ✅ | Applies to input and output text (non-streaming only) | +| Supported Providers | **All LiteLLM supported providers** | `openai`, `anthropic`, `bedrock`, `vertex_ai`, `gemini`, `azure`, `azure_ai`, etc. | + +## Usage +--- + +### LiteLLM Python SDK + +#### Anthropic + +##### Non-streaming example +```python +# Anthropic Example using LiteLLM Python SDK +import litellm +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + api_key=api_key, + model="anthropic/claude-haiku-4-5-20251001", + max_tokens=100, +) +``` + +##### Streaming example +```python +# Anthropic Streaming Example using LiteLLM Python SDK +import litellm +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + api_key=api_key, + model="anthropic/claude-haiku-4-5-20251001", + max_tokens=100, + stream=True, +) +async for chunk in response: + print(chunk) +``` + +#### OpenAI + +##### Non-streaming example +```python +# OpenAI Example using LiteLLM Python SDK +import litellm +import os + +# Set API key +os.environ["OPENAI_API_KEY"] = "your-openai-api-key" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="openai/gpt-4", + max_tokens=100, +) +``` + +##### Streaming example +```python +# OpenAI Streaming Example using LiteLLM Python SDK +import litellm +import os + +# Set API key +os.environ["OPENAI_API_KEY"] = "your-openai-api-key" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="openai/gpt-4", + max_tokens=100, + stream=True, +) +async for chunk in response: + print(chunk) +``` + +#### Google AI Studio + +##### Non-streaming example +```python +# Google Gemini Example using LiteLLM Python SDK +import litellm +import os + +# Set API key +os.environ["GEMINI_API_KEY"] = "your-gemini-api-key" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="gemini/gemini-2.0-flash-exp", + max_tokens=100, +) +``` + +##### Streaming example +```python +# Google Gemini Streaming Example using LiteLLM Python SDK +import litellm +import os + +# Set API key +os.environ["GEMINI_API_KEY"] = "your-gemini-api-key" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="gemini/gemini-2.0-flash-exp", + max_tokens=100, + stream=True, +) +async for chunk in response: + print(chunk) +``` + +#### Vertex AI + +##### Non-streaming example +```python +# Vertex AI Example using LiteLLM Python SDK +import litellm +import os + +# Set credentials - Vertex AI uses application default credentials +# Run 'gcloud auth application-default login' to authenticate +os.environ["VERTEXAI_PROJECT"] = "your-gcp-project-id" +os.environ["VERTEXAI_LOCATION"] = "us-central1" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="vertex_ai/gemini-2.0-flash-exp", + max_tokens=100, +) +``` + +##### Streaming example +```python +# Vertex AI Streaming Example using LiteLLM Python SDK +import litellm +import os + +# Set credentials - Vertex AI uses application default credentials +# Run 'gcloud auth application-default login' to authenticate +os.environ["VERTEXAI_PROJECT"] = "your-gcp-project-id" +os.environ["VERTEXAI_LOCATION"] = "us-central1" + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="vertex_ai/gemini-2.0-flash-exp", + max_tokens=100, + stream=True, +) +async for chunk in response: + print(chunk) +``` + +#### AWS Bedrock + +##### Non-streaming example +```python +# AWS Bedrock Example using LiteLLM Python SDK +import litellm +import os + +# Set AWS credentials +os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key-id" +os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-access-key" +os.environ["AWS_REGION_NAME"] = "us-west-2" # or your AWS region + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", + max_tokens=100, +) +``` + +##### Streaming example +```python +# AWS Bedrock Streaming Example using LiteLLM Python SDK +import litellm +import os + +# Set AWS credentials +os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key-id" +os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-access-key" +os.environ["AWS_REGION_NAME"] = "us-west-2" # or your AWS region + +response = await litellm.anthropic.messages.acreate( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", + max_tokens=100, + stream=True, +) +async for chunk in response: + print(chunk) +``` + +Example response: +```json +{ + "content": [ + { + "text": "Hi! this is a very short joke", + "type": "text" + } + ], + "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", + "model": "claude-3-7-sonnet-20250219", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 2095, + "output_tokens": 503, + "cache_creation_input_tokens": 2095, + "cache_read_input_tokens": 0 + } +} +``` + +### LiteLLM Proxy Server + +#### Anthropic + +1. Setup config.yaml + +```yaml +model_list: + - model_name: anthropic-claude + litellm_params: + model: claude-3-7-sonnet-latest + api_key: os.environ/ANTHROPIC_API_KEY +``` + +2. Start proxy + +```bash +litellm --config /path/to/config.yaml +``` + +3. Test it! + +```python +# Anthropic Example using LiteLLM Proxy Server +import anthropic + +# point anthropic sdk to litellm proxy +client = anthropic.Anthropic( + base_url="http://0.0.0.0:4000", + api_key="sk-1234", +) + +response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="anthropic-claude", + max_tokens=100, +) +``` + +#### OpenAI + +1. Setup config.yaml + +```yaml +model_list: + - model_name: openai-gpt4 + litellm_params: + model: openai/gpt-4 + api_key: os.environ/OPENAI_API_KEY +``` + +2. Start proxy + +```bash +litellm --config /path/to/config.yaml +``` + +3. Test it! + +```python +# OpenAI Example using LiteLLM Proxy Server +import anthropic + +# point anthropic sdk to litellm proxy +client = anthropic.Anthropic( + base_url="http://0.0.0.0:4000", + api_key="sk-1234", +) + +response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="openai-gpt4", + max_tokens=100, +) +``` + +#### Google AI Studio + +1. Setup config.yaml + +```yaml +model_list: + - model_name: gemini-2-flash + litellm_params: + model: gemini/gemini-2.0-flash-exp + api_key: os.environ/GEMINI_API_KEY +``` + +2. Start proxy + +```bash +litellm --config /path/to/config.yaml +``` + +3. Test it! + +```python +# Google Gemini Example using LiteLLM Proxy Server +import anthropic + +# point anthropic sdk to litellm proxy +client = anthropic.Anthropic( + base_url="http://0.0.0.0:4000", + api_key="sk-1234", +) + +response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="gemini-2-flash", + max_tokens=100, +) +``` + +#### Vertex AI + +1. Setup config.yaml + +```yaml +model_list: + - model_name: vertex-gemini + litellm_params: + model: vertex_ai/gemini-2.0-flash-exp + vertex_project: your-gcp-project-id + vertex_location: us-central1 +``` + +2. Start proxy + +```bash +litellm --config /path/to/config.yaml +``` + +3. Test it! + +```python +# Vertex AI Example using LiteLLM Proxy Server +import anthropic + +# point anthropic sdk to litellm proxy +client = anthropic.Anthropic( + base_url="http://0.0.0.0:4000", + api_key="sk-1234", +) + +response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="vertex-gemini", + max_tokens=100, +) +``` + +#### AWS Bedrock + +1. Setup config.yaml + +```yaml +model_list: + - model_name: bedrock-claude + litellm_params: + model: bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0 + aws_access_key_id: os.environ/AWS_ACCESS_KEY_ID + aws_secret_access_key: os.environ/AWS_SECRET_ACCESS_KEY + aws_region_name: us-west-2 +``` + +2. Start proxy + +```bash +litellm --config /path/to/config.yaml +``` + +3. Test it! + +```python +# AWS Bedrock Example using LiteLLM Proxy Server +import anthropic + +# point anthropic sdk to litellm proxy +client = anthropic.Anthropic( + base_url="http://0.0.0.0:4000", + api_key="sk-1234", +) + +response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="bedrock-claude", + max_tokens=100, +) +``` + +#### curl + +```bash +# Example using LiteLLM Proxy Server +curl -L -X POST 'http://0.0.0.0:4000/v1/messages' \ +-H 'content-type: application/json' \ +-H 'x-api-key: $LITELLM_API_KEY' \ +-H 'anthropic-version: 2023-06-01' \ +-d '{ + "model": "anthropic-claude", + "messages": [ + { + "role": "user", + "content": "Hello, can you tell me a short joke?" + } + ], + "max_tokens": 100 +}' +``` + +## Request Format +--- + +Request body will be in the Anthropic messages API format. **litellm follows the Anthropic messages specification for this endpoint.** + +#### Example request body + +```json +{ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "Hello, world" + } + ] +} +``` + +#### Required Fields +- **model** (string): + The model identifier (e.g., `"claude-3-7-sonnet-20250219"`). +- **max_tokens** (integer): + The maximum number of tokens to generate before stopping. + _Note: The model may stop before reaching this limit; value must be greater than 1._ +- **messages** (array of objects): + An ordered list of conversational turns. + Each message object must include: + - **role** (enum: `"user"` or `"assistant"`): + Specifies the speaker of the message. + - **content** (string or array of content blocks): + The text or content blocks (e.g., an array containing objects with a `type` such as `"text"`) that form the message. + _Example equivalence:_ + ```json + {"role": "user", "content": "Hello, Claude"} + ``` + is equivalent to: + ```json + {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} + ``` + +#### Optional Fields +- **metadata** (object): + Contains additional metadata about the request (e.g., `user_id` as an opaque identifier). +- **stop_sequences** (array of strings): + Custom sequences that, when encountered in the generated text, cause the model to stop. +- **stream** (boolean): + Indicates whether to stream the response using server-sent events. +- **system** (string or array): + A system prompt providing context or specific instructions to the model. +- **temperature** (number): + Controls randomness in the model's responses. Valid range: `0 < temperature < 1`. +- **thinking** (object): + Configuration for enabling extended thinking. If enabled, it includes: + - **budget_tokens** (integer): + Minimum of 1024 tokens (and less than `max_tokens`). + - **type** (enum): + E.g., `"enabled"`. +- **tool_choice** (object): + Instructs how the model should utilize any provided tools. +- **tools** (array of objects): + Definitions for tools available to the model. Each tool includes: + - **name** (string): + The tool's name. + - **description** (string): + A detailed description of the tool. + - **input_schema** (object): + A JSON schema describing the expected input format for the tool. +- **top_k** (integer): + Limits sampling to the top K options. +- **top_p** (number): + Enables nucleus sampling with a cumulative probability cutoff. Valid range: `0 < top_p < 1`. + + +## Response Format +--- + +Responses will be in the Anthropic messages API format. + +#### Example Response + +```json +{ + "content": [ + { + "text": "Hi! My name is Claude.", + "type": "text" + } + ], + "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", + "model": "claude-3-7-sonnet-20250219", + "role": "assistant", + "stop_reason": "end_turn", + "stop_sequence": null, + "type": "message", + "usage": { + "input_tokens": 2095, + "output_tokens": 503, + "cache_creation_input_tokens": 2095, + "cache_read_input_tokens": 0 + } +} +``` + +#### Response fields + +- **content** (array of objects): + Contains the generated content blocks from the model. Each block includes: + - **type** (string): + Indicates the type of content (e.g., `"text"`, `"tool_use"`, `"thinking"`, or `"redacted_thinking"`). + - **text** (string): + The generated text from the model. + _Note: Maximum length is 5,000,000 characters._ + - **citations** (array of objects or `null`): + Optional field providing citation details. Each citation includes: + - **cited_text** (string): + The excerpt being cited. + - **document_index** (integer): + An index referencing the cited document. + - **document_title** (string or `null`): + The title of the cited document. + - **start_char_index** (integer): + The starting character index for the citation. + - **end_char_index** (integer): + The ending character index for the citation. + - **type** (string): + Typically `"char_location"`. + +- **id** (string): + A unique identifier for the response message. + _Note: The format and length of IDs may change over time._ + +- **model** (string): + Specifies the model that generated the response. + +- **role** (string): + Indicates the role of the generated message. For responses, this is always `"assistant"`. + +- **stop_reason** (string): + Explains why the model stopped generating text. Possible values include: + - `"end_turn"`: The model reached a natural stopping point. + - `"max_tokens"`: The generation stopped because the maximum token limit was reached. + - `"stop_sequence"`: A custom stop sequence was encountered. + - `"tool_use"`: The model invoked one or more tools. + +- **stop_sequence** (string or `null`): + Contains the specific stop sequence that caused the generation to halt, if applicable; otherwise, it is `null`. + +- **type** (string): + Denotes the type of response object, which is always `"message"`. + +- **usage** (object): + Provides details on token usage for billing and rate limiting. This includes: + - **input_tokens** (integer): + Total number of input tokens processed. + - **output_tokens** (integer): + Total number of output tokens generated. + - **cache_creation_input_tokens** (integer or `null`): + Number of tokens used to create a cache entry. + - **cache_read_input_tokens** (integer or `null`): + Number of tokens read from the cache. diff --git a/docs/llms/prompt_caching_docs.md b/docs/llms/prompt_caching_docs.md new file mode 100644 index 00000000..0880b04c --- /dev/null +++ b/docs/llms/prompt_caching_docs.md @@ -0,0 +1,823 @@ +# Messages API Prompt Caching + +Prompt caching enables resuming from specific prefixes in prompts. This reduces processing time and costs for repetitive tasks or prompts with consistent elements. + +Here's an example of how to implement prompt caching with the Messages API using a `cache_control` block: + +```bash +curl https://api.anthropic.com/v1/messages \ + -H "content-type: application/json" \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "claude-opus-4-5-20251101", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "You are an AI assistant tasked with analyzing literary works. Your goal is to provide insightful commentary on themes, characters, and writing style.\n" + }, + { + "type": "text", + "text": "<the entire contents of Pride and Prejudice>", + "cache_control": {"type": "ephemeral"} + } + ], + "messages": [ + { + "role": "user", + "content": "Analyze the major themes in Pride and Prejudice." + } + ] + }' + +# Call the model again with the same inputs up to the cache checkpoint +curl https://api.anthropic.com/v1/messages # rest of input +``` + +```json +{"cache_creation_input_tokens":188086,"cache_read_input_tokens":0,"input_tokens":21,"output_tokens":393} +{"cache_creation_input_tokens":0,"cache_read_input_tokens":188086,"input_tokens":21,"output_tokens":393} +``` + +In this example, the entire text of “Pride and Prejudice” is cached using the `cache_control` parameter. This allows reuse of the text across API calls without reprocessing it each time. Changing only the user message enables asking various questions about the book using the cached content, which can lead to faster responses and increased efficiency. + +--- + +## How prompt caching works + +When you send a request with prompt caching enabled: + +1. The system checks if a prompt prefix, up to a specified cache breakpoint, is already cached from a recent query. +2. If found, it uses the cached version, reducing processing time and costs. +3. Otherwise, it processes the full prompt and caches the prefix once the response begins. + +This is especially useful for: + +- Prompts with many examples +- Large amounts of context or background information +- Repetitive tasks with consistent instructions +- Long multi-turn conversations + +By default, the cache has a 5-minute lifetime. The cache is refreshed for no additional cost each time the cached content is used. + +For durations longer than 5 minutes, a 1-hour cache duration is available. This feature is currently in beta. + +For more information, see [1-hour cache duration](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration). + +**Prompt caching caches the full prefix** + +Prompt caching references the entire prompt - `tools`, `system`, and `messages` (in that order) up to and including the block designated with `cache_control`. + +--- + +## Pricing + +Prompt caching introduces a new pricing structure. The table below shows the price per million tokens for each supported model: + +| Model | Base Input Tokens | 5m Cache Writes | 1h Cache Writes | Cache Hits & Refreshes | Output Tokens | +| :---------------- | :---------------- | :-------------- | :-------------- | :--------------------- | :------------ | +| Claude Opus 4.1 | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Opus 4 | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Sonnet 4 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 3.7 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 3.5 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Haiku 3.5 | $0.80 / MTok | $1 / MTok | $1.6 / MTok | $0.08 / MTok | $4 / MTok | +| Claude Opus 3 | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Haiku 3 | $0.25 / MTok | $0.30 / MTok | $0.50 / MTok | $0.03 / MTok | $1.25 / MTok | + +Note: + +- 5-minute cache write tokens are 1.25 times the base input tokens price +- 1-hour cache write tokens are 2 times the base input tokens price +- Cache read tokens are 0.1 times the base input tokens price +- Regular input and output tokens are priced at standard rates + +--- + +## How to implement prompt caching + +### Supported models + +Prompt caching is currently supported on: + +- Claude Opus 4.1 +- Claude Opus 4 +- Claude Sonnet 4 +- Claude Sonnet 3.7 +- Claude Sonnet 3.5 +- Claude Haiku 3.5 +- Claude Haiku 3 +- Claude Opus 3 + +### Structuring your prompt + +Place static content (tool definitions, system instructions, context, examples) at the beginning of your prompt. Mark the end of the reusable content for caching using the `cache_control` parameter. + +Cache prefixes are created in the following order: `tools`, `system`, then `messages`. This order forms a hierarchy where each level builds upon the previous ones. + +#### How automatic prefix checking works + +A single cache breakpoint at the end of static content is often sufficient, as the system automatically finds the longest matching prefix. Here’s how it works: + +- When you add a `cache_control` breakpoint, the system automatically checks for cache hits at all previous content block boundaries (up to approximately 20 blocks before your explicit breakpoint) +- If any of these previous positions match cached content from earlier requests, the system uses the longest matching prefix +- This means you don’t need multiple breakpoints just to enable caching - one at the end is sufficient + +#### When to use multiple breakpoints + +You can define up to 4 cache breakpoints if you want to: + +- Cache different sections that change at different frequencies (e.g., tools rarely change, but context updates daily) +- Have more control over exactly what gets cached +- Ensure caching for content more than 20 blocks before your final breakpoint + +**Important limitation**: The automatic prefix checking only looks back approximately 20 content blocks from each explicit breakpoint. If your prompt has more than 20 content blocks before your cache breakpoint, content earlier than that won’t be checked for cache hits unless you add additional breakpoints. + +### Cache limitations + +The minimum cacheable prompt length is: + +- 1024 tokens for Claude Opus 4, Claude Sonnet 4, Claude Sonnet 3.7, Claude Sonnet 3.5 and Claude Opus 3 +- 2048 tokens for Claude Haiku 3.5 and Claude Haiku 3 + +Shorter prompts cannot be cached, even if marked with `cache_control`. Any requests to cache fewer than this number of tokens will be processed without caching. To see if a prompt was cached, see the response usage [fields](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance). + +For concurrent requests, note that a cache entry only becomes available after the first response begins. If you need cache hits for parallel requests, wait for the first response before sending subsequent requests. + +### Understanding cache breakpoint costs + +Cache breakpoints do not add cost. Charges apply for: + +- **Cache writes**: When new content is written to the cache (25% more than base input tokens for 5-minute TTL) +- **Cache reads**: When cached content is used (10% of base input token price) +- **Regular input tokens**: For any uncached content + +Adding more `cache_control` breakpoints doesn’t increase your costs - you still pay the same amount based on what content is actually cached and read. The breakpoints simply give you control over what sections can be cached independently. + +### What can be cached + +Most blocks in the request can be designated for caching with `cache_control`. This includes: + +- Tools: Tool definitions in the `tools` array +- System messages: Content blocks in the `system` array +- Text messages: Content blocks in the `messages.content` array, for both user and assistant turns +- Images & Documents: Content blocks in the `messages.content` array, in user turns +- Tool use and tool results: Content blocks in the `messages.content` array, in both user and assistant turns + +Each of these elements can be marked with `cache_control` to enable caching for that portion of the request. + +### What cannot be cached + +While most request blocks can be cached, there are some exceptions: + +- Thinking blocks cannot be cached directly with `cache_control`. However, thinking blocks CAN be cached alongside other content when they appear in previous assistant turns. When cached this way, they DO count as input tokens when read from cache. + +- Sub-content blocks (like [citations](https://docs.anthropic.com/en/docs/build-with-claude/citations)) themselves cannot be cached directly. Instead, cache the top-level block. + +For citations, top-level document content blocks serving as source material can be cached. This enables prompt caching with citations by caching the referenced documents. + +- Empty text blocks cannot be cached. + +### What invalidates the cache + +Modifications to cached content can invalidate some or all of the cache. + +As described in [Structuring your prompt](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#structuring-your-prompt), the cache follows the hierarchy: `tools` → `system` → `messages`. Changes at each level invalidate that level and all subsequent levels. + +The following table shows which parts of the cache are invalidated by different types of changes. ✘ indicates that the cache is invalidated, while ✓ indicates that the cache remains valid. + +| What changes | Tools cache | System cache | Messages cache | Impact | +| :-------------------------------------------------------- | :---------: | :----------: | :------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Tool definitions** | ✘ | ✘ | ✘ | Modifying tool definitions (names, descriptions, parameters) invalidates the entire cache | +| **Web search toggle** | ✓ | ✘ | ✘ | Enabling/disabling web search modifies the system prompt | +| **Citations toggle** | ✓ | ✘ | ✘ | Enabling/disabling citations modifies the system prompt | +| **Tool choice** | ✓ | ✓ | ✘ | Changes to `tool_choice` parameter only affect message blocks | +| **Images** | ✓ | ✓ | ✘ | Adding/removing images anywhere in the prompt affects message blocks | +| **Thinking parameters** | ✓ | ✓ | ✘ | Changes to extended thinking settings (enable/disable, budget) affect message blocks | +| **Non-tool results passed to extended thinking requests** | ✓ | ✓ | ✘ | When non-tool results are passed in requests while extended thinking is enabled, all previously-cached thinking blocks are stripped from context, and any messages in context that follow those thinking blocks are removed from the cache. For more details, see [Caching with thinking blocks](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#caching-with-thinking-blocks). | + +### Tracking cache performance + +Monitor cache performance using these API response fields, within `usage` in the response (or `message_start` event if [streaming](https://docs.anthropic.com/en/docs/build-with-claude/streaming)): + +- `cache_creation_input_tokens`: Number of tokens written to the cache when creating a new entry. +- `cache_read_input_tokens`: Number of tokens retrieved from the cache for this request. +- `input_tokens`: Number of input tokens which were not read from or used to create a cache. + +### Best practices for effective caching + +To optimize prompt caching performance: + +- Cache stable, reusable content like system instructions, background information, large contexts, or frequent tool definitions. +- Place cached content at the prompt’s beginning for best performance. +- Use cache breakpoints strategically to separate different cacheable prefix sections. +- Regularly analyze cache hit rates and adjust your strategy as needed. + +### Optimizing for different use cases + +Tailor your prompt caching strategy to your scenario: + +- Conversational agents: Reduces cost and latency for extended conversations, especially those with long instructions or uploaded documents. +- Coding assistants: Improves autocomplete and codebase Q&A by keeping relevant sections or a summarized version of the codebase in the prompt. +- Large document processing: Incorporates complete long-form material including images in your prompt without increasing response latency. +- Detailed instruction sets: Extensive lists of instructions, procedures, and examples can be shared. Prompt caching supports including numerous examples (e.g., 20+) to refine responses. +- Agentic tool use: Supports scenarios involving multiple tool calls and iterative code changes, where each step typically requires a new API call. +- Longform content analysis: Supports embedding entire documents (e.g., books, papers, documentation, podcast transcripts) into the prompt for user queries. + +### Troubleshooting common issues + +If experiencing unexpected behavior: + +- Ensure cached sections are identical and marked with cache_control in the same locations across calls +- Check that calls are made within the cache lifetime (5 minutes by default) +- Verify that `tool_choice` and image usage remain consistent between calls +- Validate that you are caching at least the minimum number of tokens +- The system automatically checks for cache hits at previous content block boundaries (up to ~20 blocks before your breakpoint). For prompts with more than 20 content blocks, you may need additional `cache_control` parameters earlier in the prompt to ensure all content can be cached + +Changes to `tool_choice` or the presence/absence of images anywhere in the prompt will invalidate the cache, requiring a new cache entry to be created. For more details on cache invalidation, see [What invalidates the cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#what-invalidates-the-cache). + +### Caching with thinking blocks + +When using [extended thinking](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) with prompt caching, thinking blocks have special behavior: + +**Automatic caching alongside other content**: While thinking blocks cannot be explicitly marked with `cache_control`, they get cached as part of the request content when you make subsequent API calls with tool results. This commonly happens during tool use when you pass thinking blocks back to continue the conversation. + +**Input token counting**: When thinking blocks are read from cache, they count as input tokens in your usage metrics. This is important for cost calculation and token budgeting. + +**Cache invalidation patterns**: + +- Cache remains valid when only tool results are provided as user messages +- Cache gets invalidated when non-tool-result user content is added, causing all previous thinking blocks to be stripped +- This caching behavior occurs even without explicit `cache_control` markers + +For more details on cache invalidation, see [What invalidates the cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#what-invalidates-the-cache). + +**Example with tool use**: + +``` +Request 1: User: "What's the weather in Paris?" +Response: [thinking_block_1] + [tool_use block 1] + +Request 2: +User: ["What's the weather in Paris?"], +Assistant: [thinking_block_1] + [tool_use block 1], +User: [tool_result_1, cache=True] +Response: [thinking_block_2] + [text block 2] +# Request 2 caches its request content (not the response) +# The cache includes: user message, thinking_block_1, tool_use block 1, and tool_result_1 + +Request 3: +User: ["What's the weather in Paris?"], +Assistant: [thinking_block_1] + [tool_use block 1], +User: [tool_result_1, cache=True], +Assistant: [thinking_block_2] + [text block 2], +User: [Text response, cache=True] +# Non-tool-result user block causes all thinking blocks to be ignored +# This request is processed as if thinking blocks were never present +``` + +When a non-tool-result user block is included, it designates a new assistant loop and all previous thinking blocks are removed from context. + +For more detailed information, see the [extended thinking documentation](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#understanding-thinking-block-caching-behavior). + +--- + +## Cache storage and sharing + +- **Organization Isolation**: Caches are isolated between organizations. Different organizations never share caches, even if they use identical prompts. + +- **Exact Matching**: Cache hits require 100% identical prompt segments, including all text and images up to and including the block marked with cache control. + +- **Output Token Generation**: Prompt caching has no effect on output token generation. The response you receive will be identical to what you would get if prompt caching was not used. + +--- + +## 1-hour cache duration + +For durations longer than 5 minutes, a 1-hour cache duration is available. This feature is currently in beta. + +To use the extended cache, add `extended-cache-ttl-2025-04-11` as a [beta header](https://docs.anthropic.com/en/api/beta-headers) to your request, and then include `ttl` in the `cache_control` definition like this: + +```json +"cache_control": { + "type": "ephemeral", + "ttl": "5m" | "1h" +} +``` + +The response will include detailed cache information like the following: + +```json +{ + "usage": { + "input_tokens": ..., + "cache_read_input_tokens": ..., + "cache_creation_input_tokens": ..., + "output_tokens": ..., + + "cache_creation": { + "ephemeral_5m_input_tokens": 456, + "ephemeral_1h_input_tokens": 100 + } + } +} +``` + +Note that the current `cache_creation_input_tokens` field equals the sum of the values in the `cache_creation` object. + +### When to use the 1-hour cache + +For prompts used regularly (e.g., system prompts more frequently than every 5 minutes), the 5-minute cache remains suitable as it refreshes without additional charge. + +The 1-hour cache is suitable in the following scenarios: + +- When prompts are likely used less frequently than 5 minutes, but more frequently than every hour. For example, when an agentic side-agent will take longer than 5 minutes, or when storing a long chat conversation with a user and you generally expect that user may not respond in the next 5 minutes. +- When latency is important and follow-up prompts may be sent beyond 5 minutes. +- When improved rate limit utilization is desired, as cache hits are not deducted against your rate limit. + +Both 5-minute and 1-hour caches exhibit similar latency behavior, with typical improvements in time-to-first-token for long documents. + +### Mixing different TTLs + +You can use both 1-hour and 5-minute cache controls in the same request, but with an important constraint: Cache entries with longer TTL must appear before shorter TTLs (i.e., a 1-hour cache entry must appear before any 5-minute cache entries). + +When mixing TTLs, we determine three billing locations in your prompt: + +1. Position `A`: The token count at the highest cache hit (or 0 if no hits). +2. Position `B`: The token count at the highest 1-hour `cache_control` block after `A` (or equals `A` if none exist). +3. Position `C`: The token count at the last `cache_control` block. + +If `B` and/or `C` are larger than `A`, they will necessarily be cache misses, because `A` is the highest cache hit. + +You’ll be charged for: + +1. Cache read tokens for `A`. +2. 1-hour cache write tokens for `(B - A)`. +3. 5-minute cache write tokens for `(C - B)`. + +Here are 3 examples. This depicts the input tokens of 3 requests, each of which has different cache hits and cache misses. Each has a different calculated pricing, shown in the colored boxes, as a result. +![Mixing TTLs Diagram](https://mintlify.s3.us-west-1.amazonaws.com/anthropic/images/prompt-cache-mixed-ttl.svg) + +--- + +## Prompt caching examples + +A [prompt caching cookbook](https://github.com/anthropics/anthropic-cookbook/blob/main/misc/prompt_caching.ipynb) provides detailed examples and best practices. Code snippets are included below to demonstrate various prompt caching patterns and their practical applications: + +### Large context caching example + +```bash +curl https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data \ +'{ + "model": "claude-opus-4-5-20251101", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "You are an AI assistant tasked with analyzing legal documents." + }, + { + "type": "text", + "text": "Here is the full text of a complex legal agreement: [Insert full text of a 50-page legal agreement here]", + "cache_control": {"type": "ephemeral"} + } + ], + "messages": [ + { + "role": "user", + "content": "What are the key terms and conditions in this agreement?" + } + ] +}' + +``` + +This example demonstrates basic prompt caching usage, caching the full text of the legal agreement as a prefix while keeping the user instruction uncached. + +For the first request: + +- `input_tokens`: Number of tokens in the user message only +- `cache_creation_input_tokens`: Number of tokens in the entire system message, including the legal document +- `cache_read_input_tokens`: 0 (no cache hit on first request) + +For subsequent requests within the cache lifetime: + +- `input_tokens`: Number of tokens in the user message only +- `cache_creation_input_tokens`: 0 (no new cache creation) +- `cache_read_input_tokens`: Number of tokens in the entire cached system message + +### Caching tool definitions + +```bash +curl https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data \ +'{ + "model": "claude-opus-4-5-20251101", + "max_tokens": 1024, + "tools": [ + { + "name": "get_weather", + "description": "Get the current weather in a given location", + "input_schema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The unit of temperature, either celsius or fahrenheit" + } + }, + "required": ["location"] + } + }, + # many more tools + { + "name": "get_time", + "description": "Get the current time in a given time zone", + "input_schema": { + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": "The IANA time zone name, e.g. America/Los_Angeles" + } + }, + "required": ["timezone"] + }, + "cache_control": {"type": "ephemeral"} + } + ], + "messages": [ + { + "role": "user", + "content": "What is the weather and time in New York?" + } + ] +}' + +``` + +In this example, we demonstrate caching tool definitions. + +The `cache_control` parameter is placed on the final tool ( `get_time`) to designate all of the tools as part of the static prefix. + +This means that all tool definitions, including `get_weather` and any other tools defined before `get_time`, will be cached as a single prefix. + +This approach is useful when you have a consistent set of tools that you want to reuse across multiple requests without re-processing them each time. + +For the first request: + +- `input_tokens`: Number of tokens in the user message +- `cache_creation_input_tokens`: Number of tokens in all tool definitions and system prompt +- `cache_read_input_tokens`: 0 (no cache hit on first request) + +For subsequent requests within the cache lifetime: + +- `input_tokens`: Number of tokens in the user message +- `cache_creation_input_tokens`: 0 (no new cache creation) +- `cache_read_input_tokens`: Number of tokens in all cached tool definitions and system prompt + +### Continuing a multi-turn conversation + +```bash +curl https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data \ +'{ + "model": "claude-opus-4-5-20251101", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "...long system prompt", + "cache_control": {"type": "ephemeral"} + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello, can you tell me more about the solar system?" + } + ] + }, + { + "role": "assistant", + "content": "Certainly! The solar system is the collection of celestial bodies that orbit our Sun. It consists of eight planets, numerous moons, asteroids, comets, and other objects. The planets, in order from closest to farthest from the Sun, are: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune. Each planet has its own unique characteristics and features. Is there a specific aspect of the solar system you would like to know more about?" + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Good to know." + }, + { + "type": "text", + "text": "Tell me more about Mars.", + "cache_control": {"type": "ephemeral"} + } + ] + } + ] +}' + +``` + +In this example, we demonstrate how to use prompt caching in a multi-turn conversation. + +During each turn, we mark the final block of the final message with `cache_control` so the conversation can be incrementally cached. The system will automatically lookup and use the longest previously cached prefix for follow-up messages. That is, blocks that were previously marked with a `cache_control` block are later not marked with this, but they will still be considered a cache hit (and also a cache refresh!) if they are hit within 5 minutes. + +In addition, note that the `cache_control` parameter is placed on the system message. This is to ensure that if this gets evicted from the cache (after not being used for more than 5 minutes), it will get added back to the cache on the next request. + +This approach is useful for maintaining context in ongoing conversations without repeatedly processing the same information. + +When this is set up properly, you should see the following in the usage response of each request: + +- `input_tokens`: Number of tokens in the new user message (will be minimal) +- `cache_creation_input_tokens`: Number of tokens in the new assistant and user turns +- `cache_read_input_tokens`: Number of tokens in the conversation up to the previous turn + +### Putting it all together: Multiple cache breakpoints + +```bash +curl https://api.anthropic.com/v1/messages \ + --header "x-api-key: $ANTHROPIC_API_KEY" \ + --header "anthropic-version: 2023-06-01" \ + --header "content-type: application/json" \ + --data \ +'{ + "model": "claude-opus-4-5-20251101", + "max_tokens": 1024, + "tools": [ + { + "name": "search_documents", + "description": "Search through the knowledge base", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": ["query"] + } + }, + { + "name": "get_document", + "description": "Retrieve a specific document by ID", + "input_schema": { + "type": "object", + "properties": { + "doc_id": { + "type": "string", + "description": "Document ID" + } + }, + "required": ["doc_id"] + }, + "cache_control": {"type": "ephemeral"} + } + ], + "system": [ + { + "type": "text", + "text": "You are a helpful research assistant with access to a document knowledge base.\n\n# Instructions\n- Always search for relevant documents before answering\n- Provide citations for your sources\n- Be objective and accurate in your responses\n- If multiple documents contain relevant information, synthesize them\n- Acknowledge when information is not available in the knowledge base", + "cache_control": {"type": "ephemeral"} + }, + { + "type": "text", + "text": "# Knowledge Base Context\n\nHere are the relevant documents for this conversation:\n\n## Document 1: Solar System Overview\nThe solar system consists of the Sun and all objects that orbit it...\n\n## Document 2: Planetary Characteristics\nEach planet has unique features. Mercury is the smallest planet...\n\n## Document 3: Mars Exploration\nMars has been a target of exploration for decades...\n\n[Additional documents...]", + "cache_control": {"type": "ephemeral"} + } + ], + "messages": [ + { + "role": "user", + "content": "Can you search for information about Mars rovers?" + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "tool_1", + "name": "search_documents", + "input": {"query": "Mars rovers"} + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_1", + "content": "Found 3 relevant documents: Document 3 (Mars Exploration), Document 7 (Rover Technology), Document 9 (Mission History)" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I found 3 relevant documents about Mars rovers. Let me get more details from the Mars Exploration document." + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Yes, please tell me about the Perseverance rover specifically.", + "cache_control": {"type": "ephemeral"} + } + ] + } + ] +}' + +``` + +This example demonstrates using 4 available cache breakpoints to manage different parts of your prompt: + +1. **Tools cache** (cache breakpoint 1): The `cache_control` parameter on the last tool definition caches all tool definitions. + +2. **Reusable instructions cache** (cache breakpoint 2): The static instructions in the system prompt are cached separately. These instructions rarely change between requests. + +3. **RAG context cache** (cache breakpoint 3): The knowledge base documents are cached independently, allowing you to update the RAG documents without invalidating the tools or instructions cache. + +4. **Conversation history cache** (cache breakpoint 4): The assistant’s response is marked with `cache_control` to enable incremental caching of the conversation as it progresses. + +This approach allows flexibility: + +- If you only update the final user message, all four cache segments are reused +- If you update the RAG documents but keep the same tools and instructions, the first two cache segments are reused +- If you change the conversation but keep the same tools, instructions, and documents, the first three segments are reused +- Each cache breakpoint can be invalidated independently based on what changes in your application + +For the first request: + +- `input_tokens`: Tokens in the final user message +- `cache_creation_input_tokens`: Tokens in all cached segments (tools + instructions + RAG documents + conversation history) +- `cache_read_input_tokens`: 0 (no cache hits) + +For subsequent requests with only a new user message: + +- `input_tokens`: Tokens in the new user message only +- `cache_creation_input_tokens`: Any new tokens added to conversation history +- `cache_read_input_tokens`: All previously cached tokens (tools + instructions + RAG documents + previous conversation) + +This pattern is useful for: + +- RAG applications with large document contexts +- Agent systems that use multiple tools +- Long-running conversations that need to maintain context +- Applications that need to optimize different parts of the prompt independently + +--- + +## FAQ + +### Do I need multiple cache breakpoints or is one at the end sufficient? + +A single cache breakpoint at the end of static content is often adequate. The system automatically checks for cache hits at all previous content block boundaries (up to 20 blocks before the breakpoint) and uses the longest matching prefix. + +You only need multiple breakpoints if: + +- You have more than 20 content blocks before your desired cache point +- You want to cache sections that update at different frequencies independently +- You need explicit control over what gets cached for cost optimization + +Example: If you have system instructions (rarely change) and RAG context (changes daily), you might use two breakpoints to cache them separately. + +### Do cache breakpoints add extra cost? + +Cache breakpoints do not incur direct costs. Charges apply for: + +- Writing content to cache (25% more than base input tokens for 5-minute TTL) +- Reading from cache (10% of base input token price) +- Regular input tokens for uncached content + +The number of breakpoints doesn’t affect pricing - only the amount of content cached and read matters. + +### What is the cache lifetime? + +The cache’s default minimum lifetime (TTL) is 5 minutes. This lifetime is refreshed each time the cached content is used. + +For durations longer than 5 minutes, a [1-hour cache TTL](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration) is available. + +### How many cache breakpoints can I use? + +You can define up to 4 cache breakpoints (using `cache_control` parameters) in your prompt. + +### Is prompt caching available for all models? + +No, prompt caching is currently only available for Claude Opus 4, Claude Sonnet 4, Claude Sonnet 3.7, Claude Sonnet 3.5, Claude Haiku 3.5, Claude Haiku 3, and Claude Opus 3. + +### How does prompt caching work with extended thinking? + +Cached system prompts and tools will be reused when thinking parameters change. However, thinking changes (enabling/disabling or budget changes) will invalidate previously cached prompt prefixes with messages content. + +For more details on cache invalidation, see [What invalidates the cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#what-invalidates-the-cache). + +For more on extended thinking, including its interaction with tool use and prompt caching, see the [extended thinking documentation](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#extended-thinking-and-prompt-caching). + +### How do I enable prompt caching? + +To enable prompt caching, include at least one `cache_control` breakpoint in your API request. + +### Can I use prompt caching with other API features? + +Yes, prompt caching can be used alongside other API features like tool use and vision capabilities. However, changing whether there are images in a prompt or modifying tool use settings will break the cache. + +For more details on cache invalidation, see [What invalidates the cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#what-invalidates-the-cache). + +### How does prompt caching affect pricing? + +Prompt caching introduces a new pricing structure where cache writes cost 25% more than base input tokens, while cache hits cost only 10% of the base input token price. + +### Can I manually clear the cache? + +Currently, there’s no way to manually clear the cache. Cached prefixes automatically expire after a minimum of 5 minutes of inactivity. + +### How can I track the effectiveness of my caching strategy? + +You can monitor cache performance using the `cache_creation_input_tokens` and `cache_read_input_tokens` fields in the API response. + +### What can break the cache? + +See [What invalidates the cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#what-invalidates-the-cache) for more details on cache invalidation, including a list of changes that require creating a new cache entry. + +### How does prompt caching handle privacy and data separation? + +Prompt caching implements privacy and data separation: + +1. Cache keys are generated using a cryptographic hash of the prompts up to the cache control point. This means only requests with identical prompts can access a specific cache. + +2. Caches are organization-specific. Users within the same organization can access the same cache if they use identical prompts, but caches are not shared across different organizations, even for identical prompts. + +3. The caching mechanism maintains the integrity and privacy of each unique conversation or context. + +4. It’s safe to use `cache_control` anywhere in your prompts. For cost efficiency, it’s better to exclude highly variable parts (e.g., user’s arbitrary input) from caching. + +These measures maintain data privacy and security while providing performance benefits. + +### Can I use prompt caching with the Batches API? + +Yes, it is possible to use prompt caching with your [Batches API](https://docs.anthropic.com/en/docs/build-with-claude/batch-processing) requests. However, because asynchronous batch requests can be processed concurrently and in any order, cache hits are provided on a best-effort basis. + +The [1-hour cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration) may improve cache hits. A method for its cost-effective use is: + +- Gather a set of message requests that have a shared prefix. +- Send a batch request with just a single request that has this shared prefix and a 1-hour cache block. This will get written to the 1-hour cache. +- As soon as this is complete, submit the rest of the requests. You will have to monitor the job to know when it completes. + +This approach is generally preferred over the 5-minute cache for batch requests that may exceed 5 minutes in completion time. Efforts are underway to further enhance cache hit rates and streamline this process. + +### Why am I seeing the error `AttributeError: 'Beta' object has no attribute 'prompt_caching'` in Python? + +This error typically appears when you have upgraded your SDK or you are using outdated code examples. Prompt caching is now generally available, so you no longer need the beta prefix. Instead of: + +```python +client.beta.prompt_caching.messages.create(...) +``` + +Simply use: + +```python +client.messages.create(...) +``` + +### Why am I seeing 'TypeError: Cannot read properties of undefined (reading 'messages')'? + +This error typically appears when you have upgraded your SDK or you are using outdated code examples. Prompt caching is now generally available, so you no longer need the beta prefix. Instead of: + +```typescript +client.beta.promptCaching.messages.create(...) +``` + +Simply use: + +```typescript +client.messages.create(...) +``` diff --git a/docs/prd.md b/docs/prd.md deleted file mode 100644 index b1cf9fdd..00000000 --- a/docs/prd.md +++ /dev/null @@ -1,253 +0,0 @@ -# Product Requirements Document: ccproxy - Context-Aware Proxy for Claude Code - -## Executive Summary - -ccproxy is a context-aware proxy specifically designed for Claude Code that intelligently routes requests to different AI models based on the request context. By analyzing incoming Claude Code requests (simple queries, complex code generation, debugging tasks, refactoring operations, etc.), ccproxy routes them to the most appropriate model - using fast, cost-effective models for simple queries and powerful models for complex tasks. - -This PRD outlines the requirements for reimplementing [`claude-code-router`](https://github.com/musistudio/claude-code-router) as a Python-based transformation server using LiteLLM call hooks. **ccproxy is NOT a general-purpose LLM proxy** but is specifically tuned for Claude Code's usage patterns and request context analysis. - -## Problem Statement - -Claude Code needs intelligent request routing based on context to optimize both performance and cost: - -### Context-Based Routing Specifications - -- **Simple queries** ("What is X?", "How do I...") don't need powerful models - -- ## **Complex tasks** (debugging, architecture design, large refactoring) require advanced reasoning - -- **Background tasks** (formatting, simple fixes) can use lightweight models -- **Large context operations** (analyzing entire codebases) need specialized handling -- **Web search queries** benefit from models with internet access - -### Current Implementation Limitations - -The existing TypeScript implementation has several limitations: - -- Duplicates functionality already available in LiteLLM -- Lacks comprehensive tests and documentation -- Requires maintaining separate infrastructure -- Limited extensibility for new routing rules - -### Solution: LiteLLM-Based Context Router - -By reimplementing as LiteLLM hooks specifically for Claude Code, we can: - -- Analyze Claude Code request patterns (token count, tool usage, code complexity) -- Route to appropriate models based on context-aware rules -- Leverage LiteLLM's mature infrastructure and provider support -- Maintain Claude Code-specific optimizations and patterns - -## Goals & Objectives - -### Primary Goals - -1. **Context-Aware Routing** - Analyze Claude Code requests and route to optimal models -2. **Cost Optimization** - Use cheaper models for simple tasks without sacrificing quality -3. **Performance Enhancement** - Faster responses for simple queries, powerful models for complex tasks -4. **Claude Code Integration** - Seamless drop-in replacement maintaining API compatibility - -### Success Metrics - -- Maintain or improve response quality across all request types -- Zero breaking changes for Claude Code users -- Comprehensive test coverage (>90%) - -## User Stories - -### As a Claude Code User - -1. I want my simple questions answered quickly using fast models -2. I want complex debugging tasks to use powerful reasoning models -3. I want large file operations to use models with extended context windows -4. I want my costs optimized without manually switching models -5. I want the proxy to be transparent - no changes to my workflow - -### As a Developer - -1. I want to customize routing rules for my specific use cases -2. I want detailed logs showing routing decisions -3. I want to add new model providers easily -4. I want to monitor performance and cost metrics -5. I want fallback behavior when preferred models are unavailable - -## Claude Code Request Classification - -### Request Types and Routing - -| Request Type | Characteristics | Recommended Model | Label | -| ----------------- | ------------------------------------ | ------------------------------------- | --------------- | -| Default Query | normal use+tools, basic questions | Claude Sonnet, Gemini 2.5 Flash | `default` | -| Background Task | Model explicitly set to haiku | Claude Haiku | `background` | -| Complex Reasoning | Has thinking blocks, complex prompts | Claude Opus, Gemini 2.5 Flash | `think` | -| Large Context | >60,000 tokens | Gemini 2.5 Pro | `large_context` | -| Web Search | Uses web_search tools | Perplexity, Claude/Gemini with search | `web_search` | - -### Classification Logic (Priority Order) - -```python -def classify_request(request): - # 1. Check token count first (most objective) - if request.token_count > CONTEXT_THRESHOLD: - return "large_context" - - # 2. Check if explicitly using background model - if request.model == "claude-3-5-haiku": - return "background" - - # 3. Check for thinking - if request.body.thinking: - return "think" - - # 4. Check for web search tools - if "web_search" in request.tools: - return "web_search" - - # 5. Default - return "default" -``` - -## Technical Architecture - -### Core Components - -1. **CCProxyHandler** - Main LiteLLM CustomLogger implementation -2. **RequestClassifier** - Analyzes requests and assigns routing labels - -### LiteLLM replaces the need for - -3. **ConfigurationManager** - Handles YAML config and environment overrides -4. **ModelRouter** - Maps labels to specific model configurations -5. **MetricsCollector** - Tracks routing decisions and performance - -### Integration with LiteLLM - -```python -from litellm.integrations.custom_logger import CustomLogger - -class CCProxyHandler(CustomLogger): - async def async_pre_call_hook(self, data, **kwargs): - # Analyze request context - label = self.classifier.classify(data) - - # Route to appropriate model - data["model"] = self.router.get_model_for_label(label) - - # Log routing decision - self.logger.info(f"Routed to {data['model']} (label: {label})") - - return data -``` - -### Example LiteLLM Configuration Schema - -```yaml -# LiteLLM proxy config.yaml -model_list: - - model_name: default # model used for `default` requests - litellm_params: # all params accepted by litellm.completion() - https://docs.litellm.ai/docs/completion/input - model: claude-sonnet-4-20250514 ### MODEL NAME sent to `litellm.completion()` ### - api_base: https://api.anthropic.com - - model_name: background # model used for `background` requests - litellm_params: - model: openrouter/openai/gpt-4 - api_base: https://openrouter.ai/api/v1 - - model_name: think # model used for `think` requests - litellm_params: - model: claude-opus-4-20250514 - api_base: https://api.anthropic.com - - model_name: large_context # model used for `large_context` labeled requests - litellm_params: - model: openrouter/openai/gpt-4 - api_base: https://openrouter.ai/api/v1 - - model_name: web_search # model used for `web_search` labeled requests - litellm_params: - model: openrouter/openai/gpt-4 - api_base: https://openrouter.ai/api/v1 - -litellm_settings: - callbacks: custom_callbacks.ccproxy - - monitoring: - log_transformations: true - metrics_enabled: true - slow_transformation_threshold: 50ms - -ccproxy_settings: - context_threshold: 60000 -``` - -## Implementation Requirements - -### Phase 1: Core Routing (MVP) - -- Implement CCProxyHandler with basic routing logic -- Support all 5 routing labels from claude-code-router -- LiteLLM Proxy YAML configuration with environment overrides -- Basic logging of routing decisions - -### Phase 2: Enhanced Features - -- Request/response transformation capabilities -- Metrics collection and reporting - -### Phase 3: Production Readiness - -- Comprehensive test suite (>90% coverage) -- Performance benchmarking -- Documentation and examples -- Claude Code Wrapper - -## Security Considerations - -- API keys stored securely in environment variables -- No logging of sensitive request/response content -- HTTPS enforcement for all external calls -- Rate limiting and abuse prevention - -## Testing Strategy - -### Unit Tests - -- Request classification logic -- Configuration parsing -- Model routing decisions -- Fallback behavior - -### Integration Tests - -- Full request lifecycle through LiteLLM -- Streaming and non-streaming responses -- Error handling and retries -- Provider-specific behaviors - -### Performance Tests - -- Routing overhead measurement -- Concurrent request handling -- Memory usage under load - -## Documentation Requirements - -1. **User Guide** - Installation, configuration, basic usage -2. **API Reference** - All configuration options and APIs -3. **Migration Guide** - Moving from claude-code-router -4. **Examples** - Common routing scenarios -5. **Troubleshooting** - Common issues and solutions - -## Success Criteria - -1. All claude-code-router routing patterns supported -2. <10ms routing overhead per request -3. Zero breaking changes for Claude Code users -4. 90%+ test coverage -5. Clear documentation with examples -6. Active monitoring and metrics - -## Future Enhancements - -- Machine learning-based classification -- Dynamic model selection based on load -- Cost prediction before routing -- Custom routing rules via plugins -- Multi-model ensemble responses diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 3d61f115..00000000 --- a/examples/README.md +++ /dev/null @@ -1,185 +0,0 @@ -# CCProxy Examples - -This directory contains example custom rules and configurations to help you extend ccproxy. - -## Files - -### custom_rule.py -A comprehensive example showing four different rule patterns: - -1. **PriorityUserRule** - Routes based on user identity and message keywords -2. **TimeBasedRule** - Routes based on time of day -3. **ContentLengthRule** - Routes based on total message length -4. **ModelCapabilityRule** - Routes based on required model features - -### example_ccproxy.yaml -Complete configuration example showing how to use both built-in and custom rules. - -## Creating Your Own Rules - -### Step 1: Create Your Rule Class - -Copy `custom_rule.py` to your project and modify it: - -```python -from typing import Any -from ccproxy.rules import ClassificationRule -from ccproxy.config import CCProxyConfig - -class MyCustomRule(ClassificationRule): - def __init__(self, my_param: str) -> None: - self.my_param = my_param - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - # Your logic here - return True # Return True to use this rule's label -``` - -### Step 2: Configure in ccproxy.yaml - -Add your rule to the ccproxy configuration: - -```yaml -ccproxy: - rules: - - label: my_model_label # Must match a model_name in config.yaml - rule: myproject.MyCustomRule # Python import path - params: - - my_param: "value" -``` - -### Step 3: Ensure Model Configuration - -Make sure you have a corresponding model in your LiteLLM `config.yaml`: - -```yaml -model_list: - - model_name: my_model_label # Matches the label above - litellm_params: - model: gpt-4 -``` - -## Rule Guidelines - -### Constructor Parameters - -Rules can accept parameters in several formats: - -```yaml -# Single positional argument -params: - - "single_value" - -# Multiple positional arguments -params: - - "first" - - "second" - -# Keyword arguments -params: - - param1: "value1" - param2: "value2" - -# Mixed (multiple dicts merged) -params: - - setting1: true - - setting2: false -``` - -### Request Structure - -The `request` parameter contains the LiteLLM request data: - -```python -{ - "model": "claude-3-5-sonnet", - "messages": [ - {"role": "user", "content": "Hello"} - ], - "metadata": { - "user_email": "user@example.com", - # Other metadata - }, - "tools": [...], # If using function calling - "stream": False, - # Other LiteLLM parameters -} -``` - -### Best Practices - -1. **Type Safety**: Always use proper type hints -2. **Error Handling**: Return `False` on errors rather than raising exceptions -3. **Performance**: Keep evaluation logic fast as it runs on every request -4. **Documentation**: Document your rule's purpose and parameters -5. **Testing**: Include test code to verify your rule works correctly - -## Testing Your Rules - -Run the example to see how rules work: - -```bash -python examples/custom_rule.py -``` - -Or test in your own code: - -```python -from myproject import MyCustomRule - -rule = MyCustomRule("parameter") -test_request = { - "messages": [{"role": "user", "content": "Test"}], - # ... other request data -} - -result = rule.evaluate(test_request, config) -print(f"Rule matched: {result}") -``` - -## Advanced Patterns - -### Accessing LiteLLM Runtime - -If you need to access the LiteLLM proxy runtime: - -```python -from litellm.proxy import proxy_server - -def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - if proxy_server and proxy_server.llm_router: - model_list = proxy_server.llm_router.model_list - # Use model configuration data - return False -``` - -### Stateful Rules - -For rules that need to maintain state: - -```python -class RateLimitRule(ClassificationRule): - def __init__(self, requests_per_minute: int) -> None: - self.limit = requests_per_minute - self._request_times: list[float] = [] - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - import time - current_time = time.time() - # Clean old entries - self._request_times = [ - t for t in self._request_times - if current_time - t < 60 - ] - # Check rate limit - if len(self._request_times) >= self.limit: - return True # Route to rate-limited model - self._request_times.append(current_time) - return False -``` - -## Need Help? - -- See the main project documentation for more details -- Check existing rules in `src/ccproxy/rules.py` for more examples -- Ensure your rule follows the same patterns as the built-in rules diff --git a/examples/anthropic_sdk.py b/examples/anthropic_sdk.py new file mode 100755 index 00000000..ae6b5861 --- /dev/null +++ b/examples/anthropic_sdk.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Example using Anthropic SDK with LiteLLM proxy (credentials config). + +This example demonstrates using the Anthropic SDK pointed at the LiteLLM proxy +WITHOUT requiring an API key variable. The proxy handles authentication via +its credentials configuration. + +This is the recommended approach when the proxy has credentials forwarding +enabled, as it eliminates the need to manage API keys in your scripts. + +Note: We use a dummy API key because the SDK requires it for validation, +but the actual authentication is handled by the proxy's credentials config. +""" + +import anthropic +from rich.console import Console +from rich.panel import Panel + +console = Console() +err_console = Console(stderr=True) + + +def create_client() -> anthropic.Anthropic: + """Create Anthropic client configured for ccproxy. + + The dummy API key satisfies SDK validation, but the proxy + handles actual authentication via credentials configuration. + """ + return anthropic.Anthropic( + api_key="sk-proxy-dummy", # Dummy key - proxy handles real auth + base_url="http://127.0.0.1:4000", + ) + + +def simple_request() -> None: + """Simple non-streaming request.""" + console.print(Panel("[cyan]Simple Request Example[/cyan]", border_style="blue")) + + client = create_client() + + try: + response = client.messages.create( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="claude-sonnet-4-5-20250929", + max_tokens=100, + ) + + console.print("[green]Response:[/green]") + console.print(response.content[0].text) + console.print(f"\n[dim]Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out[/dim]") + + except anthropic.APIError as e: + err_console.print(f"[bold red]API Error:[/bold red] {e}") + raise + + +def streaming_request() -> None: + """Streaming request example.""" + console.print(Panel("[cyan]Streaming Request Example[/cyan]", border_style="blue")) + + client = create_client() + + try: + console.print("[green]Response:[/green] ", end="") + + with client.messages.stream( + messages=[{"role": "user", "content": "Count from 1 to 5."}], + model="claude-sonnet-4-5-20250929", + max_tokens=100, + ) as stream: + for text in stream.text_stream: + console.print(text, end="") + + console.print("\n") + + except anthropic.APIError as e: + err_console.print(f"[bold red]API Error:[/bold red] {e}") + raise + + +def main() -> None: + """Run examples.""" + try: + # Check if running + console.print("[yellow]Note:[/yellow] This script requires ccproxy running with credentials configuration.\n") + + # Simple request + simple_request() + console.print() + + # Streaming request + streaming_request() + + except Exception: + console.print( + "\n[yellow]Troubleshooting:[/yellow]", + "1. Start ccproxy: [cyan]ccproxy start[/cyan]", + "2. Verify credentials in ~/.ccproxy/ccproxy.yaml", + "3. Check proxy logs: [cyan]ccproxy logs[/cyan]", + sep="\n", + ) + raise + + +if __name__ == "__main__": + main() diff --git a/examples/custom_rule.py b/examples/custom_rule.py deleted file mode 100644 index f9b3467f..00000000 --- a/examples/custom_rule.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Example custom rule for ccproxy. - -This file demonstrates how to create custom classification rules for ccproxy. -Copy this template and modify it to create your own rules. - -To use this rule: -1. Copy this file to your project -2. Add it to your ccproxy.yaml configuration: - -ccproxy: - rules: - - label: high_priority - rule: myproject.rules.PriorityUserRule - params: - - priority_users: ["admin@example.com", "vip@example.com"] - - priority_keywords: ["urgent", "critical", "emergency"] - -3. Ensure you have a model configured in config.yaml with model_name: high_priority -""" - -from typing import Any - -from ccproxy.config import CCProxyConfig -from ccproxy.rules import ClassificationRule - - -class PriorityUserRule(ClassificationRule): - """Routes requests from priority users or containing priority keywords. - - This example rule demonstrates: - - Constructor with multiple parameters - - Accessing request metadata (user information) - - Checking message content for keywords - - Proper type hints and documentation - """ - - def __init__( - self, - priority_users: list[str] | None = None, - priority_keywords: list[str] | None = None, - ) -> None: - """Initialize the priority user rule. - - Args: - priority_users: List of email addresses that should be prioritized - priority_keywords: List of keywords that trigger priority routing - """ - self.priority_users = set(priority_users or []) - self.priority_keywords = [kw.lower() for kw in (priority_keywords or [])] - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - """Check if request is from a priority user or contains priority keywords. - - Args: - request: The incoming request data containing: - - metadata: Dict with user information - - messages: List of message dicts with content - - Other LiteLLM request fields - config: The ccproxy configuration instance - - Returns: - True if this is a priority request, False otherwise - """ - # Check if request is from a priority user - metadata = request.get("metadata", {}) - user_email = metadata.get("user_email", "") - - if user_email in self.priority_users: - return True - - # Check if any messages contain priority keywords - messages = request.get("messages", []) - for message in messages: - if isinstance(message, dict): - content = message.get("content", "").lower() - if any(keyword in content for keyword in self.priority_keywords): - return True - - return False - - -class TimeBasedRule(ClassificationRule): - """Routes requests based on time of day. - - This example shows how to use external dependencies and - implement time-based routing logic. - """ - - def __init__( - self, - start_hour: int = 9, - end_hour: int = 17, - timezone: str = "UTC", - ) -> None: - """Initialize the time-based rule. - - Args: - start_hour: Hour to start using this route (0-23) - end_hour: Hour to stop using this route (0-23) - timezone: Timezone name (e.g., "US/Eastern", "UTC") - """ - self.start_hour = start_hour - self.end_hour = end_hour - self.timezone = timezone - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - """Check if current time is within the specified range. - - Args: - request: The incoming request data - config: The ccproxy configuration instance - - Returns: - True if current time is within range, False otherwise - """ - from datetime import datetime - from zoneinfo import ZoneInfo - - # Get current time in specified timezone - try: - tz = ZoneInfo(self.timezone) - current_time = datetime.now(tz) - current_hour = current_time.hour - - # Handle ranges that cross midnight - if self.start_hour <= self.end_hour: - return self.start_hour <= current_hour < self.end_hour - else: - # Range like 22:00 to 02:00 - return current_hour >= self.start_hour or current_hour < self.end_hour - - except Exception: - # If timezone is invalid or any error occurs, don't route - return False - - -class ContentLengthRule(ClassificationRule): - """Routes requests based on total content length across all messages. - - This example demonstrates: - - Aggregating data across multiple messages - - Different parameter styles (single value vs dict) - - Graceful error handling - """ - - def __init__(self, max_length: int) -> None: - """Initialize the content length rule. - - Args: - max_length: Maximum total content length before routing - """ - self.max_length = max_length - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - """Check if total content length exceeds threshold. - - Args: - request: The incoming request data - config: The ccproxy configuration instance - - Returns: - True if content length exceeds max_length, False otherwise - """ - total_length = 0 - messages = request.get("messages", []) - - for message in messages: - if isinstance(message, dict): - content = message.get("content", "") - if isinstance(content, str): - total_length += len(content) - elif isinstance(content, list): - # Handle multi-modal content (text + images) - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - total_length += len(item.get("text", "")) - - return total_length > self.max_length - - -class ModelCapabilityRule(ClassificationRule): - """Routes requests that require specific model capabilities. - - This advanced example shows: - - Checking for specific request features - - Using configuration data - - Complex boolean logic - """ - - def __init__( - self, - require_vision: bool = False, - require_function_calling: bool = False, - require_streaming: bool = False, - ) -> None: - """Initialize the capability rule. - - Args: - require_vision: Route if request contains images - require_function_calling: Route if request uses tools/functions - require_streaming: Route if request requires streaming - """ - self.require_vision = require_vision - self.require_function_calling = require_function_calling - self.require_streaming = require_streaming - - def evaluate(self, request: dict[str, Any], config: CCProxyConfig) -> bool: - """Check if request requires specific capabilities. - - Args: - request: The incoming request data - config: The ccproxy configuration instance - - Returns: - True if request matches required capabilities, False otherwise - """ - # Check for vision requirements - if self.require_vision: - messages = request.get("messages", []) - for message in messages: - if isinstance(message, dict): - content = message.get("content", "") - # Check for multi-modal content - if isinstance(content, list): - for item in content: - if isinstance(item, dict) and item.get("type") == "image_url": - return True - - # Check for function calling - if self.require_function_calling and (request.get("tools") or request.get("functions")): - return True - - # Check for streaming - return bool(self.require_streaming and request.get("stream", False)) - - -# Example of how to test your custom rules -if __name__ == "__main__": - # Create a test rule - rule = PriorityUserRule( - priority_users=["admin@example.com"], - priority_keywords=["urgent", "help"], - ) - - # Test with a priority user - test_request = { - "metadata": {"user_email": "admin@example.com"}, - "messages": [{"role": "user", "content": "Hello"}], - } - - # This should return True - print(f"Priority user test: {rule.evaluate(test_request, None)}") # type: ignore - - # Test with priority keyword - test_request2 = { - "metadata": {"user_email": "regular@example.com"}, - "messages": [{"role": "user", "content": "This is urgent!"}], - } - - # This should also return True - print(f"Priority keyword test: {rule.evaluate(test_request2, None)}") # type: ignore diff --git a/examples/example_ccproxy.yaml b/examples/example_ccproxy.yaml deleted file mode 100644 index 3e596ddb..00000000 --- a/examples/example_ccproxy.yaml +++ /dev/null @@ -1,91 +0,0 @@ -# Example ccproxy.yaml configuration with custom rules -# This file demonstrates how to configure custom classification rules - -ccproxy: - # Basic settings - debug: false - metrics_enabled: true - - # Classification rules - evaluated in order - rules: - # Built-in rule: Route large requests to a model with higher context - - label: large_context - rule: ccproxy.rules.TokenCountRule - params: - - threshold: 80000 - - # Custom rule: Priority users get premium model - - label: premium - rule: myproject.rules.PriorityUserRule - params: - - priority_users: - - "ceo@company.com" - - "cto@company.com" - - "vip@customer.com" - priority_keywords: - - "urgent" - - "critical" - - "emergency" - - "asap" - - # Custom rule: Use cheaper model during off-hours - - label: off_hours - rule: myproject.rules.TimeBasedRule - params: - - start_hour: 18 # 6 PM - end_hour: 9 # 9 AM - timezone: "US/Eastern" - - # Custom rule: Route very long conversations - - label: long_conversation - rule: myproject.rules.ContentLengthRule - params: - - 50000 # Total characters across all messages - - # Custom rule: Route vision requests to multimodal model - - label: multimodal - rule: myproject.rules.ModelCapabilityRule - params: - - require_vision: true - require_function_calling: false - require_streaming: false - - # Built-in rule: Background processing for Haiku model - - label: background - rule: ccproxy.rules.MatchModelRule - params: - - model_name: "claude-3-5-haiku" - - # Built-in rule: Thinking requests need special handling - - label: think - rule: ccproxy.rules.ThinkingFieldRule - - # Built-in rule: Web search requests - - label: web_search - rule: ccproxy.rules.WebSearchToolRule - -# Note: Each label above must have a corresponding model_name entry -# in your LiteLLM config.yaml file. For example: -# -# model_list: -# - model_name: default -# litellm_params: -# model: claude-3-5-sonnet-20241022 -# -# - model_name: large_context -# litellm_params: -# model: claude-3-opus-20240229 -# -# - model_name: premium -# litellm_params: -# model: gpt-4-turbo-preview -# -# - model_name: off_hours -# litellm_params: -# model: gpt-3.5-turbo -# -# - model_name: multimodal -# litellm_params: -# model: gpt-4-vision-preview -# -# etc... diff --git a/examples/litellm_sdk.py b/examples/litellm_sdk.py new file mode 100755 index 00000000..2d59da26 --- /dev/null +++ b/examples/litellm_sdk.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Example using LiteLLM Python SDK with proxy (credentials config). + +This example demonstrates using litellm.acompletion() pointed at the ccproxy +WITHOUT requiring an API key variable. The proxy handles authentication via +its credentials configuration. + +Note: The litellm.anthropic.messages interface bypasses proxies, so we use +the standard litellm.acompletion() interface instead. +""" + +import asyncio + +import litellm +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn + +console = Console() +err_console = Console(stderr=True) + + +async def simple_request() -> None: + """Simple non-streaming request.""" + console.print(Panel("[cyan]Simple Request Example[/cyan]", border_style="blue")) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + transient=True, + ) as progress: + progress.add_task("Sending request...", total=None) + + # Use standard litellm.acompletion() with proxy + # Dummy API key satisfies validation, proxy handles real auth + response = await litellm.acompletion( + messages=[{"role": "user", "content": "Hello, can you tell me a short joke?"}], + model="claude-haiku-4-5-20251001", # Use model defined in proxy config + max_tokens=100, + api_base="http://127.0.0.1:4000", + api_key="sk-proxy-dummy", # Dummy key - proxy handles real auth + ) + + console.print("[green]Response:[/green]") + console.print(response.choices[0].message.content) + console.print(f"\n[dim]Tokens: {response.usage.prompt_tokens} in, {response.usage.completion_tokens} out[/dim]") + + +async def streaming_request() -> None: + """Streaming request example.""" + console.print(Panel("[cyan]Streaming Request Example[/cyan]", border_style="blue")) + + console.print("[green]Response:[/green] ", end="") + + # Streaming with litellm.acompletion() + response = await litellm.acompletion( + messages=[{"role": "user", "content": "Count from 1 to 5."}], + model="claude-haiku-4-5-20251001", # Use model defined in proxy config + max_tokens=200, + stream=True, + api_base="http://127.0.0.1:4000", + api_key="sk-proxy-dummy", # Dummy key - proxy handles real auth + ) + + async for chunk in response: + if chunk.choices[0].delta.content: + console.print(chunk.choices[0].delta.content, end="") + + console.print("\n") + + +async def main() -> None: + """Run examples.""" + try: + # Simple request + await simple_request() + console.print() + + # Streaming request + await streaming_request() + + except Exception as e: + console.print(f"[bold red]Error:[/bold red] {e}", style="red") + console.print( + "\n[yellow]Make sure:[/yellow]", + "1. ccproxy is running: [cyan]ccproxy start[/cyan]", + "2. Credentials are configured in ccproxy.yaml", + sep="\n", + ) + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 44d17e06..f8355d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,39 +1,56 @@ [project] -name = "ccproxy" -version = "0.1.0" -description = "LiteLLM-based transformation hook system for context-aware routing" +name = "claude-ccproxy" +version = "1.2.0" +description = "Scriptable Claude Code LiteLLM-based proxy" +readme = "README.md" requires-python = ">=3.11" +license = { text = "AGPL-3.0-or-later" } +keywords = ["litellm", "proxy", "routing", "ai", "llm"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] dependencies = [ - "litellm[proxy]>=1.13.0", - "pydantic>=2.0.0", - "pydantic-settings>=2.0.0", - "pyyaml>=6.0", - "python-dotenv>=1.0.0", - "httpx>=0.27.0", - "prometheus-client>=0.18.0", - "structlog>=24.0.0", - "attrs>=23.0.0", - "watchdog>=3.0.0", - "fasteners>=0.19.0", - "psutil>=5.9.0", - "anthropic>=0.39.0", - "types-psutil>=7.0.0.20250601", + "litellm[proxy]>=1.13.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "pyyaml>=6.0", + "python-dotenv>=1.0.0", + "httpx>=0.27.0", + "prometheus-client>=0.18.0", + "structlog>=24.0.0", + "attrs>=23.0.0", + "watchdog>=3.0.0", + "fasteners>=0.19.0", + "psutil>=5.9.0", + "anthropic>=0.39.0", + "types-psutil>=7.0.0.20250601", + "tyro>=0.7.0", + "rich>=13.7.1", + "prisma>=0.15.0", + "tiktoken>=0.5.0", + "langfuse>=2.0.0,<3.0.0", ] [project.scripts] -ccproxy = "ccproxy.cli:main" +ccproxy = "ccproxy.cli:entry_point" [project.optional-dependencies] dev = [ - "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", - "pytest-cov>=4.0.0", - "mypy>=1.8.0", - "ruff>=0.1.0", - "pre-commit>=3.5.0", - "coverage[toml]>=7.0.0", - "types-pyyaml>=6.0.0", - "types-requests>=2.31.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.0.0", + "mypy>=1.8.0", + "ruff>=0.1.0", + "pre-commit>=3.5.0", + "coverage[toml]>=7.0.0", + "types-pyyaml>=6.0.0", + "types-requests>=2.31.0", ] [build-system] @@ -44,23 +61,19 @@ build-backend = "hatchling.build" packages = ["src/ccproxy"] [tool.hatch.build.targets.sdist] -include = [ - "src/ccproxy", - "templates", - "tests", - "README.md", - "LICENSE", -] +include = ["src/ccproxy", "templates", "tests", "README.md", "LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" addopts = [ - "--verbose", - "--cov=ccproxy", - "--cov-report=term-missing", - "--cov-report=html", - "--cov-fail-under=90", + "--verbose", + "--cov=ccproxy", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-fail-under=90", + # Ignore shell integration tests - feature is TBD (generate_shell_integration function is commented out) + "--ignore=tests/test_shell_integration.py", ] [tool.coverage.run] @@ -69,15 +82,15 @@ omit = ["*/tests/*", "*/__init__.py"] [tool.coverage.report] exclude_lines = [ - "pragma: no cover", - "def __repr__", - "if self.debug:", - "if settings.DEBUG", - "raise AssertionError", - "raise NotImplementedError", - "if 0:", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", ] [tool.mypy] @@ -103,22 +116,22 @@ line-length = 120 [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "N", # pep8-naming - "YTT", # flake8-2020 - "S", # flake8-bandit - "SIM", # flake8-simplify - "PTH", # flake8-use-pathlib + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "N", # pep8-naming + "YTT", # flake8-2020 + "S", # flake8-bandit + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib ] ignore = [ - "S101", # Use of assert detected - "S104", # Possible binding to all interfaces + "S101", # Use of assert detected + "S104", # Possible binding to all interfaces ] [tool.ruff.lint.per-file-ignores] @@ -129,14 +142,16 @@ known-first-party = ["ccproxy"] [dependency-groups] dev = [ - "coverage>=7.10.1", - "mypy>=1.17.0", - "pre-commit>=4.2.0", - "pytest>=8.4.1", - "pytest-asyncio>=1.1.0", - "pytest-cov>=6.2.1", - "ruff>=0.12.6", - "types-psutil>=7.0.0.20250601", - "types-pyyaml>=6.0.12.20250516", - "types-requests>=2.32.4.20250611", + "beautysh>=6.2.1", + "coverage>=7.10.1", + "mypy>=1.17.0", + "pre-commit>=4.2.0", + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", + "pytest-cov>=6.2.1", + "ruff>=0.12.6", + "setuptools>=80.9.0", + "types-psutil>=7.0.0.20250601", + "types-pyyaml>=6.0.12.20250516", + "types-requests>=2.32.4.20250611", ] diff --git a/src/ccproxy/__init__.py b/src/ccproxy/__init__.py index fb8a7436..e69de29b 100644 --- a/src/ccproxy/__init__.py +++ b/src/ccproxy/__init__.py @@ -1,3 +0,0 @@ -from ccproxy.handler import CCProxyHandler - -instance = CCProxyHandler() diff --git a/src/ccproxy/__main__.py b/src/ccproxy/__main__.py index 29787a10..524fb884 100644 --- a/src/ccproxy/__main__.py +++ b/src/ccproxy/__main__.py @@ -1,6 +1,8 @@ """Allow ccproxy to be run as a module with -m.""" +import tyro + from ccproxy.cli import main if __name__ == "__main__": - main() + tyro.cli(main) diff --git a/src/ccproxy/classifier.py b/src/ccproxy/classifier.py index 07ded912..ba260de7 100644 --- a/src/ccproxy/classifier.py +++ b/src/ccproxy/classifier.py @@ -1,44 +1,42 @@ """Request classification module for context-aware routing.""" +import logging from typing import Any -from ccproxy.config import ConfigProvider +from ccproxy.config import get_config from ccproxy.rules import ClassificationRule +logger = logging.getLogger(__name__) + class RequestClassifier: """Main request classifier implementing rule-based classification. The classifier uses a rule-based system where rules are evaluated in the order they are configured. The first matching rule determines the - routing label. + routing model_name. - The rules are loaded from the CCProxyConfig which reads from ccproxy.yaml. + The rules are loaded from the config which reads from ccproxy.yaml. Each rule in the configuration specifies: - - label: The routing label to use if the rule matches + - name: The name for this rule (maps to model_name in LiteLLM config) - rule: The Python import path to the rule class - params: Optional parameters to pass to the rule constructor Example configuration in ccproxy.yaml: ccproxy: rules: - - label: token_count + - name: token_count rule: ccproxy.rules.TokenCountRule params: - threshold: 60000 - - label: background + - name: background rule: ccproxy.rules.MatchModelRule params: - model_name: claude-3-5-haiku-20241022 """ - def __init__(self, config_provider: ConfigProvider | None = None) -> None: - """Initialize the request classifier. - - Args: - config_provider: Optional config provider. If None, uses global config. - """ - self._config_provider = config_provider or ConfigProvider() + def __init__(self) -> None: + """Initialize the request classifier.""" self._rules: list[tuple[str, ClassificationRule]] = [] self._setup_rules() @@ -46,27 +44,27 @@ def _setup_rules(self) -> None: """Set up classification rules from configuration. Rules are loaded from the ccproxy.yaml configuration file. - Each rule configuration specifies the label and rule class to use. + Each rule configuration specifies the name and rule class to use. """ # Clear any existing rules - self.clear_rules() + self._clear_rules() # Get configuration - config = self._config_provider.get() + config = get_config() # Load rules from configuration for rule_config in config.rules: try: # Create rule instance rule_instance = rule_config.create_instance() - # Add rule with its label - self.add_rule(rule_config.label, rule_instance) + # Add rule with its model_name + self.add_rule(rule_config.model_name, rule_instance) except (ImportError, TypeError, AttributeError) as e: # Log error but continue loading other rules if config.debug: - print(f"Failed to load rule {rule_config.rule_path}: {e}") + logger.debug(f"Failed to load rule {rule_config.rule_path}: {e}") - def classify(self, request: dict[str, Any]) -> str: + def classify(self, request: Any) -> str: """Classify a request based on configured rules. Args: @@ -74,31 +72,39 @@ def classify(self, request: dict[str, Any]) -> str: pydantic models via dict conversion. Returns: - The routing label for the request + The routing model_name for the request Note: Rules are evaluated in the order they are configured. The first matching rule - determines the routing label. If no rules match, "default" is returned. + determines the routing model_name. If no rules match, "default" is returned. """ # Convert pydantic model to dict if needed - if hasattr(request, "model_dump"): - request = request.model_dump() + try: + if hasattr(request, "model_dump") and callable(getattr(request, "model_dump", None)): + request = request.model_dump() + except Exception as e: + logger.warning(f"Failed to convert request to dict: {e}") + # If conversion fails, try to use request as-is - config = self._config_provider.get() + if not isinstance(request, dict): + logger.error("Request is not a dict and could not be converted") + return "default" + + config = get_config() # Evaluate rules in order - for label, rule in self._rules: + for model_name, rule in self._rules: if rule.evaluate(request, config): - return label + return model_name # Default if no rules match return "default" - def add_rule(self, label: str, rule: ClassificationRule) -> None: - """Add a classification rule with its associated label. + def add_rule(self, model_name: str, rule: ClassificationRule) -> None: + """Add a classification rule with its associated model_name. Args: - label: The routing label to use if this rule matches + model_name: The model_name to use if this rule matches (matches model_name in LiteLLM config) rule: The rule to add Note: @@ -106,12 +112,8 @@ def add_rule(self, label: str, rule: ClassificationRule) -> None: For proper priority, use _setup_rules() to configure the standard rule set from ccproxy.yaml. """ - self._rules.append((label, rule)) + self._rules.append((model_name, rule)) - def clear_rules(self) -> None: + def _clear_rules(self) -> None: """Clear all classification rules.""" self._rules.clear() - - def reset_rules(self) -> None: - """Reset rules to the configuration from ccproxy.yaml.""" - self._setup_rules() diff --git a/src/ccproxy/cli.py b/src/ccproxy/cli.py index 64f54090..d3c8c24d 100644 --- a/src/ccproxy/cli.py +++ b/src/ccproxy/cli.py @@ -1,261 +1,117 @@ -"""CCProxy CLI for managing the LiteLLM proxy server.""" +"""ccproxy CLI for managing the LiteLLM proxy server - Tyro implementation.""" -import argparse +import json +import logging +import logging.config import os import shutil -import signal import subprocess import sys import time +from builtins import print as builtin_print from pathlib import Path -from typing import Any +from typing import Annotated -import psutil +import attrs +import tyro import yaml +from rich import print +from rich.console import Console +from rich.panel import Panel +from rich.table import Table from ccproxy.utils import get_templates_dir -class CCProxyDaemon: - """Manages the LiteLLM proxy server as a daemon process.""" +# Subcommand definitions using attrs +@attrs.define +class Start: + """Start the LiteLLM proxy server with ccproxy configuration.""" - def __init__(self, config_dir: Path) -> None: - """Initialize the daemon with configuration directory.""" - self.config_dir = config_dir - self.pid_file = config_dir / "ccproxy.pid" - self.log_file = config_dir / "ccproxy.log" + args: Annotated[list[str] | None, tyro.conf.Positional] = None + """Additional arguments to pass to litellm command.""" - def _load_litellm_config(self) -> dict[str, Any]: - """Load LiteLLM configuration from ccproxy.yaml.""" - ccproxy_config_path = self.config_dir / "ccproxy.yaml" - if not ccproxy_config_path.exists(): - return {} + detach: Annotated[bool, tyro.conf.arg(aliases=["-d"])] = False + """Run in background and save PID to litellm.lock.""" - with ccproxy_config_path.open() as f: - config = yaml.safe_load(f) - - litellm_config: dict[str, Any] = config.get("litellm", {}) if config else {} - return litellm_config - - def _build_litellm_command(self, cli_args: argparse.Namespace) -> list[str]: - """Build the litellm command with all configuration sources.""" - # Load config file defaults - config = self._load_litellm_config() - - # Apply environment variable overrides - host = os.environ.get("HOST", config.get("host", "127.0.0.1")) - port = str(os.environ.get("PORT", config.get("port", "4000"))) - num_workers = str(os.environ.get("NUM_WORKERS", config.get("num_workers", "1"))) - debug = os.environ.get("DEBUG", str(config.get("debug", False))).lower() == "true" - detailed_debug = os.environ.get("DETAILED_DEBUG", str(config.get("detailed_debug", False))).lower() == "true" - - # Apply CLI argument overrides - if hasattr(cli_args, "host") and cli_args.host: - host = cli_args.host - if hasattr(cli_args, "port") and cli_args.port: - port = str(cli_args.port) - if hasattr(cli_args, "workers") and cli_args.workers: - num_workers = str(cli_args.workers) - if hasattr(cli_args, "debug") and cli_args.debug: - debug = True - if hasattr(cli_args, "detailed_debug") and cli_args.detailed_debug: - detailed_debug = True - - # Build command - cmd = [ - "litellm", - "--config", - str(self.config_dir / "config.yaml"), - "--host", - host, - "--port", - port, - "--num_workers", - num_workers, - ] - - if debug: - cmd.append("--debug") - if detailed_debug: - cmd.append("--detailed_debug") - - return cmd - - def _daemonize(self) -> None: - """Daemonize the current process.""" - # First fork - try: - pid = os.fork() - if pid > 0: - # Parent process exits - sys.exit(0) - except OSError as e: - print(f"Fork #1 failed: {e}", file=sys.stderr) - sys.exit(1) - # Decouple from parent environment - os.chdir(str(self.config_dir)) - os.setsid() - os.umask(0) +@attrs.define +class Install: + """Install ccproxy configuration files.""" - # Second fork - try: - pid = os.fork() - if pid > 0: - # Parent process exits - sys.exit(0) - except OSError as e: - print(f"Fork #2 failed: {e}", file=sys.stderr) - sys.exit(1) + force: bool = False + """Overwrite existing configuration.""" - # Redirect standard file descriptors - sys.stdout.flush() - sys.stderr.flush() - # Open log file for output - log_fd = os.open(str(self.log_file), os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o666) - os.dup2(log_fd, sys.stdout.fileno()) - os.dup2(log_fd, sys.stderr.fileno()) - os.close(log_fd) +@attrs.define +class Run: + """Run a command with ccproxy environment.""" - def start(self, cli_args: argparse.Namespace) -> None: - """Start the LiteLLM proxy server as a daemon.""" - # Check if already running - if self.pid_file.exists(): - try: - pid = int(self.pid_file.read_text().strip()) - if psutil.pid_exists(pid): - print(f"CCProxy is already running (PID: {pid})") - sys.exit(1) - else: - # Stale PID file - self.pid_file.unlink() - except (ValueError, ProcessLookupError): - # Invalid or stale PID file - self.pid_file.unlink() + command: Annotated[list[str], tyro.conf.Positional] + """Command and arguments to execute with proxy settings.""" - # Build LiteLLM command - cmd = self._build_litellm_command(cli_args) - # Daemonize - self._daemonize() +@attrs.define +class Stop: + """Stop the background LiteLLM proxy server.""" - # Start LiteLLM as subprocess - try: - # Debug logging - print(f"Starting LiteLLM with command: {cmd}") - print(f"Working directory: {self.config_dir}") - - # Set up environment to include ccproxy in Python path - env = os.environ.copy() - # Add the site-packages directory where ccproxy is installed - import ccproxy - - ccproxy_path = Path(ccproxy.__file__).parent.parent - if "PYTHONPATH" in env: - env["PYTHONPATH"] = f"{ccproxy_path}:{env['PYTHONPATH']}" - else: - env["PYTHONPATH"] = str(ccproxy_path) - # S603: Command is built from validated config and CLI args only - # After daemonizing, stdout/stderr are already redirected to log file - # So we don't need PIPE here - process = subprocess.Popen( # noqa: S603 - cmd, stdout=None, stderr=None, text=True, cwd=str(self.config_dir), env=env - ) +@attrs.define +class Restart: + """Restart the LiteLLM proxy server (stop then start).""" - # Write PID file with LiteLLM process PID - self.pid_file.write_text(str(process.pid)) + args: Annotated[list[str] | None, tyro.conf.Positional] = None + """Additional arguments to pass to litellm command.""" - # Monitor the subprocess - print(f"Started LiteLLM proxy (PID: {process.pid})") + detach: Annotated[bool, tyro.conf.arg(aliases=["-d"])] = False + """Run in background and save PID to litellm.lock.""" - # Wait for the subprocess - process.wait() - except Exception as e: - print(f"Failed to start LiteLLM: {e}", file=sys.stderr) - sys.exit(1) - finally: - # Clean up PID file on exit - if self.pid_file.exists(): - self.pid_file.unlink() - - def stop(self) -> None: - """Stop the LiteLLM proxy server.""" - if not self.pid_file.exists(): - print("CCProxy is not running") - sys.exit(1) +@attrs.define +class Logs: + """View the LiteLLM log file.""" - try: - pid = int(self.pid_file.read_text().strip()) - - # Check if process exists - if not psutil.pid_exists(pid): - print("CCProxy is not running (stale PID file)") - self.pid_file.unlink() - sys.exit(1) - - # Send SIGTERM - os.kill(pid, signal.SIGTERM) - - # Wait for graceful shutdown (up to 10 seconds) - for _ in range(100): - if not psutil.pid_exists(pid): - break - time.sleep(0.1) - else: - # Force kill if still running - print("Process did not terminate gracefully, forcing...") - os.kill(pid, signal.SIGKILL) + follow: Annotated[bool, tyro.conf.arg(aliases=["-f"])] = False + """Follow log output (like tail -f).""" - # Remove PID file - if self.pid_file.exists(): - self.pid_file.unlink() - print(f"Stopped CCProxy (PID: {pid})") - - except (ValueError, ProcessLookupError) as e: - print(f"Failed to stop CCProxy: {e}", file=sys.stderr) - if self.pid_file.exists(): - self.pid_file.unlink() - sys.exit(1) + lines: Annotated[int, tyro.conf.arg(aliases=["-n"])] = 100 + """Number of lines to show (default: 100).""" - def status(self) -> None: - """Check the status of the LiteLLM proxy server.""" - if not self.pid_file.exists(): - print("CCProxy is not running") - sys.exit(1) - try: - pid = int(self.pid_file.read_text().strip()) +@attrs.define +class Status: + """Show the status of LiteLLM proxy and ccproxy configuration.""" - if psutil.pid_exists(pid): - try: - process = psutil.Process(pid) - print(f"CCProxy is running (PID: {pid})") - print(f" CPU: {process.cpu_percent()}%") - print(f" Memory: {process.memory_info().rss / 1024 / 1024:.1f} MB") - print(f" Started: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(process.create_time()))}") - except psutil.NoSuchProcess: - print("CCProxy is not running (process not found)") - if self.pid_file.exists(): - self.pid_file.unlink() - sys.exit(1) - else: - print("CCProxy is not running (stale PID file)") - if self.pid_file.exists(): - self.pid_file.unlink() - sys.exit(1) - - except ValueError: - print("Invalid PID file") - if self.pid_file.exists(): - self.pid_file.unlink() - sys.exit(1) + json: bool = False + """Output status as JSON with boolean values.""" + + +# @attrs.define +# class ShellIntegration: +# """Generate shell integration for automatic claude aliasing.""" +# +# shell: Annotated[str, tyro.conf.arg(help="Shell type (bash, zsh, or auto)")] = "auto" +# """Target shell for integration script.""" +# +# install: bool = False +# """Install the integration to shell config file.""" -def install(config_dir: Path, force: bool = False) -> None: - """Install CCProxy configuration files. +# Type alias for all subcommands +Command = Start | Install | Run | Stop | Restart | Logs | Status + + +def setup_logging() -> None: + """Configure logging with 100-character text width.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)-20s - %(levelname)-8s - %(message).100s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def install_config(config_dir: Path, force: bool = False) -> None: + """Install ccproxy configuration files. Args: config_dir: Directory to install configuration files to @@ -282,7 +138,6 @@ def install(config_dir: Path, force: bool = False) -> None: template_files = [ "ccproxy.yaml", "config.yaml", - "ccproxy.py", ] # Copy template files @@ -320,23 +175,6 @@ def run_with_proxy(config_dir: Path, command: list[str]) -> None: print("Run 'ccproxy install' first to set up configuration.", file=sys.stderr) sys.exit(1) - # Check if proxy is running - pid_file = config_dir / "ccproxy.pid" - if pid_file.exists(): - try: - pid = int(pid_file.read_text().strip()) - if psutil.pid_exists(pid): - print(f"Using running ccproxy instance (PID: {pid})") - else: - print("Warning: CCProxy is not running (stale PID file)", file=sys.stderr) - print("Run 'ccproxy start' to start the proxy server", file=sys.stderr) - except (ValueError, ProcessLookupError): - print("Warning: CCProxy is not running (invalid PID file)", file=sys.stderr) - print("Run 'ccproxy start' to start the proxy server", file=sys.stderr) - else: - print("Note: CCProxy is not running. Starting without proxy.", file=sys.stderr) - print("Run 'ccproxy start' to start the proxy server", file=sys.stderr) - # Load config with ccproxy_config_path.open() as f: config = yaml.safe_load(f) @@ -345,24 +183,19 @@ def run_with_proxy(config_dir: Path, command: list[str]) -> None: # Get proxy settings with defaults host = os.environ.get("HOST", litellm_config.get("host", "127.0.0.1")) - port = os.environ.get("PORT", litellm_config.get("port", "4000")) + port = int(os.environ.get("PORT", litellm_config.get("port", 4000))) # Set up environment for the subprocess env = os.environ.copy() # Set proxy environment variables proxy_url = f"http://{host}:{port}" - env["OPENAI_API_BASE"] = f"{proxy_url}/v1" - env["OPENAI_BASE_URL"] = f"{proxy_url}/v1" - env["ANTHROPIC_BASE_URL"] = f"{proxy_url}/v1" - env["LITELLM_PROXY_BASE_URL"] = proxy_url - env["LITELLM_PROXY_API_BASE"] = f"{proxy_url}/v1" - - # Also set standard HTTP proxy variables for general compatibility - env["HTTP_PROXY"] = proxy_url - env["HTTPS_PROXY"] = proxy_url - env["http_proxy"] = proxy_url - env["https_proxy"] = proxy_url + env["OPENAI_API_BASE"] = f"{proxy_url}" + env["OPENAI_BASE_URL"] = f"{proxy_url}" + env["ANTHROPIC_BASE_URL"] = f"{proxy_url}" + + # Don't set HTTP_PROXY/HTTPS_PROXY as these cause Claude Code to treat + # the LiteLLM server as a general HTTP proxy, not an API endpoint # Execute the command with the proxy environment try: @@ -376,74 +209,693 @@ def run_with_proxy(config_dir: Path, command: list[str]) -> None: sys.exit(130) # Standard exit code for Ctrl+C -def main() -> None: - """Main entry point for the CCProxy CLI.""" - parser = argparse.ArgumentParser( - description="CCProxy - LiteLLM Transformation Hook System", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) +def generate_handler_file(config_dir: Path) -> None: + """Generate the ccproxy.py handler file that LiteLLM will import. - parser.add_argument( - "--config-dir", - type=Path, - default=Path.home() / ".ccproxy", - help="Configuration directory (default: ~/.ccproxy)", - ) + Args: + config_dir: Configuration directory where ccproxy.py will be generated + """ + import yaml - subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Load ccproxy.yaml to get handler configuration + ccproxy_config_path = config_dir / "ccproxy.yaml" + handler_import = "ccproxy.handler:CCProxyHandler" # default - # Start command - start_parser = subparsers.add_parser("start", help="Start the LiteLLM proxy server") - start_parser.add_argument("--host", help="Host to bind to") - start_parser.add_argument("--port", type=int, help="Port to bind to") - start_parser.add_argument("--workers", type=int, help="Number of workers") - start_parser.add_argument("--debug", action="store_true", help="Enable debug mode") - start_parser.add_argument("--detailed-debug", action="store_true", help="Enable detailed debug mode") + if ccproxy_config_path.exists(): + try: + with ccproxy_config_path.open() as f: + config = yaml.safe_load(f) + if config and "ccproxy" in config and "handler" in config["ccproxy"]: + handler_import = config["ccproxy"]["handler"] + except Exception: + pass # Use default if config can't be loaded + + # Parse handler import path (format: "module.path:ClassName") + if ":" in handler_import: + module_path, class_name = handler_import.split(":", 1) + else: + # Fallback: assume it's just the module path + module_path = handler_import + class_name = "CCProxyHandler" - # Stop command - subparsers.add_parser("stop", help="Stop the LiteLLM proxy server") + # Check if handler file exists and is a user's custom file + handler_file = config_dir / "ccproxy.py" + if handler_file.exists(): + try: + existing_content = handler_file.read_text() + # Check if this is an auto-generated file + if "Auto-generated handler file" not in existing_content: + # This is a user's custom file - preserve it + err_console = Console(stderr=True) + err_console.print( + Panel( + "[yellow]Warning:[/yellow] Custom ccproxy.py file detected!\n\n" + f"Found existing file at: [cyan]{handler_file}[/cyan]\n\n" + "This file appears to be custom (not auto-generated).\n" + "It will NOT be overwritten.\n\n" + "To use auto-generation:\n" + f" 1. Remove the file: [dim]rm {handler_file}[/dim]\n" + " 2. Restart the proxy: [dim]ccproxy restart[/dim]\n\n" + "To use your custom handler:\n" + f" • Set [bold]handler:[/bold] in [cyan]{ccproxy_config_path}[/cyan]\n" + " • Example: [dim]handler: your_module.path:YourHandler[/dim]", + title="[bold red]Custom Handler Preserved[/bold red]", + border_style="yellow", + ) + ) + return + except OSError: + pass # If we can't read the file, proceed with generation + + # Generate the handler file + content = f'''""" +Auto-generated handler file for LiteLLM callbacks. +This file is generated by ccproxy on startup. +DO NOT EDIT - changes will be overwritten. +""" +import sys - # Status command - subparsers.add_parser("status", help="Check status of the LiteLLM proxy server") +# Import the handler class from the configured module +from {module_path} import {class_name} - # Install command - install_parser = subparsers.add_parser("install", help="Install CCProxy configuration files") - install_parser.add_argument("--force", action="store_true", help="Overwrite existing configuration") +# Create the handler instance that LiteLLM will use +handler = {class_name}() +''' - # Run command - run_parser = subparsers.add_parser("run", help="Run a command with ccproxy environment") - run_parser.add_argument("cmd", nargs=argparse.REMAINDER, help="Command to execute with proxy settings") + handler_file.write_text(content) - args = parser.parse_args() - if not args.command: - parser.print_help() +def start_litellm(config_dir: Path, args: list[str] | None = None, detach: bool = False) -> None: + """Start the LiteLLM proxy server with ccproxy configuration. + + Args: + config_dir: Configuration directory containing config files + args: Additional arguments to pass to litellm command + detach: Run in background mode with PID tracking + """ + # Check if config exists + config_path = config_dir / "config.yaml" + if not config_path.exists(): + print(f"Error: Configuration not found at {config_path}", file=sys.stderr) + print("Run 'ccproxy install' first to set up configuration.", file=sys.stderr) sys.exit(1) - # Create daemon instance - daemon = CCProxyDaemon(args.config_dir) - - # Execute command - if args.command == "start": - daemon.start(args) - elif args.command == "stop": - daemon.stop() - elif args.command == "status": - daemon.status() - elif args.command == "install": - install(args.config_dir, force=args.force) - elif args.command == "run": - # Get the actual command arguments (stored in args.cmd by argparse.REMAINDER) - cmd_args = getattr(args, "cmd", []) - if not cmd_args: - print("Error: No command specified to run", file=sys.stderr) - print("Usage: ccproxy run <command> [args...]", file=sys.stderr) + # Generate the handler file before starting LiteLLM + try: + generate_handler_file(config_dir) + except Exception as e: + print(f"Error generating handler file: {e}", file=sys.stderr) + sys.exit(1) + + # Set environment variable for ccproxy configuration location + os.environ["CCPROXY_CONFIG_DIR"] = str(config_dir.absolute()) + + # Build litellm command using the bundled version from the same venv + # This avoids PATH conflicts with standalone litellm installations + # Get the bin directory from the current Python interpreter's location + venv_bin = Path(sys.executable).parent + litellm_path = venv_bin / "litellm" + + if not litellm_path.exists(): + print(f"Error: litellm not found in virtual environment at {litellm_path}", file=sys.stderr) + print( + "Make sure ccproxy is installed with: uv tool install claude-ccproxy --with 'litellm[proxy]'", + file=sys.stderr, + ) + sys.exit(1) + + cmd = [str(litellm_path), "--config", str(config_path)] + + # Pass num_workers from ccproxy.yaml litellm section if configured + ccproxy_config_path = config_dir / "ccproxy.yaml" + if ccproxy_config_path.exists(): + with ccproxy_config_path.open() as f: + ccproxy_config = yaml.safe_load(f) + if ccproxy_config: + num_workers = ccproxy_config.get("litellm", {}).get("num_workers") + if num_workers is not None: + cmd.extend(["--num_workers", str(num_workers)]) + + # Add any additional arguments + if args: + cmd.extend(args) + + if detach: + # Run in background mode + pid_file = config_dir / "litellm.lock" + log_file = config_dir / "litellm.log" + + # Check if already running + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip()) + # Check if process is still running + try: + os.kill(pid, 0) # This doesn't kill, just checks if process exists + print(f"LiteLLM is already running with PID {pid}", file=sys.stderr) + print("To stop it, run: `ccproxy stop`", file=sys.stderr) + sys.exit(1) + except ProcessLookupError: + # Process is not running, clean up stale PID file + pid_file.unlink() + except (ValueError, OSError): + # Invalid PID file, remove it + pid_file.unlink() + + # Start process in background + try: + with log_file.open("w") as log: + # S603: Command construction is safe - we control the litellm path + process = subprocess.Popen( # noqa: S603 + cmd, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, # Detach from parent process group + env=os.environ.copy(), # Pass environment variables including CCPROXY_CONFIG_DIR + ) + + # Save PID + pid_file.write_text(str(process.pid)) + + print("LiteLLM started in background") + print(f"Log file: {log_file}") + sys.exit(0) + + except FileNotFoundError: + print("Error: litellm command not found.", file=sys.stderr) + print("Please ensure LiteLLM is installed: pip install litellm", file=sys.stderr) sys.exit(1) - run_with_proxy(args.config_dir, cmd_args) else: - parser.print_help() + # Execute litellm command in foreground + try: + # S603: Command construction is safe - we control the litellm path + result = subprocess.run(cmd, env=os.environ.copy()) # noqa: S603 + sys.exit(result.returncode) + except FileNotFoundError: + print("Error: litellm command not found.", file=sys.stderr) + print("Please ensure LiteLLM is installed: pip install litellm", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(130) + + +def stop_litellm(config_dir: Path) -> bool: + """Stop the background LiteLLM proxy server. + + Args: + config_dir: Configuration directory containing the PID file + + Returns: + True if server was stopped successfully, False otherwise + """ + pid_file = config_dir / "litellm.lock" + + # Check if PID file exists + if not pid_file.exists(): + print("No LiteLLM server is running (PID file not found)", file=sys.stderr) + return False + + try: + pid = int(pid_file.read_text().strip()) + + # Check if process is still running + try: + os.kill(pid, 0) # Check if process exists + + # Process exists, kill it + print(f"Stopping LiteLLM server (PID: {pid})...") + os.kill(pid, 15) # SIGTERM - graceful shutdown + + # Wait a moment for graceful shutdown + time.sleep(0.5) + + # Check if still running + try: + os.kill(pid, 0) + # Still running, force kill + os.kill(pid, 9) # SIGKILL + print(f"Force killed LiteLLM server (PID: {pid})") + except ProcessLookupError: + print(f"LiteLLM server stopped successfully (PID: {pid})") + + # Remove PID file + pid_file.unlink() + return True + + except ProcessLookupError: + # Process is not running, clean up stale PID file + print(f"LiteLLM server was not running (stale PID: {pid})") + pid_file.unlink() + return False + + except (ValueError, OSError) as e: + print(f"Error reading PID file: {e}", file=sys.stderr) + return False + + +# def generate_shell_integration(config_dir: Path, shell: str = "auto", install: bool = False) -> None: +# """Generate shell integration for automatic claude aliasing. +# +# Args: +# config_dir: Configuration directory +# shell: Target shell (bash, zsh, or auto) +# install: Whether to install the integration +# """ +# # Auto-detect shell if needed +# if shell == "auto": +# shell_path = os.environ.get("SHELL", "") +# if "zsh" in shell_path: +# shell = "zsh" +# elif "bash" in shell_path: +# shell = "bash" +# else: +# print("Error: Could not auto-detect shell. Please specify --shell=bash or --shell=zsh", file=sys.stderr) +# sys.exit(1) +# +# # Validate shell type +# if shell not in ["bash", "zsh"]: +# print(f"Error: Unsupported shell '{shell}'. Use 'bash' or 'zsh'.", file=sys.stderr) +# sys.exit(1) +# +# # Generate the integration script +# integration_script = f"""# ccproxy shell integration +# # This enables the 'claude' alias when LiteLLM proxy is running +# +# # Function to check if LiteLLM proxy is running +# ccproxy_check_running() {{ +# local pid_file="{config_dir}/litellm.lock" +# if [ -f "$pid_file" ]; then +# local pid=$(cat "$pid_file" 2>/dev/null) +# if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then +# return 0 # Running +# fi +# fi +# return 1 # Not running +# }} +# +# # Function to set up claude alias +# ccproxy_setup_alias() {{ +# if ccproxy_check_running; then +# alias claude='ccproxy run claude' +# else +# unalias claude 2>/dev/null || true +# fi +# }} +# +# # Set up the alias on shell startup +# ccproxy_setup_alias +# +# # For zsh: also check on each prompt +# """ +# +# if shell == "zsh": +# integration_script += """if [[ -n "$ZSH_VERSION" ]]; then +# # Add to precmd hooks to check before each prompt +# if ! (( $precmd_functions[(I)ccproxy_setup_alias] )); then +# precmd_functions+=(ccproxy_setup_alias) +# fi +# fi +# """ +# elif shell == "bash": +# integration_script += """if [[ -n "$BASH_VERSION" ]]; then +# # For bash, check on PROMPT_COMMAND +# if [[ ! "$PROMPT_COMMAND" =~ ccproxy_setup_alias ]]; then +# PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\\n'}ccproxy_setup_alias" +# fi +# fi +# """ +# +# if install: +# # Determine shell config file +# home = Path.home() +# if shell == "zsh": +# config_files = [home / ".zshrc", home / ".config/zsh/.zshrc"] +# else: # bash +# config_files = [home / ".bashrc", home / ".bash_profile", home / ".profile"] +# +# # Find the first existing config file +# shell_config = None +# for cf in config_files: +# if cf.exists(): +# shell_config = cf +# break +# +# if not shell_config: +# # Create .zshrc or .bashrc if none exist +# shell_config = home / f".{shell}rc" +# shell_config.touch() +# +# # Check if already installed +# marker = "# ccproxy shell integration" +# existing_content = shell_config.read_text() +# +# if marker in existing_content: +# print(f"ccproxy integration already installed in {shell_config}") +# print("To update, remove the existing integration first.") +# sys.exit(0) +# +# # Append the integration +# with shell_config.open("a") as f: +# f.write("\n") +# f.write(integration_script) +# f.write("\n") +# +# print(f"✓ ccproxy shell integration installed to {shell_config}") +# print("\nTo activate now, run:") +# print(f" source {shell_config}") +# print(f"\nOr start a new {shell} session.") +# print("\nThe 'claude' alias will be available when LiteLLM proxy is running.") +# else: +# # Just print the script +# print(f"# Add this to your {shell} configuration file:") +# print(integration_script) +# print("\n# To install automatically, run:") +# print(f" ccproxy shell-integration --shell={shell} --install") + + +def view_logs(config_dir: Path, follow: bool = False, lines: int = 100) -> None: + """View the LiteLLM log file using system pager. + + Args: + config_dir: Configuration directory containing the log file + follow: Follow log output (like tail -f) + lines: Number of lines to show + """ + log_file = config_dir / "litellm.log" + + # Check if log file exists + if not log_file.exists(): + print("[red]No log file found[/red]", file=sys.stderr) + print(f"[dim]Expected at: {log_file}[/dim]", file=sys.stderr) sys.exit(1) + if follow: + # Use tail -f for following logs + try: + # S603, S607: tail is a standard system command, file path is validated + result = subprocess.run(["tail", "-f", str(log_file)]) # noqa: S603, S607 + sys.exit(result.returncode) + except KeyboardInterrupt: + sys.exit(0) + except FileNotFoundError: + print("[red]Error: 'tail' command not found[/red]", file=sys.stderr) + sys.exit(1) + else: + # Get the pager from environment or use default + pager = os.environ.get("PAGER", "less") + + # Read the last N lines + try: + with log_file.open("r") as f: + # Read all lines and get the last N + all_lines = f.readlines() + tail_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + content = "".join(tail_lines) + + if not content.strip(): + print("[yellow]Log file is empty[/yellow]") + sys.exit(0) + + # Use the pager if output is substantial + if len(tail_lines) > 20 or pager == "cat": + # For cat or when there are many lines, use pager + # S603: pager comes from PAGER env var, standard practice for CLI tools + process = subprocess.Popen([pager], stdin=subprocess.PIPE) # noqa: S603 + process.communicate(content.encode()) + sys.exit(process.returncode) + else: + # For short output, just print directly + print(content, end="") + sys.exit(0) + + except OSError as e: + print(f"[red]Error reading log file: {e}[/red]", file=sys.stderr) + sys.exit(1) + + +def show_status(config_dir: Path, json_output: bool = False) -> None: + """Show the status of LiteLLM proxy and ccproxy configuration. + + Args: + config_dir: Configuration directory to check + json_output: Output status as JSON with boolean values + """ + # Check LiteLLM proxy status + pid_file = config_dir / "litellm.lock" + log_file = config_dir / "litellm.log" + + proxy_running = False + + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip()) + # Check if process is still running + try: + os.kill(pid, 0) + proxy_running = True + except ProcessLookupError: + pass + except (ValueError, OSError): + pass + + # Check configuration files + ccproxy_config = config_dir / "ccproxy.yaml" + litellm_config = config_dir / "config.yaml" + user_hooks = config_dir / "ccproxy.py" + + # Build config paths dict + config_paths = {} + if ccproxy_config.exists(): + config_paths["ccproxy.yaml"] = str(ccproxy_config) + if litellm_config.exists(): + config_paths["config.yaml"] = str(litellm_config) + if user_hooks.exists(): + config_paths["ccproxy.py"] = str(user_hooks) + + # Extract callbacks and model_list from config.yaml + callbacks = [] + model_list = [] + if litellm_config.exists(): + try: + with litellm_config.open() as f: + config_data = yaml.safe_load(f) + if config_data: + litellm_settings = config_data.get("litellm_settings", {}) + callbacks = litellm_settings.get("callbacks", []) + model_list = config_data.get("model_list", []) + except (yaml.YAMLError, OSError): + pass + + # Extract hooks and proxy URL from ccproxy.yaml + hooks = [] + proxy_url = None + if ccproxy_config.exists(): + try: + with ccproxy_config.open() as f: + ccproxy_data = yaml.safe_load(f) + if ccproxy_data: + ccproxy_section = ccproxy_data.get("ccproxy", {}) + hooks = ccproxy_section.get("hooks", []) + # Get proxy URL from litellm config section + litellm_section = ccproxy_data.get("litellm", {}) + host = os.environ.get("HOST", litellm_section.get("host", "127.0.0.1")) + port = int(os.environ.get("PORT", litellm_section.get("port", 4000))) + proxy_url = f"http://{host}:{port}" + except (yaml.YAMLError, OSError): + pass + + # Build status data + status_data = { + "proxy": proxy_running, + "url": proxy_url, + "config": config_paths, + "callbacks": callbacks, + "hooks": hooks, + "model_list": model_list, + "log": str(log_file) if log_file.exists() else None, + } + + if json_output: + builtin_print(json.dumps(status_data, indent=2)) + else: + # Rich table output + console = Console() + + table = Table(show_header=False, show_lines=True) + table.add_column("Key", style="white", width=15) + table.add_column("Value", style="yellow") + + # Proxy status + proxy_status = "[green]true[/green]" if status_data["proxy"] else "[red]false[/red]" + table.add_row("proxy", proxy_status) + + # Config files + if status_data["config"]: + config_display = "\n".join(f"[cyan]{key}[/cyan]: {value}" for key, value in status_data["config"].items()) + else: + config_display = "[red]No config files found[/red]" + table.add_row("config", config_display) + + # Callbacks + if status_data["callbacks"]: + callbacks_display = "\n".join(f"[green]• {cb}[/green]" for cb in status_data["callbacks"]) + else: + callbacks_display = "[dim]No callbacks configured[/dim]" + table.add_row("callbacks", callbacks_display) + + # Log file + log_display = status_data["log"] if status_data["log"] else "[yellow]No log file[/yellow]" + table.add_row("log", log_display) + + console.print(Panel(table, title="[bold]ccproxy Status[/bold]", border_style="blue")) + + # Hooks table + if status_data["hooks"]: + hooks_table = Table(show_header=True, show_lines=True) + hooks_table.add_column("#", style="dim", width=3) + hooks_table.add_column("Hook", style="cyan") + hooks_table.add_column("Parameters", style="yellow") + + for i, hook in enumerate(status_data["hooks"], 1): + if isinstance(hook, str): + # Simple string format - extract function name + hook_name = hook.split(".")[-1] + hook_path = hook + params_display = "[dim]none[/dim]" + else: + # Dict format with params + hook_path = hook.get("hook", "") + hook_name = hook_path.split(".")[-1] if hook_path else "" + params = hook.get("params", {}) + if params: + params_display = ", ".join(f"{k}={v}" for k, v in params.items()) + else: + params_display = "[dim]none[/dim]" + + hooks_table.add_row(str(i), f"[bold]{hook_name}[/bold]\n[dim]{hook_path}[/dim]", params_display) + + console.print(Panel(hooks_table, title="[bold]Hooks[/bold]", border_style="green")) + + # Model deployments table + if status_data["model_list"]: + models_table = Table(show_header=True, show_lines=True, expand=True) + models_table.add_column("Model Name", style="cyan", no_wrap=True) + models_table.add_column("Provider Model", style="yellow", no_wrap=True) + models_table.add_column("API Base", style="dim", no_wrap=True) + + # Build lookup for resolving model aliases + model_lookup = {m.get("model_name", ""): m for m in status_data["model_list"]} + + for model in status_data["model_list"]: + model_name = model.get("model_name", "") + litellm_params = model.get("litellm_params", {}) + provider_model = litellm_params.get("model", "") + api_base = litellm_params.get("api_base") + + # Resolve API base from target model if this is an alias + if not api_base and provider_model in model_lookup: + target = model_lookup[provider_model] + api_base = target.get("litellm_params", {}).get("api_base") + + # Shorten API base to just the hostname + if api_base: + from urllib.parse import urlparse + + parsed = urlparse(api_base) + api_base_display = parsed.netloc or api_base + else: + api_base_display = "[dim]default[/dim]" + + models_table.add_row(model_name, provider_model, api_base_display) + + console.print(Panel(models_table, title="[bold]Model Deployments[/bold]", border_style="magenta")) + + +def main( + cmd: Annotated[Command, tyro.conf.arg(name="")], + *, + config_dir: Annotated[Path | None, tyro.conf.arg(help="Configuration directory")] = None, +) -> None: + """ccproxy - LiteLLM Transformation Hook System. + + A powerful routing system for LiteLLM that dynamically routes requests + to different models based on configurable rules. + """ + if config_dir is None: + config_dir = Path.home() / ".ccproxy" + + # Setup logging with 100-character text width + setup_logging() + + # Handle each command type + if isinstance(cmd, Start): + start_litellm(config_dir, args=cmd.args, detach=cmd.detach) + + elif isinstance(cmd, Install): + install_config(config_dir, force=cmd.force) + + elif isinstance(cmd, Run): + if not cmd.command: + print("Error: No command specified to run", file=sys.stderr) + print("Usage: ccproxy run <command> [args...]", file=sys.stderr) + sys.exit(1) + run_with_proxy(config_dir, cmd.command) + + elif isinstance(cmd, Stop): + success = stop_litellm(config_dir) + sys.exit(0 if success else 1) + + elif isinstance(cmd, Restart): + # Stop the server first + pid_file = config_dir / "litellm.lock" + if pid_file.exists(): + print("Stopping LiteLLM server...") + stop_litellm(config_dir) + else: + print("No server running, starting fresh...") + + # Wait for clean shutdown + time.sleep(1) + + # Start the server + print("Starting LiteLLM server...") + start_litellm(config_dir, args=cmd.args, detach=cmd.detach) + + elif isinstance(cmd, Logs): + view_logs(config_dir, follow=cmd.follow, lines=cmd.lines) + + elif isinstance(cmd, Status): + show_status(config_dir, json_output=cmd.json) + + +def entry_point() -> None: + """Entry point for the ccproxy command.""" + # Handle 'run' subcommand specially to avoid tyro parsing command arguments + # This allows: ccproxy run claude -p foo (without needing --) + args = sys.argv[1:] + + # Find 'run' subcommand position (skip past any global flags like --config-dir) + subcommands = {"start", "stop", "restart", "install", "logs", "status", "run"} + run_idx = None + for i, arg in enumerate(args): + if arg == "run": + run_idx = i + break + # Stop if we hit a different subcommand + if arg in subcommands: + break + + if run_idx is not None: + # Extract command after 'run' + command_args = args[run_idx + 1 :] + + # Only insert '--' if not already present (backwards compatibility) + if command_args and command_args[0] != "--": + # Rebuild argv: keep everything up to and including 'run', then '--' to escape the rest + sys.argv = [sys.argv[0]] + args[: run_idx + 1] + ["--"] + command_args + + tyro.cli(main) + if __name__ == "__main__": - main() + entry_point() diff --git a/src/ccproxy/config.py b/src/ccproxy/config.py index ca82fde1..35c3306c 100644 --- a/src/ccproxy/config.py +++ b/src/ccproxy/config.py @@ -1,14 +1,68 @@ -"""Configuration management for ccproxy.""" +"""Configuration management for ccproxy. + +Configuration Discovery Precedence (Highest to Lowest Priority): +=============================================================== + +1. **CCPROXY_CONFIG_DIR Environment Variable** (Highest Priority) + - Set by CLI or manually: `export CCPROXY_CONFIG_DIR=/path/to/config` + - Looks for: `${CCPROXY_CONFIG_DIR}/ccproxy.yaml` + - Use case: Development, testing, custom deployments + +2. **LiteLLM Proxy Server Runtime Directory** + - Automatically detected from proxy_server.config_path + - Looks for: `{proxy_runtime_dir}/ccproxy.yaml` + - Use case: Production deployments with LiteLLM proxy + +3. **~/.ccproxy Directory** (Fallback) + - User's home directory default location + - Looks for: `~/.ccproxy/ccproxy.yaml` + - Use case: Default user installations + +The first existing `ccproxy.yaml` found in this order is used. +If no `ccproxy.yaml` is found, default configuration is applied. + +Examples: +-------- +# Override with environment variable (highest priority) +export CCPROXY_CONFIG_DIR=/custom/path +litellm --config /custom/path/config.yaml + +# Use proxy runtime directory (automatic detection) +litellm --config /etc/litellm/config.yaml +# Will look for /etc/litellm/ccproxy.yaml + +# Fallback to user directory +# Will look for ~/.ccproxy/ccproxy.yaml +""" import importlib +import logging +import subprocess import threading from pathlib import Path from typing import Any import yaml -from pydantic import Field +from pydantic import BaseModel, Field, PrivateAttr from pydantic_settings import BaseSettings, SettingsConfigDict +logger = logging.getLogger(__name__) + + +class OAuthSource(BaseModel): + """OAuth token source configuration. + + Can be specified as either a simple string (shell command) or + an object with command and optional user_agent. + """ + + command: str + """Shell command to retrieve the OAuth token""" + + user_agent: str | None = None + """Optional custom User-Agent header to send with requests using this token""" + + # Import proxy_server to access runtime configuration try: from litellm.proxy import proxy_server @@ -17,18 +71,32 @@ proxy_server = None +class HookConfig: + """Configuration for a single hook with optional parameters.""" + + def __init__(self, hook_path: str, params: dict[str, Any] | None = None) -> None: + """Initialize a hook configuration. + + Args: + hook_path: Python import path to the hook function + params: Optional parameters to pass to the hook via kwargs + """ + self.hook_path = hook_path + self.params = params or {} + + class RuleConfig: """Configuration for a single classification rule.""" - def __init__(self, label: str, rule_path: str, params: list[Any] | None = None) -> None: + def __init__(self, name: str, rule_path: str, params: list[Any] | None = None) -> None: """Initialize a rule configuration. Args: - label: The routing label for this rule + name: The name for this rule (maps to model_name in LiteLLM config) rule_path: Python import path to the rule class params: Optional parameters to pass to the rule constructor """ - self.label = label + self.model_name = name self.rule_path = rule_path self.params = params or [] @@ -80,6 +148,24 @@ class CCProxyConfig(BaseSettings): # Core settings debug: bool = False metrics_enabled: bool = True + default_model_passthrough: bool = True + + # Handler import path (e.g., "ccproxy.handler:CCProxyHandler") + handler: str = "ccproxy.handler:CCProxyHandler" + + # OAuth token sources - dict mapping provider name to shell command or OAuthSource + # Example: {"anthropic": "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json"} + # Extended: {"gemini": {"command": "jq -r '.token' ~/.gemini/creds.json", "user_agent": "MyApp/1.0"}} + oat_sources: dict[str, str | OAuthSource] = Field(default_factory=dict) + + # Cached OAuth tokens (loaded at startup) - dict mapping provider name to token + _oat_values: dict[str, str] = PrivateAttr(default_factory=dict) + + # Cached OAuth user agents (loaded at startup) - dict mapping provider name to user-agent + _oat_user_agents: dict[str, str] = PrivateAttr(default_factory=dict) + + # Hook configurations (function import paths or dict with params) + hooks: list[str | dict[str, Any]] = Field(default_factory=list) # Rule configurations rules: list[RuleConfig] = Field(default_factory=list) @@ -90,6 +176,166 @@ class CCProxyConfig(BaseSettings): # Path to LiteLLM config (for model lookups) litellm_config_path: Path = Field(default_factory=lambda: Path("./config.yaml")) + @property + def oat_values(self) -> dict[str, str]: + """Get the cached OAuth token values. + + Returns: + Dict mapping provider name to OAuth token + """ + return self._oat_values + + def get_oauth_token(self, provider: str) -> str | None: + """Get OAuth token for a specific provider. + + Args: + provider: Provider name (e.g., "anthropic", "gemini") + + Returns: + OAuth token string or None if not configured for this provider + """ + return self._oat_values.get(provider) + + def get_oauth_user_agent(self, provider: str) -> str | None: + """Get custom User-Agent for a specific provider. + + Args: + provider: Provider name (e.g., "anthropic", "gemini") + + Returns: + Custom User-Agent string or None if not configured for this provider + """ + return self._oat_user_agents.get(provider) + + def _load_credentials(self) -> None: + """Execute shell commands to load OAuth tokens for all configured providers at startup. + + Raises: + RuntimeError: If any shell command fails to execute or returns empty token + """ + if not self.oat_sources: + # No OAuth sources configured + self._oat_values = {} + self._oat_user_agents = {} + return + + loaded_tokens = {} + loaded_user_agents = {} + errors = [] + + for provider, source in self.oat_sources.items(): + # Normalize to OAuthSource for consistent handling + if isinstance(source, str): + oauth_source = OAuthSource(command=source) + elif isinstance(source, OAuthSource): + oauth_source = source + elif isinstance(source, dict): + # Handle dict from YAML + oauth_source = OAuthSource(**source) + else: + error_msg = f"Invalid OAuth source type for provider '{provider}': {type(source)}" + logger.error(error_msg) + errors.append(error_msg) + continue + + try: + # Execute shell command + result = subprocess.run( # noqa: S602 + oauth_source.command, + shell=True, # Intentional: command is user-configured + capture_output=True, + text=True, + timeout=5, # 5 second timeout + ) + + if result.returncode != 0: + error_msg = ( + f"OAuth command for provider '{provider}' failed with exit code " + f"{result.returncode}: {result.stderr.strip()}" + ) + logger.error(error_msg) + errors.append(error_msg) + continue + + token = result.stdout.strip() + if not token: + error_msg = f"OAuth command for provider '{provider}' returned empty output" + logger.error(error_msg) + errors.append(error_msg) + continue + + loaded_tokens[provider] = token + logger.debug(f"Successfully loaded OAuth token for provider '{provider}'") + + # Store user-agent if specified + if oauth_source.user_agent: + loaded_user_agents[provider] = oauth_source.user_agent + logger.debug(f"Loaded custom User-Agent for provider '{provider}': {oauth_source.user_agent}") + + except subprocess.TimeoutExpired: + error_msg = f"OAuth command for provider '{provider}' timed out after 5 seconds" + logger.error(error_msg) + errors.append(error_msg) + except Exception as e: + error_msg = f"Failed to execute OAuth command for provider '{provider}': {e}" + logger.error(error_msg) + errors.append(error_msg) + + # Store successfully loaded tokens and user-agents + self._oat_values = loaded_tokens + self._oat_user_agents = loaded_user_agents + + # If we had errors but successfully loaded some tokens, log warning + if errors and loaded_tokens: + logger.warning( + f"Loaded OAuth tokens for {len(loaded_tokens)} provider(s), " + f"but {len(errors)} provider(s) failed to load" + ) + + # If all providers failed, raise error + if errors and not loaded_tokens: + raise RuntimeError( + f"Failed to load OAuth tokens for all {len(self.oat_sources)} provider(s):\n" + + "\n".join(f" - {err}" for err in errors) + ) + + def load_hooks(self) -> list[tuple[Any, dict[str, Any]]]: + """Load hook functions from their import paths. + + Returns: + List of (hook_function, params) tuples + + Raises: + ImportError: If a hook cannot be imported + """ + loaded_hooks = [] + for hook_entry in self.hooks: + # Parse hook entry (string or dict format) + if isinstance(hook_entry, str): + hook_path = hook_entry + params: dict[str, Any] = {} + elif isinstance(hook_entry, dict): + hook_path = hook_entry.get("hook", "") + params = hook_entry.get("params", {}) + if not hook_path: + logger.error(f"Hook entry missing 'hook' key: {hook_entry}") + continue + else: + logger.error(f"Invalid hook entry type: {type(hook_entry)}") + continue + + try: + # Import the hook function + module_path, func_name = hook_path.rsplit(".", 1) + module = importlib.import_module(module_path) + hook_func = getattr(module, func_name) + loaded_hooks.append((hook_func, params)) + logger.debug(f"Loaded hook: {hook_path}" + (f" with params: {params}" if params else "")) + except (ImportError, AttributeError) as e: + logger.error(f"Failed to load hook {hook_path}: {e}") + # Continue loading other hooks even if one fails + return loaded_hooks + @classmethod def from_proxy_runtime(cls, **kwargs: Any) -> "CCProxyConfig": """Load configuration from ccproxy.yaml file in the same directory as config.yaml. @@ -118,6 +364,9 @@ def from_yaml(cls, yaml_path: Path, **kwargs: Any) -> "CCProxyConfig": Returns: CCProxyConfig instance + + Raises: + RuntimeError: If credentials shell command fails during startup """ instance = cls(ccproxy_config_path=yaml_path, **kwargs) @@ -134,71 +383,124 @@ def from_yaml(cls, yaml_path: Path, **kwargs: Any) -> "CCProxyConfig": instance.debug = ccproxy_data["debug"] if "metrics_enabled" in ccproxy_data: instance.metrics_enabled = ccproxy_data["metrics_enabled"] + if "default_model_passthrough" in ccproxy_data: + instance.default_model_passthrough = ccproxy_data["default_model_passthrough"] + if "oat_sources" in ccproxy_data: + instance.oat_sources = ccproxy_data["oat_sources"] + + # Backwards compatibility: migrate deprecated 'credentials' field + if "credentials" in ccproxy_data: + logger.error( + "DEPRECATED: The 'credentials' field is deprecated and will be removed in a future version. " + "Please migrate to 'oat_sources' in your ccproxy.yaml configuration. " + "Example:\n" + " oat_sources:\n" + " anthropic: \"jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json\"\n" + "The deprecated 'credentials' field has been automatically migrated to " + "oat_sources['anthropic'] for this session." + ) + # Migrate credentials to oat_sources for anthropic provider + if "anthropic" not in instance.oat_sources: + instance.oat_sources["anthropic"] = ccproxy_data["credentials"] + else: + logger.warning( + "Both 'credentials' and 'oat_sources[\"anthropic\"]' are configured. " + "Using 'oat_sources[\"anthropic\"]' and ignoring deprecated 'credentials' field." + ) + + # Load hooks + hooks_data = ccproxy_data.get("hooks", []) + if hooks_data: + instance.hooks = hooks_data # Load rules rules_data = ccproxy_data.get("rules", []) instance.rules = [] for rule_data in rules_data: if isinstance(rule_data, dict): - label = rule_data.get("label", "") + name = rule_data.get("name", "") rule_path = rule_data.get("rule", "") params = rule_data.get("params", []) - if label and rule_path: - rule_config = RuleConfig(label, rule_path, params) + if name and rule_path: + rule_config = RuleConfig(name, rule_path, params) instance.rules.append(rule_config) - return instance - - def get_model_for_label(self, label: str) -> str | None: - """Get the model name for a given routing label from LiteLLM runtime config.""" - # Try to get from proxy_server runtime first - if proxy_server and hasattr(proxy_server, "llm_router") and proxy_server.llm_router: - model_list = proxy_server.llm_router.model_list or [] - - # Look for model with matching model_name - for model in model_list: - if model.get("model_name") == label: - # Return the actual model identifier from litellm_params - litellm_params = model.get("litellm_params", {}) - model_name = litellm_params.get("model") - return model_name if isinstance(model_name, str) else None + # Load credentials at startup (raises RuntimeError if fails) + instance._load_credentials() - # Fall back to reading from YAML if proxy_server not available - if self.litellm_config_path.exists(): - with self.litellm_config_path.open() as f: - litellm_data = yaml.safe_load(f) or {} - model_list = litellm_data.get("model_list", []) - - for model in model_list: - if model.get("model_name") == label: - litellm_params = model.get("litellm_params", {}) - model_name = litellm_params.get("model") - return model_name if isinstance(model_name, str) else None - - return None + return instance -# Singleton instance holder with thread safety +# Global configuration instance _config_instance: CCProxyConfig | None = None _config_lock = threading.Lock() def get_config() -> CCProxyConfig: - """Get the singleton configuration instance (thread-safe).""" + """Get the configuration instance.""" global _config_instance if _config_instance is None: with _config_lock: # Double-check locking pattern if _config_instance is None: - # Try to load from ccproxy.yaml - ccproxy_path = Path("./ccproxy.yaml") - if ccproxy_path.exists(): - _config_instance = CCProxyConfig.from_yaml(ccproxy_path) + # Configuration discovery precedence: + # 1. CCPROXY_CONFIG_DIR environment variable (highest priority) + # 2. LiteLLM proxy server runtime directory + # 3. ~/.ccproxy directory (fallback) + + import os + + config_path = None + config_source = None + + # Priority 1: Environment variable + env_config_dir = os.environ.get("CCPROXY_CONFIG_DIR") + if env_config_dir: + config_path = Path(env_config_dir) + config_source = f"ENV:CCPROXY_CONFIG_DIR={env_config_dir}" + logger.info(f"Using config directory from environment: {config_path}") + else: + # Priority 2: LiteLLM proxy server runtime directory + try: + from litellm.proxy import proxy_server + + if proxy_server and hasattr(proxy_server, "config_path") and proxy_server.config_path: + config_path = Path(proxy_server.config_path).parent + config_source = f"PROXY_RUNTIME:{config_path}" + logger.info(f"Using config directory from proxy runtime: {config_path}") + except ImportError: + logger.debug("LiteLLM proxy server not available for config discovery") + + if config_path: + # Try to load ccproxy.yaml from discovered path + ccproxy_yaml_path = config_path / "ccproxy.yaml" + if ccproxy_yaml_path.exists(): + logger.info(f"Loading ccproxy config from: {ccproxy_yaml_path} (source: {config_source})") + _config_instance = CCProxyConfig.from_yaml(ccproxy_yaml_path) + _config_instance.litellm_config_path = config_path / "config.yaml" + else: + logger.info( + f"ccproxy.yaml not found at {ccproxy_yaml_path}, using default config " + f"(source: {config_source})" + ) + # Create default config with proper paths + _config_instance = CCProxyConfig( + litellm_config_path=config_path / "config.yaml", ccproxy_config_path=ccproxy_yaml_path + ) else: - # Use from_proxy_runtime which will look for ccproxy.yaml - # in the same directory as config.yaml - _config_instance = CCProxyConfig.from_proxy_runtime() + # Priority 3: Fallback to ~/.ccproxy directory + fallback_config_dir = Path.home() / ".ccproxy" + ccproxy_path = fallback_config_dir / "ccproxy.yaml" + if ccproxy_path.exists(): + logger.info(f"Using fallback config directory: {fallback_config_dir}") + _config_instance = CCProxyConfig.from_yaml(ccproxy_path) + _config_instance.litellm_config_path = fallback_config_dir / "config.yaml" + else: + logger.info("No ccproxy.yaml found in any location, using proxy runtime defaults") + # Use from_proxy_runtime which will look for ccproxy.yaml + # in the same directory as config.yaml + _config_instance = CCProxyConfig.from_proxy_runtime() return _config_instance @@ -206,44 +508,10 @@ def get_config() -> CCProxyConfig: def set_config_instance(config: CCProxyConfig) -> None: """Set the global configuration instance (for testing).""" global _config_instance - with _config_lock: - _config_instance = config + _config_instance = config def clear_config_instance() -> None: """Clear the global configuration instance (for testing).""" global _config_instance - with _config_lock: - _config_instance = None - - -class ConfigProvider: - """Dependency injection provider for configuration. - - This provides an alternative to the singleton pattern, allowing - for easier testing and multiple configuration instances. - """ - - def __init__(self, config: CCProxyConfig | None = None) -> None: - """Initialize the config provider. - - Args: - config: Optional initial configuration. If not provided, - will load from environment on first access. - """ - self._config = config - self._lock = threading.Lock() - - def get(self) -> CCProxyConfig: - """Get the configuration instance.""" - if self._config is None: - with self._lock: - if self._config is None: - # Use the global singleton if no config was provided - self._config = get_config() - return self._config - - def set(self, config: CCProxyConfig) -> None: - """Set the configuration instance.""" - with self._lock: - self._config = config + _config_instance = None diff --git a/src/ccproxy/handler.py b/src/ccproxy/handler.py index 705b07c7..30e6a946 100644 --- a/src/ccproxy/handler.py +++ b/src/ccproxy/handler.py @@ -1,13 +1,15 @@ -"""CCProxyHandler - Main LiteLLM CustomLogger implementation.""" +"""ccproxy handler - Main LiteLLM CustomLogger implementation.""" import logging from typing import Any, TypedDict -from litellm.integrations.custom_logger import CustomLogger # type: ignore[import-not-found] +from litellm.integrations.custom_logger import CustomLogger +from rich import print from ccproxy.classifier import RequestClassifier from ccproxy.config import get_config from ccproxy.router import get_router +from ccproxy.utils import calculate_duration_ms # Set up structured logging logger = logging.getLogger(__name__) @@ -22,60 +24,36 @@ class RequestData(TypedDict, total=False): metadata: dict[str, Any] | None -def ccproxy_get_model(data: dict[str, Any]) -> str: - """Main routing function that determines which model to use. - - This function is called by LiteLLM to determine model routing. - It provides backward compatibility for direct function calls. - - Args: - data: Request data from LiteLLM - - Returns: - Model name to route to - """ - config = get_config() - router = get_router() - classifier = RequestClassifier() - - # Classify the request - label = classifier.classify(data) - - # Get model for label from router - but only if the specific label exists - router_available_models = router.get_available_models() - - if label in router_available_models: - # The specific label is configured, use it - model_config = router.get_model_for_label(label) - if model_config is not None: - model: str = str(model_config["litellm_params"]["model"]) - else: - # Should not happen, but fallback to original - model = str(data.get("model", "claude-3-5-sonnet-20241022")) - else: - # The specific label is not configured, use original model - model = str(data.get("model", "claude-3-5-sonnet-20241022")) - - # Log routing decision if debug enabled - if config.debug: - print(f"[ccproxy] Routed to {model} (label: {label})") - - return model - - -class CCProxyHandler(CustomLogger): # type: ignore[misc] - """LiteLLM CustomLogger for context-aware request routing. - - This handler integrates with LiteLLM's callback system to provide - context-aware routing for Claude Code requests. - """ +class CCProxyHandler(CustomLogger): + """Main module of ccproxy, an instance of CCProxyHandler is instantiated in the LiteLLM callback python script""" def __init__(self) -> None: - """Initialize CCProxyHandler.""" super().__init__() - self.config = get_config() self.classifier = RequestClassifier() self.router = get_router() + self._langfuse_client = None + + config = get_config() + if config.debug: + logger.setLevel(logging.DEBUG) + + # Load hooks from configuration (list of (hook_func, params) tuples) + self.hooks = config.load_hooks() + if config.debug and self.hooks: + hook_names = [f"{h.__module__}.{h.__name__}" for h, _ in self.hooks] + logger.debug(f"Loaded {len(self.hooks)} hooks: {', '.join(hook_names)}") + + @property + def langfuse(self): + """Lazy-loaded Langfuse client.""" + if self._langfuse_client is None: + try: + from langfuse import Langfuse + + self._langfuse_client = Langfuse() + except Exception: + pass + return self._langfuse_client async def async_pre_call_hook( self, @@ -83,91 +61,120 @@ async def async_pre_call_hook( user_api_key_dict: dict[str, Any], **kwargs: Any, ) -> dict[str, Any]: - """Pre-call hook for request routing. - - This hook is called before the LLM request is made, allowing us to - modify the request data including the target model. - - Args: - data: Request data dictionary - user_api_key_dict: User API key information - **kwargs: Additional arguments from LiteLLM - - Returns: - Modified request data - """ - # Store original model for logging - original_model = data.get("model", "unknown") - - # Classify the request - label = self.classifier.classify(data) - - # Get model configuration from router - but only if the specific label exists - router_available_models = self.router.get_available_models() - model_config = None - - if label in router_available_models: - # The specific label is configured, use it - model_config = self.router.get_model_for_label(label) - if model_config is not None: - data["model"] = model_config["litellm_params"]["model"] - routed_model = data["model"] - else: - # Should not happen, but keep original - routed_model = original_model - else: - # The specific label is not configured, keep original model - routed_model = original_model - - # Add metadata for tracking - if "metadata" not in data: - data["metadata"] = {} - - data["metadata"]["ccproxy_label"] = label - data["metadata"]["ccproxy_original_model"] = original_model - data["metadata"]["ccproxy_routed_model"] = routed_model - - # Generate request ID if not present - if "request_id" not in data["metadata"]: - import uuid - - data["metadata"]["request_id"] = str(uuid.uuid4()) + # Skip custom routing for LiteLLM internal health checks + # Health checks need to validate actual configured models, not routed ones + metadata = data.get("metadata", {}) + tags = metadata.get("tags", []) + if "litellm-internal-health-check" in tags: + logger.debug("Skipping hooks for health check request") + return data + + # Debug: Print thinking parameters if present + thinking_params = data.get("thinking") + if thinking_params is not None: + print(f"🧠 Thinking parameters: {thinking_params}") + + # Run all processors in sequence with error handling + for hook, params in self.hooks: + try: + data = hook(data, user_api_key_dict, classifier=self.classifier, router=self.router, **params) + except Exception as e: + logger.error( + f"Hook {hook.__name__} failed with error: {e}", + extra={ + "hook_name": hook.__name__, + "error_type": type(e).__name__, + "error_message": str(e), + }, + exc_info=True, + ) + # Continue with other hooks even if one fails + # The request will proceed with partial processing # Log routing decision with structured logging + metadata = data.get("metadata", {}) self._log_routing_decision( - label=label, - original_model=original_model, - routed_model=routed_model, - request_id=data["metadata"]["request_id"], - model_config=model_config, + model_name=metadata.get("ccproxy_model_name", None), + original_model=metadata.get("ccproxy_alias_model", None), + routed_model=metadata.get("ccproxy_litellm_model", None), + model_config=metadata.get("ccproxy_model_config"), + is_passthrough=metadata.get("ccproxy_is_passthrough", False), ) return data def _log_routing_decision( self, - label: str, + model_name: str, original_model: str, routed_model: str, - request_id: str, model_config: dict[str, Any] | None, + is_passthrough: bool = False, ) -> None: """Log routing decision with structured logging. Args: - label: Classification label + model_name: Classification model_name original_model: Original model requested routed_model: Model after routing - request_id: Unique request identifier - model_config: Model configuration from router (None if fallback) + model_config: Model configuration from router (None if fallback or passthrough) + is_passthrough: Whether this was a passthrough decision (no rule applied + passthrough enabled) """ + # Get config to check debug mode + config = get_config() + + # Only display colored routing decision when debug is enabled + if config.debug: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + # Create console with 80 char width limit + console = Console(width=80) + + # Color scheme based on routing + if is_passthrough: + # Passthrough (no rule applied, passthrough enabled) - dim + color = "dim" + routing_type = "PASSTHROUGH" + elif original_model == routed_model: + # No change but rule was applied - blue + color = "blue" + routing_type = "NO CHANGE" + else: + # Routed - green + color = "green" + routing_type = "ROUTED" + + # Helper function to truncate and wrap long model names + def format_model_name(name: str, max_width: int = 60) -> str: + """Format model name to fit within max width.""" + if len(name) <= max_width: + return name + # Truncate with ellipsis + return name[: max_width - 3] + "..." + + # Create the routing message + routing_text = Text() + routing_text.append("[ccproxy] Request Routed\n", style="bold cyan") + routing_text.append("├─ Type: ", style="dim") + routing_text.append(f"{routing_type}\n", style=f"bold {color}") + routing_text.append("├─ Model Name: ", style="dim") + routing_text.append(f"{format_model_name(model_name)}\n", style="magenta") + routing_text.append("├─ Original: ", style="dim") + routing_text.append(f"{format_model_name(original_model)}\n", style="blue") + routing_text.append("└─ Routed to: ", style="dim") + routing_text.append(f"{format_model_name(routed_model)}", style=f"bold {color}") + + # Print the panel with width constraint + console.print(Panel(routing_text, border_style=color, padding=(0, 1), width=78)) + log_data = { "event": "ccproxy_routing", - "label": label, + "model_name": model_name, "original_model": original_model, "routed_model": routed_model, - "request_id": request_id, - "fallback_used": model_config is None, + "is_passthrough": is_passthrough, } # Add model info if available (excluding sensitive data) @@ -182,7 +189,7 @@ def _log_routing_decision( if safe_info: log_data["model_info"] = safe_info - logger.info("CCProxy routing decision", extra=log_data) + logger.info("ccproxy routing decision", extra=log_data) async def async_log_success_event( self, @@ -199,17 +206,38 @@ async def async_log_success_event( start_time: Request start timestamp end_time: Request completion timestamp """ + # Retrieve stored metadata and update Langfuse trace + from ccproxy.hooks import get_request_metadata + + call_id = kwargs.get("litellm_call_id") + litellm_params = kwargs.get("litellm_params", {}) + if not call_id: + call_id = litellm_params.get("litellm_call_id") + stored = get_request_metadata(call_id) if call_id else {} + + if stored and self.langfuse: + standard_logging_obj = kwargs.get("standard_logging_object") + if standard_logging_obj: + trace_id = standard_logging_obj.get("trace_id") + if trace_id: + try: + # Update trace with stored metadata + trace_metadata = stored.get("trace_metadata", {}) + if trace_metadata: + self.langfuse.trace(id=trace_id, metadata=trace_metadata) + self.langfuse.flush() + except Exception as e: + logger.debug(f"Failed to update Langfuse trace: {e}") + metadata = kwargs.get("metadata", {}) - request_id = metadata.get("request_id", "unknown") - label = metadata.get("ccproxy_label", "unknown") + model_name = metadata.get("ccproxy_model_name", "unknown") - # Calculate duration - duration_ms = (end_time - start_time) * 1000 + # Calculate duration using utility function + duration_ms = calculate_duration_ms(start_time, end_time) log_data = { "event": "ccproxy_success", - "request_id": request_id, - "label": label, + "model_name": model_name, "duration_ms": round(duration_ms, 2), "model": kwargs.get("model", "unknown"), } @@ -223,7 +251,7 @@ async def async_log_success_event( "total_tokens": getattr(usage, "total_tokens", 0), } - logger.info("CCProxy request completed", extra=log_data) + logger.info("ccproxy request completed", extra=log_data) async def async_log_failure_event( self, @@ -241,32 +269,25 @@ async def async_log_failure_event( end_time: Request completion timestamp """ metadata = kwargs.get("metadata", {}) - request_id = metadata.get("request_id", "unknown") - label = metadata.get("ccproxy_label", "unknown") + model_name = metadata.get("ccproxy_model_name", "unknown") - # Calculate duration - duration_ms = (end_time - start_time) * 1000 + # Calculate duration using utility function + duration_ms = calculate_duration_ms(start_time, end_time) log_data = { "event": "ccproxy_failure", - "request_id": request_id, - "label": label, + "model_name": model_name, "duration_ms": round(duration_ms, 2), "model": kwargs.get("model", "unknown"), "error_type": type(response_obj).__name__, } - # Add error message if available (but mask sensitive content) + # Add error message if available if hasattr(response_obj, "message"): error_message = str(response_obj.message) - # Basic masking of potential API keys or tokens - import re - - error_message = re.sub(r"sk-[a-zA-Z0-9]{20,}", "[REDACTED_API_KEY]", error_message) - error_message = re.sub(r"[a-fA-F0-9]{32,}", "[REDACTED_TOKEN]", error_message) log_data["error_message"] = error_message[:500] # Truncate long messages - logger.error("CCProxy request failed", extra=log_data) + logger.error("ccproxy request failed", extra=log_data) async def async_log_stream_event( self, @@ -284,19 +305,17 @@ async def async_log_stream_event( end_time: Request completion timestamp """ metadata = kwargs.get("metadata", {}) - request_id = metadata.get("request_id", "unknown") - label = metadata.get("ccproxy_label", "unknown") + model_name = metadata.get("ccproxy_model_name", "unknown") - # Calculate duration - duration_ms = (end_time - start_time) * 1000 + # Calculate duration using utility function + duration_ms = calculate_duration_ms(start_time, end_time) log_data = { "event": "ccproxy_stream_complete", - "request_id": request_id, - "label": label, + "model_name": model_name, "duration_ms": round(duration_ms, 2), "model": kwargs.get("model", "unknown"), "streaming": True, } - logger.info("CCProxy streaming request completed", extra=log_data) + logger.info("ccproxy streaming request completed", extra=log_data) diff --git a/src/ccproxy/hooks.py b/src/ccproxy/hooks.py new file mode 100644 index 00000000..e37d9fb9 --- /dev/null +++ b/src/ccproxy/hooks.py @@ -0,0 +1,489 @@ +import logging +import re +import threading +import time +from typing import Any + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + +from ccproxy.classifier import RequestClassifier +from ccproxy.config import get_config +from ccproxy.router import ModelRouter + +# Set up structured logging +logger = logging.getLogger(__name__) + +# Global storage for request metadata, keyed by litellm_call_id +# Required because LiteLLM doesn't preserve custom metadata from async_pre_call_hook +# to logging callbacks - only internal fields like user_id and hidden_params survive. +_request_metadata_store: dict[str, tuple[dict[str, Any], float]] = {} +_store_lock = threading.Lock() +_STORE_TTL = 60.0 # Clean up entries older than 60 seconds + + +def store_request_metadata(call_id: str, metadata: dict[str, Any]) -> None: + """Store metadata for a request by its call ID.""" + with _store_lock: + _request_metadata_store[call_id] = (metadata, time.time()) + # Clean up old entries + now = time.time() + expired = [k for k, (_, ts) in _request_metadata_store.items() if now - ts > _STORE_TTL] + for k in expired: + del _request_metadata_store[k] + + +def get_request_metadata(call_id: str) -> dict[str, Any]: + """Retrieve metadata for a request by its call ID.""" + with _store_lock: + entry = _request_metadata_store.get(call_id) + if entry: + metadata, _ = entry + return metadata + return {} + + +# Beta headers required for Claude Code impersonation (Claude Max OAuth support) +ANTHROPIC_BETA_HEADERS = [ + "oauth-2025-04-20", + "claude-code-20250219", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", +] + +# Headers containing secrets - redact but show prefix/suffix for identification +SENSITIVE_PATTERNS = { + "authorization": r"^(Bearer sk-[a-z]+-|Bearer |sk-[a-z]+-)", # Keep "Bearer sk-ant-" or "Bearer " or "sk-ant-" + "x-api-key": r"^(sk-[a-z]+-)", + "cookie": None, # Fully redact +} + + +def _redact_value(header: str, value: str) -> str: + """Redact sensitive header values, keeping prefix and last 4 chars.""" + header_lower = header.lower() + if header_lower in SENSITIVE_PATTERNS: + pattern = SENSITIVE_PATTERNS[header_lower] + if pattern is None: + return "[REDACTED]" + match = re.match(pattern, value) + prefix = match.group(0) if match else "" + suffix = value[-4:] if len(value) > 8 else "" + return f"{prefix}...{suffix}" + return str(value)[:200] + + +def rule_evaluator(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + classifier = kwargs.get("classifier") + if not isinstance(classifier, RequestClassifier): + logger.warning("Classifier not found or invalid type in rule_evaluator") + return data + + if "metadata" not in data: + data["metadata"] = {} + + # Store original model + data["metadata"]["ccproxy_alias_model"] = data.get("model") + + # Classify the request + data["metadata"]["ccproxy_model_name"] = classifier.classify(data) + return data + + +def model_router(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + router = kwargs.get("router") + if not isinstance(router, ModelRouter): + logger.warning("Router not found or invalid type in model_router") + return data + + # Ensure metadata exists + if "metadata" not in data: + data["metadata"] = {} + + # Get model_name with safe default + model_name = data.get("metadata", {}).get("ccproxy_model_name", "default") + if not model_name: + logger.warning("No ccproxy_model_name found, using default") + model_name = "default" + + # Check if we should pass through the original model for "default" routing + config = get_config() + if model_name == "default" and config.default_model_passthrough: + # Use the original model that Claude Code requested + original_model = data["metadata"].get("ccproxy_alias_model") + if original_model: + # Keep the original model - no routing needed + data["metadata"]["ccproxy_litellm_model"] = original_model + data["metadata"]["ccproxy_model_config"] = None # No specific config since we're not routing + data["metadata"]["ccproxy_is_passthrough"] = True # Mark as passthrough decision + logger.debug(f"Using passthrough mode for default routing: keeping original model {original_model}") + # Skip the routing logic and go directly to request ID generation + else: + logger.warning("No original model found for passthrough mode, falling back to routing") + # Continue with routing logic below + model_config = router.get_model_for_label(model_name) + else: + # Standard routing logic - get model for model_name from router + model_config = router.get_model_for_label(model_name) + + # Only process model_config if we didn't already handle passthrough above + passthrough_handled = ( + model_name == "default" and config.default_model_passthrough and data["metadata"].get("ccproxy_litellm_model") + ) + if not passthrough_handled: + if model_config is not None: + routed_model = model_config.get("litellm_params", {}).get("model") + if routed_model: + data["model"] = routed_model + else: + logger.warning(f"No model found in config for model_name: {model_name}") + data["metadata"]["ccproxy_litellm_model"] = routed_model + data["metadata"]["ccproxy_model_config"] = model_config + data["metadata"]["ccproxy_is_passthrough"] = False # Mark as routed decision + else: + # No model config found (not even default) + # This can happen during startup when LiteLLM proxy is still initializing + logger.warning( + f"No model configured for model_name '{model_name}' and no 'default' model available as fallback" + ) + + # Try to reload models in case they weren't loaded properly + router.reload_models() + model_config = router.get_model_for_label(model_name) + + if model_config is not None: + routed_model = model_config.get("litellm_params", {}).get("model") + if routed_model: + data["model"] = routed_model + data["metadata"]["ccproxy_litellm_model"] = routed_model + data["metadata"]["ccproxy_model_config"] = model_config + data["metadata"]["ccproxy_is_passthrough"] = False # Mark as routed decision + logger.info(f"Successfully routed after model reload: {model_name} -> {routed_model}") + else: + # Final fallback - still no models available, raise error + raise ValueError( + f"No model configured for model_name '{model_name}' and no 'default' model available as fallback" + ) + + return data + + +def extract_session_id(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Extract session_id from Claude Code's user_id field for LangFuse session tracking. + + Claude Code embeds session info in the metadata.user_id field with format: + user_{hash}_account_{uuid}_session_{uuid} + + This hook extracts the session_id and sets it on metadata["session_id"] for LangFuse. + """ + if "metadata" not in data: + data["metadata"] = {} + + # Get user_id from request body metadata + request = data.get("proxy_server_request", {}) + body = request.get("body", {}) + if isinstance(body, dict): + body_metadata = body.get("metadata", {}) + user_id = body_metadata.get("user_id", "") + + if user_id and "_session_" in user_id: + # Parse: user_{hash}_account_{uuid}_session_{uuid} + parts = user_id.split("_session_") + if len(parts) == 2: + session_id = parts[1] + data["metadata"]["session_id"] = session_id + logger.debug(f"Extracted session_id: {session_id}") + + # Also extract user and account for trace_metadata + prefix = parts[0] + if "_account_" in prefix: + user_account = prefix.split("_account_") + if len(user_account) == 2: + user_hash = user_account[0].replace("user_", "") + account_id = user_account[1] + if "trace_metadata" not in data["metadata"]: + data["metadata"]["trace_metadata"] = {} + data["metadata"]["trace_metadata"]["claude_user_hash"] = user_hash + data["metadata"]["trace_metadata"]["claude_account_id"] = account_id + + return data + + +def capture_headers(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Capture HTTP headers as LangFuse trace_metadata with sensitive value redaction. + + Headers are added to metadata["trace_metadata"] which flows to LangFuse trace metadata. + This is the proper mechanism for structured key-value data (tags are for categorization only). + + Args: + data: Request data from LiteLLM + user_api_key_dict: User API key dictionary + **kwargs: Additional keyword arguments including: + - headers: Optional list of header names to capture (captures all if not specified) + """ + if "metadata" not in data: + data["metadata"] = {} + if "trace_metadata" not in data["metadata"]: + data["metadata"]["trace_metadata"] = {} + + trace_metadata = data["metadata"]["trace_metadata"] + + # Get optional headers filter from params + headers_filter: list[str] | None = kwargs.get("headers") + + request = data.get("proxy_server_request", {}) + headers = request.get("headers", {}) + + # Also get raw headers for auth info + secret_fields = data.get("secret_fields") + if secret_fields and hasattr(secret_fields, "raw_headers"): + raw_headers = secret_fields.raw_headers or {} + else: + raw_headers = {} + + # Merge headers (raw has auth, cleaned has rest) + all_headers = {**headers, **raw_headers} + + for name, value in all_headers.items(): + if not value: + continue + name_lower = name.lower() + # Filter headers if a filter list is provided + if headers_filter is not None: + if name_lower not in [h.lower() for h in headers_filter]: + continue + # Add to trace_metadata with header_ prefix + redacted_value = _redact_value(name, str(value)) + trace_metadata[f"header_{name_lower}"] = redacted_value + + # Add HTTP method and path + http_method = request.get("method", "") + if http_method: + trace_metadata["http_method"] = http_method + + url = request.get("url", "") + if url: + from urllib.parse import urlparse + + path = urlparse(url).path + if path: + trace_metadata["http_path"] = path + + # Store in global store for retrieval in success callback + # LiteLLM doesn't preserve custom metadata through its internal flow + call_id = data.get("litellm_call_id") + if not call_id: + import uuid + + call_id = str(uuid.uuid4()) + data["litellm_call_id"] = call_id + store_request_metadata(call_id, {"trace_metadata": trace_metadata.copy()}) + + return data + + +def forward_oauth(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Forward OAuth token to provider if configured. + + This hook checks if the request is going to a provider that has an OAuth token + configured in oat_sources, and if so, forwards that token in the authorization header. + """ + request = data.get("proxy_server_request") + if request is None: + # No proxy server request, skip OAuth forwarding + return data + + headers = request.get("headers", {}) + user_agent = headers.get("user-agent", "") + + # Determine which provider this request is going to + metadata = data.get("metadata", {}) + model_config = metadata.get("ccproxy_model_config", {}) + routed_model = metadata.get("ccproxy_litellm_model", "") + + # Handle case where model_config is None (passthrough mode) + if model_config is None: + model_config = {} + + litellm_params = model_config.get("litellm_params", {}) + api_base = litellm_params.get("api_base") + custom_provider = litellm_params.get("custom_llm_provider") + + # Get the raw headers to check if auth is already present in the request + secret_fields = data.get("secret_fields") or {} + raw_headers = secret_fields.get("raw_headers") or {} + auth_header = raw_headers.get("authorization", "") + + # If no routed model, skip OAuth forwarding + # We only forward OAuth when we know the target model/provider from routing + if not routed_model: + return data + + # Use LiteLLM's official provider detection + # Returns: (model, custom_llm_provider, dynamic_api_key, api_base) + try: + _, provider_name, _, _ = get_llm_provider( + model=routed_model, + custom_llm_provider=custom_provider, + api_base=api_base, + ) + except Exception as e: + # If provider detection fails, skip OAuth forwarding + logger.debug(f"Could not determine provider for model {routed_model}: {e}") + return data + + if not provider_name: + # Cannot determine provider, skip OAuth forwarding + return data + + # If no auth header found in request, try to use cached OAuth token as fallback + if not auth_header: + config = get_config() + oauth_token = config.get_oauth_token(provider_name) + + if oauth_token: + logger.debug(f"No authorization header found, using cached OAuth token for provider '{provider_name}'") + # Format as Bearer token if not already formatted + if not oauth_token.startswith("Bearer "): + auth_header = f"Bearer {oauth_token}" + else: + auth_header = oauth_token + else: + # No auth header in request and no cached OAuth token + return data + + # Only forward if we have an auth header + if auth_header: + # Ensure the provider_specific_header structure exists + if "provider_specific_header" not in data: + data["provider_specific_header"] = {} + if "extra_headers" not in data["provider_specific_header"]: + data["provider_specific_header"]["extra_headers"] = {} + + # Set the authorization header + data["provider_specific_header"]["extra_headers"]["authorization"] = auth_header + + # Set custom User-Agent if configured for this provider + config = get_config() + custom_user_agent = config.get_oauth_user_agent(provider_name) + if custom_user_agent: + data["provider_specific_header"]["extra_headers"]["user-agent"] = custom_user_agent + logger.debug(f"Setting custom User-Agent for provider '{provider_name}': {custom_user_agent}") + + # Log OAuth forwarding (without exposing the token) + # Check if this is from Claude CLI for backwards-compatible logging + is_claude_cli = user_agent and "claude-cli" in user_agent + log_msg = ( + "Forwarding request with Claude Code OAuth authentication" + if is_claude_cli + else f"Forwarding request with OAuth authentication for provider '{provider_name}'" + ) + + logger.info( + log_msg, + extra={ + "event": "oauth_forwarding", + "provider": provider_name, + "user_agent": custom_user_agent or user_agent, + "model": routed_model, + "auth_present": bool(auth_header), + "custom_user_agent": bool(custom_user_agent), + }, + ) + + return data + + +def forward_apikey(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Forward x-api-key header from incoming request to proxied request. + + This hook simply forwards the x-api-key header if it exists in the incoming request. + + Args: + data: Request data from LiteLLM + user_api_key_dict: User API key dictionary + **kwargs: Additional keyword arguments + + Returns: + Modified request data with x-api-key header forwarded (if present) + """ + request = data.get("proxy_server_request") + if request is None: + # No proxy server request, skip API key forwarding + return data + + # Get the x-api-key from incoming request headers + secret_fields = data.get("secret_fields") or {} + raw_headers = secret_fields.get("raw_headers") or {} + api_key = raw_headers.get("x-api-key", "") + + # Only forward if we have an API key + if api_key: + # Ensure the provider_specific_header structure exists + if "provider_specific_header" not in data: + data["provider_specific_header"] = {} + if "extra_headers" not in data["provider_specific_header"]: + data["provider_specific_header"]["extra_headers"] = {} + + # Set the x-api-key header + data["provider_specific_header"]["extra_headers"]["x-api-key"] = api_key + + # Log API key forwarding (without exposing the key) + logger.info( + "Forwarding request with x-api-key header", + extra={ + "event": "apikey_forwarding", + "api_key_present": True, + }, + ) + + return data + + +def add_beta_headers(data: dict[str, Any], user_api_key_dict: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + """Add anthropic-beta headers for Claude Code impersonation. + + When routing to Anthropic, adds the required beta headers that allow + Claude Max OAuth tokens to be accepted by Anthropic's API. + """ + metadata = data.get("metadata", {}) + routed_model = metadata.get("ccproxy_litellm_model", "") + model_config = metadata.get("ccproxy_model_config") or {} + + if not routed_model: + return data + + # Detect provider using same logic as forward_oauth + litellm_params = model_config.get("litellm_params", {}) + api_base = litellm_params.get("api_base") + custom_provider = litellm_params.get("custom_llm_provider") + + try: + _, provider_name, _, _ = get_llm_provider( + model=routed_model, + custom_llm_provider=custom_provider, + api_base=api_base, + ) + except Exception: + return data + + if provider_name != "anthropic": + return data + + # Ensure header structure exists + if "provider_specific_header" not in data: + data["provider_specific_header"] = {} + if "extra_headers" not in data["provider_specific_header"]: + data["provider_specific_header"]["extra_headers"] = {} + + # Merge beta headers (preserve existing, add ours, dedupe) + existing = data["provider_specific_header"]["extra_headers"].get("anthropic-beta", "") + existing_list = [b.strip() for b in existing.split(",") if b.strip()] + merged = list(dict.fromkeys(ANTHROPIC_BETA_HEADERS + existing_list)) + data["provider_specific_header"]["extra_headers"]["anthropic-beta"] = ",".join(merged) + + logger.info( + "Added anthropic-beta headers for Claude Code impersonation", + extra={"event": "beta_headers_added", "model": routed_model}, + ) + + return data diff --git a/src/ccproxy/router.py b/src/ccproxy/router.py index 2eaa6516..e0fbc8c9 100644 --- a/src/ccproxy/router.py +++ b/src/ccproxy/router.py @@ -1,9 +1,10 @@ """Model routing component for mapping classification labels to models.""" +import logging import threading from typing import Any -from ccproxy.config import ConfigProvider +logger = logging.getLogger(__name__) class ModelRouter: @@ -36,21 +37,39 @@ class ModelRouter: Configuration updates are performed atomically. """ - def __init__(self, config_provider: ConfigProvider | None = None) -> None: - """Initialize the model router. - - Args: - config_provider: Optional config provider. If None, uses global config. - """ - self._config_provider = config_provider or ConfigProvider() + def __init__(self) -> None: + """Initialize the model router.""" self._lock = threading.RLock() self._model_map: dict[str, dict[str, Any]] = {} self._model_list: list[dict[str, Any]] = [] self._model_group_alias: dict[str, list[str]] = {} self._available_models: set[str] = set() + self._models_loaded = False + + # Models will be loaded on first actual request when proxy is guaranteed to be ready + + def _ensure_models_loaded(self) -> None: + """Ensure models are loaded on first request when proxy is ready.""" + if self._models_loaded: + return + + with self._lock: + # Double-check pattern + if self._models_loaded: + return + + self._load_model_mapping() - # Load initial configuration - self._load_model_mapping() + # Mark as loaded regardless of success - models should be available by now + # If no models are found, it's likely a configuration issue + self._models_loaded = True + + if self._available_models: + logger.info( + f"Successfully loaded {len(self._available_models)} models: {sorted(self._available_models)}" + ) + else: + logger.error("No models were loaded from LiteLLM proxy - check configuration") def _load_model_mapping(self) -> None: """Load and parse model mapping from configuration. @@ -58,8 +77,6 @@ def _load_model_mapping(self) -> None: This method extracts model routing information from the LiteLLM proxy configuration and builds internal lookup structures. """ - config = self._config_provider.get() - with self._lock: # Clear existing mappings self._model_map.clear() @@ -67,18 +84,15 @@ def _load_model_mapping(self) -> None: self._model_group_alias.clear() self._available_models.clear() - # Try to load from proxy_server runtime first - try: - from litellm.proxy import proxy_server + # Get model list from proxy server + from litellm.proxy import proxy_server - if proxy_server and hasattr(proxy_server, "llm_router") and proxy_server.llm_router: - model_list = proxy_server.llm_router.model_list or [] - else: - # Fallback to loading from YAML - model_list = self._load_models_from_yaml(config) - except ImportError: - # proxy_server not available, load from YAML - model_list = self._load_models_from_yaml(config) + if proxy_server and hasattr(proxy_server, "llm_router") and proxy_server.llm_router: + model_list = proxy_server.llm_router.model_list or [] + logger.debug(f"Loaded {len(model_list)} models from LiteLLM proxy server") + else: + model_list = [] + logger.warning("LiteLLM proxy server or llm_router not available - no models loaded") # Build model mapping and list for model_entry in model_list: @@ -105,35 +119,18 @@ def _load_model_mapping(self) -> None: self._model_group_alias[underlying_model] = [] self._model_group_alias[underlying_model].append(model_name) - def _load_models_from_yaml(self, config: Any) -> list[dict[str, Any]]: - """Load model list from LiteLLM YAML config file. + def get_model_for_label(self, model_name: str) -> dict[str, Any] | None: + """Get model configuration for a given classification model_name. Args: - config: The CCProxyConfig instance - - Returns: - List of model configurations - """ - import yaml - - if config.litellm_config_path.exists(): - with config.litellm_config_path.open() as f: - litellm_data = yaml.safe_load(f) or {} - return list(litellm_data.get("model_list", [])) - return [] - - def get_model_for_label(self, label: str) -> dict[str, Any] | None: - """Get model configuration for a given classification label. - - Args: - label: The routing label to map to a model + model_name: The model_name to map to a model Returns: Model configuration dict with keys: - model_name: The model alias name - litellm_params: Parameters for litellm.completion() - model_info: Optional metadata (if present) - Returns None if no model is mapped to the label. + Returns None if no model is mapped to the model_name. Example: >>> router = ModelRouter() @@ -141,16 +138,19 @@ def get_model_for_label(self, label: str) -> dict[str, Any] | None: >>> print(model["model_name"]) # "background" >>> print(model["litellm_params"]["model"]) # "claude-3-5-haiku-20241022" """ - label_str = label + # Ensure models are loaded before accessing + self._ensure_models_loaded() + + model_name_str = model_name with self._lock: # Try to get the direct mapping first - model = self._model_map.get(label_str) + model = self._model_map.get(model_name_str) if model is not None: return model - # Fallback logic: try to find an alternative model - return self._get_fallback_model(label_str) + # Fallback to 'default' model if model_name not found + return self._model_map.get("default") def get_model_list(self) -> list[dict[str, Any]]: """Get the complete list of available models. @@ -164,6 +164,9 @@ def get_model_list(self) -> list[dict[str, Any]]: This method is designed for use by LiteLLM hooks to access the full model configuration. """ + # Ensure models are loaded before accessing + self._ensure_models_loaded() + with self._lock: return self._model_list.copy() @@ -184,10 +187,13 @@ def model_group_alias(self) -> dict[str, list[str]]: Dict mapping underlying model names to lists of aliases. For example: { - "claude-3-5-sonnet-20241022": ["default", "think", "token_count"], + "claude-sonnet-4-5-20250929": ["default", "think", "token_count"], "claude-3-5-haiku-20241022": ["background"] } """ + # Ensure models are loaded before accessing + self._ensure_models_loaded() + with self._lock: return self._model_group_alias.copy() @@ -197,6 +203,9 @@ def get_available_models(self) -> list[str]: Returns: List of model alias names (e.g., ["default", "background", "think"]) """ + # Ensure models are loaded before accessing + self._ensure_models_loaded() + with self._lock: return sorted(self._available_models) @@ -209,52 +218,37 @@ def is_model_available(self, model_name: str) -> bool: Returns: True if the model is available, False otherwise """ + # Ensure models are loaded before accessing + self._ensure_models_loaded() + with self._lock: return model_name in self._available_models - def _get_fallback_model(self, label: str) -> dict[str, Any] | None: - """Get a fallback model when the preferred model is unavailable. + def reload_models(self) -> None: + """Force reload model configuration from LiteLLM proxy. - This method implements a fallback strategy: - 1. If label is unknown, try 'default' model - 2. If 'default' is unavailable, use first available model - 3. Return None only if no models are available - - Args: - label: The routing label that was not found - - Returns: - A fallback model configuration or None + This can be used to refresh model configuration if it changes + during runtime. """ - # Try 'default' model first as the primary fallback - if label != "default" and "default" in self._model_map: - return self._model_map["default"] - - # If no default found, use the first available model - if self._model_list: - return self._model_list[0].copy() - - # No models available at all - return None + with self._lock: + self._models_loaded = False + self._ensure_models_loaded() -# Global singleton instance for LiteLLM hook access +# Global router instance _router_instance: ModelRouter | None = None -_router_lock = threading.Lock() def get_router() -> ModelRouter: """Get the global ModelRouter instance. Returns: - The singleton ModelRouter instance + The global ModelRouter instance """ global _router_instance if _router_instance is None: - with _router_lock: - if _router_instance is None: - _router_instance = ModelRouter() + _router_instance = ModelRouter() return _router_instance @@ -266,5 +260,4 @@ def clear_router() -> None: between test runs. """ global _router_instance - with _router_lock: - _router_instance = None + _router_instance = None diff --git a/src/ccproxy/rules.py b/src/ccproxy/rules.py index 95c59cf1..4d08b1ac 100644 --- a/src/ccproxy/rules.py +++ b/src/ccproxy/rules.py @@ -1,8 +1,11 @@ """Classification rules for request routing.""" +import logging from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from ccproxy.config import CCProxyConfig @@ -32,55 +35,26 @@ def evaluate(self, request: dict[str, Any], config: "CCProxyConfig") -> bool: """ -class TokenCountRule(ClassificationRule): - """Rule for classifying requests based on token count.""" +class DefaultRule(ClassificationRule): + def __init__(self, passthrough: bool): + self.passthrough = passthrough - def __init__(self, threshold: int) -> None: - """Initialize the rule with a threshold. - Args: - threshold: The token count threshold - """ - self.threshold = threshold +class ThinkingRule(ClassificationRule): + """Rule for classifying requests with thinking field.""" def evaluate(self, request: dict[str, Any], config: "CCProxyConfig") -> bool: - """Evaluate if request has high token count based on threshold. + """Evaluate if request has thinking field. Args: request: The request to evaluate config: The current configuration Returns: - True if token count exceeds threshold, False otherwise + True if request has thinking field, False otherwise """ - # Check various token count fields - token_count = 0 - - # Check messages token count - messages = request.get("messages", []) - if isinstance(messages, list): - # Simple estimation: ~4 chars per token - total_chars = 0 - for msg in messages: - if isinstance(msg, dict): - # Handle message dict format - content = msg.get("content", "") - total_chars += len(str(content)) - else: - # Handle simple string messages - total_chars += len(str(msg)) - token_count = total_chars // 4 - - # Check explicit token count fields - token_count = max( - token_count, - request.get("token_count", 0) or 0, - request.get("num_tokens", 0) or 0, - request.get("input_tokens", 0) or 0, - ) - - # Check against threshold - return token_count > self.threshold + # Check top-level thinking field + return "thinking" in request class MatchModelRule(ClassificationRule): @@ -108,21 +82,123 @@ def evaluate(self, request: dict[str, Any], config: "CCProxyConfig") -> bool: return isinstance(model, str) and self.model_name in model -class ThinkingRule(ClassificationRule): - """Rule for classifying requests with thinking field.""" +class TokenCountRule(ClassificationRule): + """Rule for classifying requests based on token count.""" + + def __init__(self, threshold: int) -> None: + """Initialize the rule with a threshold. + + Args: + threshold: The token count threshold + """ + self.threshold = threshold + self._tokenizer_cache: dict[str, Any] = {} + + def _get_tokenizer(self, model: str) -> Any: + """Get appropriate tokenizer for the model. + + Args: + model: Model name to get tokenizer for + + Returns: + Tokenizer instance or None if not available + """ + # Cache tokenizers to avoid repeated initialization + if model in self._tokenizer_cache: + return self._tokenizer_cache[model] + + try: + import tiktoken + + # Map model names to appropriate tiktoken encodings + if "gpt-4" in model or "gpt-3.5" in model: + encoding = tiktoken.encoding_for_model(model) + elif "claude" in model: + # Claude uses similar tokenization to cl100k_base + encoding = tiktoken.get_encoding("cl100k_base") + elif "gemini" in model: + # Gemini uses similar tokenization to cl100k_base + encoding = tiktoken.get_encoding("cl100k_base") + else: + # Default to cl100k_base for unknown models + encoding = tiktoken.get_encoding("cl100k_base") + + self._tokenizer_cache[model] = encoding + return encoding + except Exception: + # If tiktoken fails, return None to fall back to estimation + return None + + def _count_tokens(self, text: str, model: str) -> int: + """Count tokens in text using model-specific tokenizer. + + Args: + text: Text to count tokens for + model: Model name for tokenizer selection + + Returns: + Token count + """ + tokenizer = self._get_tokenizer(model) + if tokenizer: + try: + return len(tokenizer.encode(text)) + except Exception as e: + logger.warning(f"Token encoding failed for model {model}: {e}") + # Fall through to estimation + + # Fallback to estimation if tokenizer not available + # Updated estimation: ~3 chars per token for better accuracy + return len(text) // 3 def evaluate(self, request: dict[str, Any], config: "CCProxyConfig") -> bool: - """Evaluate if request has thinking field. + """Evaluate if request has high token count based on threshold. Args: request: The request to evaluate config: The current configuration Returns: - True if request has thinking field, False otherwise + True if token count exceeds threshold, False otherwise """ - # Check top-level thinking field - return "thinking" in request + # Check various token count fields + token_count = 0 + + # Get model for tokenizer selection + model = request.get("model", "") + + # Check messages token count + messages = request.get("messages", []) + if isinstance(messages, list): + total_text = "" + for msg in messages: + if isinstance(msg, dict): + # Handle message dict format + content = msg.get("content", "") + if isinstance(content, str): + total_text += content + " " + elif isinstance(content, list): + # Handle multi-modal content + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + total_text += item.get("text", "") + " " + else: + # Handle simple string messages + total_text += str(msg) + " " + + if total_text: + token_count = self._count_tokens(total_text.strip(), model) + + # Check explicit token count fields + token_count = max( + token_count, + request.get("token_count", 0) or 0, + request.get("num_tokens", 0) or 0, + request.get("input_tokens", 0) or 0, + ) + + # Check against threshold + return token_count > self.threshold class MatchToolRule(ClassificationRule): diff --git a/src/ccproxy/templates/README.md b/src/ccproxy/templates/README.md deleted file mode 100644 index c7e2b532..00000000 --- a/src/ccproxy/templates/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# CCProxy Templates - -This directory contains template files that are copied to `~/.ccproxy` during installation. - -## Files - -- `ccproxy.yaml` - Main configuration file with routing rules and LiteLLM settings -- `config.yaml` - LiteLLM proxy configuration with model definitions -- `ccproxy.py` - Custom logger implementation for LiteLLM hooks diff --git a/src/ccproxy/templates/ccproxy.py b/src/ccproxy/templates/ccproxy.py deleted file mode 100644 index 5a0a08a0..00000000 --- a/src/ccproxy/templates/ccproxy.py +++ /dev/null @@ -1,4 +0,0 @@ -from ccproxy.handler import CCProxyHandler - -# Create the instance that LiteLLM will use -handler = CCProxyHandler() diff --git a/src/ccproxy/templates/ccproxy.yaml b/src/ccproxy/templates/ccproxy.yaml index f395a3f4..dd06d556 100644 --- a/src/ccproxy/templates/ccproxy.yaml +++ b/src/ccproxy/templates/ccproxy.yaml @@ -1,24 +1,36 @@ +ccproxy: + debug: true + handler: "ccproxy.handler:CCProxyHandler" + + # OAuth token sources - shell commands to retrieve tokens for each provider + oat_sources: + # Simple string form + anthropic: "jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json" + + # Extended form with custom User-Agent + # gemini: + # command: "jq -r '.access_token' ~/.gemini/oauth_creds.json" + # user_agent: "MyApp/1.0.0" + + hooks: + - ccproxy.hooks.rule_evaluator # evaluates rules against request + - ccproxy.hooks.model_router # routes to appropriate model (coupled with rule_evaluator) + - ccproxy.hooks.capture_headers # captures all HTTP headers with sensitive value redaction + # Hook with params example - capture only specific headers: + # - hook: ccproxy.hooks.capture_headers + # params: + # headers: [user-agent, x-request-id, content-type] + - ccproxy.hooks.forward_oauth # forwards oauth token to provider (place after routing logic) + # - ccproxy.hooks.forward_apikey # forwards x-api-key header from request (enable if needed) + + # uses the original model that Claude Code requested when no routing rule matches. + # NOTE: model deployments in config.yaml are still required + default_model_passthrough: true + rules: [] + litellm: host: 127.0.0.1 port: 4000 - num_workers: 1 - debug: false - detailed_debug: false - -ccproxy: + num_workers: 4 debug: true - rules: - - label: token_count - rule: ccproxy.rules.TokenCountRule - params: - - threshold: 60000 - - label: background - rule: ccproxy.rules.MatchModelRule - params: - - model_name: claude-3-5-haiku-20241022 - - label: think - rule: ccproxy.rules.ThinkingRule - - label: web_search - rule: ccproxy.rules.MatchToolRule - params: - - tool_name: WebSearch + detailed_debug: true diff --git a/src/ccproxy/templates/config.yaml b/src/ccproxy/templates/config.yaml index bc355d26..c0a984d2 100644 --- a/src/ccproxy/templates/config.yaml +++ b/src/ccproxy/templates/config.yaml @@ -1,29 +1,42 @@ # See https://docs.litellm.ai/docs/proxy/configs model_list: - # Default model for regular use + # Default model - model_name: default litellm_params: - model: anthropic/claude-sonnet-4-20250514 + model: claude-sonnet-4-5-20250929 - # Background model, see: https://docs.anthropic.com/en/docs/claude-code/costs#background-token-usage - - model_name: model_name + # Anthropic provided claude models, no `api_key` needed + - model_name: claude-sonnet-4-5-20250929 litellm_params: - model: anthropic/claude-3-5-haiku-20241022 + model: anthropic/claude-sonnet-4-5-20250929 + api_base: https://api.anthropic.com - # Thinking model for complex reasoning (request.body.think = true) - - model_name: think + - model_name: claude-opus-4-6 litellm_params: - model: anthropic/claude-opus-4-20250514 + model: anthropic/claude-opus-4-6 + api_base: https://api.anthropic.com - # Large context model for >60k tokens (threshold configurable in ccproxy.yaml) - - model_name: token_count + - model_name: claude-opus-4-5-20251101 litellm_params: - model: gemini/gemini-2.5-pro + model: anthropic/claude-opus-4-5-20251101 + api_base: https://api.anthropic.com - # Web search model for execution when the WebSearch tool is present - - model_name: web_search + - model_name: claude-haiku-4-5-20251001 litellm_params: - model: gemini/gemini-2.5-flash + model: anthropic/claude-haiku-4-5-20251001 + api_base: https://api.anthropic.com + + - model_name: claude-3-5-haiku-20241022 + litellm_params: + model: anthropic/claude-3-5-haiku-20241022 + api_base: https://api.anthropic.com litellm_settings: - callbacks: ccproxy.handler + callbacks: + - ccproxy.handler + - langfuse + success_callback: + - langfuse + +general_settings: + forward_client_headers_to_llm_api: true diff --git a/src/ccproxy/types.py b/src/ccproxy/types.py deleted file mode 100644 index 81873087..00000000 --- a/src/ccproxy/types.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Type definitions for ccproxy.""" - -from typing import Literal, TypeAlias - -# Routing labels -RoutingLabel: TypeAlias = Literal["default", "background", "think", "large_context", "web_search"] - -# Model provider types -ModelProvider: TypeAlias = Literal[ - "openai", - "anthropic", - "google", - "azure", - "openrouter", - "perplexity", - "ollama", - "bedrock", - "vertex", -] - -# Log formats -LogFormat: TypeAlias = Literal["json", "text"] - -# Log levels -LogLevel: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] diff --git a/src/ccproxy/utils.py b/src/ccproxy/utils.py index 9fa35e3f..3f6542b2 100644 --- a/src/ccproxy/utils.py +++ b/src/ccproxy/utils.py @@ -1,7 +1,12 @@ """Utility functions for ccproxy.""" -import sys +import inspect from pathlib import Path +from typing import Any + +from rich import box +from rich.console import Console +from rich.table import Table def get_templates_dir() -> Path: @@ -16,30 +21,19 @@ def get_templates_dir() -> Path: Raises: RuntimeError: If templates directory cannot be found """ - # First, try relative to this module (development mode) module_dir = Path(__file__).parent + + # Development mode: templates at project root dev_templates = module_dir.parent.parent / "templates" - if dev_templates.exists(): + if dev_templates.exists() and (dev_templates / "ccproxy.yaml").exists(): return dev_templates - # When installed as a package, templates will be inside the ccproxy package + # Installed mode: templates inside the package package_templates = module_dir / "templates" - if package_templates.exists(): + if package_templates.exists() and (package_templates / "ccproxy.yaml").exists(): return package_templates - # Then try in site-packages (installed mode) - # When installed, templates will be at the package root level - for path in sys.path: - site_templates = Path(path) / "templates" - if site_templates.exists() and (site_templates / "ccproxy.yaml").exists(): - return site_templates - - # Try one more location - next to the package directory - parent_templates = module_dir.parent / "templates" - if parent_templates.exists(): - return parent_templates - - raise RuntimeError("Could not find templates directory. " "Please ensure ccproxy is properly installed.") + raise RuntimeError("Could not find templates directory. Please ensure ccproxy is properly installed.") def get_template_file(filename: str) -> Path: @@ -61,3 +55,232 @@ def get_template_file(filename: str) -> Path: raise FileNotFoundError(f"Template file not found: {filename}") return template_path + + +def calculate_duration_ms(start_time: Any, end_time: Any) -> float: + """Calculate duration in milliseconds between two timestamps. + + Handles both float timestamps and timedelta objects. + + Args: + start_time: Start timestamp (float or timedelta) + end_time: End timestamp (float or timedelta) + + Returns: + Duration in milliseconds, rounded to 2 decimal places + """ + try: + if isinstance(end_time, float) and isinstance(start_time, float): + duration_ms = (end_time - start_time) * 1000 + else: + # Handle timedelta objects or mixed types + duration_seconds = (end_time - start_time).total_seconds() # type: ignore[operator,unused-ignore,unreachable] + duration_ms = duration_seconds * 1000 + except (TypeError, AttributeError): + duration_ms = 0.0 + + return round(duration_ms, 2) + + +# Debug printing utilities +console = Console() + + +def debug_table( + obj: Any, + title: str | None = None, + max_width: int | None = None, + show_methods: bool = False, + compact: bool = True, +) -> None: + """Print any object as a compact debug table. + + Args: + obj: Object to debug print + title: Optional title for the table + max_width: Maximum width for values + show_methods: Include methods in output + compact: Use compact table style + """ + if isinstance(obj, dict): + _print_dict(obj, title or "Dict", max_width, compact) + elif isinstance(obj, list | tuple): + _print_list(obj, title or type(obj).__name__, max_width, compact) + elif hasattr(obj, "__dict__"): + _print_object(obj, title or obj.__class__.__name__, max_width, show_methods, compact) + else: + from rich.pretty import Pretty + + console.print(Pretty(obj)) + + +def _print_dict(data: dict[Any, Any], title: str, max_width: int | None, compact: bool) -> None: + """Print dictionary as table.""" + table = Table( + title=f"[cyan]{title}[/cyan]", + box=box.SIMPLE if compact else box.ROUNDED, + show_edge=not compact, + padding=(0, 1) if compact else (0, 1), + collapse_padding=compact, + ) + + table.add_column("Key", style="yellow", no_wrap=True) + table.add_column("Value", style="white", max_width=max_width) + table.add_column("Type", style="dim cyan") + + for key, value in data.items(): + table.add_row(str(key), _format_value(value, max_width), type(value).__name__) + + console.print(table) + + +def _print_list(data: list[Any] | tuple[Any, ...], title: str, max_width: int | None, compact: bool) -> None: + """Print list/tuple as table.""" + table = Table( + title=f"[cyan]{title}[/cyan] ({len(data)} items)", + box=box.SIMPLE if compact else box.ROUNDED, + show_edge=not compact, + padding=(0, 1) if compact else (0, 1), + ) + + table.add_column("#", style="dim", justify="right", width=4) + table.add_column("Value", max_width=max_width) + table.add_column("Type", style="dim cyan") + + for i, value in enumerate(data): + table.add_row(str(i), _format_value(value, max_width), type(value).__name__) + + console.print(table) + + +def _print_object(obj: Any, title: str, max_width: int | None, show_methods: bool, compact: bool) -> None: + """Print object attributes as table.""" + table = Table( + title=f"[cyan]{title}[/cyan]", + box=box.SIMPLE if compact else box.ROUNDED, + show_edge=not compact, + padding=(0, 1) if compact else (0, 1), + ) + + table.add_column("Attribute", style="yellow", no_wrap=True) + table.add_column("Value", max_width=max_width) + table.add_column("Type", style="dim cyan") + + # Get all attributes + attrs = {} + for name in dir(obj): + if name.startswith("_"): + continue + try: + value = getattr(obj, name) + if not show_methods and callable(value): + continue + attrs[name] = value + except Exception: + attrs[name] = "<unable to access>" + + # Sort and display + for name in sorted(attrs.keys()): + value = attrs[name] + table.add_row(name, _format_value(value, max_width), type(value).__name__) + + console.print(table) + + +def _format_value(value: Any, max_width: int | None = None) -> str: + """Format value for display.""" + if value is None: + return "[dim]None[/dim]" + elif isinstance(value, bool): + return "[green]True[/green]" if value else "[red]False[/red]" + elif isinstance(value, int | float): + return f"[cyan]{value}[/cyan]" + elif isinstance(value, str): + # Escape markup and truncate if needed + s = str(value).replace("[", r"\[") + if max_width and len(s) > max_width: + s = s[: max_width - 3] + "..." + return f'"{s}"' + elif isinstance(value, list | tuple): + return f"[dim]{type(value).__name__}[{len(value)}][/dim]" + elif isinstance(value, dict): + return f"[dim]dict[{len(value)}][/dim]" + elif callable(value): + return f"[magenta]{value.__name__}()[/magenta]" + else: + s = str(value) + if max_width and len(s) > max_width: + s = s[: max_width - 3] + "..." + return s.replace("[", r"\[") + + +def dt(obj: Any, **kwargs: Any) -> None: + """Quick debug table (alias for debug_table).""" + debug_table(obj, **kwargs) + + +def dv(*args: Any, **kwargs: Any) -> None: + """Debug multiple variables with their names.""" + frame = inspect.currentframe() + if frame is None or frame.f_back is None: + var_names = [f"arg{i}" for i in range(len(args))] + else: + code_context = inspect.getframeinfo(frame.f_back).code_context + if code_context: + code = code_context[0].strip() + else: + code = "" + + # Extract variable names from the call + import re + + match = re.search(r"dv\((.*?)\)", code) + var_names = [n.strip() for n in match.group(1).split(",")] if match else [f"arg{i}" for i in range(len(args))] + + # Create table for all variables + table = Table(title="[cyan]Debug Variables[/cyan]", box=box.SIMPLE, show_edge=False, padding=(0, 1)) + + table.add_column("Name", style="yellow", no_wrap=True) + table.add_column("Value", max_width=50) + table.add_column("Type", style="dim cyan") + + for name, value in zip(var_names, args, strict=False): + table.add_row(name, _format_value(value, 50), type(value).__name__) + + if kwargs: + for name, value in kwargs.items(): + table.add_row(name, _format_value(value, 50), type(value).__name__) + + console.print(table) + + +def d(obj: Any, w: int = 60) -> None: + """Ultra-compact debug print.""" + debug_table(obj, max_width=w, compact=True) + + +def p(obj: Any) -> None: + """Print object as minimal compact table for debugging.""" + table = Table(box=box.SIMPLE, show_edge=False) + + if isinstance(obj, dict): + table.add_column("Key", style="yellow") + table.add_column("Value") + for k, v in obj.items(): + table.add_row(str(k), repr(v)) + elif isinstance(obj, list | tuple): + table.add_column("#", style="dim") + table.add_column("Value") + for i, v in enumerate(obj): + table.add_row(str(i), repr(v)) + elif hasattr(obj, "__dict__"): + table.add_column("Attr", style="yellow") + table.add_column("Value") + for k, v in obj.__dict__.items(): + if not k.startswith("_"): + table.add_row(k, repr(v)) + else: + console.print(obj) + return + + console.print(table) diff --git a/stubs/httpx/__init__.pyi b/stubs/httpx/__init__.pyi new file mode 100644 index 00000000..ffc89a18 --- /dev/null +++ b/stubs/httpx/__init__.pyi @@ -0,0 +1,22 @@ +"""Type stubs for httpx library.""" + +from types import TracebackType +from typing import Any + +class Response: + status_code: int + def json(self) -> dict[str, Any]: ... + +class ConnectError(Exception): ... +class TimeoutError(Exception): ... + +class Client: + def __init__(self, timeout: float | None = None) -> None: ... + def __enter__(self) -> Client: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: ... + def get(self, url: str, timeout: float | None = None) -> Response: ... diff --git a/stubs/litellm/integrations/__init__.pyi b/stubs/litellm/integrations/__init__.pyi new file mode 100644 index 00000000..583ef207 --- /dev/null +++ b/stubs/litellm/integrations/__init__.pyi @@ -0,0 +1 @@ +"""Type stubs for litellm.integrations.""" diff --git a/stubs/litellm/integrations/custom_logger.pyi b/stubs/litellm/integrations/custom_logger.pyi new file mode 100644 index 00000000..51015fc6 --- /dev/null +++ b/stubs/litellm/integrations/custom_logger.pyi @@ -0,0 +1,35 @@ +"""Type stubs for litellm.integrations.custom_logger.""" + +from typing import Any + +class CustomLogger: + """Base class for custom loggers in LiteLLM.""" + + def __init__(self) -> None: ... + async def async_pre_call_hook( + self, + data: dict[str, Any], + user_api_key_dict: dict[str, Any], + **kwargs: Any, + ) -> dict[str, Any]: ... + async def async_log_success_event( + self, + kwargs: dict[str, Any], + response_obj: Any, + start_time: float, + end_time: float, + ) -> None: ... + async def async_log_failure_event( + self, + kwargs: dict[str, Any], + response_obj: Any, + start_time: float, + end_time: float, + ) -> None: ... + async def async_log_stream_event( + self, + kwargs: dict[str, Any], + response_obj: Any, + start_time: float, + end_time: float, + ) -> None: ... diff --git a/stubs/rich/__init__.pyi b/stubs/rich/__init__.pyi new file mode 100644 index 00000000..17114f8d --- /dev/null +++ b/stubs/rich/__init__.pyi @@ -0,0 +1,5 @@ +"""Type stubs for rich library.""" + +from typing import Any, TextIO + +def print(*args: Any, file: TextIO | None = None, **kwargs: Any) -> None: ... diff --git a/stubs/rich/console.pyi b/stubs/rich/console.pyi new file mode 100644 index 00000000..2b0ea328 --- /dev/null +++ b/stubs/rich/console.pyi @@ -0,0 +1,9 @@ +"""Type stubs for rich.console.""" + +from typing import Any + +class Console: + """Rich Console type stub.""" + + def __init__(self, **kwargs: Any) -> None: ... + def print(self, *args: Any, **kwargs: Any) -> None: ... diff --git a/stubs/rich/panel.pyi b/stubs/rich/panel.pyi new file mode 100644 index 00000000..99ed39cf --- /dev/null +++ b/stubs/rich/panel.pyi @@ -0,0 +1,15 @@ +"""Type stubs for rich.panel.""" + +from typing import Any + +class Panel: + """Rich Panel type stub.""" + + def __init__( + self, + renderable: Any, + *, + border_style: str | None = None, + padding: tuple[int, int] | int | None = None, + **kwargs: Any, + ) -> None: ... diff --git a/stubs/rich/text.pyi b/stubs/rich/text.pyi new file mode 100644 index 00000000..aa6a6d9a --- /dev/null +++ b/stubs/rich/text.pyi @@ -0,0 +1,9 @@ +"""Type stubs for rich.text.""" + +from typing import Any + +class Text: + """Rich Text type stub.""" + + def __init__(self, text: str = "", **kwargs: Any) -> None: ... + def append(self, text: str, *, style: str | None = None, **kwargs: Any) -> None: ... diff --git a/stubs/tiktoken.pyi b/stubs/tiktoken.pyi new file mode 100644 index 00000000..f14f3808 --- /dev/null +++ b/stubs/tiktoken.pyi @@ -0,0 +1,7 @@ +"""Type stubs for tiktoken.""" + +class Encoding: + def encode(self, text: str) -> list[int]: ... + +def encoding_for_model(model: str) -> Encoding: ... +def get_encoding(encoding_name: str) -> Encoding: ... diff --git a/stubs/tyro/__init__.pyi b/stubs/tyro/__init__.pyi new file mode 100644 index 00000000..470dc4df --- /dev/null +++ b/stubs/tyro/__init__.pyi @@ -0,0 +1,44 @@ +"""Type stubs for tyro.""" + +from collections.abc import Callable +from typing import Any, Generic, TypeVar, overload + +_T = TypeVar("_T") + +@overload +def cli( + f: type[_T], + *, + prog: str | None = None, + description: str | None = None, + args: list[str] | None = None, + default: _T | None = None, + console_outputs: bool = True, +) -> _T: ... +@overload +def cli( + f: Callable[..., _T], + *, + prog: str | None = None, + description: str | None = None, + args: list[str] | None = None, + console_outputs: bool = True, +) -> _T: ... + +class Conf: + @staticmethod + def arg( + *, + name: str | None = None, + help: str | None = None, + metavar: str | None = None, + constructor: Callable[..., Any] | None = None, + ) -> Any: ... + + class Positional(Generic[_T]): + pass + + class Fixed(Generic[_T]): + pass + +conf = Conf diff --git a/stubs/tyro/extras.pyi b/stubs/tyro/extras.pyi new file mode 100644 index 00000000..cc011292 --- /dev/null +++ b/stubs/tyro/extras.pyi @@ -0,0 +1,20 @@ +"""Type stubs for tyro.extras.""" + +from collections.abc import Callable +from typing import Any + +class SubcommandApp: + def __init__(self) -> None: ... + def command( + self, + func: Callable[..., Any] | None = None, + *, + name: str | None = None, + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + def cli( + self, + *, + prog: str | None = None, + description: str | None = None, + args: list[str] | None = None, + ) -> None: ... diff --git a/templates/README.md b/templates/README.md deleted file mode 100644 index c7e2b532..00000000 --- a/templates/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# CCProxy Templates - -This directory contains template files that are copied to `~/.ccproxy` during installation. - -## Files - -- `ccproxy.yaml` - Main configuration file with routing rules and LiteLLM settings -- `config.yaml` - LiteLLM proxy configuration with model definitions -- `ccproxy.py` - Custom logger implementation for LiteLLM hooks diff --git a/templates/ccproxy.py b/templates/ccproxy.py deleted file mode 100644 index 5a0a08a0..00000000 --- a/templates/ccproxy.py +++ /dev/null @@ -1,4 +0,0 @@ -from ccproxy.handler import CCProxyHandler - -# Create the instance that LiteLLM will use -handler = CCProxyHandler() diff --git a/templates/ccproxy.yaml b/templates/ccproxy.yaml deleted file mode 100644 index f395a3f4..00000000 --- a/templates/ccproxy.yaml +++ /dev/null @@ -1,24 +0,0 @@ -litellm: - host: 127.0.0.1 - port: 4000 - num_workers: 1 - debug: false - detailed_debug: false - -ccproxy: - debug: true - rules: - - label: token_count - rule: ccproxy.rules.TokenCountRule - params: - - threshold: 60000 - - label: background - rule: ccproxy.rules.MatchModelRule - params: - - model_name: claude-3-5-haiku-20241022 - - label: think - rule: ccproxy.rules.ThinkingRule - - label: web_search - rule: ccproxy.rules.MatchToolRule - params: - - tool_name: WebSearch diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..058e98ad --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +"""Shared test fixtures and helpers.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ccproxy.config import clear_config_instance +from ccproxy.router import clear_router + + +@pytest.fixture(autouse=True) +def cleanup(): + """Ensure clean state between tests.""" + yield + # Clean up singleton instances + clear_config_instance() + clear_router() + + +@pytest.fixture +def mock_proxy_server(): + """Create a mock proxy_server with configurable model list.""" + + def _create_mock(model_list=None): + if model_list is None: + model_list = [] + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = model_list + + # Create a mock module that contains proxy_server + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + return mock_module + + return _create_mock + + +@pytest.fixture +def patch_litellm_proxy(mock_proxy_server): + """Patch litellm.proxy module to use mock proxy_server.""" + + def _patch(model_list=None): + mock_module = mock_proxy_server(model_list) + return patch.dict("sys.modules", {"litellm.proxy": mock_module}) + + return _patch diff --git a/tests/test_beta_headers.py b/tests/test_beta_headers.py new file mode 100644 index 00000000..eaa34629 --- /dev/null +++ b/tests/test_beta_headers.py @@ -0,0 +1,166 @@ +"""Test anthropic-beta header injection for Claude Code impersonation.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ccproxy.config import clear_config_instance +from ccproxy.hooks import ANTHROPIC_BETA_HEADERS, add_beta_headers +from ccproxy.router import clear_router + + +@pytest.fixture +def cleanup(): + """Clean up config and router after each test.""" + yield + clear_config_instance() + clear_router() + + +@pytest.fixture +def anthropic_model_data(): + """Request data routed to an Anthropic model.""" + return { + "model": "anthropic/claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": { + "ccproxy_litellm_model": "anthropic/claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": { + "model": "anthropic/claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + }, + }, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62"}}, + } + + +@pytest.fixture +def openai_model_data(): + """Request data routed to an OpenAI model.""" + return { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "test"}], + "metadata": { + "ccproxy_litellm_model": "gpt-4o", + "ccproxy_model_config": { + "litellm_params": { + "model": "gpt-4o", + "api_base": "https://api.openai.com", + }, + }, + }, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62"}}, + } + + +class TestAddBetaHeaders: + """Tests for the add_beta_headers hook.""" + + def test_adds_beta_headers_for_anthropic(self, anthropic_model_data, cleanup): + """Verify all required beta headers are added for Anthropic provider.""" + result = add_beta_headers(anthropic_model_data, {}) + + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + + beta_header = result["provider_specific_header"]["extra_headers"]["anthropic-beta"] + beta_values = [b.strip() for b in beta_header.split(",")] + + for expected in ANTHROPIC_BETA_HEADERS: + assert expected in beta_values, f"Missing beta header: {expected}" + + def test_skips_non_anthropic_providers(self, openai_model_data, cleanup): + """Verify no headers added for non-Anthropic providers.""" + result = add_beta_headers(openai_model_data, {}) + + extra_headers = result.get("provider_specific_header", {}).get("extra_headers", {}) + assert "anthropic-beta" not in extra_headers + + def test_merges_with_existing_beta_headers(self, anthropic_model_data, cleanup): + """Verify existing beta headers are preserved and merged.""" + existing_beta = "some-custom-beta-2025" + anthropic_model_data["provider_specific_header"]["extra_headers"]["anthropic-beta"] = ( + existing_beta + ) + + result = add_beta_headers(anthropic_model_data, {}) + + beta_header = result["provider_specific_header"]["extra_headers"]["anthropic-beta"] + beta_values = [b.strip() for b in beta_header.split(",")] + + # All required headers present + for expected in ANTHROPIC_BETA_HEADERS: + assert expected in beta_values + + # Original custom header preserved + assert existing_beta in beta_values + + def test_deduplicates_beta_headers(self, anthropic_model_data, cleanup): + """Verify duplicate beta headers are removed.""" + # Pre-populate with a header that will be added by the hook + anthropic_model_data["provider_specific_header"]["extra_headers"]["anthropic-beta"] = ( + "oauth-2025-04-20" + ) + + result = add_beta_headers(anthropic_model_data, {}) + + beta_header = result["provider_specific_header"]["extra_headers"]["anthropic-beta"] + beta_values = [b.strip() for b in beta_header.split(",")] + + # Should only appear once + assert beta_values.count("oauth-2025-04-20") == 1 + + def test_skips_when_no_routed_model(self, cleanup): + """Verify hook skips gracefully when no routed model in metadata.""" + data = { + "model": "anthropic/claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + } + + result = add_beta_headers(data, {}) + + extra_headers = result.get("provider_specific_header", {}).get("extra_headers", {}) + assert "anthropic-beta" not in extra_headers + + def test_creates_header_structure_if_missing(self, cleanup): + """Verify hook creates provider_specific_header structure if missing.""" + data = { + "model": "anthropic/claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": { + "ccproxy_litellm_model": "anthropic/claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": {"model": "anthropic/claude-sonnet-4-5-20250929"}, + }, + }, + } + + result = add_beta_headers(data, {}) + + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert "anthropic-beta" in result["provider_specific_header"]["extra_headers"] + + def test_handles_none_model_config(self, cleanup): + """Verify hook handles None model_config gracefully (passthrough mode).""" + data = { + "model": "anthropic/claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": { + "ccproxy_litellm_model": "anthropic/claude-sonnet-4-5-20250929", + "ccproxy_model_config": None, + }, + "provider_specific_header": {"extra_headers": {}}, + } + + result = add_beta_headers(data, {}) + + # Should still add headers since we have a routed model + beta_header = result["provider_specific_header"]["extra_headers"]["anthropic-beta"] + assert "oauth-2025-04-20" in beta_header diff --git a/tests/test_classifier.py b/tests/test_classifier.py index bb882e02..cd77843c 100644 --- a/tests/test_classifier.py +++ b/tests/test_classifier.py @@ -6,7 +6,7 @@ import pytest from ccproxy.classifier import RequestClassifier -from ccproxy.config import CCProxyConfig, ConfigProvider, RuleConfig +from ccproxy.config import CCProxyConfig, RuleConfig, clear_config_instance, set_config_instance from ccproxy.rules import ClassificationRule @@ -20,31 +20,35 @@ def config(self) -> CCProxyConfig: config = CCProxyConfig(debug=True) config.rules = [ RuleConfig("token_count", "ccproxy.rules.TokenCountRule", [{"threshold": 50000}]), - RuleConfig("background", "ccproxy.rules.MatchModelRule", [{"model_name": "claude-3-5-haiku"}]), + RuleConfig("background", "ccproxy.rules.MatchModelRule", [{"model_name": "claude-haiku-4-5-20251001"}]), RuleConfig("think", "ccproxy.rules.ThinkingRule", []), RuleConfig("web_search", "ccproxy.rules.MatchToolRule", [{"tool_name": "web_search"}]), ] return config @pytest.fixture - def config_provider(self, config: CCProxyConfig) -> ConfigProvider: - """Create a config provider with test config.""" - return ConfigProvider(config) - - @pytest.fixture - def classifier(self, config_provider: ConfigProvider) -> RequestClassifier: + def classifier(self, config: CCProxyConfig) -> RequestClassifier: """Create a classifier with test config.""" - return RequestClassifier(config_provider) + # Set the test config as the global config + clear_config_instance() + set_config_instance(config) + try: + yield RequestClassifier() + finally: + clear_config_instance() def test_initialization(self, classifier: RequestClassifier) -> None: """Test classifier initialization.""" - assert classifier._config_provider is not None assert len(classifier._rules) == 4 # 4 default rules are set up def test_initialization_without_provider(self) -> None: """Test classifier initialization without config provider.""" - classifier = RequestClassifier() - assert classifier._config_provider is not None + clear_config_instance() + try: + classifier = RequestClassifier() + assert classifier is not None + finally: + clear_config_instance() def test_classify_default(self, classifier: RequestClassifier) -> None: """Test that classify returns DEFAULT when no rules match.""" @@ -70,7 +74,7 @@ def test_add_rule(self, classifier: RequestClassifier) -> None: mock_rule = mock.Mock(spec=ClassificationRule) mock_rule.evaluate.return_value = True - # Add the rule with label + # Add the rule with model_name classifier.add_rule("think", mock_rule) assert len(classifier._rules) == initial_count + 1 @@ -83,6 +87,9 @@ def test_add_rule(self, classifier: RequestClassifier) -> None: def test_multiple_rules_priority(self, classifier: RequestClassifier, config: CCProxyConfig) -> None: """Test that rules are evaluated in order.""" + # Clear existing rules first to avoid interference + classifier._clear_rules() + # Create mock rules rule1 = mock.Mock(spec=ClassificationRule) rule1.evaluate.return_value = False # Doesn't match @@ -93,13 +100,13 @@ def test_multiple_rules_priority(self, classifier: RequestClassifier, config: CC rule3 = mock.Mock(spec=ClassificationRule) rule3.evaluate.return_value = True # Also matches but shouldn't be reached - # Add rules in order with labels + # Add rules in order with model_names classifier.add_rule("token_count", rule1) classifier.add_rule("background", rule2) classifier.add_rule("think", rule3) # Classify - request = {"model": "claude-3-haiku", "messages": []} + request = {"model": "claude-haiku-4-5-20251001", "messages": []} result = classifier.classify(request) # Should return the first matching rule @@ -113,7 +120,7 @@ def test_multiple_rules_priority(self, classifier: RequestClassifier, config: CC def test_clear_rules(self, classifier: RequestClassifier) -> None: """Test clearing all rules.""" # Clear existing rules first - classifier.clear_rules() + classifier._clear_rules() assert len(classifier._rules) == 0 # Add some rules @@ -124,25 +131,74 @@ def test_clear_rules(self, classifier: RequestClassifier) -> None: assert len(classifier._rules) == 2 # Clear rules - classifier.clear_rules() + classifier._clear_rules() assert len(classifier._rules) == 0 - def test_reset_rules(self, classifier: RequestClassifier) -> None: - """Test resetting rules to default.""" + def test_setup_rules(self, classifier: RequestClassifier) -> None: + """Test setting up rules from config.""" # Clear existing rules - classifier.clear_rules() + classifier._clear_rules() # Add a custom rule mock_rule = mock.Mock(spec=ClassificationRule) classifier.add_rule("custom", mock_rule) assert len(classifier._rules) == 1 - # Reset rules - classifier.reset_rules() + # Setup rules from config + classifier._setup_rules() # Should have cleared custom rules and set up defaults assert len(classifier._rules) == 4 # Back to 4 default rules + def test_rule_loading_exception_handling(self) -> None: + """Test exception handling when rule loading fails (lines 62-65).""" + from ccproxy.config import RuleConfig + + # Create config with a bad rule that will fail to load + config = CCProxyConfig(debug=True) + config.rules = [ + RuleConfig("broken_rule", "nonexistent.module.NonExistentRule", []), + ] + + clear_config_instance() + set_config_instance(config) + + try: + # This should handle the ImportError gracefully + classifier = RequestClassifier() + # Should have 0 rules since the rule failed to load + assert len(classifier._rules) == 0 + finally: + clear_config_instance() + + def test_pydantic_conversion_exception_handling(self, classifier: RequestClassifier) -> None: + """Test exception handling for pydantic model conversion failure (lines 85-86).""" + # Create a mock object that has model_dump but raises an exception + mock_model = mock.Mock() + mock_model.model_dump.side_effect = Exception("Conversion failed") + + # This should handle the exception and use the object as-is + result = classifier.classify(mock_model) + # Since the mock object isn't a dict, it should return "default" + assert result == "default" + + def test_non_dict_request_handling(self, classifier: RequestClassifier) -> None: + """Test handling of non-dict requests that can't be converted (lines 90-91).""" + # Test with a simple string that can't be converted to dict + result = classifier.classify("invalid request") + assert result == "default" + + # Test with an int + result = classifier.classify(42) + assert result == "default" + + # Test with an object without model_dump + class PlainObject: + pass + + result = classifier.classify(PlainObject()) + assert result == "default" + class TestClassificationRuleProtocol: """Tests for ClassificationRule abstract base class.""" diff --git a/tests/test_classifier_integration.py b/tests/test_classifier_integration.py index 1852e344..bad6a7db 100644 --- a/tests/test_classifier_integration.py +++ b/tests/test_classifier_integration.py @@ -3,7 +3,7 @@ import pytest from ccproxy.classifier import RequestClassifier -from ccproxy.config import CCProxyConfig, ConfigProvider, RuleConfig +from ccproxy.config import CCProxyConfig, RuleConfig, clear_config_instance, set_config_instance class TestRequestClassifierIntegration: @@ -16,28 +16,29 @@ def config(self) -> CCProxyConfig: config = CCProxyConfig() config.rules = [ RuleConfig("large_context", "ccproxy.rules.TokenCountRule", [{"threshold": 10000}]), - RuleConfig("background", "ccproxy.rules.MatchModelRule", [{"model_name": "claude-3-5-haiku"}]), + RuleConfig("background", "ccproxy.rules.MatchModelRule", [{"model_name": "claude-haiku-4-5-20251001"}]), RuleConfig("think", "ccproxy.rules.ThinkingRule", []), RuleConfig("web_search", "ccproxy.rules.MatchToolRule", [{"tool_name": "web_search"}]), ] return config @pytest.fixture - def config_provider(self, config: CCProxyConfig) -> ConfigProvider: - """Create a config provider with test config.""" - return ConfigProvider(config) - - @pytest.fixture - def classifier(self, config_provider: ConfigProvider) -> RequestClassifier: + def classifier(self, config: CCProxyConfig) -> RequestClassifier: """Create a classifier with all rules configured.""" - return RequestClassifier(config_provider) + # Set the test config as the global config + clear_config_instance() + set_config_instance(config) + try: + yield RequestClassifier() + finally: + clear_config_instance() def test_priority_1_token_count_overrides_all(self, classifier: RequestClassifier) -> None: """Test that large context has highest priority.""" # Request that matches multiple rules request = { "token_count": 15000, # > 10000 threshold - "model": "claude-3-5-haiku", # Would match background + "model": "claude-haiku-4-5-20251001", # Would match background "thinking": True, # Would match thinking "tools": ["web_search"], # Would match web_search } @@ -48,7 +49,7 @@ def test_priority_2_background_overrides_lower(self, classifier: RequestClassifi """Test that background model has second priority.""" request = { "token_count": 5000, # Below threshold - "model": "claude-3-5-haiku-20241022", # Matches background + "model": "claude-haiku-4-5-20251001-20241022", # Matches background "thinking": True, # Would match thinking "tools": ["web_search"], # Would match web_search } @@ -91,7 +92,7 @@ def test_priority_5_default(self, classifier: RequestClassifier) -> None: def test_realistic_claude_code_request(self, classifier: RequestClassifier) -> None: """Test with a realistic Claude Code API request.""" request = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", "messages": [ {"role": "user", "content": "Write a Python function to calculate fibonacci"}, ], @@ -103,10 +104,13 @@ def test_realistic_claude_code_request(self, classifier: RequestClassifier) -> N def test_realistic_long_context_request(self, classifier: RequestClassifier) -> None: """Test with a realistic long context request.""" - # Create a very long message - long_content = "x" * 50000 # ~12500 tokens + # Create a very long message that exceeds 10000 token threshold + # Using varied text to prevent efficient encoding of repeated characters + varied_text = "The quick brown fox jumps over the lazy dog. " * 500 + # This will be ~5001 tokens, need to double for >10000 + long_content = varied_text * 3 # ~15,003 tokens request = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", "messages": [ {"role": "user", "content": long_content}, ], @@ -117,7 +121,7 @@ def test_realistic_long_context_request(self, classifier: RequestClassifier) -> def test_realistic_thinking_request(self, classifier: RequestClassifier) -> None: """Test with a realistic thinking request.""" request = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", "messages": [ {"role": "user", "content": "Solve this complex problem..."}, ], @@ -129,7 +133,7 @@ def test_realistic_thinking_request(self, classifier: RequestClassifier) -> None def test_realistic_background_task(self, classifier: RequestClassifier) -> None: """Test with a realistic background task using haiku.""" request = { - "model": "claude-3-5-haiku", + "model": "claude-haiku-4-5-20251001", "messages": [ {"role": "user", "content": "Format this JSON data"}, ], @@ -141,7 +145,7 @@ def test_realistic_background_task(self, classifier: RequestClassifier) -> None: def test_realistic_web_search_request(self, classifier: RequestClassifier) -> None: """Test with a realistic web search request.""" request = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", "messages": [ {"role": "user", "content": "Search for the latest news about AI"}, ], @@ -172,36 +176,37 @@ def test_edge_case_malformed_messages(self, classifier: RequestClassifier) -> No assert classifier.classify(request) == "default" def test_custom_rules_after_reset(self, classifier: RequestClassifier) -> None: - """Test that reset_rules restores default behavior.""" + """Test that _setup_rules restores default behavior.""" # Clear all rules - classifier.clear_rules() + classifier._clear_rules() # Should return default (no rules) request = {"thinking": True} assert classifier.classify(request) == "default" # Reset to defaults - classifier.reset_rules() + classifier._setup_rules() # Should now match thinking rule assert classifier.classify(request) == "think" def test_token_estimation_from_messages(self, classifier: RequestClassifier) -> None: """Test accurate token estimation from message content.""" - # Each message ~2500 tokens (10000 chars / 4) + # Using varied text for realistic tokenization + base_text = "The quick brown fox jumps over the lazy dog. " * 50 # ~501 tokens messages = [ - {"role": "user", "content": "x" * 10000}, - {"role": "assistant", "content": "y" * 10000}, - {"role": "user", "content": "z" * 10000}, + {"role": "user", "content": base_text * 6}, # ~3006 tokens + {"role": "assistant", "content": base_text * 6}, # ~3006 tokens + {"role": "user", "content": base_text * 3}, # ~1503 tokens ] request = {"messages": messages} - # Total ~7500 tokens, below 10000 threshold + # Total ~7515 tokens, below 10000 threshold assert classifier.classify(request) == "default" - # Add one more large message to go well over threshold - messages.append({"role": "assistant", "content": "a" * 15000}) + # Add one more message to go over threshold + messages.append({"role": "assistant", "content": base_text * 6}) # ~3006 tokens request = {"messages": messages} - # Total ~11250 tokens, should trigger large context + # Total ~10521 tokens, should trigger large context assert classifier.classify(request) == "large_context" diff --git a/tests/test_claude_code_integration.py b/tests/test_claude_code_integration.py new file mode 100644 index 00000000..873038f5 --- /dev/null +++ b/tests/test_claude_code_integration.py @@ -0,0 +1,101 @@ +"""End-to-end integration tests for Claude Code with ccproxy. + +This test suite validates that the `claude` command works correctly when routed through ccproxy. +""" + +import os +import socket +import subprocess +import tempfile +from collections.abc import Generator +from contextlib import closing +from pathlib import Path + +import pytest +import yaml + + +def find_free_port() -> int: + """Find a free port to use for testing.""" + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(("", 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + + +@pytest.mark.skipif( + subprocess.run(["which", "claude"], capture_output=True).returncode != 0, reason="claude command not available" +) +class TestClaudeCodeE2E: + """End-to-end test that validates claude command works through ccproxy.""" + + @pytest.fixture + def test_config_dir(self) -> Generator[Path, None, None]: + """Create a test configuration directory with minimal ccproxy config.""" + with tempfile.TemporaryDirectory() as temp_dir: + config_dir = Path(temp_dir) + + # Create minimal litellm proxy config with Anthropic models + litellm_config = { + "model_list": [ + { + "model_name": "default", + "litellm_params": { + "model": "claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + } + ] + } + + # Create minimal ccproxy config + ccproxy_config = { + "litellm": {"host": "127.0.0.1", "port": find_free_port(), "num_workers": 1, "telemetry": False}, + "ccproxy": { + "debug": False, + "hooks": ["ccproxy.hooks.model_router", "ccproxy.hooks.forward_oauth"], + "rules": [], + }, + } + + # Write config files + (config_dir / "config.yaml").write_text(yaml.dump(litellm_config)) + (config_dir / "ccproxy.yaml").write_text(yaml.dump(ccproxy_config)) + + yield config_dir + + def test_claude_simple_query_with_mock(self, test_config_dir): + """Test that claude command environment is set up correctly by ccproxy run.""" + # Create a mock claude script that just verifies environment is set + mock_claude = test_config_dir / "claude" + mock_claude.write_text(r"""#!/bin/bash +# Check if ANTHROPIC_BASE_URL is set to something that looks like a proxy +if [[ "$ANTHROPIC_BASE_URL" =~ ^http://127\.0\.0\.1:[0-9]+$ ]]; then + echo "SUCCESS: Environment configured correctly" + echo "ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL" + echo "Args: $@" + exit 0 +else + echo "FAIL: ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL (should match http://127.0.0.1:PORT)" + exit 1 +fi +""") + mock_claude.chmod(0o755) + + # Add mock claude to PATH + env = os.environ.copy() + env["PATH"] = f"{test_config_dir}:{env['PATH']}" + env["CCPROXY_CONFIG_DIR"] = str(test_config_dir) + + # Run ccproxy run command with proper argument separation + result = subprocess.run( + ["uv", "run", "ccproxy", "run", "--", "claude", "-p", "Hello"], + env=env, + cwd=test_config_dir, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, f"Command failed. stdout: {result.stdout}, stderr: {result.stderr}" + assert "SUCCESS" in result.stdout diff --git a/tests/test_cli.py b/tests/test_cli.py index d4679b08..f08e16d3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,399 +1,262 @@ -"""Tests for the CCProxy CLI.""" +"""Tests for the ccproxy CLI.""" +import json import os -import signal -import sys +import subprocess from pathlib import Path from unittest.mock import Mock, patch -import psutil import pytest -from ccproxy.cli import CCProxyDaemon, install, main, run_with_proxy - +from ccproxy.cli import ( + Install, + Logs, + Run, + Start, + Status, + Stop, + generate_handler_file, + install_config, + main, + run_with_proxy, + show_status, + start_litellm, + stop_litellm, + view_logs, +) + + +class TestStartProxy: + """Test suite for start_proxy function.""" + + def test_litellm_no_config(self, tmp_path: Path, capsys) -> None: + """Test litellm when config doesn't exist.""" + with pytest.raises(SystemExit) as exc_info: + start_litellm(tmp_path) -class TestCCProxyDaemon: - """Test suite for CCProxyDaemon class.""" + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Configuration not found" in captured.err + assert "Run 'ccproxy install' first" in captured.err - def test_init(self, tmp_path: Path) -> None: - """Test daemon initialization.""" - daemon = CCProxyDaemon(tmp_path) - assert daemon.config_dir == tmp_path - assert daemon.pid_file == tmp_path / "ccproxy.pid" - assert daemon.log_file == tmp_path / "ccproxy.log" + @patch("subprocess.run") + def test_start_proxy_success(self, mock_run: Mock, tmp_path: Path) -> None: + """Test successful litellm execution.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - def test_load_litellm_config_exists(self, tmp_path: Path) -> None: - """Test loading existing litellm config.""" - config_file = tmp_path / "ccproxy.yaml" - config_file.write_text(""" -litellm: - host: 0.0.0.0 - port: 8080 - num_workers: 4 - debug: true -""") - daemon = CCProxyDaemon(tmp_path) - config = daemon._load_litellm_config() - - assert config["host"] == "0.0.0.0" - assert config["port"] == 8080 - assert config["num_workers"] == 4 - assert config["debug"] is True - - def test_load_litellm_config_not_exists(self, tmp_path: Path) -> None: - """Test loading litellm config when file doesn't exist.""" - daemon = CCProxyDaemon(tmp_path) - config = daemon._load_litellm_config() - assert config == {} - - def test_build_litellm_command_defaults(self, tmp_path: Path) -> None: - """Test building litellm command with defaults.""" - daemon = CCProxyDaemon(tmp_path) - args = Mock() - args.host = None - args.port = None - args.workers = None - args.debug = False - args.detailed_debug = False - - cmd = daemon._build_litellm_command(args) - - assert cmd[0] == "litellm" - assert "--config" in cmd - assert str(tmp_path / "config.yaml") in cmd - assert "--host" in cmd - assert "127.0.0.1" in cmd - assert "--port" in cmd - assert "4000" in cmd - assert "--num_workers" in cmd - assert "1" in cmd - assert "--debug" not in cmd - - def test_build_litellm_command_with_env_vars(self, tmp_path: Path) -> None: - """Test building litellm command with environment variables.""" - daemon = CCProxyDaemon(tmp_path) - args = Mock() - args.host = None - args.port = None - args.workers = None - args.debug = False - args.detailed_debug = False - - with patch.dict(os.environ, {"HOST": "192.168.1.1", "PORT": "9000", "DEBUG": "true"}): - cmd = daemon._build_litellm_command(args) - - assert "192.168.1.1" in cmd - assert "9000" in cmd - assert "--debug" in cmd - - def test_build_litellm_command_with_cli_args(self, tmp_path: Path) -> None: - """Test building litellm command with CLI arguments.""" - daemon = CCProxyDaemon(tmp_path) - args = Mock() - args.host = "10.0.0.1" - args.port = 5000 - args.workers = 8 - args.debug = True - args.detailed_debug = True - - cmd = daemon._build_litellm_command(args) - - assert "10.0.0.1" in cmd - assert "5000" in cmd - assert "8" in cmd - assert "--debug" in cmd - assert "--detailed_debug" in cmd - - @patch("os.fork") - @patch("os.setsid") - @patch("os.umask") - @patch("os.chdir") - @patch("os.open") - @patch("os.dup2") - @patch("os.close") - def test_daemonize( - self, - mock_close: Mock, - mock_dup2: Mock, - mock_open: Mock, - mock_chdir: Mock, - mock_umask: Mock, - mock_setsid: Mock, - mock_fork: Mock, - tmp_path: Path, - ) -> None: - """Test daemonization process.""" - daemon = CCProxyDaemon(tmp_path) - - # Mock fork to return 0 (child process) - mock_fork.return_value = 0 - mock_open.return_value = 3 - - daemon._daemonize() - - assert mock_fork.call_count == 2 - mock_chdir.assert_called_once_with(str(tmp_path)) - mock_setsid.assert_called_once() - mock_umask.assert_called_once_with(0) - - @patch("os.fork") - def test_daemonize_fork1_failure(self, mock_fork: Mock, tmp_path: Path) -> None: - """Test daemonization when first fork fails.""" - daemon = CCProxyDaemon(tmp_path) - - # Mock fork to raise OSError - mock_fork.side_effect = OSError("Fork failed") + mock_run.return_value = Mock(returncode=0) with pytest.raises(SystemExit) as exc_info: - daemon._daemonize() + start_litellm(tmp_path) - assert exc_info.value.code == 1 - mock_fork.assert_called_once() + assert exc_info.value.code == 0 + # Check the command structure - first arg is the litellm executable path + call_args = mock_run.call_args[0][0] + assert call_args[0].endswith("litellm") + assert call_args[1:] == ["--config", str(config_file)] - @patch("os.fork") - @patch("os.setsid") - @patch("os.umask") - @patch("os.chdir") - def test_daemonize_fork2_failure( - self, mock_chdir: Mock, mock_umask: Mock, mock_setsid: Mock, mock_fork: Mock, tmp_path: Path - ) -> None: - """Test daemonization when second fork fails.""" - daemon = CCProxyDaemon(tmp_path) + @patch("subprocess.run") + def test_litellm_with_args(self, mock_run: Mock, tmp_path: Path) -> None: + """Test litellm with additional arguments.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - # First fork succeeds, second fails - mock_fork.side_effect = [0, OSError("Fork failed")] + mock_run.return_value = Mock(returncode=0) with pytest.raises(SystemExit) as exc_info: - daemon._daemonize() + start_litellm(tmp_path, args=["--debug", "--port", "8080"]) - assert exc_info.value.code == 1 - assert mock_fork.call_count == 2 + assert exc_info.value.code == 0 + # Check the command structure - first arg is the litellm executable path + call_args = mock_run.call_args[0][0] + assert call_args[0].endswith("litellm") + assert call_args[1:] == ["--config", str(config_file), "--debug", "--port", "8080"] - @patch("subprocess.Popen") - @patch.object(CCProxyDaemon, "_daemonize") - @patch("psutil.pid_exists") - def test_start_already_running( - self, mock_pid_exists: Mock, mock_daemonize: Mock, mock_popen: Mock, tmp_path: Path - ) -> None: - """Test starting when daemon is already running.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") + @patch("subprocess.run") + def test_litellm_command_not_found(self, mock_run: Mock, tmp_path: Path, capsys) -> None: + """Test litellm when command is not found.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - mock_pid_exists.return_value = True + mock_run.side_effect = FileNotFoundError() with pytest.raises(SystemExit) as exc_info: - daemon.start(Mock()) + start_litellm(tmp_path) assert exc_info.value.code == 1 - mock_daemonize.assert_not_called() - mock_popen.assert_not_called() + captured = capsys.readouterr() + assert "litellm command not found" in captured.err + assert "pip install litellm" in captured.err - @patch("subprocess.Popen") - @patch.object(CCProxyDaemon, "_daemonize") - @patch("psutil.pid_exists") - def test_start_stale_pid( - self, mock_pid_exists: Mock, mock_daemonize: Mock, mock_popen: Mock, tmp_path: Path - ) -> None: - """Test starting with stale PID file.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") + @patch("subprocess.run") + def test_litellm_keyboard_interrupt(self, mock_run: Mock, tmp_path: Path) -> None: + """Test litellm with keyboard interrupt.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - mock_pid_exists.return_value = False - mock_process = Mock() - mock_process.pid = 99999 - mock_process.wait.return_value = 0 - mock_popen.return_value = mock_process + mock_run.side_effect = KeyboardInterrupt() - daemon.start(Mock()) + with pytest.raises(SystemExit) as exc_info: + start_litellm(tmp_path) - mock_daemonize.assert_called_once() - mock_popen.assert_called_once() - # PID file should be removed in finally block, but process continues + assert exc_info.value.code == 130 @patch("subprocess.Popen") - @patch.object(CCProxyDaemon, "_daemonize") - def test_start_exception(self, mock_daemonize: Mock, mock_popen: Mock, tmp_path: Path) -> None: - """Test start when subprocess raises exception.""" - daemon = CCProxyDaemon(tmp_path) + def test_litellm_detach_success(self, mock_popen: Mock, tmp_path: Path, capsys) -> None: + """Test successful litellm execution in detached mode.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - mock_popen.side_effect = Exception("Failed to start") + mock_process = Mock() + mock_process.pid = 12345 + mock_popen.return_value = mock_process with pytest.raises(SystemExit) as exc_info: - daemon.start(Mock()) + start_litellm(tmp_path, detach=True) - assert exc_info.value.code == 1 - mock_daemonize.assert_called_once() - - @patch("os.kill") - @patch("psutil.pid_exists") - def test_stop_success(self, mock_pid_exists: Mock, mock_kill: Mock, tmp_path: Path) -> None: - """Test successful stop.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") - - mock_pid_exists.side_effect = [True, False] # Exists, then doesn't + assert exc_info.value.code == 0 - daemon.stop() + # Check PID file was created + pid_file = tmp_path / "litellm.lock" + assert pid_file.exists() + assert pid_file.read_text() == "12345" - mock_kill.assert_called_once_with(12345, signal.SIGTERM) - assert not pid_file.exists() + # Check output + captured = capsys.readouterr() + assert "LiteLLM started in background" in captured.out + assert "Log file:" in captured.out + assert str(tmp_path / "litellm.log") in captured.out @patch("os.kill") - @patch("psutil.pid_exists") - @patch("time.sleep") - def test_stop_force_kill(self, mock_sleep: Mock, mock_pid_exists: Mock, mock_kill: Mock, tmp_path: Path) -> None: - """Test force kill when process doesn't terminate gracefully.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") - - # Process continues to exist after SIGTERM - mock_pid_exists.return_value = True + def test_litellm_detach_already_running(self, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test litellm detach when already running.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - daemon.stop() + # Create existing PID file + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("67890") - # Should send SIGTERM first, then SIGKILL - assert mock_kill.call_count == 2 - mock_kill.assert_any_call(12345, signal.SIGTERM) - mock_kill.assert_any_call(12345, signal.SIGKILL) - assert mock_sleep.call_count == 100 # Waited full timeout - - def test_stop_not_running(self, tmp_path: Path) -> None: - """Test stop when daemon is not running.""" - daemon = CCProxyDaemon(tmp_path) + # Mock process is still running + mock_kill.return_value = None with pytest.raises(SystemExit) as exc_info: - daemon.stop() + start_litellm(tmp_path, detach=True) assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "LiteLLM is already running with PID 67890" in captured.err + @patch("subprocess.Popen") @patch("os.kill") - @patch("psutil.pid_exists") - def test_stop_invalid_pid(self, mock_pid_exists: Mock, mock_kill: Mock, tmp_path: Path) -> None: - """Test stop with invalid PID in file.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("invalid") + def test_litellm_detach_stale_pid(self, mock_kill: Mock, mock_popen: Mock, tmp_path: Path) -> None: + """Test litellm detach with stale PID file.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - with pytest.raises(SystemExit) as exc_info: - daemon.stop() + # Create existing PID file + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("67890") - assert exc_info.value.code == 1 - mock_kill.assert_not_called() + # Mock process is not running (raises ProcessLookupError) + mock_kill.side_effect = ProcessLookupError() - @patch("os.kill") - @patch("psutil.pid_exists") - def test_stop_permission_error(self, mock_pid_exists: Mock, mock_kill: Mock, tmp_path: Path) -> None: - """Test stop when permission denied to kill process.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") - - mock_pid_exists.return_value = True - mock_kill.side_effect = PermissionError("Permission denied") + mock_process = Mock() + mock_process.pid = 12345 + mock_popen.return_value = mock_process - # PermissionError is not caught by the stop method, so it will raise - with pytest.raises(PermissionError): - daemon.stop() + with pytest.raises(SystemExit) as exc_info: + start_litellm(tmp_path, detach=True) - @patch("psutil.Process") - @patch("psutil.pid_exists") - def test_status_running(self, mock_pid_exists: Mock, mock_process: Mock, tmp_path: Path, capsys) -> None: - """Test status when daemon is running.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") + assert exc_info.value.code == 0 - mock_pid_exists.return_value = True - mock_proc_instance = Mock() - mock_proc_instance.cpu_percent.return_value = 15.5 - mock_proc_instance.memory_info.return_value = Mock(rss=104857600) # 100MB - mock_proc_instance.create_time.return_value = 1234567890 - mock_process.return_value = mock_proc_instance + # Check PID file was updated + assert pid_file.read_text() == "12345" - daemon.status() + @patch("subprocess.Popen") + @patch("os.kill") + def test_litellm_detach_invalid_pid_file(self, mock_kill: Mock, mock_popen: Mock, tmp_path: Path) -> None: + """Test litellm detach with invalid PID file content.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - captured = capsys.readouterr() - assert "CCProxy is running (PID: 12345)" in captured.out - assert "CPU: 15.5%" in captured.out - assert "Memory: 100.0 MB" in captured.out - - @patch("psutil.Process") - @patch("psutil.pid_exists") - def test_status_process_error(self, mock_pid_exists: Mock, mock_process: Mock, tmp_path: Path, capsys) -> None: - """Test status when process info lookup fails.""" - daemon = CCProxyDaemon(tmp_path) - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") + # Create PID file with invalid content + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("not-a-number") - mock_pid_exists.return_value = True - mock_process.side_effect = psutil.NoSuchProcess(12345) + mock_process = Mock() + mock_process.pid = 12345 + mock_popen.return_value = mock_process with pytest.raises(SystemExit) as exc_info: - daemon.status() + start_litellm(tmp_path, detach=True) - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "CCProxy is not running (process not found)" in captured.out - # PID file should be removed - assert not pid_file.exists() + assert exc_info.value.code == 0 + # Check PID file was updated with new PID + assert pid_file.read_text() == "12345" + + @patch("subprocess.Popen") + def test_litellm_detach_file_not_found(self, mock_popen: Mock, tmp_path: Path) -> None: + """Test litellm detach when command is not found.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("litellm: config") - def test_status_not_running(self, tmp_path: Path, capsys) -> None: - """Test status when daemon is not running.""" - daemon = CCProxyDaemon(tmp_path) + # Mock FileNotFoundError (command not found) + mock_popen.side_effect = FileNotFoundError("Command not found") with pytest.raises(SystemExit) as exc_info: - daemon.status() + start_litellm(tmp_path, detach=True) assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "CCProxy is not running" in captured.out -class TestInstallCommand: - """Test suite for install command.""" +class TestInstallConfig: + """Test suite for install_config function.""" @patch("ccproxy.cli.get_templates_dir") - def test_install_fresh(self, mock_get_templates: Mock, tmp_path: Path) -> None: + def test_install_fresh(self, mock_get_templates: Mock, tmp_path: Path, capsys) -> None: """Test fresh installation.""" templates_dir = tmp_path / "templates" templates_dir.mkdir() - # Create template files + # Create template files (ccproxy.py is no longer a template - it's auto-generated on start) (templates_dir / "ccproxy.yaml").write_text("test: config") (templates_dir / "config.yaml").write_text("litellm: config") - (templates_dir / "ccproxy.py").write_text("# hook code") mock_get_templates.return_value = templates_dir config_dir = tmp_path / "config" - install(config_dir) + install_config(config_dir) assert (config_dir / "ccproxy.yaml").exists() assert (config_dir / "config.yaml").exists() - assert (config_dir / "ccproxy.py").exists() + # ccproxy.py is not installed - it's generated on startup + + captured = capsys.readouterr() + assert "Installation complete!" in captured.out + assert "Next steps:" in captured.out - def test_install_exists_no_force(self, tmp_path: Path) -> None: + def test_install_exists_no_force(self, tmp_path: Path, capsys) -> None: """Test install when config already exists without force.""" config_dir = tmp_path / "config" config_dir.mkdir() with pytest.raises(SystemExit) as exc_info: - install(config_dir, force=False) + install_config(config_dir, force=False) assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "already" in captured.out and "exists" in captured.out + assert "Use --force to overwrite" in captured.out @patch("ccproxy.cli.get_templates_dir") - def test_install_with_force(self, mock_get_templates: Mock, tmp_path: Path) -> None: + def test_install_with_force(self, mock_get_templates: Mock, tmp_path: Path, capsys) -> None: """Test install with force overwrites existing files.""" templates_dir = tmp_path / "templates" templates_dir.mkdir() (templates_dir / "ccproxy.yaml").write_text("new: config") (templates_dir / "config.yaml").write_text("new: litellm") - (templates_dir / "ccproxy.py").write_text("# new hook") mock_get_templates.return_value = templates_dir @@ -401,20 +264,375 @@ def test_install_with_force(self, mock_get_templates: Mock, tmp_path: Path) -> N config_dir.mkdir() (config_dir / "ccproxy.yaml").write_text("old: config") - install(config_dir, force=True) + install_config(config_dir, force=True) assert (config_dir / "ccproxy.yaml").read_text() == "new: config" + captured = capsys.readouterr() + assert "Copied ccproxy.yaml" in captured.out + + @patch("ccproxy.cli.get_templates_dir") + def test_install_template_not_found(self, mock_get_templates: Mock, tmp_path: Path, capsys) -> None: + """Test install when template file is missing.""" + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + # Only create some template files + (templates_dir / "ccproxy.yaml").write_text("test: config") + + mock_get_templates.return_value = templates_dir + + config_dir = tmp_path / "config" + install_config(config_dir) + + captured = capsys.readouterr() + assert "Warning: Template config.yaml not found" in captured.err + # ccproxy.py is no longer a template, so no warning expected + + def test_install_template_dir_error(self, tmp_path: Path) -> None: + """Test install when get_templates_dir raises RuntimeError.""" + config_dir = tmp_path / "config" + + with patch("ccproxy.cli.get_templates_dir", side_effect=RuntimeError("Templates not found")): + with pytest.raises(SystemExit) as exc_info: + install_config(config_dir) + assert exc_info.value.code == 1 + + def test_install_skip_existing_file(self, tmp_path: Path, capsys) -> None: + """Test install skips existing files without force flag.""" + templates_dir = tmp_path / "templates" + templates_dir.mkdir() + (templates_dir / "ccproxy.yaml").write_text("template content") + + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "ccproxy.yaml").write_text("existing content") + + with patch("ccproxy.cli.get_templates_dir", return_value=templates_dir): + with pytest.raises(SystemExit) as exc_info: + install_config(config_dir) + assert exc_info.value.code == 1 + + # Verify file wasn't overwritten + assert (config_dir / "ccproxy.yaml").read_text() == "existing content" + + +class TestHandlerGeneration: + """Test suite for generate_handler_file function.""" + + def test_generate_handler_default(self, tmp_path: Path) -> None: + """Test handler generation with default configuration.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create minimal ccproxy.yaml with default handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" +""" + ) + + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + assert handler_file.exists() + + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + assert "handler = CCProxyHandler()" in content + assert "Auto-generated" in content + assert "DO NOT EDIT" in content + + def test_generate_handler_custom(self, tmp_path: Path) -> None: + """Test handler generation with custom handler class.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create ccproxy.yaml with custom handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "mypackage.custom:MyCustomHandler" +""" + ) + + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + content = handler_file.read_text() + assert "from mypackage.custom import MyCustomHandler" in content + assert "handler = MyCustomHandler()" in content + + def test_generate_handler_no_colon(self, tmp_path: Path) -> None: + """Test handler generation with module path only (no colon).""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Handler without colon should use CCProxyHandler as class name + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler" +""" + ) + + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + assert "handler = CCProxyHandler()" in content + + def test_generate_handler_missing_config(self, tmp_path: Path) -> None: + """Test handler generation when ccproxy.yaml doesn't exist.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Should use default handler when config is missing + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + assert handler_file.exists() + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + assert "handler = CCProxyHandler()" in content + + def test_generate_handler_malformed_yaml(self, tmp_path: Path) -> None: + """Test handler generation with malformed YAML.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create malformed YAML + (config_dir / "ccproxy.yaml").write_text("invalid: {yaml: [") + + # Should fall back to default handler + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + assert handler_file.exists() + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + + def test_generate_handler_missing_handler_key(self, tmp_path: Path) -> None: + """Test handler generation when handler key is missing from config.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Config without handler key + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + debug: true +""" + ) + + # Should fall back to default handler + generate_handler_file(config_dir) + + handler_file = config_dir / "ccproxy.py" + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + + def test_generate_handler_preserve_custom(self, tmp_path: Path) -> None: + """Test that custom handler files are preserved (not overwritten).""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + handler_file = config_dir / "ccproxy.py" + handler_file.write_text("# custom user content") + + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "new.module:NewHandler" +""" + ) + + generate_handler_file(config_dir) + + # Custom file should be preserved + content = handler_file.read_text() + assert "# custom user content" in content + assert "from new.module import NewHandler" not in content + + def test_generate_handler_overwrite_autogenerated(self, tmp_path: Path) -> None: + """Test that auto-generated files get overwritten with new content.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create an auto-generated file with the marker + handler_file = config_dir / "ccproxy.py" + old_autogen_content = '''""" +Auto-generated handler file for LiteLLM callbacks. +This file is generated by ccproxy on startup. +DO NOT EDIT - changes will be overwritten. +""" +import sys + +from ccproxy.handler import CCProxyHandler + +handler = CCProxyHandler() +''' + handler_file.write_text(old_autogen_content) + + # Configure new handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "new.module:NewHandler" +""" + ) + + # Generate handler file + generate_handler_file(config_dir) + + # Verify it was overwritten with new content + content = handler_file.read_text() + assert "from new.module import NewHandler" in content + assert "handler = NewHandler()" in content + assert "Auto-generated handler file" in content + assert "DO NOT EDIT" in content + assert "from ccproxy.handler import CCProxyHandler" not in content + + def test_generate_handler_preserve_custom_file(self, tmp_path: Path, capsys) -> None: + """Test that custom files (without auto-generated marker) are preserved.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create a custom handler file WITHOUT the auto-generated marker + handler_file = config_dir / "ccproxy.py" + custom_content = '''""" +Custom handler file written by user. +""" +from ccproxy.handler import CCProxyHandler + +class CustomHandler(CCProxyHandler): + def custom_method(self): + pass + +handler = CustomHandler() +''' + handler_file.write_text(custom_content) + + # Configure handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" +""" + ) + + # Generate handler file + generate_handler_file(config_dir) + + # Verify file was NOT overwritten + content = handler_file.read_text() + assert content == custom_content + assert "Custom handler file written by user" in content + assert "custom_method" in content + + # Verify warning was printed to stderr + captured = capsys.readouterr() + assert "Custom ccproxy.py file detected" in captured.err + assert "will NOT be overwritten" in captured.err + + def test_generate_handler_no_file_creates_new(self, tmp_path: Path) -> None: + """Test that handler generation creates new file when none exists.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + handler_file = config_dir / "ccproxy.py" + assert not handler_file.exists() + + # Configure handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" +""" + ) + + # Generate handler file + generate_handler_file(config_dir) + + # Verify file was created + assert handler_file.exists() + content = handler_file.read_text() + assert "from ccproxy.handler import CCProxyHandler" in content + assert "handler = CCProxyHandler()" in content + assert "Auto-generated handler file" in content + + def test_generate_handler_empty_file_treated_as_custom(self, tmp_path: Path, capsys) -> None: + """Test that empty file is treated as custom and preserved.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create empty file + handler_file = config_dir / "ccproxy.py" + handler_file.write_text("") + + # Configure handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" +""" + ) + + # Generate handler file + generate_handler_file(config_dir) + + # Verify empty file was preserved (treated as custom) + content = handler_file.read_text() + assert content == "" + + # Verify warning was printed + captured = capsys.readouterr() + assert "Custom ccproxy.py file detected" in captured.err + assert "will NOT be overwritten" in captured.err + + def test_generate_handler_whitespace_only_treated_as_custom(self, tmp_path: Path, capsys) -> None: + """Test that whitespace-only file is treated as custom and preserved.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create file with only whitespace + handler_file = config_dir / "ccproxy.py" + whitespace_content = " \n\n\t\n " + handler_file.write_text(whitespace_content) + + # Configure handler + (config_dir / "ccproxy.yaml").write_text( + """ +ccproxy: + handler: "ccproxy.handler:CCProxyHandler" +""" + ) + + # Generate handler file + generate_handler_file(config_dir) + + # Verify whitespace file was preserved + content = handler_file.read_text() + assert content == whitespace_content + + # Verify warning was printed + captured = capsys.readouterr() + assert "Custom ccproxy.py file detected" in captured.err + assert "will NOT be overwritten" in captured.err class TestRunWithProxy: """Test suite for run_with_proxy function.""" - def test_run_no_config(self, tmp_path: Path) -> None: + def test_run_no_config(self, tmp_path: Path, capsys) -> None: """Test run when config doesn't exist.""" with pytest.raises(SystemExit) as exc_info: run_with_proxy(tmp_path, ["echo", "test"]) assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Configuration not found" in captured.err + assert "Run 'ccproxy install' first" in captured.err @patch("subprocess.run") def test_run_with_proxy_success(self, mock_run: Mock, tmp_path: Path) -> None: @@ -436,45 +654,10 @@ def test_run_with_proxy_success(self, mock_run: Mock, tmp_path: Path) -> None: # Check environment variables were set call_args = mock_run.call_args env = call_args[1]["env"] - assert env["OPENAI_API_BASE"] == "http://192.168.1.1:8888/v1" - assert env["ANTHROPIC_BASE_URL"] == "http://192.168.1.1:8888/v1" - assert env["HTTP_PROXY"] == "http://192.168.1.1:8888" - - @patch("subprocess.run") - @patch("psutil.pid_exists") - def test_run_with_proxy_daemon_running(self, mock_pid_exists: Mock, mock_run: Mock, tmp_path: Path, capsys) -> None: - """Test run command when daemon is running.""" - config_file = tmp_path / "ccproxy.yaml" - config_file.write_text("litellm: {}") - - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("12345") - - mock_pid_exists.return_value = True - mock_run.return_value = Mock(returncode=0) - - with pytest.raises(SystemExit): - run_with_proxy(tmp_path, ["echo", "test"]) - - captured = capsys.readouterr() - assert "Using running ccproxy instance (PID: 12345)" in captured.out - - @patch("subprocess.run") - def test_run_with_proxy_invalid_pid(self, mock_run: Mock, tmp_path: Path, capsys) -> None: - """Test run with invalid PID file.""" - config_file = tmp_path / "ccproxy.yaml" - config_file.write_text("litellm: {}") - - pid_file = tmp_path / "ccproxy.pid" - pid_file.write_text("invalid") - - mock_run.return_value = Mock(returncode=0) - - with pytest.raises(SystemExit): - run_with_proxy(tmp_path, ["echo", "test"]) - - captured = capsys.readouterr() - assert "Warning: CCProxy is not running (invalid PID file)" in captured.err + assert env["OPENAI_API_BASE"] == "http://192.168.1.1:8888" + assert env["ANTHROPIC_BASE_URL"] == "http://192.168.1.1:8888" + # HTTP_PROXY should not be set to avoid CONNECT issues + assert "HTTP_PROXY" not in env or env.get("HTTP_PROXY") == os.environ.get("HTTP_PROXY") @patch("subprocess.run") def test_run_with_env_override(self, mock_run: Mock, tmp_path: Path) -> None: @@ -488,17 +671,21 @@ def test_run_with_env_override(self, mock_run: Mock, tmp_path: Path) -> None: mock_run.return_value = Mock(returncode=0) - with patch.dict(os.environ, {"HOST": "10.0.0.1", "PORT": "9999"}), pytest.raises(SystemExit): + with ( + patch.dict(os.environ, {"HOST": "10.0.0.1", "PORT": "9999"}), + pytest.raises(SystemExit), + ): run_with_proxy(tmp_path, ["echo", "test"]) # Check environment variables use env overrides call_args = mock_run.call_args env = call_args[1]["env"] - assert env["OPENAI_API_BASE"] == "http://10.0.0.1:9999/v1" - assert env["HTTP_PROXY"] == "http://10.0.0.1:9999" + assert env["OPENAI_API_BASE"] == "http://10.0.0.1:9999" + # HTTP_PROXY should not be set to avoid CONNECT issues + assert "HTTP_PROXY" not in env or env.get("HTTP_PROXY") == os.environ.get("HTTP_PROXY") @patch("subprocess.run") - def test_run_command_not_found(self, mock_run: Mock, tmp_path: Path) -> None: + def test_run_command_not_found(self, mock_run: Mock, tmp_path: Path, capsys) -> None: """Test run with non-existent command.""" config_file = tmp_path / "ccproxy.yaml" config_file.write_text("litellm: {}") @@ -509,6 +696,8 @@ def test_run_command_not_found(self, mock_run: Mock, tmp_path: Path) -> None: run_with_proxy(tmp_path, ["nonexistent", "command"]) assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Command not found: nonexistent" in captured.err @patch("subprocess.run") def test_run_command_keyboard_interrupt(self, mock_run: Mock, tmp_path: Path) -> None: @@ -524,55 +713,445 @@ def test_run_command_keyboard_interrupt(self, mock_run: Mock, tmp_path: Path) -> assert exc_info.value.code == 130 # Standard exit code for Ctrl+C -class TestMainFunction: - """Test suite for main CLI function.""" +class TestStopLiteLLM: + """Test suite for stop_litellm function.""" + + def test_stop_no_pid_file(self, tmp_path: Path, capsys) -> None: + """Test stop when PID file doesn't exist.""" + result = stop_litellm(tmp_path) + + assert result is False + captured = capsys.readouterr() + assert "No LiteLLM server is running (PID file not found)" in captured.err + + @patch("os.kill") + @patch("time.sleep") + def test_stop_successful(self, mock_sleep: Mock, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test successful stop of running process.""" + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # First call: check if running (returns None) + # Second call: send SIGTERM (returns None) + # Third call: check if still running (raises ProcessLookupError - stopped) + mock_kill.side_effect = [None, None, ProcessLookupError()] - @patch("ccproxy.cli.CCProxyDaemon") - def test_main_no_command(self, mock_daemon_class: Mock, capsys) -> None: - """Test main with no command.""" - with patch.object(sys, "argv", ["ccproxy"]), pytest.raises(SystemExit) as exc_info: - main() + result = stop_litellm(tmp_path) + + assert result is True + assert not pid_file.exists() # PID file should be removed + + captured = capsys.readouterr() + assert "Stopping LiteLLM server (PID: 12345)" in captured.out + assert "LiteLLM server stopped successfully (PID: 12345)" in captured.out + + # Verify kill calls + assert mock_kill.call_count == 3 + mock_kill.assert_any_call(12345, 0) # Check if running + mock_kill.assert_any_call(12345, 15) # SIGTERM + + @patch("os.kill") + @patch("time.sleep") + def test_stop_force_kill(self, mock_sleep: Mock, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test force kill when process doesn't respond to SIGTERM.""" + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # Process keeps running after SIGTERM + mock_kill.side_effect = [None, None, None, None] + + result = stop_litellm(tmp_path) + + assert result is True + assert not pid_file.exists() + + captured = capsys.readouterr() + assert "Force killed LiteLLM server (PID: 12345)" in captured.out + + # Verify kill calls + assert mock_kill.call_count == 4 + mock_kill.assert_any_call(12345, 9) # SIGKILL + + @patch("os.kill") + def test_stop_stale_pid(self, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test stop with stale PID file.""" + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # Process not running + mock_kill.side_effect = ProcessLookupError() + + result = stop_litellm(tmp_path) + + assert result is False + assert not pid_file.exists() # Stale PID file should be removed + + captured = capsys.readouterr() + assert "LiteLLM server was not running (stale PID: 12345)" in captured.out + + def test_stop_invalid_pid_file(self, tmp_path: Path, capsys) -> None: + """Test stop with invalid PID file content.""" + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("invalid-pid") + + result = stop_litellm(tmp_path) + + assert result is False + captured = capsys.readouterr() + assert "Error reading PID file" in captured.err + + +class TestViewLogs: + """Test suite for view_logs function.""" + + def test_logs_no_file(self, tmp_path: Path, capsys) -> None: + """Test logs when log file doesn't exist.""" + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path) assert exc_info.value.code == 1 captured = capsys.readouterr() - assert "usage:" in captured.out + assert "No log file found" in captured.err + assert str(tmp_path / "litellm.log") in captured.err + + @patch("subprocess.run") + def test_logs_follow(self, mock_run: Mock, tmp_path: Path) -> None: + """Test logs with follow option.""" + log_file = tmp_path / "litellm.log" + log_file.write_text("log content") + + mock_run.return_value = Mock(returncode=0) + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path, follow=True) + + assert exc_info.value.code == 0 + mock_run.assert_called_once_with(["tail", "-f", str(log_file)]) + + @patch("subprocess.run") + def test_logs_follow_keyboard_interrupt(self, mock_run: Mock, tmp_path: Path) -> None: + """Test logs follow with keyboard interrupt.""" + log_file = tmp_path / "litellm.log" + log_file.write_text("log content") + + mock_run.side_effect = KeyboardInterrupt() + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path, follow=True) + + assert exc_info.value.code == 0 + + def test_logs_empty_file(self, tmp_path: Path, capsys) -> None: + """Test logs with empty log file.""" + log_file = tmp_path / "litellm.log" + log_file.write_text("") + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Log file is empty" in captured.out + + def test_logs_short_content(self, tmp_path: Path, capsys) -> None: + """Test logs with short content (no pager).""" + log_file = tmp_path / "litellm.log" + content = "\n".join([f"Line {i}" for i in range(10)]) + log_file.write_text(content) + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path, lines=20) + + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Line 0" in captured.out + assert "Line 9" in captured.out + + @patch("subprocess.Popen") + def test_logs_long_content_with_pager(self, mock_popen: Mock, tmp_path: Path) -> None: + """Test logs with long content (uses pager).""" + log_file = tmp_path / "litellm.log" + content = "\n".join([f"Line {i}" for i in range(30)]) + log_file.write_text(content) + + mock_process = Mock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + mock_popen.return_value = mock_process + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path, lines=25) + + assert exc_info.value.code == 0 + mock_popen.assert_called_once() + + # Verify last 25 lines were passed to pager + call_args = mock_process.communicate.call_args[0][0].decode() + assert "Line 5" in call_args + assert "Line 29" in call_args + assert "Line 4" not in call_args + + @patch("subprocess.Popen") + @patch.dict(os.environ, {"PAGER": "cat"}) + def test_logs_with_cat_pager(self, mock_popen: Mock, tmp_path: Path) -> None: + """Test logs with cat as pager.""" + log_file = tmp_path / "litellm.log" + content = "Some log content" + log_file.write_text(content) + + mock_process = Mock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + mock_popen.return_value = mock_process + + with pytest.raises(SystemExit) as exc_info: + view_logs(tmp_path) + + assert exc_info.value.code == 0 + mock_popen.assert_called_once_with(["cat"], stdin=subprocess.PIPE) + + +class TestShowStatus: + """Test suite for show_status function.""" + + @patch("os.kill") + def test_status_json_proxy_running(self, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test status JSON output with proxy running.""" + # Create config files + ccproxy_config = tmp_path / "ccproxy.yaml" + ccproxy_config.write_text("litellm: {}") + + litellm_config = tmp_path / "config.yaml" + litellm_config.write_text(""" +litellm_settings: + callbacks: + - ccproxy.handler + - langfuse +""") + + user_hooks = tmp_path / "ccproxy.py" + user_hooks.write_text("# hooks") + + log_file = tmp_path / "litellm.log" + log_file.write_text("log content") + + # Create PID file + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # Mock process is running + mock_kill.return_value = None + + show_status(tmp_path, json_output=True) + + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["proxy"] is True + assert status["config"]["ccproxy.yaml"] == str(ccproxy_config) + assert status["config"]["config.yaml"] == str(litellm_config) + assert status["config"]["ccproxy.py"] == str(user_hooks) + assert status["callbacks"] == ["ccproxy.handler", "langfuse"] + assert status["log"] == str(log_file) - @patch("ccproxy.cli.CCProxyDaemon") - def test_main_start_command(self, mock_daemon_class: Mock) -> None: - """Test main with start command.""" - mock_daemon = Mock() - mock_daemon_class.return_value = mock_daemon + def test_status_json_proxy_stopped(self, tmp_path: Path, capsys) -> None: + """Test status JSON output with proxy stopped.""" + # Create only config files + ccproxy_config = tmp_path / "ccproxy.yaml" + ccproxy_config.write_text("litellm: {}") - with patch.object(sys, "argv", ["ccproxy", "start"]): - main() + litellm_config = tmp_path / "config.yaml" + litellm_config.write_text("litellm_settings: {}") - mock_daemon.start.assert_called_once() + show_status(tmp_path, json_output=True) - @patch("ccproxy.cli.install") - def test_main_install_command(self, mock_install: Mock) -> None: + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["proxy"] is False + assert status["config"]["ccproxy.yaml"] == str(ccproxy_config) + assert status["config"]["config.yaml"] == str(litellm_config) + assert "ccproxy.py" not in status["config"] + assert status["callbacks"] == [] + assert status["log"] is None + + def test_status_json_no_config(self, tmp_path: Path, capsys) -> None: + """Test status JSON output with no config files.""" + show_status(tmp_path, json_output=True) + + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["proxy"] is False + assert status["config"] == {} + assert status["callbacks"] == [] + assert status["log"] is None + + @patch("os.kill") + def test_status_json_with_stale_pid(self, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test status JSON output with stale PID file.""" + # Create PID file + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # Mock process is not running + mock_kill.side_effect = ProcessLookupError() + + show_status(tmp_path, json_output=True) + + captured = capsys.readouterr() + status = json.loads(captured.out) + assert status["proxy"] is False + + @patch("os.kill") + def test_status_rich_output_proxy_running(self, mock_kill: Mock, tmp_path: Path, capsys) -> None: + """Test status rich output with proxy running.""" + # Create config files + ccproxy_config = tmp_path / "ccproxy.yaml" + ccproxy_config.write_text("litellm: {}") + + litellm_config = tmp_path / "config.yaml" + litellm_config.write_text(""" +litellm_settings: + callbacks: + - ccproxy.handler +""") + + log_file = tmp_path / "litellm.log" + log_file.write_text("log content") + + # Create PID file + pid_file = tmp_path / "litellm.lock" + pid_file.write_text("12345") + + # Mock process is running + mock_kill.return_value = None + + show_status(tmp_path, json_output=False) + + captured = capsys.readouterr() + assert "ccproxy Status" in captured.out + assert "proxy" in captured.out + assert "true" in captured.out + assert "config" in captured.out + assert "ccproxy.yaml" in captured.out + assert "callbacks" in captured.out + assert "ccproxy.handler" in captured.out + + def test_status_rich_output_no_callbacks(self, tmp_path: Path, capsys) -> None: + """Test status rich output with no callbacks configured.""" + litellm_config = tmp_path / "config.yaml" + litellm_config.write_text("litellm_settings: {}") + + show_status(tmp_path, json_output=False) + + captured = capsys.readouterr() + assert "No callbacks configured" in captured.out + + def test_status_rich_output_no_config(self, tmp_path: Path, capsys) -> None: + """Test status rich output with no config files.""" + show_status(tmp_path, json_output=False) + + captured = capsys.readouterr() + assert "No config files found" in captured.out + + +class TestMainFunction: + """Test suite for main CLI function using Tyro.""" + + @patch("ccproxy.cli.start_litellm") + def test_main_litellm_command(self, mock_litellm: Mock, tmp_path: Path) -> None: + """Test main with litellm command.""" + cmd = Start(args=["--debug", "--port", "8080"]) + main(cmd, config_dir=tmp_path) + + mock_litellm.assert_called_once_with(tmp_path, args=["--debug", "--port", "8080"], detach=False) + + @patch("ccproxy.cli.start_litellm") + def test_main_litellm_no_args(self, mock_litellm: Mock, tmp_path: Path) -> None: + """Test main with litellm command without args.""" + cmd = Start() + main(cmd, config_dir=tmp_path) + + mock_litellm.assert_called_once_with(tmp_path, args=None, detach=False) + + @patch("ccproxy.cli.start_litellm") + def test_main_litellm_detach(self, mock_litellm: Mock, tmp_path: Path) -> None: + """Test main with litellm command in detach mode.""" + cmd = Start(detach=True) + main(cmd, config_dir=tmp_path) + + mock_litellm.assert_called_once_with(tmp_path, args=None, detach=True) + + @patch("ccproxy.cli.install_config") + def test_main_install_command(self, mock_install: Mock, tmp_path: Path) -> None: """Test main with install command.""" - with patch.object(sys, "argv", ["ccproxy", "install", "--force"]): - main() + cmd = Install(force=True) + main(cmd, config_dir=tmp_path) - mock_install.assert_called_once() - # Check keyword arguments - assert mock_install.call_args.kwargs["force"] is True + mock_install.assert_called_once_with(tmp_path, force=True) @patch("ccproxy.cli.run_with_proxy") - def test_main_run_command(self, mock_run: Mock) -> None: + def test_main_run_command(self, mock_run: Mock, tmp_path: Path) -> None: """Test main with run command.""" - with patch.object(sys, "argv", ["ccproxy", "run", "echo", "hello"]): - main() + cmd = Run(command=["echo", "hello", "world"]) + main(cmd, config_dir=tmp_path) - mock_run.assert_called_once() - call_args = mock_run.call_args[0] - assert call_args[1] == ["echo", "hello"] + mock_run.assert_called_once_with(tmp_path, ["echo", "hello", "world"]) - def test_main_run_no_args(self, capsys) -> None: + def test_main_run_no_args(self, tmp_path: Path, capsys) -> None: """Test main run command without arguments.""" - with patch.object(sys, "argv", ["ccproxy", "run"]), pytest.raises(SystemExit) as exc_info: - main() + cmd = Run(command=[]) + + with pytest.raises(SystemExit) as exc_info: + main(cmd, config_dir=tmp_path) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "No command specified" in captured.err + assert "Usage: ccproxy run <command>" in captured.err + + def test_main_default_config_dir(self, tmp_path: Path) -> None: + """Test main uses default config directory when not specified.""" + with ( + patch.object(Path, "home", return_value=tmp_path), + patch("ccproxy.cli.start_litellm") as mock_litellm, + ): + cmd = Start() + main(cmd) + + # Check that litellm was called with the default config dir + mock_litellm.assert_called_once_with(tmp_path / ".ccproxy", args=None, detach=False) + + @patch("ccproxy.cli.stop_litellm") + def test_main_stop_command(self, mock_stop: Mock, tmp_path: Path) -> None: + """Test main with stop command.""" + cmd = Stop() + mock_stop.return_value = True # Simulate successful stop + + with pytest.raises(SystemExit) as exc_info: + main(cmd, config_dir=tmp_path) + + assert exc_info.value.code == 0 + mock_stop.assert_called_once_with(tmp_path) + + @patch("ccproxy.cli.view_logs") + def test_main_logs_command(self, mock_logs: Mock, tmp_path: Path) -> None: + """Test main with logs command.""" + cmd = Logs(follow=True, lines=50) + main(cmd, config_dir=tmp_path) + + mock_logs.assert_called_once_with(tmp_path, follow=True, lines=50) + + @patch("ccproxy.cli.show_status") + def test_main_status_command(self, mock_status: Mock, tmp_path: Path) -> None: + """Test main with status command.""" + cmd = Status(json=False) + main(cmd, config_dir=tmp_path) + + mock_status.assert_called_once_with(tmp_path, json_output=False) + + @patch("ccproxy.cli.show_status") + def test_main_status_command_json(self, mock_status: Mock, tmp_path: Path) -> None: + """Test main with status command with JSON output.""" + cmd = Status(json=True) + main(cmd, config_dir=tmp_path) + + mock_status.assert_called_once_with(tmp_path, json_output=True) diff --git a/tests/test_config.py b/tests/test_config.py index f566c83b..e935c2d3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,6 @@ from ccproxy.config import ( CCProxyConfig, - ConfigProvider, RuleConfig, clear_config_instance, get_config, @@ -14,7 +13,7 @@ class TestCCProxyConfig: - """Tests for main CCProxyConfig.""" + """Tests for main config class.""" def test_default_config(self) -> None: """Test default configuration values.""" @@ -36,8 +35,8 @@ def test_config_attributes(self) -> None: def test_rule_config(self) -> None: """Test rule configuration.""" # Create a rule config - rule = RuleConfig("test_label", "ccproxy.rules.TokenCountRule", [{"threshold": 5000}]) - assert rule.label == "test_label" + rule = RuleConfig("test_name", "ccproxy.rules.TokenCountRule", [{"threshold": 5000}]) + assert rule.model_name == "test_name" assert rule.rule_path == "ccproxy.rules.TokenCountRule" assert rule.params == [{"threshold": 5000}] @@ -54,26 +53,26 @@ def test_from_yaml_files(self) -> None: debug: true metrics_enabled: false rules: - - label: token_count + - name: token_count rule: ccproxy.rules.TokenCountRule params: - threshold: 80000 - - label: background + - name: background rule: ccproxy.rules.MatchModelRule params: - - model_name: claude-3-5-haiku + - model_name: claude-haiku-4-5-20251001 """ litellm_yaml_content = """ model_list: - model_name: default litellm_params: - model: claude-3-5-sonnet-20241022 + model: claude-sonnet-4-5-20250929 - model_name: background litellm_params: - model: claude-3-5-haiku-20241022 + model: claude-haiku-4-5-20251001-20241022 - model_name: think litellm_params: - model: claude-3-5-sonnet-20241022 + model: claude-opus-4-5-20251101 - model_name: token_count litellm_params: model: gemini-2.5-pro @@ -96,15 +95,10 @@ def test_from_yaml_files(self) -> None: assert config.debug is True assert config.metrics_enabled is False assert len(config.rules) == 2 - assert config.rules[0].label == "token_count" - assert config.rules[1].label == "background" + assert config.rules[0].model_name == "token_count" + assert config.rules[1].model_name == "background" - # Test model lookup (reads from YAML when proxy_server is None) - assert config.get_model_for_label("default") == "claude-3-5-sonnet-20241022" - assert config.get_model_for_label("background") == "claude-3-5-haiku-20241022" - assert config.get_model_for_label("token_count") == "gemini-2.5-pro" - assert config.get_model_for_label("web_search") == "perplexity/llama-3.1-sonar-large-128k-online" - assert config.get_model_for_label("nonexistent") is None + # Model lookup functionality has been moved to router.py finally: ccproxy_path.unlink() @@ -139,7 +133,7 @@ def test_yaml_config_values(self) -> None: debug: true metrics_enabled: false rules: - - label: custom_rule + - name: custom_rule rule: ccproxy.rules.TokenCountRule params: - threshold: 70000 @@ -154,14 +148,59 @@ def test_yaml_config_values(self) -> None: assert config.debug is True assert config.metrics_enabled is False assert len(config.rules) == 1 - assert config.rules[0].label == "custom_rule" + assert config.rules[0].model_name == "custom_rule" assert config.rules[0].params == [{"threshold": 70000}] finally: yaml_path.unlink() - def test_get_model_for_label(self) -> None: - """Test model lookup by routing label.""" + def test_hook_parameters_from_yaml(self) -> None: + """Test that hooks with parameters are loaded correctly.""" + yaml_content = """ +ccproxy: + debug: false + hooks: + - ccproxy.hooks.rule_evaluator + - hook: ccproxy.hooks.capture_headers + params: + headers: [user-agent, x-request-id] +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # Both hook formats should be in hooks list + assert len(config.hooks) == 2 + assert config.hooks[0] == "ccproxy.hooks.rule_evaluator" + assert config.hooks[1] == { + "hook": "ccproxy.hooks.capture_headers", + "params": {"headers": ["user-agent", "x-request-id"]}, + } + + # load_hooks should return tuples of (func, params) + loaded = config.load_hooks() + assert len(loaded) == 2 + + # First hook - string format, empty params + func1, params1 = loaded[0] + assert callable(func1) + assert func1.__name__ == "rule_evaluator" + assert params1 == {} + + # Second hook - dict format with params + func2, params2 = loaded[1] + assert callable(func2) + assert func2.__name__ == "capture_headers" + assert params2 == {"headers": ["user-agent", "x-request-id"]} + + finally: + yaml_path.unlink() + + def test_model_loading_from_yaml(self) -> None: + """Test that model configuration can be loaded from YAML files.""" litellm_yaml_content = """ model_list: - model_name: default @@ -186,10 +225,9 @@ def test_get_model_for_label(self) -> None: try: config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) - # Should return models from YAML when proxy_server is None - assert config.get_model_for_label("default") == "gpt-4" - assert config.get_model_for_label("background") == "gpt-3.5-turbo" - assert config.get_model_for_label("think") is None # Not in model_list + # Config should have the litellm_config_path set + assert config.litellm_config_path == litellm_path + # Model lookup functionality has been moved to router.py finally: litellm_path.unlink() @@ -222,67 +260,6 @@ def test_get_config_singleton(self) -> None: clear_config_instance() -class TestConfigProvider: - """Tests for ConfigProvider dependency injection.""" - - def test_provider_initialization(self) -> None: - """Test ConfigProvider initialization.""" - # With config - config = CCProxyConfig(debug=True) - provider = ConfigProvider(config) - assert provider.get() is config - assert provider.get().debug is True - - def test_provider_lazy_load(self) -> None: - """Test ConfigProvider lazy loading.""" - # Clear any existing instance - clear_config_instance() - - # Set a custom config in the global singleton - custom_config = CCProxyConfig(metrics_enabled=False) - from ccproxy.config import set_config_instance - - set_config_instance(custom_config) - - try: - provider = ConfigProvider() - - # Should load from singleton on first access - config = provider.get() - assert config.metrics_enabled is False - - # Subsequent calls return same instance - assert provider.get() is config - - finally: - clear_config_instance() - - def test_provider_set(self) -> None: - """Test ConfigProvider set functionality.""" - provider = ConfigProvider() - - # Set a specific config - custom_config = CCProxyConfig(debug=True, metrics_enabled=False) - provider.set(custom_config) - - # Should get the custom config - assert provider.get() is custom_config - assert provider.get().debug is True - assert provider.get().metrics_enabled is False - - def test_multiple_providers(self) -> None: - """Test that multiple providers can coexist.""" - # Each provider has its own config - provider1 = ConfigProvider(CCProxyConfig(debug=True)) - provider2 = ConfigProvider(CCProxyConfig(debug=False)) - - assert provider1.get().debug is True - assert provider2.get().debug is False - - # They should be independent - assert provider1.get() is not provider2.get() - - class TestProxyRuntimeConfig: """Tests for loading configuration from proxy_server runtime.""" @@ -308,7 +285,7 @@ def test_from_proxy_runtime_with_ccproxy_yaml(self) -> None: debug: true metrics_enabled: false rules: - - label: test + - name: test rule: ccproxy.rules.TokenCountRule params: - threshold: 75000 @@ -317,13 +294,12 @@ def test_from_proxy_runtime_with_ccproxy_yaml(self) -> None: # Mock Path("config.yaml") to return our temp config.yaml with mock.patch("ccproxy.config.Path") as mock_path: mock_path.return_value = config_yaml - config = CCProxyConfig.from_proxy_runtime() assert config.debug is True assert config.metrics_enabled is False assert len(config.rules) == 1 - assert config.rules[0].label == "test" + assert config.rules[0].model_name == "test" def test_from_proxy_runtime_without_ccproxy_yaml(self) -> None: """Test loading config when ccproxy.yaml doesn't exist.""" @@ -336,7 +312,6 @@ def test_from_proxy_runtime_without_ccproxy_yaml(self) -> None: # Mock Path("config.yaml") to return our temp config.yaml with mock.patch("ccproxy.config.Path") as mock_path: mock_path.return_value = config_yaml - config = CCProxyConfig.from_proxy_runtime() # Should use defaults @@ -354,7 +329,6 @@ def test_from_proxy_runtime_default_paths(self) -> None: # Mock Path to return our non-existent config.yaml with mock.patch("ccproxy.config.Path") as mock_path: mock_path.return_value = config_yaml - config = CCProxyConfig.from_proxy_runtime() # Should use defaults @@ -362,8 +336,8 @@ def test_from_proxy_runtime_default_paths(self) -> None: assert config.metrics_enabled is True assert config.rules == [] - def test_get_model_for_label_from_runtime(self) -> None: - """Test model lookup from proxy_server runtime.""" + def test_config_from_runtime(self) -> None: + """Test loading configuration from proxy_server runtime.""" # Mock proxy_server mock_proxy_server = mock.MagicMock() mock_proxy_server.general_settings = {} @@ -372,14 +346,14 @@ def test_get_model_for_label_from_runtime(self) -> None: { "model_name": "default", "litellm_params": { - "model": "claude-3-5-sonnet-20241022", + "model": "anthropic/claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com", }, }, { "model_name": "background", "litellm_params": { - "model": "claude-3-5-haiku-20241022", + "model": "anthropic/claude-haiku-4-5-20251001-20241022", "api_base": "https://api.anthropic.com", }, }, @@ -388,9 +362,9 @@ def test_get_model_for_label_from_runtime(self) -> None: with mock.patch("ccproxy.config.proxy_server", mock_proxy_server): config = CCProxyConfig.from_proxy_runtime() - assert config.get_model_for_label("default") == "claude-3-5-sonnet-20241022" - assert config.get_model_for_label("background") == "claude-3-5-haiku-20241022" - assert config.get_model_for_label("unknown") is None + # Config should be created successfully + assert config is not None + # Model lookup functionality has been moved to router.py def test_get_config_uses_runtime_when_available(self) -> None: """Test that get_config prefers runtime config when available.""" @@ -406,7 +380,7 @@ def test_get_config_uses_runtime_when_available(self) -> None: ccproxy: debug: true rules: - - label: runtime_test + - name: runtime_test rule: ccproxy.rules.TokenCountRule params: - threshold: 90000 @@ -431,7 +405,11 @@ def test_get_config_uses_runtime_when_available(self) -> None: os.chdir(temp_dir) try: - with mock.patch("ccproxy.config.proxy_server", mock_proxy_server): + # Set environment variable to point to test directory + with ( + mock.patch("ccproxy.config.proxy_server", mock_proxy_server), + mock.patch.dict(os.environ, {"CCPROXY_CONFIG_DIR": temp_dir}), + ): config = get_config() assert config.debug is True assert len(config.rules) == 1 @@ -458,7 +436,7 @@ def test_concurrent_get_config(self) -> None: ccproxy: debug: true rules: - - label: concurrent_test + - name: concurrent_test rule: ccproxy.rules.TokenCountRule params: - threshold: 50000 diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 84dab2df..5e2f67dd 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -59,7 +59,7 @@ def test_messages_with_numeric_content(self) -> None: def test_empty_model_string(self) -> None: """Test MatchModelRule with empty string model.""" - rule = MatchModelRule(model_name="claude-3-5-haiku") + rule = MatchModelRule(model_name="claude-haiku-4-5-20251001") config = CCProxyConfig() request = {"model": ""} @@ -214,14 +214,14 @@ def test_concurrent_token_fields(self) -> None: def test_model_name_partial_matches(self) -> None: """Test MatchModelRule substring matching behavior.""" - rule = MatchModelRule(model_name="claude-3-5-haiku") + rule = MatchModelRule(model_name="claude-haiku-4-5-20251001") config = CCProxyConfig() - # These should match (contain "claude-3-5-haiku") + # These should match (contain "claude-haiku-4-5-20251001") matches = [ - "claude-3-5-haiku", # Exact substring - "claude-3-5-haiku-20241022", # With version - "claude-3-5-haiku-vision", # With suffix + "claude-haiku-4-5-20251001", # Exact substring + "claude-haiku-4-5-20251001-20241022", # With version + "claude-haiku-4-5-20251001-vision", # With suffix ] for model in matches: @@ -231,10 +231,10 @@ def test_model_name_partial_matches(self) -> None: # These should NOT match non_matches = [ - "claude-3-5-sonnet", # Different model + "claude-sonnet-4-5-20250929", # Different model "claude-3-5", # Incomplete "haiku", # Just the suffix - "claude-3-haiku", # Missing "-5" + "claude-haiku-3-20241022", # Different version "claude-35-haiku", # Missing hyphens ] diff --git a/tests/test_env.py b/tests/test_env.py deleted file mode 100644 index 53eab827..00000000 --- a/tests/test_env.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for environment variable loading.""" - -import os -from pathlib import Path -from unittest import mock - -from dotenv import load_dotenv - - -def test_env_example_exists() -> None: - """Test that .env.example file exists.""" - env_example = Path(__file__).parent.parent / ".env.example" - assert env_example.exists() - assert env_example.is_file() - - -def test_env_example_contains_required_vars() -> None: - """Test that .env.example contains all required environment variables.""" - env_example = Path(__file__).parent.parent / ".env.example" - content = env_example.read_text() - - required_vars = [ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "LOG_LEVEL", - ] - - for var in required_vars: - assert var in content, f"Missing required variable: {var}" - - -def test_env_loading_with_dotenv() -> None: - """Test that environment variables can be loaded with python-dotenv.""" - # Create a temporary .env file - test_env_content = """ -LOG_LEVEL=DEBUG -ANTHROPIC_API_KEY=test_key -""" - - with ( - mock.patch("pathlib.Path.exists", return_value=True), - mock.patch("pathlib.Path.read_text", return_value=test_env_content), - ): - # Clear existing env vars - for key in ["LOG_LEVEL", "ANTHROPIC_API_KEY"]: - os.environ.pop(key, None) - - # Load from mocked file - load_dotenv() - - # Note: Since we're mocking, we need to manually set these - # In real usage, load_dotenv would handle this - os.environ["LOG_LEVEL"] = "DEBUG" - os.environ["ANTHROPIC_API_KEY"] = "test_key" - - # Verify values - assert os.getenv("LOG_LEVEL") == "DEBUG" - assert os.getenv("ANTHROPIC_API_KEY") == "test_key" - - -def test_default_values_when_env_not_set() -> None: - """Test that sensible defaults are used when environment variables are not set.""" - # Clear environment variables - os.environ.pop("LOG_LEVEL", None) - - # Test defaults - log_level = os.getenv("LOG_LEVEL", "INFO") - - assert log_level == "INFO" diff --git a/tests/test_extensibility.py b/tests/test_extensibility.py index 712a129f..20813970 100644 --- a/tests/test_extensibility.py +++ b/tests/test_extensibility.py @@ -46,25 +46,25 @@ def test_add_custom_rule(self) -> None: classifier = RequestClassifier() custom_rule = CustomHeaderRule() - # Add custom rule with label + # Add custom rule with model_name classifier.add_rule("background", custom_rule) # Test that custom rule works request = { - "model": "claude-3-5-sonnet", + "model": "claude-sonnet-4-5-20250929", "messages": [{"role": "user", "content": "Hello"}], "headers": {"X-Priority": "low"}, } - label = classifier.classify(request) - assert label == "background" + model_name = classifier.classify(request) + assert model_name == "background" def test_custom_rule_priority(self) -> None: """Test that custom rules respect order of addition.""" classifier = RequestClassifier() # Clear default rules and add custom rules - classifier.clear_rules() + classifier._clear_rules() classifier.add_rule("background", CustomHeaderRule()) # Maps to background classifier.add_rule("think", CustomUserAgentRule()) # Maps to think @@ -77,17 +77,17 @@ def test_custom_rule_priority(self) -> None: } # Should match first rule (CustomHeaderRule) - label = classifier.classify(request) - assert label == "background" + model_name = classifier.classify(request) + assert model_name == "background" # Now reverse the order - classifier.clear_rules() + classifier._clear_rules() classifier.add_rule("think", CustomUserAgentRule()) classifier.add_rule("background", CustomHeaderRule()) # Same request should now return think (first matching rule) - label = classifier.classify(request) - assert label == "think" + model_name = classifier.classify(request) + assert model_name == "think" def test_custom_rule_with_config(self) -> None: """Test custom rule that uses configuration.""" @@ -97,19 +97,19 @@ def test_custom_rule_with_config(self) -> None: classifier.add_rule("think", env_rule) request = { - "model": "claude-3-5-sonnet", + "model": "claude-sonnet-4-5-20250929", "metadata": {"environment": "staging"}, } - label = classifier.classify(request) - assert label == "think" + model_name = classifier.classify(request) + assert model_name == "think" def test_replace_all_rules(self) -> None: """Test completely replacing default rules with custom ones.""" classifier = RequestClassifier() # Clear all default rules - classifier.clear_rules() + classifier._clear_rules() # Add only custom rules classifier.add_rule("background", CustomHeaderRule()) @@ -118,86 +118,91 @@ def test_replace_all_rules(self) -> None: # Test that default rules no longer apply # This would normally trigger TokenCountRule request = { - "model": "claude-3-5-sonnet", + "model": "claude-sonnet-4-5-20250929", "token_count": 100000, # Would trigger token_count normally } - label = classifier.classify(request) - assert label == "default" # No rules match + model_name = classifier.classify(request) + assert model_name == "default" # No rules match # But custom rules still work request["headers"] = {"X-Priority": "low"} - label = classifier.classify(request) - assert label == "background" + model_name = classifier.classify(request) + assert model_name == "background" def test_reset_to_default_rules(self) -> None: """Test resetting to default rules after customization.""" - from unittest.mock import Mock - from ccproxy.config import ConfigProvider, RuleConfig + from ccproxy.config import CCProxyConfig, RuleConfig, clear_config_instance, set_config_instance - # Mock config with token_count rule - mock_config = Mock() - mock_config.rules = [ - RuleConfig(label="token_count", rule_path="ccproxy.rules.TokenCountRule", params=[{"threshold": 60000}]) + # Create test config with token_count rule + test_config = CCProxyConfig() + test_config.rules = [ + RuleConfig(name="token_count", rule_path="ccproxy.rules.TokenCountRule", params=[{"threshold": 60000}]) ] - mock_provider = Mock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + # Set the test config + clear_config_instance() + set_config_instance(test_config) - classifier = RequestClassifier(config_provider=mock_provider) + try: + classifier = RequestClassifier() - # Add custom rule - classifier.add_rule("background", CustomHeaderRule()) + # Add custom rule + classifier.add_rule("background", CustomHeaderRule()) - # Clear and add only custom - classifier.clear_rules() - classifier.add_rule("background", CustomHeaderRule()) + # Clear and add only custom + classifier._clear_rules() + classifier.add_rule("background", CustomHeaderRule()) - # Verify default rules don't work - request = {"token_count": 100000} - label = classifier.classify(request) - assert label == "default" + # Verify default rules don't work + request = {"token_count": 100000} + model_name = classifier.classify(request) + assert model_name == "default" - # Reset to defaults - classifier.reset_rules() + # Reset to defaults + classifier._setup_rules() - # Now default rules work again - label = classifier.classify(request) - assert label == "token_count" + # Now default rules work again + model_name = classifier.classify(request) + assert model_name == "token_count" + finally: + clear_config_instance() def test_mixed_default_and_custom_rules(self) -> None: """Test using both default and custom rules together.""" - from unittest.mock import Mock - - from ccproxy.config import ConfigProvider, RuleConfig + from ccproxy.config import CCProxyConfig, RuleConfig, clear_config_instance, set_config_instance - # Mock config with token_count rule - mock_config = Mock() - mock_config.rules = [ - RuleConfig(label="token_count", rule_path="ccproxy.rules.TokenCountRule", params=[{"threshold": 60000}]) + # Create test config with token_count rule + test_config = CCProxyConfig() + test_config.rules = [ + RuleConfig(name="token_count", rule_path="ccproxy.rules.TokenCountRule", params=[{"threshold": 60000}]) ] - mock_provider = Mock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + # Set the test config + clear_config_instance() + set_config_instance(test_config) - classifier = RequestClassifier(config_provider=mock_provider) + try: + classifier = RequestClassifier() - # Add custom rule on top of defaults - classifier.add_rule("production", CustomEnvironmentRule("production")) + # Add custom rule on top of defaults + classifier.add_rule("production", CustomEnvironmentRule("production")) - # Test default rule (token count) - request = {"token_count": 100000} - label = classifier.classify(request) - assert label == "token_count" + # Test default rule (token count) + request = {"token_count": 100000} + model_name = classifier.classify(request) + assert model_name == "token_count" - # Test custom rule - request = { - "model": "claude-3-5-sonnet", - "metadata": {"environment": "production"}, - } - label = classifier.classify(request) - assert label == "production" + # Test custom rule + request = { + "model": "claude-sonnet-4-5-20250929", + "metadata": {"environment": "production"}, + } + model_name = classifier.classify(request) + assert model_name == "production" + finally: + clear_config_instance() def test_custom_rule_edge_cases(self) -> None: """Test edge cases with custom rules.""" @@ -222,13 +227,13 @@ def evaluate(self, request: dict, config: CCProxyConfig) -> bool: # Test never-matching rule request = {"model": "any"} - label = classifier.classify(request) - assert label == "default" + model_name = classifier.classify(request) + assert model_name == "default" # Test nested data rule request = {"data": {"nested": {"value": "special"}}} - label = classifier.classify(request) - assert label == "web_search" + model_name = classifier.classify(request) + assert model_name == "web_search" def test_stateful_custom_rule(self) -> None: """Test custom rule with internal state.""" @@ -250,13 +255,13 @@ def evaluate(self, request: dict, config: CCProxyConfig) -> bool: request = {"model": "claude"} # First call - no match (count=1) - label = classifier.classify(request) - assert label == "default" + model_name = classifier.classify(request) + assert model_name == "default" # Second call - match (count=2) - label = classifier.classify(request) - assert label == "background" + model_name = classifier.classify(request) + assert model_name == "background" # Third call - no match (count=3) - label = classifier.classify(request) - assert label == "default" + model_name = classifier.classify(request) + assert model_name == "default" diff --git a/tests/test_handler.py b/tests/test_handler.py index a3b056c2..c383c273 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,19 +1,36 @@ -"""Tests for CCProxyHandler and routing function.""" +"""Tests for ccproxy handler and routing function.""" import tempfile from pathlib import Path -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock, patch import pytest import yaml -from ccproxy.config import CCProxyConfig, clear_config_instance, set_config_instance -from ccproxy.handler import CCProxyHandler, ccproxy_get_model -from ccproxy.router import clear_router +from ccproxy.config import CCProxyConfig, RuleConfig, clear_config_instance, set_config_instance +from ccproxy.handler import CCProxyHandler +from ccproxy.router import ModelRouter, clear_router -class TestCCProxyGetModel: - """Tests for ccproxy_get_model routing function.""" +class TestCCProxyRouting: + """Tests for ccproxy handler routing logic.""" + + def _create_router_with_models(self, model_list: list) -> ModelRouter: + """Helper to create a router with mocked models.""" + mock_config = MagicMock(spec=CCProxyConfig) + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = model_list + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + with ( + patch("ccproxy.router.get_config", return_value=mock_config), + patch.dict("sys.modules", {"litellm.proxy": mock_module}), + ): + return ModelRouter() @pytest.fixture def config_files(self): @@ -24,13 +41,13 @@ def config_files(self): { "model_name": "default", "litellm_params": { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", }, }, { "model_name": "background", "litellm_params": { - "model": "claude-3-5-haiku-20241022", + "model": "claude-haiku-4-5-20251001-20241022", }, }, { @@ -58,24 +75,29 @@ def config_files(self): ccproxy_data = { "ccproxy": { "debug": False, + "hooks": [ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", + "ccproxy.hooks.forward_oauth", + ], "rules": [ { - "label": "token_count", + "name": "token_count", "rule": "ccproxy.rules.TokenCountRule", "params": [{"threshold": 60000}], }, { - "label": "background", + "name": "background", "rule": "ccproxy.rules.MatchModelRule", - "params": [{"model_name": "claude-3-5-haiku-20241022"}], + "params": [{"model_name": "claude-haiku-4-5-20251001-20241022"}], }, { - "label": "think", + "name": "think", "rule": "ccproxy.rules.ThinkingRule", "params": [], }, { - "label": "web_search", + "name": "web_search", "rule": "ccproxy.rules.MatchToolRule", "params": [{"tool_name": "web_search"}], }, @@ -97,7 +119,7 @@ def config_files(self): litellm_path.unlink() ccproxy_path.unlink() - def test_route_to_default(self, config_files): + async def test_route_to_default(self, config_files): """Test routing simple request to default model.""" ccproxy_path, litellm_path = config_files @@ -105,14 +127,101 @@ def test_route_to_default(self, config_files): config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) set_config_instance(config) + # Create model list for mocking + test_model_list = [ + { + "model_name": "default", + "litellm_params": {"model": "claude-sonnet-4-5-20250929"}, + }, + { + "model_name": "background", + "litellm_params": {"model": "claude-haiku-4-5-20251001-20241022"}, + }, + { + "model_name": "think", + "litellm_params": {"model": "claude-3-5-opus-20250514"}, + }, + { + "model_name": "token_count", + "litellm_params": {"model": "gemini-2.5-pro"}, + }, + { + "model_name": "web_search", + "litellm_params": {"model": "perplexity/llama-3.1-sonar-large-128k-online"}, + }, + ] + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = test_model_list + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + try: - request_data = { - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Hello"}], - } + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + handler = CCProxyHandler() + request_data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "Hello"}], + } + user_api_key_dict = {} + + result = await handler.async_pre_call_hook(request_data, user_api_key_dict) + assert result["model"] == "claude-sonnet-4-5-20250929" + finally: + clear_config_instance() + clear_router() - model = ccproxy_get_model(request_data) - assert model == "claude-3-5-sonnet-20241022" + async def test_route_to_background(self, config_files): + """Test routing haiku model to background.""" + ccproxy_path, litellm_path = config_files + + config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) + set_config_instance(config) + + # Create model list for mocking + test_model_list = [ + { + "model_name": "default", + "litellm_params": {"model": "claude-sonnet-4-5-20250929"}, + }, + { + "model_name": "background", + "litellm_params": {"model": "claude-haiku-4-5-20251001-20241022"}, + }, + { + "model_name": "think", + "litellm_params": {"model": "claude-3-5-opus-20250514"}, + }, + { + "model_name": "token_count", + "litellm_params": {"model": "gemini-2.5-pro"}, + }, + { + "model_name": "web_search", + "litellm_params": {"model": "perplexity/llama-3.1-sonar-large-128k-online"}, + }, + ] + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = test_model_list + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + try: + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + handler = CCProxyHandler() + request_data = { + "model": "claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "Format this code"}], + } + user_api_key_dict = {} + + result = await handler.async_pre_call_hook(request_data, user_api_key_dict) + assert result["model"] == "claude-haiku-4-5-20251001-20241022" finally: clear_config_instance() clear_router() @@ -121,9 +230,99 @@ def test_route_to_default(self, config_files): class TestHandlerHookMethods: """Test suite for individual hook methods that haven't been covered.""" + @pytest.fixture + def config_files(self): + """Create temporary ccproxy.yaml and litellm config files.""" + # Create litellm config + litellm_data = { + "model_list": [ + { + "model_name": "default", + "litellm_params": { + "model": "claude-sonnet-4-5-20250929", + }, + }, + { + "model_name": "background", + "litellm_params": { + "model": "claude-haiku-4-5-20251001-20241022", + }, + }, + ], + } + + # Create ccproxy config + ccproxy_data = { + "ccproxy": { + "debug": False, + "hooks": [ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", + "ccproxy.hooks.forward_oauth", + ], + "rules": [ + { + "name": "background", + "rule": "ccproxy.rules.MatchModelRule", + "params": [{"model_name": "claude-haiku-4-5-20251001-20241022"}], + }, + ], + } + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as litellm_file: + yaml.dump(litellm_data, litellm_file) + litellm_path = Path(litellm_file.name) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as ccproxy_file: + yaml.dump(ccproxy_data, ccproxy_file) + ccproxy_path = Path(ccproxy_file.name) + + yield ccproxy_path, litellm_path + + # Cleanup + litellm_path.unlink() + ccproxy_path.unlink() + + @pytest.fixture + def handler(self) -> CCProxyHandler: + """Create a ccproxy handler instance with mocked router.""" + # Create a minimal config with hooks + config = CCProxyConfig( + debug=False, + hooks=[ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", + ], + rules=[], + ) + set_config_instance(config) + + # Mock proxy server with default model + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": {"model": "claude-sonnet-4-5-20250929"}, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + try: + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() # Clear any existing router + handler = CCProxyHandler() + yield handler + finally: + clear_config_instance() + clear_router() + @pytest.mark.asyncio async def test_log_success_hook(self, handler: CCProxyHandler) -> None: - """Test async_log_success_hook method.""" + """Test async_log_success_event method.""" kwargs = { "litellm_params": {}, "start_time": 1234567890, @@ -133,11 +332,11 @@ async def test_log_success_hook(self, handler: CCProxyHandler) -> None: response_obj = Mock(model="test-model", usage=Mock(completion_tokens=10, prompt_tokens=20, total_tokens=30)) # Should not raise any exceptions - await handler.async_log_success_hook(kwargs, response_obj, 1234567890, 1234567900) + await handler.async_log_success_event(kwargs, response_obj, 1234567890, 1234567900) @pytest.mark.asyncio async def test_log_failure_hook(self, handler: CCProxyHandler) -> None: - """Test async_log_failure_hook method.""" + """Test async_log_failure_event method.""" kwargs = { "litellm_params": {}, "start_time": 1234567890, @@ -146,49 +345,53 @@ async def test_log_failure_hook(self, handler: CCProxyHandler) -> None: response_obj = Mock() # Should not raise any exceptions - await handler.async_log_failure_hook(kwargs, response_obj, 1234567890, 1234567900) + await handler.async_log_failure_event(kwargs, response_obj, 1234567890, 1234567900) @pytest.mark.asyncio async def test_logging_hook_with_completion(self, handler: CCProxyHandler) -> None: - """Test async_logging_hook with completion call type.""" + """Test async_pre_call_hook with completion call type.""" # Create mock data - kwargs = {"litellm_params": {}} - response_obj = Mock() - call_type = "completion" + data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "Hello"}], + } + user_api_key_dict = {} # Should return without error - result = await handler.async_logging_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=None, - end_time=None, - user_api_key_dict={}, - call_type=call_type, + result = await handler.async_pre_call_hook( + data, + user_api_key_dict, ) - # Should return None or the response_obj - assert result is None or result == response_obj + # Should return the modified data + assert isinstance(result, dict) + assert "model" in result + assert "metadata" in result @pytest.mark.asyncio async def test_logging_hook_with_unsupported_call_type(self, handler: CCProxyHandler) -> None: - """Test async_logging_hook with unsupported call type.""" - # Create mock data - kwargs = {"litellm_params": {}} - response_obj = Mock() - call_type = "embeddings" # Not supported + """Test async_pre_call_hook with various request data.""" + # Create mock data with a different model + data = { + "model": "gpt-4", # Not in our config, should use default + "messages": [{"role": "user", "content": "Test"}], + } + user_api_key_dict = {} # Should return without error - result = await handler.async_logging_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=None, - end_time=None, - user_api_key_dict={}, - call_type=call_type, + result = await handler.async_pre_call_hook( + data, + user_api_key_dict, ) - # Should return None or the response_obj - assert result is None or result == response_obj + # Should return the modified data - gpt-4 is not in our config so it gets classified as default + # With passthrough enabled, default requests keep the original model instead of routing + assert isinstance(result, dict) + assert result["model"] == "gpt-4" # Should keep original model due to passthrough + # Metadata should be added + assert "metadata" in result + assert result["metadata"]["ccproxy_model_name"] == "default" + assert result["metadata"]["ccproxy_alias_model"] == "gpt-4" @pytest.mark.asyncio async def test_log_stream_event(self, handler: CCProxyHandler) -> None: @@ -212,28 +415,9 @@ async def test_async_log_stream_event(self, handler: CCProxyHandler) -> None: # Should not raise any exceptions await handler.async_log_stream_event(kwargs, response_obj, start_time, end_time) - def test_route_to_background(self, config_files): - """Test routing haiku model to background.""" - ccproxy_path, litellm_path = config_files - - config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) - set_config_instance(config) - - try: - request_data = { - "model": "claude-3-5-haiku-20241022", - "messages": [{"role": "user", "content": "Format this code"}], - } - - model = ccproxy_get_model(request_data) - assert model == "claude-3-5-haiku-20241022" - finally: - clear_config_instance() - clear_router() - class TestCCProxyHandler: - """Tests for CCProxyHandler class.""" + """Tests for ccproxy handler class.""" @pytest.fixture def handler(self, config_files): @@ -242,9 +426,43 @@ def handler(self, config_files): config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) set_config_instance(config) - yield CCProxyHandler() - clear_config_instance() - clear_router() + + # Create model list for mocking + test_model_list = [ + { + "model_name": "default", + "litellm_params": {"model": "claude-sonnet-4-5-20250929"}, + }, + { + "model_name": "background", + "litellm_params": {"model": "claude-haiku-4-5-20251001-20241022"}, + }, + ] + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = test_model_list + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # We need to patch the proxy_server import for the handler's initialization + # This will ensure the router gets the mocked model list + import sys + + original_module = sys.modules.get("litellm.proxy") + sys.modules["litellm.proxy"] = mock_module + + try: + handler = CCProxyHandler() + yield handler + finally: + if original_module is None: + sys.modules.pop("litellm.proxy", None) + else: + sys.modules["litellm.proxy"] = original_module + clear_config_instance() + clear_router() @pytest.fixture def config_files(self): @@ -255,13 +473,13 @@ def config_files(self): { "model_name": "default", "litellm_params": { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", }, }, { "model_name": "background", "litellm_params": { - "model": "claude-3-5-haiku-20241022", + "model": "claude-haiku-4-5-20251001-20241022", }, }, ], @@ -271,11 +489,16 @@ def config_files(self): ccproxy_data = { "ccproxy": { "debug": False, + "hooks": [ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", + "ccproxy.hooks.forward_oauth", + ], "rules": [ { - "label": "background", + "name": "background", "rule": "ccproxy.rules.MatchModelRule", - "params": [{"model_name": "claude-3-5-haiku-20241022"}], + "params": [{"model_name": "claude-haiku-4-5-20251001-20241022"}], }, ], } @@ -298,7 +521,7 @@ def config_files(self): async def test_async_pre_call_hook(self, handler): """Test async_pre_call_hook modifies request correctly.""" request_data = { - "model": "claude-3-5-haiku-20241022", + "model": "claude-haiku-4-5-20251001-20241022", "messages": [{"role": "user", "content": "Hello"}], } user_api_key_dict = {} @@ -310,17 +533,17 @@ async def test_async_pre_call_hook(self, handler): ) # Check model was routed - assert modified_data["model"] == "claude-3-5-haiku-20241022" + assert modified_data["model"] == "claude-haiku-4-5-20251001-20241022" # Check metadata was added assert "metadata" in modified_data - assert modified_data["metadata"]["ccproxy_label"] == "background" - assert modified_data["metadata"]["ccproxy_original_model"] == "claude-3-5-haiku-20241022" + assert modified_data["metadata"]["ccproxy_model_name"] == "background" + assert modified_data["metadata"]["ccproxy_alias_model"] == "claude-haiku-4-5-20251001-20241022" async def test_async_pre_call_hook_preserves_existing_metadata(self, handler): """Test that existing metadata is preserved.""" request_data = { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", "messages": [{"role": "user", "content": "Hello"}], "metadata": { "existing_key": "existing_value", @@ -338,18 +561,50 @@ async def test_async_pre_call_hook_preserves_existing_metadata(self, handler): assert modified_data["metadata"]["existing_key"] == "existing_value" # Check new metadata added - assert modified_data["metadata"]["ccproxy_label"] == "default" - assert modified_data["metadata"]["ccproxy_original_model"] == "claude-3-5-sonnet-20241022" + assert modified_data["metadata"]["ccproxy_model_name"] == "default" + assert modified_data["metadata"]["ccproxy_alias_model"] == "claude-sonnet-4-5-20250929" async def test_handler_uses_config_threshold(self): """Test that handler uses context threshold from config.""" # Create config with custom threshold - litellm_data = { - "model_list": [ + ccproxy_data = { + "ccproxy": { + "debug": False, + "hooks": [ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", + ], + "rules": [ + { + "name": "token_count", + "rule": "ccproxy.rules.TokenCountRule", + "params": [{"threshold": 10000}], # Lower threshold + }, + ], + } + } + + # Create a dummy litellm config file (required by CCProxyConfig) + litellm_data = {"model_list": []} + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as litellm_file: + yaml.dump(litellm_data, litellm_file) + litellm_path = Path(litellm_file.name) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as ccproxy_file: + yaml.dump(ccproxy_data, ccproxy_file) + ccproxy_path = Path(ccproxy_file.name) + + try: + config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) + set_config_instance(config) + + # Create model list for mocking + test_model_list = [ { "model_name": "default", "litellm_params": { - "model": "claude-3-5-sonnet-20241022", + "model": "claude-sonnet-4-5-20250929", }, }, { @@ -358,22 +613,61 @@ async def test_handler_uses_config_threshold(self): "model": "gemini-2.5-pro", }, }, - ], - } + ] + + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = test_model_list + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + handler = CCProxyHandler() + + # Create request with >10k tokens using varied text + base_text = "The quick brown fox jumps over the lazy dog. " * 50 # ~501 tokens + large_message = base_text * 21 # ~10521 tokens (above 10000 threshold) + request_data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": large_message}], + } + user_api_key_dict = {} + + # Call the hook + modified_data = await handler.async_pre_call_hook( + request_data, + user_api_key_dict, + ) + + # Should route to token_count + assert modified_data["model"] == "gemini-2.5-pro" + assert modified_data["metadata"]["ccproxy_model_name"] == "token_count" + + finally: + ccproxy_path.unlink() + litellm_path.unlink() + clear_config_instance() + clear_router() + + @pytest.mark.asyncio + async def test_hooks_loaded_from_config(self) -> None: + """Test that hooks are loaded from configuration file.""" + # Create config with hooks ccproxy_data = { "ccproxy": { "debug": False, - "rules": [ - { - "label": "token_count", - "rule": "ccproxy.rules.TokenCountRule", - "params": [{"threshold": 10000}], # Lower threshold - }, + "hooks": [ + "ccproxy.hooks.rule_evaluator", + "ccproxy.hooks.model_router", ], + "rules": [], } } + # Create a dummy litellm config file + litellm_data = {"model_list": []} + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as litellm_file: yaml.dump(litellm_data, litellm_file) litellm_path = Path(litellm_file.name) @@ -386,124 +680,134 @@ async def test_handler_uses_config_threshold(self): config = CCProxyConfig.from_yaml(ccproxy_path, litellm_config_path=litellm_path) set_config_instance(config) - handler = CCProxyHandler() + # Mock proxy server + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [] - # Create request with >10k tokens (10k threshold * 4 chars/token = 40k+ chars) - large_message = "a" * 45000 # ~11.25k tokens - request_data = { - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": large_message}], - } - user_api_key_dict = {} + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server - # Call the hook - modified_data = await handler.async_pre_call_hook( - request_data, - user_api_key_dict, - ) + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + handler = CCProxyHandler() - # Should route to token_count - assert modified_data["model"] == "gemini-2.5-pro" - assert modified_data["metadata"]["ccproxy_label"] == "token_count" + # Verify hooks were loaded + assert len(handler.hooks) == 2 + assert any("rule_evaluator" in str(h) for h in handler.hooks) + assert any("model_router" in str(h) for h in handler.hooks) finally: - litellm_path.unlink() ccproxy_path.unlink() + litellm_path.unlink() clear_config_instance() clear_router() + @pytest.mark.asyncio + async def test_no_default_model_fallback(self) -> None: + """Test that handler continues processing when no 'default' label is configured.""" + # Create config without a 'default' model + ccproxy_config = CCProxyConfig( + debug=False, + rules=[ + RuleConfig( + name="token_count", + rule_path="ccproxy.rules.TokenCountRule", + params=[{"threshold": 60000}], + ), + ], + ) + set_config_instance(ccproxy_config) + + # Mock proxy server with only token_count model (no default) + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "token_count", + "litellm_params": {"model": "gemini-2.5-pro"}, + }, + ] -class TestHandlerLoggingHookMethods: - """Test suite for individual hook methods that haven't been covered.""" + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server - @pytest.mark.asyncio - async def test_log_success_hook(self) -> None: - """Test async_log_success_hook method.""" - handler = CCProxyHandler() - kwargs = { - "litellm_params": {}, - "start_time": 1234567890, - "end_time": 1234567900, - "cache_hit": False, - } - response_obj = Mock(model="test-model", usage=Mock(completion_tokens=10, prompt_tokens=20, total_tokens=30)) + try: + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() # Clear router to force reload + handler = CCProxyHandler() - # Should not raise any exceptions - await handler.async_log_success_hook(kwargs, response_obj, 1234567890, 1234567900) + # Test with request that doesn't match any rule + request_data = { + "model": "claude-opus-4-5-20251101", + "messages": [{"role": "user", "content": "Hello"}], + "token_count": 100, # Below threshold + } + user_api_key_dict = {} - @pytest.mark.asyncio - async def test_log_failure_hook(self, handler: CCProxyHandler) -> None: - """Test async_log_failure_hook method.""" - kwargs = { - "litellm_params": {}, - "start_time": 1234567890, - "end_time": 1234567900, - } - response_obj = Mock() + # Should log error but continue processing + result = await handler.async_pre_call_hook(request_data, user_api_key_dict) - # Should not raise any exceptions - await handler.async_log_failure_hook(kwargs, response_obj, 1234567890, 1234567900) + # Verify request continues with original model + assert result["model"] == "claude-opus-4-5-20251101" - @pytest.mark.asyncio - async def test_logging_hook_with_completion(self, handler: CCProxyHandler) -> None: - """Test async_logging_hook with completion call type.""" - # Create mock data - kwargs = {"litellm_params": {}} - response_obj = Mock() - call_type = "completion" + # Test with missing model field + request_data_no_model = { + "messages": [{"role": "user", "content": "Hello"}], + "token_count": 100, # Below threshold + } - # Should return without error - result = await handler.async_logging_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=None, - end_time=None, - user_api_key_dict={}, - call_type=call_type, - ) + # Should log error but continue processing + await handler.async_pre_call_hook(request_data_no_model, user_api_key_dict) - # Should return None or the response_obj - assert result is None or result == response_obj + finally: + clear_config_instance() + clear_router() @pytest.mark.asyncio - async def test_logging_hook_with_unsupported_call_type(self, handler: CCProxyHandler) -> None: - """Test async_logging_hook with unsupported call type.""" - # Create mock data - kwargs = {"litellm_params": {}} - response_obj = Mock() - call_type = "embeddings" # Not supported + async def test_log_routing_decision_fallback_scenario(self) -> None: + """Test _log_routing_decision with fallback scenario (lines 135-136).""" + # Set up handler with debug mode + config = CCProxyConfig(debug=True) + clear_config_instance() + set_config_instance(config) - # Should return without error - result = await handler.async_logging_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=None, - end_time=None, - user_api_key_dict={}, - call_type=call_type, - ) + try: + handler = CCProxyHandler() + + # Test fallback scenario where model_config is None + # This tests lines 135-136: color = "yellow", routing_type = "FALLBACK" + handler._log_routing_decision( + model_name="default", + original_model="gpt-4", + routed_model="claude-sonnet-4-5-20250929", + model_config=None, # This triggers the fallback path + ) - # Should return None or the response_obj - assert result is None or result == response_obj + finally: + clear_config_instance() + clear_router() @pytest.mark.asyncio - async def test_log_stream_event(self, handler: CCProxyHandler) -> None: - """Test log_stream_event method.""" - kwargs = {"litellm_params": {}} - response_obj = Mock() - start_time = 1234567890 - end_time = 1234567900 + async def test_log_routing_decision_passthrough_scenario(self) -> None: + """Test _log_routing_decision with passthrough scenario (lines 139-140).""" + # Set up handler with debug mode + config = CCProxyConfig(debug=True) + clear_config_instance() + set_config_instance(config) - # Should not raise any exceptions - handler.log_stream_event(kwargs, response_obj, start_time, end_time) + try: + handler = CCProxyHandler() - @pytest.mark.asyncio - async def test_async_log_stream_event(self, handler: CCProxyHandler) -> None: - """Test async_log_stream_event method.""" - kwargs = {"litellm_params": {}} - response_obj = Mock() - start_time = 1234567890 - end_time = 1234567900 + # Test passthrough scenario where original_model == routed_model + # This tests lines 139-140: color = "dim", routing_type = "PASSTHROUGH" + model_config = {"model_info": {"some": "config"}} + handler._log_routing_decision( + model_name="default", + original_model="claude-sonnet-4-5-20250929", + routed_model="claude-sonnet-4-5-20250929", # Same as original = passthrough + model_config=model_config, + ) - # Should not raise any exceptions - await handler.async_log_stream_event(kwargs, response_obj, start_time, end_time) + finally: + clear_config_instance() + clear_router() diff --git a/tests/test_handler_logging.py b/tests/test_handler_logging.py index eb500423..d3bb822c 100644 --- a/tests/test_handler_logging.py +++ b/tests/test_handler_logging.py @@ -1,10 +1,11 @@ -"""Additional tests for CCProxyHandler logging hook methods.""" +"""Additional tests for ccproxy handler logging hook methods.""" +from datetime import timedelta from unittest.mock import Mock, patch import pytest -from ccproxy.handler import CCProxyHandler, ccproxy_get_model +from ccproxy.handler import CCProxyHandler class TestHandlerLoggingHookMethods: @@ -14,7 +15,7 @@ class TestHandlerLoggingHookMethods: async def test_log_success_event(self) -> None: """Test async_log_success_event method.""" handler = CCProxyHandler() - kwargs = {"metadata": {"request_id": "test-123", "ccproxy_label": "default"}, "model": "test-model"} + kwargs = {"metadata": {"ccproxy_model_name": "default"}, "model": "test-model"} response_obj = Mock(model="test-model", usage=Mock(prompt_tokens=20, completion_tokens=10, total_tokens=30)) # Should not raise any exceptions @@ -24,7 +25,7 @@ async def test_log_success_event(self) -> None: async def test_log_failure_event(self) -> None: """Test async_log_failure_event method.""" handler = CCProxyHandler() - kwargs = {"metadata": {"request_id": "test-123", "ccproxy_label": "default"}, "model": "test-model"} + kwargs = {"metadata": {"ccproxy_model_name": "default"}, "model": "test-model"} response_obj = Exception("Test error") # Should not raise any exceptions @@ -34,7 +35,7 @@ async def test_log_failure_event(self) -> None: async def test_async_log_stream_event(self) -> None: """Test async_log_stream_event method.""" handler = CCProxyHandler() - kwargs = {"metadata": {"request_id": "test-123", "ccproxy_label": "default"}, "model": "test-model"} + kwargs = {"metadata": {"ccproxy_model_name": "default"}, "model": "test-model"} response_obj = Mock() start_time = 1234567890 end_time = 1234567900 @@ -45,67 +46,115 @@ async def test_async_log_stream_event(self) -> None: @pytest.mark.asyncio async def test_async_pre_call_hook_with_invalid_request(self) -> None: """Test async_pre_call_hook with invalid request format.""" - handler = CCProxyHandler() + # Mock the router to provide a default model + with ( + patch("ccproxy.handler.get_router") as mock_get_router, + patch("ccproxy.handler.get_config") as mock_get_config, + ): + from ccproxy.router import ModelRouter + + mock_router = Mock(spec=ModelRouter) + mock_router.get_model_for_label.return_value = { + "model_name": "default", + "litellm_params": {"model": "claude-sonnet-4-5-20250929"}, + } + mock_get_router.return_value = mock_router + + # Mock config to include hooks + mock_config = Mock() + mock_config.debug = False + + # Create a mock hook that adds metadata and model + def mock_rule_evaluator(data, user_api_key_dict, **kwargs): + if "metadata" not in data: + data["metadata"] = {} + data["metadata"]["ccproxy_model_name"] = "default" + data["metadata"]["ccproxy_alias_model"] = None + # Add model field if missing (simulating model_router hook) + if "model" not in data: + data["model"] = "claude-sonnet-4-5-20250929" + return data + + mock_config.load_hooks.return_value = [(mock_rule_evaluator, {})] + mock_get_config.return_value = mock_config + + handler = CCProxyHandler() + + # Missing model field - should use default + data = {"messages": [{"role": "user", "content": "test"}]} + + # Should not raise - adds metadata and uses default model + result = await handler.async_pre_call_hook(data, {}) + assert "metadata" in result + assert result["metadata"]["ccproxy_model_name"] == "default" + assert result["metadata"]["ccproxy_alias_model"] is None + assert result["model"] == "claude-sonnet-4-5-20250929" + + @pytest.mark.asyncio + async def test_handler_with_debug_hook_logging(self) -> None: + """Test handler debug logging of hooks during initialization.""" + with ( + patch("ccproxy.handler.get_router") as mock_get_router, + patch("ccproxy.handler.get_config") as mock_get_config, + patch("ccproxy.handler.logger") as mock_logger, + ): + # Mock config with debug=True and hooks + mock_config = Mock() + mock_config.debug = True + + def mock_hook(data, user_api_key_dict, **kwargs): + return data + + mock_hook.__module__ = "test_module" + mock_hook.__name__ = "test_hook" + + mock_config.load_hooks.return_value = [(mock_hook, {})] + mock_get_config.return_value = mock_config + + mock_router = Mock() + mock_get_router.return_value = mock_router + + # Create handler - should log hooks + handler = CCProxyHandler() + + # Verify debug logging occurred + mock_logger.debug.assert_called_once_with("Loaded 1 hooks: test_module.test_hook") + + @pytest.mark.asyncio + async def test_hook_error_handling(self) -> None: + """Test handler error handling when hooks fail.""" + with ( + patch("ccproxy.handler.get_router") as mock_get_router, + patch("ccproxy.handler.get_config") as mock_get_config, + patch("ccproxy.handler.logger") as mock_logger, + ): + # Mock router + mock_router = Mock() + mock_get_router.return_value = mock_router + + # Mock config with a failing hook + mock_config = Mock() + mock_config.debug = False + + def failing_hook(data, user_api_key_dict, **kwargs): + raise ValueError("Hook failed!") + + failing_hook.__name__ = "failing_hook" + + mock_config.load_hooks.return_value = [(failing_hook, {})] + mock_get_config.return_value = mock_config + + handler = CCProxyHandler() + data = {"messages": [{"role": "user", "content": "test"}]} - # Missing model field - should use default - data = {"messages": [{"role": "user", "content": "test"}]} - - # Should not raise - adds metadata and uses original model - result = await handler.async_pre_call_hook(data, {}) - assert "metadata" in result - assert result["metadata"]["ccproxy_label"] == "default" - assert result["metadata"]["ccproxy_original_model"] == "unknown" - - @patch("ccproxy.handler.get_config") - @patch("ccproxy.handler.get_router") - @patch("ccproxy.handler.RequestClassifier") - def test_ccproxy_get_model(self, mock_classifier_class: Mock, mock_get_router: Mock, mock_get_config: Mock) -> None: - """Test ccproxy_get_model function.""" - # Setup mocks - mock_config = Mock(debug=True) - mock_get_config.return_value = mock_config - - mock_router = Mock() - mock_router.get_available_models.return_value = ["default", "large_context"] - mock_router.get_model_for_label.return_value = {"litellm_params": {"model": "gemini-2.0-flash-exp"}} - mock_get_router.return_value = mock_router - - mock_classifier = Mock() - mock_classifier.classify.return_value = "large_context" - mock_classifier_class.return_value = mock_classifier - - # Test with label that exists - data = {"model": "claude-3-5-sonnet", "messages": []} - result = ccproxy_get_model(data) - - assert result == "gemini-2.0-flash-exp" - mock_classifier.classify.assert_called_once_with(data) - - @patch("ccproxy.handler.get_config") - @patch("ccproxy.handler.get_router") - @patch("ccproxy.handler.RequestClassifier") - def test_ccproxy_get_model_label_not_configured( - self, mock_classifier_class: Mock, mock_get_router: Mock, mock_get_config: Mock - ) -> None: - """Test ccproxy_get_model when label is not in available models.""" - # Setup mocks - mock_config = Mock(debug=False) - mock_get_config.return_value = mock_config - - mock_router = Mock() - mock_router.get_available_models.return_value = ["default"] # "large_context" not available - mock_get_router.return_value = mock_router - - mock_classifier = Mock() - mock_classifier.classify.return_value = "large_context" - mock_classifier_class.return_value = mock_classifier - - # Test with label that doesn't exist - data = {"model": "claude-3-5-sonnet", "messages": []} - result = ccproxy_get_model(data) - - # Should return original model - assert result == "claude-3-5-sonnet" + # Should not raise but should log error + result = await handler.async_pre_call_hook(data, {}) + + # Verify error was logged + mock_logger.error.assert_called_once() + args = mock_logger.error.call_args[0] + assert "Hook failing_hook failed with error" in args[0] + assert "Hook failed!" in args[0] @patch("ccproxy.handler.logger") def test_log_routing_decision(self, mock_logger: Mock) -> None: @@ -122,28 +171,61 @@ def test_log_routing_decision(self, mock_logger: Mock) -> None: } handler._log_routing_decision( - label="large_context", - original_model="claude-3-5-sonnet", + model_name="token_count", + original_model="claude-sonnet-4-5-20250929", routed_model="gemini-2.0-flash-exp", - request_id="test-123", model_config=model_config, ) - # Check logger was called + # Check logger was called with structured data mock_logger.info.assert_called_once() call_args = mock_logger.info.call_args - assert call_args[0][0] == "CCProxy routing decision" - # Check extra data + # Check structured data (important for monitoring/alerting) extra = call_args[1]["extra"] assert extra["event"] == "ccproxy_routing" - assert extra["label"] == "large_context" - assert extra["original_model"] == "claude-3-5-sonnet" + assert extra["model_name"] == "token_count" + assert extra["original_model"] == "claude-sonnet-4-5-20250929" assert extra["routed_model"] == "gemini-2.0-flash-exp" - assert extra["request_id"] == "test-123" - assert extra["fallback_used"] is False + assert extra["is_passthrough"] is False # Check sensitive data was filtered assert "api_key" not in extra["model_info"] assert extra["model_info"]["provider"] == "google" assert extra["model_info"]["max_tokens"] == 1000000 + + @pytest.mark.asyncio + async def test_timedelta_duration_handling(self) -> None: + """Test that handler correctly handles timedelta objects for timestamps.""" + handler = CCProxyHandler() + kwargs = {"metadata": {"ccproxy_model_name": "default"}, "model": "test-model"} + response_obj = Mock() + + # Test with timedelta objects (simulating LiteLLM's behavior) + start_time = timedelta(seconds=100) + end_time = timedelta(seconds=102, milliseconds=500) + + # Should not raise any exceptions - test success logging + await handler.async_log_success_event(kwargs, response_obj, start_time, end_time) + + # Should not raise any exceptions - test failure logging + await handler.async_log_failure_event(kwargs, response_obj, start_time, end_time) + + # Should not raise any exceptions - test streaming logging + await handler.async_log_stream_event(kwargs, response_obj, start_time, end_time) + + @pytest.mark.asyncio + async def test_mixed_timestamp_types_handling(self) -> None: + """Test that handler correctly handles mixed float/timedelta timestamp types.""" + handler = CCProxyHandler() + kwargs = {"metadata": {"ccproxy_model_name": "default"}, "model": "test-model"} + response_obj = Mock() + + # Test with mixed types (float start, timedelta end) + start_time = 100.0 + end_time = timedelta(seconds=102, milliseconds=500) + + # Should not raise any exceptions and handle gracefully + await handler.async_log_success_event(kwargs, response_obj, start_time, end_time) + await handler.async_log_failure_event(kwargs, response_obj, start_time, end_time) + await handler.async_log_stream_event(kwargs, response_obj, start_time, end_time) diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 00000000..dbc58da3 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,1260 @@ +"""Comprehensive tests for ccproxy hooks.""" + +import logging +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from ccproxy.classifier import RequestClassifier +from ccproxy.config import clear_config_instance +from ccproxy.hooks import ( + capture_headers, + extract_session_id, + forward_apikey, + forward_oauth, + model_router, + rule_evaluator, +) +from ccproxy.router import ModelRouter, clear_router + + +@pytest.fixture +def mock_classifier(): + """Create a mock classifier that returns 'test_model_name'.""" + classifier = MagicMock(spec=RequestClassifier) + classifier.classify.return_value = "test_model_name" + return classifier + + +@pytest.fixture +def mock_router(): + """Create a mock router with test model configurations.""" + router = MagicMock(spec=ModelRouter) + + # Default successful routing + router.get_model_for_label.return_value = { + "litellm_params": {"model": "claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com"} + } + + return router + + +@pytest.fixture +def basic_request_data(): + """Create basic request data for testing.""" + return { + "model": "claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "test message"}], + } + + +@pytest.fixture +def user_api_key_dict(): + """Create empty user API key dict.""" + return {} + + +@pytest.fixture(autouse=True) +def cleanup(): + """Clean up config and router between tests.""" + yield + clear_config_instance() + clear_router() + + +class TestRuleEvaluator: + """Test the rule_evaluator hook function.""" + + def test_rule_evaluator_success(self, mock_classifier, basic_request_data, user_api_key_dict): + """Test successful rule evaluation.""" + # Call rule_evaluator with classifier + result = rule_evaluator(basic_request_data, user_api_key_dict, classifier=mock_classifier) + + # Verify metadata was added + assert "metadata" in result + assert result["metadata"]["ccproxy_alias_model"] == "claude-haiku-4-5-20251001-20241022" + assert result["metadata"]["ccproxy_model_name"] == "test_model_name" + + # Verify classifier was called + mock_classifier.classify.assert_called_once_with(basic_request_data) + + def test_rule_evaluator_existing_metadata(self, mock_classifier, user_api_key_dict): + """Test rule_evaluator preserves existing metadata.""" + data_with_metadata = { + "model": "claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"existing_key": "existing_value"}, + } + + result = rule_evaluator(data_with_metadata, user_api_key_dict, classifier=mock_classifier) + + # Verify existing metadata preserved and new metadata added + assert result["metadata"]["existing_key"] == "existing_value" + assert result["metadata"]["ccproxy_alias_model"] == "claude-haiku-4-5-20251001-20241022" + assert result["metadata"]["ccproxy_model_name"] == "test_model_name" + + def test_rule_evaluator_missing_classifier(self, basic_request_data, user_api_key_dict, caplog): + """Test rule_evaluator handles missing classifier gracefully.""" + with caplog.at_level(logging.WARNING): + result = rule_evaluator(basic_request_data, user_api_key_dict) + + # Should return original data unchanged + assert result == basic_request_data + assert "Classifier not found or invalid type in rule_evaluator" in caplog.text + + def test_rule_evaluator_invalid_classifier(self, basic_request_data, user_api_key_dict, caplog): + """Test rule_evaluator handles invalid classifier type.""" + with caplog.at_level(logging.WARNING): + result = rule_evaluator(basic_request_data, user_api_key_dict, classifier="invalid_classifier") + + # Should return original data unchanged + assert result == basic_request_data + assert "Classifier not found or invalid type in rule_evaluator" in caplog.text + + def test_rule_evaluator_no_model_in_data(self, mock_classifier, user_api_key_dict): + """Test rule_evaluator handles data without model.""" + data_no_model = { + "messages": [{"role": "user", "content": "test"}], + } + + result = rule_evaluator(data_no_model, user_api_key_dict, classifier=mock_classifier) + + # Should still add metadata + assert "metadata" in result + assert result["metadata"]["ccproxy_alias_model"] is None + assert result["metadata"]["ccproxy_model_name"] == "test_model_name" + + +class TestModelRouter: + """Test the model_router hook function.""" + + def test_model_router_success(self, mock_router, user_api_key_dict): + """Test successful model routing.""" + data_with_metadata = { + "model": "original_model", + "messages": [{"role": "user", "content": "test"}], + "metadata": {"ccproxy_model_name": "test_model"}, + } + + result = model_router(data_with_metadata, user_api_key_dict, router=mock_router) + + # Verify model was routed + assert result["model"] == "claude-sonnet-4-5-20250929" + assert result["metadata"]["ccproxy_litellm_model"] == "claude-sonnet-4-5-20250929" + assert "ccproxy_model_config" in result["metadata"] + + # Verify router was called + mock_router.get_model_for_label.assert_called_once_with("test_model") + + def test_model_router_missing_router(self, user_api_key_dict, caplog): + """Test model_router handles missing router gracefully.""" + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict) + + # Should return original data unchanged + assert result == data + assert "Router not found or invalid type in model_router" in caplog.text + + def test_model_router_invalid_router(self, user_api_key_dict, caplog): + """Test model_router handles invalid router type.""" + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict, router="invalid_router") + + # Should return original data unchanged + assert result == data + assert "Router not found or invalid type in model_router" in caplog.text + + def test_model_router_no_metadata(self, mock_router, user_api_key_dict, caplog): + """Test model_router handles missing metadata gracefully.""" + data = {"model": "original_model"} + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should use default model name and create metadata + mock_router.get_model_for_label.assert_called_once_with("default") + assert "metadata" in result + + def test_model_router_empty_model_name(self, mock_router, user_api_key_dict, caplog): + """Test model_router handles empty model name.""" + data = {"model": "original_model", "metadata": {"ccproxy_model_name": ""}} + + with caplog.at_level(logging.WARNING): + model_router(data, user_api_key_dict, router=mock_router) + + # Should use default and log warning + mock_router.get_model_for_label.assert_called_once_with("default") + assert "No ccproxy_model_name found, using default" in caplog.text + + def test_model_router_no_litellm_params(self, mock_router, user_api_key_dict, caplog): + """Test model_router handles config without litellm_params.""" + mock_router.get_model_for_label.return_value = {"other_config": "value"} + + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should log warning about missing model + assert "No model found in config for model_name: test_model" in caplog.text + assert result["metadata"]["ccproxy_litellm_model"] is None + + def test_model_router_no_model_in_litellm_params(self, mock_router, user_api_key_dict, caplog): + """Test model_router handles litellm_params without model.""" + mock_router.get_model_for_label.return_value = {"litellm_params": {"api_base": "https://api.anthropic.com"}} + + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should log warning about missing model + assert "No model found in config for model_name: test_model" in caplog.text + assert result["metadata"]["ccproxy_litellm_model"] is None + + def test_model_router_no_config_with_reload_success(self, mock_router, user_api_key_dict, caplog): + """Test model_router handles missing config with successful reload.""" + # First call returns None, second call (after reload) returns config + mock_router.get_model_for_label.side_effect = [ + None, # First call + { # Second call after reload + "litellm_params": {"model": "claude-sonnet-4-5-20250929"} + }, + ] + + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with caplog.at_level(logging.INFO): + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should reload and succeed + mock_router.reload_models.assert_called_once() + assert mock_router.get_model_for_label.call_count == 2 + assert result["model"] == "claude-sonnet-4-5-20250929" + assert "Successfully routed after model reload: test_model -> claude-sonnet-4-5-20250929" in caplog.text + + def test_model_router_no_config_reload_fails(self, mock_router, user_api_key_dict): + """Test model_router raises error when reload fails.""" + # Both calls return None + mock_router.get_model_for_label.return_value = None + + data = {"model": "original_model", "metadata": {"ccproxy_model_name": "test_model"}} + + with pytest.raises(ValueError, match="No model configured for model_name 'test_model'"): + model_router(data, user_api_key_dict, router=mock_router) + + # Should try reload + mock_router.reload_models.assert_called_once() + assert mock_router.get_model_for_label.call_count == 2 + + @patch("ccproxy.hooks.get_config") + def test_model_router_default_passthrough_enabled(self, mock_get_config, mock_router, user_api_key_dict): + """Test model_router with default_model_passthrough=True uses original model.""" + # Configure passthrough mode + mock_config = MagicMock() + mock_config.default_model_passthrough = True + mock_get_config.return_value = mock_config + + data = { + "model": "original_model", + "metadata": {"ccproxy_model_name": "default", "ccproxy_alias_model": "claude-sonnet-4-5-20250929"}, + } + + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should keep original model and not call router + assert result["model"] == "original_model" + assert result["metadata"]["ccproxy_litellm_model"] == "claude-sonnet-4-5-20250929" + assert result["metadata"]["ccproxy_model_config"] is None + mock_router.get_model_for_label.assert_not_called() + + @patch("ccproxy.hooks.get_config") + def test_model_router_default_passthrough_disabled(self, mock_get_config, mock_router, user_api_key_dict): + """Test model_router with default_model_passthrough=False uses router.""" + # Configure routing mode + mock_config = MagicMock() + mock_config.default_model_passthrough = False + mock_get_config.return_value = mock_config + + # Update mock router to return expected values + mock_router.get_model_for_label.return_value = {"litellm_params": {"model": "routed_model"}} + + data = { + "model": "original_model", + "metadata": {"ccproxy_model_name": "default", "ccproxy_alias_model": "claude-sonnet-4-5-20250929"}, + } + + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should use router for "default" label + mock_router.get_model_for_label.assert_called_once_with("default") + assert result["model"] == "routed_model" + assert result["metadata"]["ccproxy_litellm_model"] == "routed_model" + + @patch("ccproxy.hooks.get_config") + def test_model_router_passthrough_no_original_model(self, mock_get_config, mock_router, user_api_key_dict, caplog): + """Test model_router passthrough mode when no original model is available.""" + # Configure passthrough mode + mock_config = MagicMock() + mock_config.default_model_passthrough = True + mock_get_config.return_value = mock_config + + # Update mock router to return expected values + mock_router.get_model_for_label.return_value = {"litellm_params": {"model": "routed_model"}} + + data = { + "model": "original_model", + "metadata": { + "ccproxy_model_name": "default" + # No ccproxy_alias_model + }, + } + + with caplog.at_level(logging.WARNING): + result = model_router(data, user_api_key_dict, router=mock_router) + + # Should fallback to routing and log warning + assert "No original model found for passthrough mode" in caplog.text + mock_router.get_model_for_label.assert_called_once_with("default") + assert result["model"] == "routed_model" + + +class TestForwardOAuth: + """Test the forward_oauth hook function.""" + + def test_forward_oauth_no_proxy_request(self, user_api_key_dict): + """Test forward_oauth handles missing proxy_server_request.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": {"ccproxy_litellm_model": "claude-sonnet-4-5-20250929"}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should return unchanged data + assert result == data + + def test_forward_oauth_claude_cli_anthropic_api_base(self, user_api_key_dict, caplog): + """Test OAuth forwarding for claude-cli with Anthropic API base.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://api.anthropic.com"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + with caplog.at_level(logging.INFO): + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth token + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + # Should log OAuth forwarding + assert "Forwarding request with Claude Code OAuth authentication" in caplog.text + + def test_forward_oauth_claude_cli_anthropic_hostname(self, user_api_key_dict): + """Test OAuth forwarding for claude-cli with anthropic.com hostname.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://anthropic.com/v1/messages"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth token + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_claude_cli_custom_provider_anthropic(self, user_api_key_dict): + """Test OAuth forwarding with custom_llm_provider=anthropic.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"custom_llm_provider": "anthropic"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth token + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_claude_cli_anthropic_prefix_model(self, user_api_key_dict): + """Test OAuth forwarding for anthropic/ prefix models.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "anthropic/claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth token + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_claude_cli_claude_prefix_model(self, user_api_key_dict): + """Test OAuth forwarding for claude prefix models.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth token + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_missing_auth_header(self, user_api_key_dict): + """Test no OAuth forwarding when auth header is missing and no credentials configured.""" + from ccproxy.config import CCProxyConfig, set_config_instance + + # Configure without credentials to disable fallback + config = CCProxyConfig(credentials=None) + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://api.anthropic.com"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": { + "raw_headers": {} # No auth header + }, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should not forward OAuth token when no header and no fallback + assert "provider_specific_header" not in result + + def test_forward_oauth_missing_secret_fields(self, user_api_key_dict): + """Test no OAuth forwarding when secret_fields is missing and no credentials configured.""" + from ccproxy.config import CCProxyConfig, set_config_instance + + # Configure without credentials to disable fallback + config = CCProxyConfig(credentials=None) + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://api.anthropic.com"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + # secret_fields is missing + } + + result = forward_oauth(data, user_api_key_dict) + + # Should not forward OAuth token when no secret_fields and no fallback + assert "provider_specific_header" not in result + + def test_forward_oauth_preserves_existing_extra_headers(self, user_api_key_dict): + """Test OAuth forwarding preserves existing extra_headers.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://api.anthropic.com"}}, + }, + "provider_specific_header": {"extra_headers": {"existing-header": "existing-value"}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should preserve existing headers and add auth + assert result["provider_specific_header"]["extra_headers"]["existing-header"] == "existing-value" + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_creates_provider_specific_header_structure(self, user_api_key_dict): + """Test OAuth forwarding creates provider_specific_header structure when missing.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": {"litellm_params": {"api_base": "https://api.anthropic.com"}}, + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + # provider_specific_header is missing + } + + result = forward_oauth(data, user_api_key_dict) + + # Should create the structure and add auth + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_missing_model_config(self, user_api_key_dict): + """Test OAuth forwarding with missing model config.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929" + # ccproxy_model_config is missing + }, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should still forward for claude prefix model + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token" + + def test_forward_oauth_none_model_config(self, user_api_key_dict): + """Test forward_oauth handles None model_config (passthrough mode).""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.0"}}, + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": None, # This happens in passthrough mode + }, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-api03-test"}}, + } + + # Should not crash and should work for anthropic models + result = forward_oauth(data, user_api_key_dict) + + # Should forward OAuth for anthropic models even with None config + assert "provider_specific_header" in result + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-api03-test" + + +class TestForwardOAuthWithCredentialsFallback: + """Test forward_oauth hook with cached credentials fallback via oat_sources.""" + + def test_oauth_uses_header_when_present(self, user_api_key_dict): + """Test that existing authorization header takes precedence over cached credentials.""" + from ccproxy.config import CCProxyConfig, set_config_instance + from ccproxy.hooks import forward_oauth + + # Set up config with oat_sources for anthropic + config = CCProxyConfig(oat_sources={"anthropic": "echo fallback-token"}) + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.0"}}, + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": {"model": "claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com"} + }, + }, + "secret_fields": {"raw_headers": {"authorization": "Bearer header-token"}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should use header token, not cached credentials + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer header-token" + + def test_oauth_uses_cached_credentials_fallback(self, user_api_key_dict): + """Test that cached credentials are used when no authorization header present.""" + from ccproxy.config import CCProxyConfig, set_config_instance + from ccproxy.hooks import forward_oauth + + # Set up config with oat_sources for anthropic + config = CCProxyConfig(oat_sources={"anthropic": "echo cached-token-456"}) + config._load_credentials() # Load the OAuth tokens + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.0"}}, + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": {"model": "claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com"} + }, + }, + "secret_fields": { + "raw_headers": {} # No authorization header + }, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should use cached credentials with Bearer prefix added + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer cached-token-456" + + def test_oauth_cached_credentials_bearer_prefix(self, user_api_key_dict): + """Test that Bearer prefix is added if not present in cached credentials.""" + from ccproxy.config import CCProxyConfig, set_config_instance + from ccproxy.hooks import forward_oauth + + # Set up config with credentials that already include Bearer + config = CCProxyConfig(oat_sources={"anthropic": "echo 'Bearer already-prefixed-token'"}) + config._load_credentials() # Load the OAuth tokens + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.0"}}, + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": {"model": "claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com"} + }, + }, + "secret_fields": {"raw_headers": {}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should not double-prefix Bearer + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer already-prefixed-token" + + def test_oauth_no_fallback_when_not_configured(self, user_api_key_dict): + """Test that no fallback occurs when credentials not configured.""" + from ccproxy.config import CCProxyConfig, set_config_instance + from ccproxy.hooks import forward_oauth + + # Set up config without credentials + config = CCProxyConfig(credentials=None) + set_config_instance(config) + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.0"}}, + "metadata": { + "ccproxy_litellm_model": "claude-sonnet-4-5-20250929", + "ccproxy_model_config": { + "litellm_params": {"model": "claude-sonnet-4-5-20250929", "api_base": "https://api.anthropic.com"} + }, + }, + "secret_fields": {"raw_headers": {}}, + } + + result = forward_oauth(data, user_api_key_dict) + + # Should not add any authorization header + if "provider_specific_header" in result: + assert "authorization" not in result["provider_specific_header"].get("extra_headers", {}) + + +class TestForwardApiKey: + """Test the forward_apikey hook function.""" + + def test_apikey_forwards_header(self, user_api_key_dict): + """Test that x-api-key header is forwarded from request.""" + + data = { + "model": "gpt-4", + "proxy_server_request": {"headers": {"content-type": "application/json"}}, + "secret_fields": {"raw_headers": {"x-api-key": "sk-test-api-key-123"}}, + } + + result = forward_apikey(data, user_api_key_dict) + + assert "provider_specific_header" in result + assert result["provider_specific_header"]["extra_headers"]["x-api-key"] == "sk-test-api-key-123" + + def test_apikey_no_proxy_request(self, user_api_key_dict): + """Test that hook handles missing proxy_server_request gracefully.""" + + data = {"model": "gpt-4", "secret_fields": {"raw_headers": {"x-api-key": "sk-test-key"}}} + + result = forward_apikey(data, user_api_key_dict) + + # Should return data unchanged + assert result == data + + def test_apikey_missing_header(self, user_api_key_dict): + """Test that hook handles missing x-api-key header gracefully.""" + + data = { + "model": "gpt-4", + "proxy_server_request": {"headers": {"content-type": "application/json"}}, + "secret_fields": { + "raw_headers": {} # No x-api-key header + }, + } + + result = forward_apikey(data, user_api_key_dict) + + # Should not add any x-api-key header + if "provider_specific_header" in result: + assert "x-api-key" not in result["provider_specific_header"].get("extra_headers", {}) + + +class TestCaptureHeadersHook: + """Test the capture_headers hook function. + + The capture_headers hook outputs to metadata["trace_metadata"] for LangFuse compatibility. + Headers are stored as "header_{name}" keys, plus "http_method" and "http_path". + """ + + def _get_trace_metadata(self, result: dict) -> dict[str, Any]: + """Extract trace_metadata from result data.""" + return result.get("metadata", {}).get("trace_metadata", {}) + + def _get_headers(self, result: dict) -> dict[str, str]: + """Helper to extract header values into a dict for easier assertions.""" + trace_metadata = self._get_trace_metadata(result) + headers = {} + for key, value in trace_metadata.items(): + if key.startswith("header_"): + header_name = key[7:] # Remove "header_" prefix + headers[header_name] = value + return headers + + def test_basic_header_capture_all_headers(self, user_api_key_dict): + """Test capturing all headers when no filter is provided.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": { + "content-type": "application/json", + "user-agent": "claude-cli/1.0.0", + "x-custom-header": "custom-value", + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + }, + } + + result = capture_headers(data, user_api_key_dict) + + assert "metadata" in result + assert "trace_metadata" in result["metadata"] + + headers = self._get_headers(result) + trace_meta = self._get_trace_metadata(result) + assert headers["content-type"] == "application/json" + assert headers["user-agent"] == "claude-cli/1.0.0" + assert headers["x-custom-header"] == "custom-value" + assert trace_meta["http_method"] == "POST" + assert trace_meta["http_path"] == "/v1/messages" + + def test_header_filtering(self, user_api_key_dict): + """Test capturing only specified headers with filter.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": { + "content-type": "application/json", + "user-agent": "claude-cli/1.0.0", + "x-custom-header": "custom-value", + }, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + }, + } + + result = capture_headers(data, user_api_key_dict, headers=["content-type", "user-agent"]) + + headers = self._get_headers(result) + assert headers["content-type"] == "application/json" + assert headers["user-agent"] == "claude-cli/1.0.0" + assert "x-custom-header" not in headers + + def test_header_filtering_case_insensitive(self, user_api_key_dict): + """Test header filtering is case-insensitive.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": { + "Content-Type": "application/json", + "User-Agent": "claude-cli/1.0.0", + }, + "method": "POST", + }, + } + + result = capture_headers(data, user_api_key_dict, headers=["content-type", "user-agent"]) + + headers = self._get_headers(result) + assert "content-type" in headers + assert "user-agent" in headers + + def test_authorization_header_redaction(self, user_api_key_dict): + """Test authorization header is redacted properly.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"authorization": "Bearer sk-ant-oat01-1234567890abcdef"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + auth_value = headers["authorization"] + assert auth_value.startswith("Bearer sk-ant-") + assert auth_value.endswith("cdef") + assert "..." in auth_value + assert "1234567890ab" not in auth_value + + def test_authorization_header_redaction_no_prefix(self, user_api_key_dict): + """Test authorization header redaction when no standard prefix.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"authorization": "custom-token-1234567890"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + auth_value = headers["authorization"] + assert "..." in auth_value + assert auth_value.endswith("7890") + + def test_x_api_key_redaction(self, user_api_key_dict): + """Test x-api-key header is redacted properly.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"x-api-key": "sk-openai-1234567890abcdef"} + + data = { + "model": "gpt-4", + "proxy_server_request": {"headers": {}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + api_key = headers["x-api-key"] + assert api_key.startswith("sk-openai-") + assert api_key.endswith("cdef") + assert "..." in api_key + + def test_cookie_full_redaction(self, user_api_key_dict): + """Test cookie header is fully redacted.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": {"cookie": "session=abc123; user_id=456"}, + "method": "POST", + }, + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert headers["cookie"] == "[REDACTED]" + + def test_missing_headers_handling(self, user_api_key_dict): + """Test handling of missing or empty headers.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": {"empty-header": "", "null-header": None}, + "method": "POST", + }, + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert "empty-header" not in headers + assert "null-header" not in headers + + def test_metadata_initialization(self, user_api_key_dict): + """Test metadata is initialized when not present.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"content-type": "application/json"}, "method": "POST"}, + } + + result = capture_headers(data, user_api_key_dict) + + assert "metadata" in result + assert "trace_metadata" in result["metadata"] + headers = self._get_headers(result) + assert headers["content-type"] == "application/json" + + def test_existing_metadata_preserved(self, user_api_key_dict): + """Test existing metadata is preserved.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": {"existing_key": "existing_value"}, + "proxy_server_request": {"headers": {"content-type": "application/json"}, "method": "POST"}, + } + + result = capture_headers(data, user_api_key_dict) + + assert result["metadata"]["existing_key"] == "existing_value" + assert "trace_metadata" in result["metadata"] + + def test_http_method_capture(self, user_api_key_dict): + """Test HTTP method is captured correctly.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "GET"}, + } + + result = capture_headers(data, user_api_key_dict) + + trace_meta = self._get_trace_metadata(result) + assert trace_meta["http_method"] == "GET" + + def test_http_path_capture(self, user_api_key_dict): + """Test HTTP path is extracted from URL.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": {}, + "method": "POST", + "url": "https://api.anthropic.com/v1/messages?query=test", + }, + } + + result = capture_headers(data, user_api_key_dict) + + trace_meta = self._get_trace_metadata(result) + assert trace_meta["http_path"] == "/v1/messages" + + def test_http_path_empty_url(self, user_api_key_dict): + """Test HTTP path handling when URL is empty.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "POST", "url": ""}, + } + + result = capture_headers(data, user_api_key_dict) + + trace_meta = self._get_trace_metadata(result) + assert "http_path" not in trace_meta + + def test_raw_headers_from_secret_fields(self, user_api_key_dict): + """Test raw headers from secret_fields are merged.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"authorization": "Bearer sk-ant-oat01-test1234"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"content-type": "application/json"}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert "content-type" in headers + assert "authorization" in headers + + def test_raw_headers_priority(self, user_api_key_dict): + """Test raw headers override regular headers.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"content-type": "application/json"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"content-type": "text/plain"}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert headers["content-type"] == "application/json" + + def test_no_proxy_server_request(self, user_api_key_dict): + """Test handling when proxy_server_request is missing.""" + data = {"model": "claude-sonnet-4-5-20250929"} + + result = capture_headers(data, user_api_key_dict) + + assert "metadata" in result + assert "trace_metadata" in result["metadata"] + trace_meta = self._get_trace_metadata(result) + assert trace_meta == {} + + def test_empty_headers_dict(self, user_api_key_dict): + """Test handling when headers dict is empty.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "POST"}, + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert headers == {} + trace_meta = self._get_trace_metadata(result) + assert trace_meta["http_method"] == "POST" + + def test_secret_fields_missing_raw_headers(self, user_api_key_dict): + """Test handling when secret_fields exists but has no raw_headers.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"content-type": "application/json"}, "method": "POST"}, + "secret_fields": {}, + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert headers["content-type"] == "application/json" + + def test_secret_fields_with_raw_headers_attribute(self, user_api_key_dict): + """Test handling when secret_fields is object with raw_headers attribute.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"authorization": "Bearer sk-ant-test1234"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert "authorization" in headers + + def test_secret_fields_raw_headers_none(self, user_api_key_dict): + """Test handling when raw_headers attribute is None.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = None + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"content-type": "application/json"}, "method": "POST"}, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert headers["content-type"] == "application/json" + + def test_long_header_value_truncation(self, user_api_key_dict): + """Test non-sensitive headers are truncated to 200 chars.""" + long_value = "x" * 300 + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"headers": {"x-long-header": long_value}, "method": "POST"}, + } + + result = capture_headers(data, user_api_key_dict) + + headers = self._get_headers(result) + assert len(headers["x-long-header"]) == 200 + assert headers["x-long-header"] == "x" * 200 + + def test_multiple_headers_with_mixed_filtering(self, user_api_key_dict): + """Test filtering with mix of allowed and blocked headers.""" + + class MockSecretFields: + def __init__(self): + self.raw_headers = {"authorization": "Bearer sk-ant-test1234"} + + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "headers": { + "content-type": "application/json", + "user-agent": "claude-cli/1.0.0", + "x-custom-1": "value1", + "x-custom-2": "value2", + }, + "method": "POST", + }, + "secret_fields": MockSecretFields(), + } + + result = capture_headers(data, user_api_key_dict, headers=["content-type", "authorization"]) + + headers = self._get_headers(result) + assert len(headers) == 2 + assert "content-type" in headers + assert "authorization" in headers + assert "user-agent" not in headers + assert "x-custom-1" not in headers + + +class TestExtractSessionId: + """Test the extract_session_id hook function. + + Claude Code embeds session info in the metadata.user_id field with format: + user_{hash}_account_{uuid}_session_{uuid} + """ + + def test_extract_session_id_full_format(self, user_api_key_dict): + """Test extraction from full Claude Code user_id format.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": { + "body": { + "metadata": { + "user_id": "user_e53ac6083b2e0160d086641d3099fb09829d77e5b4ef8e6146f92588d76041dc_account_a929b7ef-d758-4a98-b88e-07166e6c8537_session_d2101641-25fd-4f4b-b8de-30cf972ee5d3" + } + } + }, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert result["metadata"]["session_id"] == "d2101641-25fd-4f4b-b8de-30cf972ee5d3" + assert "trace_metadata" in result["metadata"] + trace_meta = result["metadata"]["trace_metadata"] + assert trace_meta["claude_user_hash"] == "e53ac6083b2e0160d086641d3099fb09829d77e5b4ef8e6146f92588d76041dc" + assert trace_meta["claude_account_id"] == "a929b7ef-d758-4a98-b88e-07166e6c8537" + + def test_extract_session_id_preserves_existing_metadata(self, user_api_key_dict): + """Test that existing metadata is preserved.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": {"existing_key": "existing_value"}, + "proxy_server_request": {"body": {"metadata": {"user_id": "user_abc123_account_uuid1_session_uuid2"}}}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert result["metadata"]["existing_key"] == "existing_value" + assert result["metadata"]["session_id"] == "uuid2" + + def test_extract_session_id_no_session_in_user_id(self, user_api_key_dict): + """Test handling when user_id doesn't contain session.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"body": {"metadata": {"user_id": "regular_user_id_without_session"}}}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_empty_user_id(self, user_api_key_dict): + """Test handling when user_id is empty.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"body": {"metadata": {"user_id": ""}}}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_no_metadata_in_body(self, user_api_key_dict): + """Test handling when body has no metadata.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"body": {}}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_no_body(self, user_api_key_dict): + """Test handling when proxy_server_request has no body.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_no_proxy_request(self, user_api_key_dict): + """Test handling when proxy_server_request is missing.""" + data = {"model": "claude-sonnet-4-5-20250929"} + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_body_not_dict(self, user_api_key_dict): + """Test handling when body is not a dict.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"body": "string body"}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert "metadata" in result + assert "session_id" not in result["metadata"] + + def test_extract_session_id_no_account_in_prefix(self, user_api_key_dict): + """Test handling when user_id has session but no account.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "proxy_server_request": {"body": {"metadata": {"user_id": "user_abc123_session_uuid2"}}}, + } + + result = extract_session_id(data, user_api_key_dict) + + assert result["metadata"]["session_id"] == "uuid2" + trace_meta = result["metadata"].get("trace_metadata", {}) + assert "claude_user_hash" not in trace_meta + assert "claude_account_id" not in trace_meta + + def test_extract_session_id_preserves_existing_trace_metadata(self, user_api_key_dict): + """Test that existing trace_metadata is preserved.""" + data = { + "model": "claude-sonnet-4-5-20250929", + "metadata": {"trace_metadata": {"existing_trace_key": "existing_trace_value"}}, + "proxy_server_request": {"body": {"metadata": {"user_id": "user_hash123_account_acct456_session_sess789"}}}, + } + + result = extract_session_id(data, user_api_key_dict) + + trace_meta = result["metadata"]["trace_metadata"] + assert trace_meta["existing_trace_key"] == "existing_trace_value" + assert trace_meta["claude_user_hash"] == "hash123" + assert trace_meta["claude_account_id"] == "acct456" diff --git a/tests/test_main.py b/tests/test_main.py index c482eaf2..164a023a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,12 +8,14 @@ class TestMain: """Test suite for __main__ module.""" - @patch("ccproxy.cli.main") - def test_main_entry_point(self, mock_main) -> None: - """Test that __main__ calls the CLI main function.""" + @patch("tyro.cli") + def test_main_entry_point(self, mock_tyro_cli) -> None: + """Test that __main__ calls tyro.cli with main function.""" + from ccproxy.cli import main + # Run the module as __main__ with patch.object(sys, "argv", ["ccproxy"]): runpy.run_module("ccproxy", run_name="__main__") - # Verify it called the CLI main - mock_main.assert_called_once() + # Verify it called tyro.cli with the main function + mock_tyro_cli.assert_called_once_with(main) diff --git a/tests/test_num_workers.py b/tests/test_num_workers.py new file mode 100644 index 00000000..4dbff824 --- /dev/null +++ b/tests/test_num_workers.py @@ -0,0 +1,28 @@ +"""Tests for num_workers configuration passthrough.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from ccproxy.cli import start_litellm + + +class TestNumWorkers: + """Test suite for num_workers in ccproxy.yaml.""" + + @patch("subprocess.run") + def test_num_workers_passed_to_litellm(self, mock_run: Mock, tmp_path: Path) -> None: + """Test num_workers from ccproxy.yaml is passed as --num_workers to litellm.""" + (tmp_path / "config.yaml").write_text("model_list: []") + (tmp_path / "ccproxy.yaml").write_text( + "ccproxy:\n handler: 'ccproxy.handler:CCProxyHandler'\nlitellm:\n num_workers: 8\n" + ) + mock_run.return_value = Mock(returncode=0) + + with pytest.raises(SystemExit): + start_litellm(tmp_path) + + cmd = mock_run.call_args[0][0] + assert "--num_workers" in cmd, f"--num_workers missing from command: {cmd}" + assert cmd[cmd.index("--num_workers") + 1] == "8" diff --git a/tests/test_oauth_forwarding.py b/tests/test_oauth_forwarding.py new file mode 100644 index 00000000..9695b31e --- /dev/null +++ b/tests/test_oauth_forwarding.py @@ -0,0 +1,254 @@ +"""Test OAuth token forwarding for Claude CLI requests.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ccproxy.config import clear_config_instance +from ccproxy.handler import CCProxyHandler +from ccproxy.router import clear_router + + +@pytest.fixture +def mock_handler(): + """Create a ccproxy handler with mocked router that provides a default model.""" + # Mock proxy server with default model + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + }, + { + "model_name": "background", + "litellm_params": { + "model": "claude-haiku-4-5-20251001-20241022", + "api_base": "https://api.anthropic.com", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Set up config with hooks + from ccproxy.config import CCProxyConfig, set_config_instance + + config = CCProxyConfig( + debug=False, + default_model_passthrough=False, # Disable passthrough to test actual routing + hooks=["ccproxy.hooks.rule_evaluator", "ccproxy.hooks.model_router", "ccproxy.hooks.forward_oauth"], + rules=[], + ) + set_config_instance(config) + + # Patch the proxy server import + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() # Clear any existing router + handler = CCProxyHandler() # Create actual handler instance + yield handler + + # Cleanup + clear_config_instance() + clear_router() + + +@pytest.mark.asyncio +async def test_oauth_forwarding_for_claude_cli(mock_handler): + """Test that OAuth tokens are forwarded for claude-cli requests.""" + handler = mock_handler + + # Test data for Anthropic model with required structure + data = { + "model": "anthropic/claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify OAuth token was forwarded in authorization header + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token-123" + + +@pytest.mark.asyncio +async def test_oauth_forwarding_handles_missing_headers(mock_handler): + """Test that OAuth forwarding handles missing headers gracefully.""" + handler = mock_handler + + # Test data with missing secret_fields + data = { + "model": "anthropic/claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + # secret_fields is missing + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook - should not crash + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify no OAuth token was added + assert "authorization" not in result["provider_specific_header"]["extra_headers"] + + +@pytest.mark.asyncio +async def test_oauth_forwarding_preserves_existing_extra_headers(mock_handler): + """Test that OAuth forwarding preserves existing extra_headers.""" + handler = mock_handler + + # Test data with existing extra_headers + data = { + "model": "anthropic/claude-haiku-4-5-20251001-20241022", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {"existing-header": "existing-value"}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify both headers are present + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token-123" + assert result["provider_specific_header"]["extra_headers"]["existing-header"] == "existing-value" + + +@pytest.mark.asyncio +async def test_oauth_forwarding_with_claude_prefix_model(mock_handler): + """Test that OAuth tokens are forwarded for models starting with 'claude'.""" + handler = mock_handler + + # Test data for model starting with 'claude' + data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify OAuth token was forwarded + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token-123" + + +@pytest.mark.asyncio +async def test_oauth_forwarding_with_routed_model(mock_handler): + """Test that OAuth forwarding works based on the routed model destination.""" + handler = mock_handler + + # Test data that will be routed to an Anthropic model + data = { + "model": "default", # This will be routed to an anthropic model + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # OAuth forwarding should be based on the routed model destination + # Since the routed model is an Anthropic model, OAuth SHOULD be forwarded + # regardless of what the original model was + assert result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token-123" + + # Verify the model was routed correctly + assert result["model"] == "claude-sonnet-4-5-20250929" + + +@pytest.mark.asyncio +async def test_oauth_forwarding_for_anthropic_direct_api(): + """Test that OAuth tokens ARE forwarded for models going to Anthropic's API directly.""" + # Create a handler with Anthropic model going to Anthropic's API + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "anthropic/claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Set up config with hooks + from ccproxy.config import CCProxyConfig, set_config_instance + + config = CCProxyConfig( + debug=False, + default_model_passthrough=False, # Disable passthrough to test actual routing + hooks=["ccproxy.hooks.rule_evaluator", "ccproxy.hooks.model_router", "ccproxy.hooks.forward_oauth"], + rules=[], + ) + set_config_instance(config) + + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() + handler = CCProxyHandler() + + # Test data from claude-cli + data = { + "model": "default", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62 (external, cli)"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-ant-oat01-test-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # OAuth SHOULD be forwarded since it's going to Anthropic directly + assert ( + result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer sk-ant-oat01-test-token-123" + ) + + # Verify the model was routed correctly + assert result["model"] == "anthropic/claude-sonnet-4-5-20250929" + + clear_config_instance() + clear_router() diff --git a/tests/test_oauth_user_agent.py b/tests/test_oauth_user_agent.py new file mode 100644 index 00000000..074b4779 --- /dev/null +++ b/tests/test_oauth_user_agent.py @@ -0,0 +1,476 @@ +"""Tests for custom User-Agent support in OAuth token sources.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ccproxy.config import CCProxyConfig, OAuthSource, clear_config_instance +from ccproxy.handler import CCProxyHandler +from ccproxy.router import clear_router + + +class TestOAuthSource: + """Tests for OAuthSource model.""" + + def test_oauth_source_with_command_only(self) -> None: + """Test OAuthSource with just command (no user_agent).""" + source = OAuthSource(command="echo 'test-token'") + assert source.command == "echo 'test-token'" + assert source.user_agent is None + + def test_oauth_source_with_user_agent(self) -> None: + """Test OAuthSource with both command and user_agent.""" + source = OAuthSource(command="echo 'test-token'", user_agent="MyApp/1.0.0") + assert source.command == "echo 'test-token'" + assert source.user_agent == "MyApp/1.0.0" + + +class TestOAuthSourceConfigLoading: + """Tests for loading OAuth sources with user-agent from YAML.""" + + def test_string_format_backwards_compatibility(self) -> None: + """Test that simple string format still works (backwards compatible).""" + yaml_content = """ +ccproxy: + oat_sources: + anthropic: echo 'anthropic-token-123' +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # Token should be loaded + assert config.get_oauth_token("anthropic") == "anthropic-token-123" + # No user-agent should be configured + assert config.get_oauth_user_agent("anthropic") is None + + finally: + yaml_path.unlink() + + def test_extended_format_with_user_agent(self) -> None: + """Test loading OAuth source with custom user_agent.""" + yaml_content = """ +ccproxy: + oat_sources: + vertex_ai: + command: echo 'vertex-ai-token-456' + user_agent: MyApp/1.0.0 +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # Token should be loaded + assert config.get_oauth_token("vertex_ai") == "vertex-ai-token-456" + # User-agent should be configured + assert config.get_oauth_user_agent("vertex_ai") == "MyApp/1.0.0" + + finally: + yaml_path.unlink() + + def test_mixed_format_sources(self) -> None: + """Test mixing string and extended formats in same config.""" + yaml_content = """ +ccproxy: + oat_sources: + anthropic: echo 'anthropic-token-123' + vertex_ai: + command: echo 'vertex-ai-token-456' + user_agent: VertexAIClient/2.1.0 + openai: echo 'openai-token-789' +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # All tokens should be loaded + assert config.get_oauth_token("anthropic") == "anthropic-token-123" + assert config.get_oauth_token("vertex_ai") == "vertex-ai-token-456" + assert config.get_oauth_token("openai") == "openai-token-789" + + # Only gemini should have user-agent + assert config.get_oauth_user_agent("anthropic") is None + assert config.get_oauth_user_agent("vertex_ai") == "VertexAIClient/2.1.0" + assert config.get_oauth_user_agent("openai") is None + + finally: + yaml_path.unlink() + + def test_extended_format_without_user_agent(self) -> None: + """Test extended format with only command field.""" + yaml_content = """ +ccproxy: + oat_sources: + vertex_ai: + command: echo 'vertex-ai-token-456' +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # Token should be loaded + assert config.get_oauth_token("vertex_ai") == "vertex-ai-token-456" + # No user-agent + assert config.get_oauth_user_agent("vertex_ai") is None + + finally: + yaml_path.unlink() + + def test_user_agent_cached_during_load(self) -> None: + """Test that user-agent is cached when credentials are loaded.""" + yaml_content = """ +ccproxy: + oat_sources: + provider1: + command: echo 'token-1' + user_agent: Provider1Client/1.0 + provider2: + command: echo 'token-2' + user_agent: Provider2Client/2.0 +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + + # Check internal _oat_user_agents cache + assert config._oat_user_agents == { + "provider1": "Provider1Client/1.0", + "provider2": "Provider2Client/2.0", + } + + finally: + yaml_path.unlink() + + def test_get_oauth_user_agent_nonexistent_provider(self) -> None: + """Test getting user-agent for non-configured provider.""" + config = CCProxyConfig() + assert config.get_oauth_user_agent("nonexistent") is None + + +class TestOAuthUserAgentForwarding: + """Tests for User-Agent header forwarding in forward_oauth hook.""" + + @pytest.mark.asyncio + async def test_custom_user_agent_forwarded(self) -> None: + """Test that custom user-agent is forwarded in request.""" + # Set up mock proxy server + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "gemini-2.5-pro", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Create config with gemini OAuth source that has custom user-agent + yaml_content = """ +ccproxy: + oat_sources: + vertex_ai: + command: echo 'vertex-ai-token-123' + user_agent: MyCustomApp/3.0.0 + default_model_passthrough: false + hooks: + - ccproxy.hooks.rule_evaluator + - ccproxy.hooks.model_router + - ccproxy.hooks.forward_oauth +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + from ccproxy.config import set_config_instance + + set_config_instance(config) + + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() + handler = CCProxyHandler() + + # Test data for Gemini model + data = { + "model": "gemini-2.5-pro", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "original-client/1.0"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer vertex-ai-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify custom User-Agent was set + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + assert result["provider_specific_header"]["extra_headers"]["user-agent"] == "MyCustomApp/3.0.0" + # Authorization should also be forwarded + assert ( + result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer vertex-ai-token-123" + ) + + finally: + yaml_path.unlink() + clear_config_instance() + clear_router() + + @pytest.mark.asyncio + async def test_no_user_agent_when_not_configured(self) -> None: + """Test that no user-agent is set when not configured for provider.""" + # Set up mock proxy server + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Create config with anthropic OAuth source WITHOUT custom user-agent + yaml_content = """ +ccproxy: + oat_sources: + anthropic: echo 'anthropic-token-123' + default_model_passthrough: false + hooks: + - ccproxy.hooks.rule_evaluator + - ccproxy.hooks.model_router + - ccproxy.hooks.forward_oauth +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + from ccproxy.config import set_config_instance + + set_config_instance(config) + + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() + handler = CCProxyHandler() + + # Test data for Anthropic model + data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "claude-cli/1.0.62"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer anthropic-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify custom User-Agent was NOT set (because not configured) + assert "provider_specific_header" in result + assert "extra_headers" in result["provider_specific_header"] + # user-agent should not be in extra_headers + assert "user-agent" not in result["provider_specific_header"]["extra_headers"] + # Authorization should still be forwarded + assert ( + result["provider_specific_header"]["extra_headers"]["authorization"] == "Bearer anthropic-token-123" + ) + + finally: + yaml_path.unlink() + clear_config_instance() + clear_router() + + @pytest.mark.asyncio + async def test_user_agent_overrides_original(self) -> None: + """Test that configured user-agent overrides the original client user-agent.""" + # Set up mock proxy server + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "gemini-2.5-pro", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Create config with gemini OAuth source with custom user-agent + yaml_content = """ +ccproxy: + oat_sources: + vertex_ai: + command: echo 'vertex-ai-token-123' + user_agent: ProxyOverride/1.0 + default_model_passthrough: false + hooks: + - ccproxy.hooks.rule_evaluator + - ccproxy.hooks.model_router + - ccproxy.hooks.forward_oauth +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + from ccproxy.config import set_config_instance + + set_config_instance(config) + + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() + handler = CCProxyHandler() + + # Test data with original user-agent that should be overridden + data = { + "model": "gemini-2.5-pro", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "OriginalClient/9.9.9"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer vertex-ai-token-123"}}, + } + + user_api_key_dict = {} + kwargs = {} + + # Call the hook + result = await handler.async_pre_call_hook(data, user_api_key_dict, **kwargs) + + # Verify custom User-Agent overrode the original + assert result["provider_specific_header"]["extra_headers"]["user-agent"] == "ProxyOverride/1.0" + # Not the original + assert result["provider_specific_header"]["extra_headers"]["user-agent"] != "OriginalClient/9.9.9" + + finally: + yaml_path.unlink() + clear_config_instance() + clear_router() + + @pytest.mark.asyncio + async def test_multiple_providers_with_different_user_agents(self) -> None: + """Test that different providers can have different user-agents.""" + # Set up mock proxy server with multiple providers + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", + }, + }, + { + "model_name": "vertex_model", + "litellm_params": { + "model": "gemini-2.5-pro", + }, + }, + ] + + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server + + # Create config with multiple providers with different user-agents + # Use passthrough mode so the requested model is used directly + yaml_content = """ +ccproxy: + oat_sources: + anthropic: + command: echo 'anthropic-token-123' + user_agent: AnthropicClient/1.0 + vertex_ai: + command: echo 'vertex-ai-token-456' + user_agent: VertexAIClient/2.0 + default_model_passthrough: true + hooks: + - ccproxy.hooks.rule_evaluator + - ccproxy.hooks.model_router + - ccproxy.hooks.forward_oauth +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + yaml_path = Path(f.name) + + try: + config = CCProxyConfig.from_yaml(yaml_path) + from ccproxy.config import set_config_instance + + set_config_instance(config) + + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + clear_router() + handler = CCProxyHandler() + + # Test Anthropic request + anthropic_data = { + "model": "claude-sonnet-4-5-20250929", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "original/1.0"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer anthropic-token-123"}}, + } + + result = await handler.async_pre_call_hook(anthropic_data, {}) + assert result["provider_specific_header"]["extra_headers"]["user-agent"] == "AnthropicClient/1.0" + + # Test Gemini request + gemini_data = { + "model": "gemini-2.5-pro", + "messages": [{"role": "user", "content": "test"}], + "metadata": {}, + "provider_specific_header": {"extra_headers": {}}, + "proxy_server_request": {"headers": {"user-agent": "original/1.0"}}, + "secret_fields": {"raw_headers": {"authorization": "Bearer vertex-ai-token-456"}}, + } + + result = await handler.async_pre_call_hook(gemini_data, {}) + assert result["provider_specific_header"]["extra_headers"]["user-agent"] == "VertexAIClient/2.0" + + finally: + yaml_path.unlink() + clear_config_instance() + clear_router() diff --git a/tests/test_router.py b/tests/test_router.py index ef98083c..826e5b97 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,57 +1,70 @@ """Tests for the ModelRouter component.""" import threading -from pathlib import Path from unittest.mock import MagicMock, patch -import yaml +import pytest -from ccproxy.config import CCProxyConfig, ConfigProvider -from ccproxy.router import ModelRouter, get_router +from ccproxy.router import ModelRouter, clear_router, get_router class TestModelRouter: """Test suite for ModelRouter.""" + @pytest.fixture(autouse=True) + def setup_cleanup(self): + """Clear router singleton before each test.""" + clear_router() + yield + clear_router() + + def _create_router_with_models(self, model_list: list) -> ModelRouter: + """Helper to create a router with mocked models.""" + # Create a mock that will be returned by the import + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = model_list + + # Patch the import where it's used and return both router and patcher + patcher = patch("litellm.proxy.proxy_server", mock_proxy_server) + patcher.start() + + try: + router = ModelRouter() + # Force loading of models by calling a method that triggers _ensure_models_loaded + router.get_available_models() + return router + finally: + patcher.stop() + def test_init_loads_config(self) -> None: """Test that initialization loads model mapping from config.""" - # Create temporary YAML file with model config - test_yaml_content = { - "model_list": [ - { - "model_name": "default", - "litellm_params": {"model": "claude-3-5-sonnet-20241022", "api_base": "https://api.anthropic.com"}, + # Create test model list + test_model_list = [ + { + "model_name": "default", + "litellm_params": { + "model": "anthropic/claude-sonnet-4-5-20250929", + "api_base": "https://api.anthropic.com", }, - { - "model_name": "background", - "litellm_params": {"model": "claude-3-5-haiku-20241022", "api_base": "https://api.anthropic.com"}, - "model_info": {"priority": "low"}, + }, + { + "model_name": "background", + "litellm_params": { + "model": "anthropic/claude-haiku-4-5-20251001-20241022", + "api_base": "https://api.anthropic.com", }, - ] - } - - # Create mock config - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - # Mock open to return our test YAML - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + "model_info": {"priority": "low"}, + }, + ] - router = ModelRouter(config_provider=mock_provider) - - # Verify config was loaded - assert mock_provider.get.called + router = self._create_router_with_models(test_model_list) # Check model mapping model = router.get_model_for_label("default") assert model is not None assert model["model_name"] == "default" - assert model["litellm_params"]["model"] == "claude-3-5-sonnet-20241022" + assert model["litellm_params"]["model"] == "anthropic/claude-sonnet-4-5-20250929" # Check model with metadata model = router.get_model_for_label("background") @@ -60,21 +73,9 @@ def test_init_loads_config(self) -> None: def test_get_model_for_label_with_string(self) -> None: """Test get_model_for_label with string labels.""" - test_yaml_content = { - "model_list": [{"model_name": "think", "litellm_params": {"model": "claude-3-5-sonnet-20241022"}}] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + test_model_list = [{"model_name": "think", "litellm_params": {"model": "claude-opus-4-5-20251101"}}] - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) + router = self._create_router_with_models(test_model_list) # Test with string model = router.get_model_for_label("think") @@ -82,431 +83,359 @@ def test_get_model_for_label_with_string(self) -> None: assert model["model_name"] == "think" def test_get_model_for_unknown_label(self) -> None: - """Test get_model_for_label returns None for unknown labels.""" - test_yaml_content = {"model_list": []} + """Test get_model_for_label returns default fallback for unknown labels.""" + test_model_list = [ + {"model_name": "default", "litellm_params": {"model": "claude-sonnet-4-5-20250929"}}, + ] - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + router = self._create_router_with_models(test_model_list) - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + # Test unknown label returns default model + model = router.get_model_for_label("non_existent") + assert model is not None + assert model["model_name"] == "default" - router = ModelRouter(config_provider=mock_provider) + def test_get_model_list(self) -> None: + """Test get_model_list returns all configured models.""" + test_model_list = [ + {"model_name": "alpha", "litellm_params": {"model": "model-a"}}, + {"model_name": "beta", "litellm_params": {"model": "model-b"}}, + ] - assert router.get_model_for_label("unknown") is None - assert router.get_model_for_label("default") is None + router = self._create_router_with_models(test_model_list) - def test_get_model_list(self) -> None: - """Test get_model_list returns all models.""" - test_yaml_content = { - "model_list": [ - {"model_name": "default", "litellm_params": {"model": "claude-3-5-sonnet-20241022"}}, - {"model_name": "custom-model", "litellm_params": {"model": "gpt-4"}}, - {"model_name": "background", "litellm_params": {"model": "claude-3-5-haiku-20241022"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - models = router.get_model_list() - assert len(models) == 3 - assert models[0]["model_name"] == "default" - assert models[1]["model_name"] == "custom-model" - assert models[2]["model_name"] == "background" + model_list = router.get_model_list() + assert len(model_list) == 2 + assert model_list[0]["model_name"] == "alpha" + assert model_list[1]["model_name"] == "beta" def test_model_list_property(self) -> None: """Test model_list property access.""" - test_yaml_content = {"model_list": [{"model_name": "default", "litellm_params": {"model": "claude"}}]} - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + test_model_list = [{"model_name": "test", "litellm_params": {"model": "model-test"}}] - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + router = self._create_router_with_models(test_model_list) - router = ModelRouter(config_provider=mock_provider) - - # Property should return same as method + # Test property access assert router.model_list == router.get_model_list() def test_model_group_alias(self) -> None: """Test model_group_alias groups models by underlying model.""" - test_yaml_content = { - "model_list": [ - {"model_name": "default", "litellm_params": {"model": "claude-3-5-sonnet-20241022"}}, - {"model_name": "think", "litellm_params": {"model": "claude-3-5-sonnet-20241022"}}, - {"model_name": "background", "litellm_params": {"model": "claude-3-5-haiku-20241022"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - groups = router.model_group_alias - assert "claude-3-5-sonnet-20241022" in groups - assert set(groups["claude-3-5-sonnet-20241022"]) == {"default", "think"} - assert groups["claude-3-5-haiku-20241022"] == ["background"] + test_model_list = [ + {"model_name": "default", "litellm_params": {"model": "anthropic/claude-sonnet-4-5-20250929"}}, + {"model_name": "think", "litellm_params": {"model": "anthropic/claude-sonnet-4-5-20250929"}}, + {"model_name": "background", "litellm_params": {"model": "anthropic/claude-haiku-4-5-20251001-20241022"}}, + ] + + router = self._create_router_with_models(test_model_list) + + aliases = router.model_group_alias + assert "anthropic/claude-sonnet-4-5-20250929" in aliases + assert set(aliases["anthropic/claude-sonnet-4-5-20250929"]) == {"default", "think"} + assert aliases["anthropic/claude-haiku-4-5-20251001-20241022"] == ["background"] def test_get_available_models(self) -> None: """Test get_available_models returns sorted model names.""" - test_yaml_content = { - "model_list": [ - {"model_name": "think", "litellm_params": {"model": "claude"}}, - {"model_name": "background", "litellm_params": {"model": "claude"}}, - {"model_name": "default", "litellm_params": {"model": "claude"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) + test_model_list = [ + {"model_name": "zebra", "litellm_params": {"model": "model-z"}}, + {"model_name": "alpha", "litellm_params": {"model": "model-a"}}, + {"model_name": "beta", "litellm_params": {"model": "model-b"}}, + ] + + router = self._create_router_with_models(test_model_list) available = router.get_available_models() - assert available == ["background", "default", "think"] # Sorted + assert available == ["alpha", "beta", "zebra"] # Sorted def test_malformed_config_handling(self) -> None: - """Test handling of malformed configurations.""" - # Test with missing model_name entries - test_yaml_content = { - "model_list": [ - {"no_model_name": "test"}, - {"model_name": "valid", "litellm_params": {"model": "claude"}}, - {"model_name": "", "litellm_params": {"model": "claude"}}, # Empty name - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - models = router.get_model_list() - assert len(models) == 1 - assert models[0]["model_name"] == "valid" + """Test handling of malformed model configurations.""" + test_model_list = [ + {"model_name": "valid", "litellm_params": {"model": "model-v"}}, + {"model_name": "no_params"}, # Missing litellm_params + {"litellm_params": {"model": "model-x"}}, # Missing model_name + {"model_name": "", "litellm_params": {"model": "model-e"}}, # Empty model_name + ] + + router = self._create_router_with_models(test_model_list) + + # Only valid models should be available + available = router.get_available_models() + assert available == ["no_params", "valid"] # Sorted def test_missing_litellm_params(self) -> None: - """Test handling of models without litellm_params.""" - test_yaml_content = { - "model_list": [ - {"model_name": "default"}, # No litellm_params - {"model_name": "background", "litellm_params": None}, # None params - {"model_name": "think", "litellm_params": {"model": "claude"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - # All models should be in list - assert len(router.get_model_list()) == 3 - - # Only model with valid params should be in groups - groups = router.model_group_alias - assert "claude" in groups - assert groups["claude"] == ["think"] + """Test model without litellm_params is still accessible.""" + test_model_list = [ + {"model_name": "incomplete"}, # No litellm_params + ] - def test_config_update(self) -> None: - """Test configuration update handling.""" - initial_yaml_content = {"model_list": [{"model_name": "default", "litellm_params": {"model": "claude"}}]} + router = self._create_router_with_models(test_model_list) - updated_yaml_content = { - "model_list": [ - {"model_name": "default", "litellm_params": {"model": "gpt-4"}}, - {"model_name": "background", "litellm_params": {"model": "claude"}}, - ] - } + # Model should still be available but without underlying model mapping + assert "incomplete" in router.get_available_models() + model = router.get_model_for_label("incomplete") + assert model is not None + assert model["model_name"] == "incomplete" - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + def test_empty_config(self) -> None: + """Test handling of empty model list.""" + router = self._create_router_with_models([]) - # Start with initial config - yaml_content = initial_yaml_content + assert router.get_available_models() == [] + assert router.get_model_list() == [] + assert router.get_model_for_label("anything") is None - def mock_yaml_load(*args, **kwargs): - return yaml_content + def test_no_proxy_server(self) -> None: + """Test handling when proxy_server is not available.""" + # Create a mock module without proxy_server + mock_module = MagicMock() + mock_module.proxy_server = None - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(initial_yaml_content) - with patch("yaml.safe_load", side_effect=mock_yaml_load): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + router = ModelRouter() - router = ModelRouter(config_provider=mock_provider) + assert router.get_available_models() == [] + assert router.get_model_list() == [] + assert router.get_model_for_label("anything") is None - # Initial state - assert len(router.get_model_list()) == 1 - assert router.get_model_for_label("default")["litellm_params"]["model"] == "claude" + def test_no_llm_router(self) -> None: + """Test handling when proxy_server has no llm_router.""" + # Create a mock with no llm_router + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = None - # Simulate config update - yaml_content = updated_yaml_content - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(updated_yaml_content) - router._load_model_mapping() # Manually trigger mapping update + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server - # Check updated state - assert len(router.get_model_list()) == 2 - assert router.get_model_for_label("default")["litellm_params"]["model"] == "gpt-4" - assert router.get_model_for_label("background") is not None + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + router = ModelRouter() - def test_thread_safety(self) -> None: - """Test thread-safe access to router methods.""" - test_yaml_content = { - "model_list": [{"model_name": f"model-{i}", "litellm_params": {"model": "claude"}} for i in range(10)] - } + assert router.get_available_models() == [] + assert router.get_model_list() == [] + assert router.get_model_for_label("anything") is None - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + def test_missing_model_list(self) -> None: + """Test handling when llm_router has no model_list.""" + # Create a mock with None model_list + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = None - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + mock_module = MagicMock() + mock_module.proxy_server = mock_proxy_server - router = ModelRouter(config_provider=mock_provider) + with patch.dict("sys.modules", {"litellm.proxy": mock_module}): + router = ModelRouter() - results = [] - errors = [] - - def access_router(): - try: - # Perform multiple operations - router.get_model_list() - router.get_available_models() - _ = router.model_group_alias - router.get_model_for_label("model-5") - results.append("success") - except Exception as e: - errors.append(e) - - # Create multiple threads - threads = [threading.Thread(target=access_router) for _ in range(10)] + assert router.get_available_models() == [] + assert router.get_model_list() == [] + assert router.get_model_for_label("anything") is None - # Start all threads - for t in threads: - t.start() + def test_config_update(self) -> None: + """Test that router loads new models when re-initialized.""" + test_model_list_1 = [{"model_name": "default", "litellm_params": {"model": "model-1"}}] + test_model_list_2 = [{"model_name": "updated", "litellm_params": {"model": "model-2"}}] - # Wait for completion - for t in threads: - t.join() + router1 = self._create_router_with_models(test_model_list_1) + assert router1.get_available_models() == ["default"] - # Verify no errors - assert len(errors) == 0 - assert len(results) == 10 + # Create a new router with updated models + router2 = self._create_router_with_models(test_model_list_2) + assert router2.get_available_models() == ["updated"] - @patch("ccproxy.router.ConfigProvider") - def test_get_router_singleton(self, mock_config_provider_class: MagicMock) -> None: - """Test get_router returns singleton instance.""" - # Mock config provider - mock_provider = MagicMock() - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = False - mock_provider.get.return_value = mock_config - mock_config_provider_class.return_value = mock_provider + def test_double_check_pattern_early_return(self) -> None: + """Test double-check pattern returns early when models already loaded.""" + test_model_list = [{"model_name": "test", "litellm_params": {"model": "test-model"}}] - # Reset global instance for test - import ccproxy.router + router = self._create_router_with_models(test_model_list) - ccproxy.router._router_instance = None + # First call loads models + router._ensure_models_loaded() + assert router._models_loaded is True - router1 = get_router() - router2 = get_router() + # Create a mock that would fail if called + original_load = router._load_model_mapping + router._load_model_mapping = MagicMock(side_effect=Exception("Should not be called")) - assert router1 is router2 + # Second call should return early without calling _load_model_mapping + router._ensure_models_loaded() # This should hit line 59 - early return - # Test thread-safe singleton creation - routers = [] + # Restore original method + router._load_model_mapping = original_load - def get_router_instance(): - routers.append(get_router()) + def test_thread_safety(self) -> None: + """Test that model router operations are thread-safe.""" + test_model_list = [ + {"model_name": f"model-{i}", "litellm_params": {"model": f"underlying-{i}"}} for i in range(10) + ] + + router = self._create_router_with_models(test_model_list) + results = [] - threads = [threading.Thread(target=get_router_instance) for _ in range(5)] + def access_router() -> None: + # Perform various operations + model = router.get_model_for_label("model-5") + models = router.get_available_models() + list_copy = router.get_model_list() + aliases = router.model_group_alias + results.append((model is not None, len(models), len(list_copy), len(aliases))) + # Run multiple threads + threads = [threading.Thread(target=access_router) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() - # All should be same instance - assert all(r is routers[0] for r in routers) + # All threads should get consistent results + assert all(r == results[0] for r in results) + + def test_global_router_singleton(self) -> None: + """Test that get_router returns singleton instance.""" + router1 = get_router() + router2 = get_router() + assert router1 is router2 + + # Clear and get new instance + clear_router() + router3 = get_router() + assert router3 is not router1 def test_fallback_to_default_model(self) -> None: - """Test fallback to default model when requested label is unavailable.""" - test_yaml_content = { - "model_list": [ - {"model_name": "default", "litellm_params": {"model": "claude-3-5-sonnet-20241022"}}, - {"model_name": "background", "litellm_params": {"model": "claude-3-5-haiku-20241022"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - # Request unavailable model, should fallback to default - model = router.get_model_for_label("think") + """Test fallback to 'default' model when label not found.""" + test_model_list = [ + {"model_name": "default", "litellm_params": {"model": "anthropic/claude-sonnet-4-5-20250929"}}, + {"model_name": "other", "litellm_params": {"model": "other-model"}}, + ] + + router = self._create_router_with_models(test_model_list) + + # Unknown label should fallback to 'default' + model = router.get_model_for_label("unknown_label") assert model is not None assert model["model_name"] == "default" def test_fallback_priority_order(self) -> None: - """Test fallback follows priority order when default is unavailable.""" - test_yaml_content = { - "model_list": [ - {"model_name": "background", "litellm_params": {"model": "claude-3-5-haiku-20241022"}}, - {"model_name": "token_count", "litellm_params": {"model": "gpt-4"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - # Request unavailable model, should fallback to first (background) - model = router.get_model_for_label("think") - assert model is not None - assert model["model_name"] == "background" + """Test fallback logic when model not found.""" + # Test 1: No models at all + router = self._create_router_with_models([]) + assert router.get_model_for_label("anything") is None + + # Test 2: Has models but no 'default' + test_model_list = [ + {"model_name": "model1", "litellm_params": {"model": "m1"}}, + {"model_name": "model2", "litellm_params": {"model": "m2"}}, + ] + + router = self._create_router_with_models(test_model_list) + # Should return None if no 'default' model exists + assert router.get_model_for_label("unknown") is None def test_fallback_to_first_available(self) -> None: - """Test fallback to first available model when no priority models exist.""" - test_yaml_content = { - "model_list": [ - {"model_name": "custom-model-1", "litellm_params": {"model": "gpt-4"}}, - {"model_name": "custom-model-2", "litellm_params": {"model": "claude"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - # Request unavailable model with no standard fallbacks - model = router.get_model_for_label("think") + """Test that direct label match works without fallback.""" + test_model_list = [ + {"model_name": "first", "litellm_params": {"model": "m1"}}, + {"model_name": "second", "litellm_params": {"model": "m2"}}, + ] + + router = self._create_router_with_models(test_model_list) + + # Direct match should work + model = router.get_model_for_label("first") assert model is not None - assert model["model_name"] == "custom-model-1" # First in list + assert model["model_name"] == "first" - def test_no_fallback_when_empty_config(self) -> None: - """Test returns None when no models are available.""" - test_yaml_content = {"model_list": []} + def test_is_model_available(self) -> None: + """Test is_model_available method.""" + test_model_list = [ + {"model_name": "available", "litellm_params": {"model": "m1"}}, + ] - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True + router = self._create_router_with_models(test_model_list) - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config + assert router.is_model_available("available") is True + assert router.is_model_available("not_available") is False - router = ModelRouter(config_provider=mock_provider) + def test_reload_models(self) -> None: + """Test reload_models functionality.""" + test_model_list = [ + {"model_name": "initial", "litellm_params": {"model": "model-1"}}, + ] - # Should return None when no models available - assert router.get_model_for_label("think") is None - assert router.get_model_for_label("default") is None + # Create a mock that will be returned by the import + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = test_model_list - def test_is_model_available(self) -> None: - """Test is_model_available method.""" - test_yaml_content = { - "model_list": [ - {"model_name": "default", "litellm_params": {"model": "claude"}}, - {"model_name": "background", "litellm_params": {"model": "haiku"}}, - ] - } - - mock_config = MagicMock(spec=CCProxyConfig) - mock_config.litellm_config_path = MagicMock(spec=Path) - mock_config.litellm_config_path.exists.return_value = True - - with patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = yaml.dump(test_yaml_content) - with patch("yaml.safe_load", return_value=test_yaml_content): - mock_provider = MagicMock(spec=ConfigProvider) - mock_provider.get.return_value = mock_config - - router = ModelRouter(config_provider=mock_provider) - - # Test available models - assert router.is_model_available("default") is True - assert router.is_model_available("background") is True - - # Test unavailable models - assert router.is_model_available("think") is False - assert router.is_model_available("unknown") is False - assert router.is_model_available("") is False + # Patch the import throughout the test + with patch("litellm.proxy.proxy_server", mock_proxy_server): + router = ModelRouter() + router.get_available_models() # Force initial load + assert router.is_model_available("initial") is True + + # Test reload_models method - this should trigger the missing lines 231-233 + router.reload_models() + + # Verify models are still available after reload + assert router.is_model_available("initial") is True + + def test_double_check_pattern_in_ensure_models_loaded(self) -> None: + """Test the double-check pattern when models are already loaded.""" + # Create a router without loading models first + with patch("litellm.proxy.proxy_server", None): + router = ModelRouter() + + # Monkey patch the method to directly test the inside-lock condition + original_method = router._ensure_models_loaded + + # We need to manually construct the scenario where: + # 1. _models_loaded = False (so we pass the first check and enter the method) + # 2. We acquire the lock + # 3. _models_loaded becomes True (simulating another thread) + # 4. We hit the double-check on line 59 + + def test_double_check_scenario(): + # Set up initial state: not loaded + router._models_loaded = False + + # Manually execute the double-check pattern + if router._models_loaded: # First check (line 53-54) - should pass + return + + with router._lock: + # Simulate race condition: another thread loaded models + router._models_loaded = True + + # Now execute the double-check (this should hit line 58-59) + if router._models_loaded: + return # This should cover line 59 + + # This code should not execute since _models_loaded is True + router._load_model_mapping() + router._models_loaded = True + + # Call our test scenario + test_double_check_scenario() + + # Verify models are marked as loaded + assert router._models_loaded is True + + def test_double_check_return_statement_line_59(self) -> None: + """Test the specific double-check return statement on line 59.""" + test_model_list = [ + {"model_name": "test", "litellm_params": {"model": "model-1"}}, + ] + + with patch("litellm.proxy.proxy_server") as mock_proxy: + mock_proxy.llm_router.model_list = test_model_list + + router = ModelRouter() + + # Force initial loading + router._ensure_models_loaded() + assert router._models_loaded is True + + # Now call _ensure_models_loaded again when models are already loaded + # This should hit the double-check pattern on line 59 and return early + router._ensure_models_loaded() + + # If we get here without error, line 59 was covered + assert router._models_loaded is True diff --git a/tests/test_router_helpers.py b/tests/test_router_helpers.py new file mode 100644 index 00000000..9f2758ca --- /dev/null +++ b/tests/test_router_helpers.py @@ -0,0 +1,19 @@ +"""Helper functions for router tests.""" + +from typing import Any +from unittest.mock import MagicMock, patch + + +def create_mock_proxy_server(model_list: list[dict[str, Any]]) -> MagicMock: + """Create a mock proxy_server with the given model list.""" + mock_proxy_server = MagicMock() + mock_proxy_server.llm_router = MagicMock() + mock_proxy_server.llm_router.model_list = model_list + return mock_proxy_server + + +def patch_proxy_server(model_list: list[dict[str, Any]]): + """Context manager to patch proxy_server with the given model list.""" + mock_proxy_server = create_mock_proxy_server(model_list) + # Patch at the point where it's imported inside the method + return patch("litellm.proxy.proxy_server", mock_proxy_server) diff --git a/tests/test_rules.py b/tests/test_rules.py index 8702bcc0..4fd93433 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -46,13 +46,15 @@ def test_input_tokens_field(self, rule: TokenCountRule, config: CCProxyConfig) - def test_messages_estimation(self, rule: TokenCountRule, config: CCProxyConfig) -> None: """Test token estimation from messages.""" - # Create messages with ~4000 characters (estimated ~1000 tokens) - long_message = "x" * 4000 - request = {"messages": [{"content": long_message}]} + # Create messages with realistic text that tokenizes properly + # ~800 tokens (below threshold of 1000) + base_text = "The quick brown fox jumps over the lazy dog. " * 10 + short_message = base_text * 8 # ~800 tokens + request = {"messages": [{"content": short_message}]} assert rule.evaluate(request, config) is False - # Create messages with >4000 characters (estimated >1000 tokens) - longer_message = "x" * 5000 + # Create messages with >1000 tokens + longer_message = base_text * 15 # ~1501 tokens request = {"messages": [{"content": longer_message}]} assert rule.evaluate(request, config) is True @@ -82,14 +84,95 @@ def test_configurable_threshold(self) -> None: boundary_rule = TokenCountRule(threshold=6000) assert boundary_rule.evaluate(request, config) is False # Equal to threshold, not above + def test_gpt_model_tokenizer(self, config: CCProxyConfig) -> None: + """Test GPT model tokenizer path (line 68).""" + rule = TokenCountRule(threshold=10) + + # Test with GPT-4 model to trigger line 68 + request = {"model": "gpt-4", "messages": [{"content": "This is a test message"}]} + # This should trigger the GPT tokenizer path + result = rule.evaluate(request, config) + assert isinstance(result, bool) + + def test_gemini_model_tokenizer(self, config: CCProxyConfig) -> None: + """Test Gemini model tokenizer path (line 74).""" + rule = TokenCountRule(threshold=10) + + # Test with Gemini model to trigger line 74 + request = {"model": "gemini-pro", "messages": [{"content": "This is a test message"}]} + # This should trigger the Gemini tokenizer path + result = rule.evaluate(request, config) + assert isinstance(result, bool) + + def test_tokenizer_exception_handling(self, config: CCProxyConfig) -> None: + """Test tokenizer exception handling (lines 81-83).""" + from unittest.mock import patch + + rule = TokenCountRule(threshold=10) + + # Mock tiktoken import to fail, triggering the except block on lines 81-83 + with patch("builtins.__import__") as mock_import: + + def import_side_effect(name, *args, **kwargs): + if name == "tiktoken": + raise ImportError("Mock tiktoken import error") + return __import__(name, *args, **kwargs) + + mock_import.side_effect = import_side_effect + + request = {"model": "gpt-4", "messages": [{"content": "Test message"}]} + # Should fall back to estimation when tiktoken import fails + result = rule.evaluate(request, config) + assert isinstance(result, bool) + + def test_token_encoding_exception_handling(self, config: CCProxyConfig) -> None: + """Test token encoding exception handling (lines 99-105).""" + from unittest.mock import MagicMock, patch + + rule = TokenCountRule(threshold=10) + + # Create a mock tokenizer that raises exception on encode + mock_tokenizer = MagicMock() + mock_tokenizer.encode.side_effect = Exception("Encoding error") + + with patch.object(rule, "_get_tokenizer", return_value=mock_tokenizer): + request = { + "model": "gpt-4", + "messages": [{"content": "Test message with sufficient length to exceed threshold"}], + } + # Should fall back to estimation when encoding fails + result = rule.evaluate(request, config) + assert isinstance(result, bool) + + def test_multimodal_content_handling(self, config: CCProxyConfig) -> None: + """Test multi-modal content handling (lines 135-137).""" + rule = TokenCountRule(threshold=10) + + # Test with multi-modal content structure + request = { + "model": "gpt-4", + "messages": [ + { + "content": [ + {"type": "text", "text": "This is text content"}, + {"type": "image", "image_url": "http://example.com/image.jpg"}, + {"type": "text", "text": "More text content"}, + ] + } + ], + } + # Should extract text from multi-modal content + result = rule.evaluate(request, config) + assert isinstance(result, bool) + class TestModelMatchRule: """Tests for MatchModelRule.""" @pytest.fixture def rule(self) -> MatchModelRule: - """Create a model name rule for claude-3-5-haiku.""" - return MatchModelRule(model_name="claude-3-5-haiku") + """Create a model name rule for claude-haiku-4-5-20251001.""" + return MatchModelRule(model_name="claude-haiku-4-5-20251001") @pytest.fixture def config(self) -> CCProxyConfig: @@ -97,18 +180,18 @@ def config(self) -> CCProxyConfig: return CCProxyConfig() def test_claude_haiku_model(self, rule: MatchModelRule, config: CCProxyConfig) -> None: - """Test request with claude-3-5-haiku model.""" - request = {"model": "claude-3-5-haiku"} + """Test request with claude-haiku-4-5-20251001 model.""" + request = {"model": "claude-haiku-4-5-20251001"} assert rule.evaluate(request, config) is True def test_claude_haiku_with_suffix(self, rule: MatchModelRule, config: CCProxyConfig) -> None: - """Test request with claude-3-5-haiku variant.""" - request = {"model": "claude-3-5-haiku-20241022"} + """Test request with claude-haiku-4-5-20251001 variant.""" + request = {"model": "claude-haiku-4-5-20251001-20241022"} assert rule.evaluate(request, config) is True def test_other_models(self, rule: MatchModelRule, config: CCProxyConfig) -> None: """Test request with other models.""" - models = ["gpt-4", "claude-3-opus", "claude-3-sonnet", "gpt-3.5-turbo"] + models = ["gpt-4", "claude-opus-4-5-20251101", "claude-sonnet-4-5-20250929", "gpt-3.5-turbo"] for model in models: request = {"model": model} assert rule.evaluate(request, config) is False @@ -217,6 +300,14 @@ def test_mixed_tool_types(self, rule: MatchToolRule, config: CCProxyConfig) -> N } assert rule.evaluate(request, config) is True + def test_openai_function_format(self, rule: MatchToolRule, config: CCProxyConfig) -> None: + """Test OpenAI function format (line 234).""" + # Test OpenAI function.name format to cover line 234 + request = { + "tools": [{"type": "function", "function": {"name": "web_search_api", "description": "Search the web"}}] + } + assert rule.evaluate(request, config) is True + class TestParameterizedModelNameRule: """Tests for parameterized MatchModelRule.""" diff --git a/tests/test_shell_integration.py b/tests/test_shell_integration.py new file mode 100644 index 00000000..70a384b1 --- /dev/null +++ b/tests/test_shell_integration.py @@ -0,0 +1,143 @@ +"""Test shell integration functionality.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from ccproxy.cli import generate_shell_integration + + +def test_generate_shell_integration_auto_detect_zsh(tmp_path: Path, capsys): + """Test auto-detection of zsh shell.""" + with patch.dict("os.environ", {"SHELL": "/usr/bin/zsh"}): + generate_shell_integration(tmp_path, shell="auto", install=False) # noqa: S604 + + captured = capsys.readouterr() + assert "# ccproxy shell integration" in captured.out + assert "ccproxy_check_running()" in captured.out + assert "alias claude='ccproxy run claude'" in captured.out + assert "precmd_functions" in captured.out # zsh-specific + assert "PROMPT_COMMAND" not in captured.out # bash-specific + + +def test_generate_shell_integration_auto_detect_bash(tmp_path: Path, capsys): + """Test auto-detection of bash shell.""" + with patch.dict("os.environ", {"SHELL": "/bin/bash"}): + generate_shell_integration(tmp_path, shell="auto", install=False) # noqa: S604 + + captured = capsys.readouterr() + assert "# ccproxy shell integration" in captured.out + assert "ccproxy_check_running()" in captured.out + assert "alias claude='ccproxy run claude'" in captured.out + assert "PROMPT_COMMAND" in captured.out # bash-specific + assert "precmd_functions" not in captured.out # zsh-specific + + +def test_generate_shell_integration_auto_detect_failure(tmp_path: Path): + """Test auto-detection failure.""" + with patch.dict("os.environ", {"SHELL": "/bin/fish"}): + with pytest.raises(SystemExit) as exc_info: + generate_shell_integration(tmp_path, shell="auto", install=False) # noqa: S604 + assert exc_info.value.code == 1 + + +def test_generate_shell_integration_explicit_shell(tmp_path: Path, capsys): + """Test explicit shell specification.""" + generate_shell_integration(tmp_path, shell="zsh", install=False) # noqa: S604 + + captured = capsys.readouterr() + assert "# ccproxy shell integration" in captured.out + # Check the path components separately to handle line breaks + assert str(tmp_path) in captured.out + # Check for lock file by looking for the pattern split across lines + assert "local" in captured.out + assert "pid_file=" in captured.out + assert "litellm.lock" in captured.out.replace("\n", "") # Handle line breaks + + +def test_generate_shell_integration_unsupported_shell(tmp_path: Path): + """Test unsupported shell type.""" + with pytest.raises(SystemExit) as exc_info: + generate_shell_integration(tmp_path, shell="fish", install=False) # noqa: S604 + assert exc_info.value.code == 1 + + +def test_generate_shell_integration_install_zsh(tmp_path: Path, capsys): + """Test installing integration to zsh config.""" + # Create a fake .zshrc + zshrc = tmp_path / ".zshrc" + zshrc.write_text("# Existing zsh config\n") + + with patch("pathlib.Path.home", return_value=tmp_path): + generate_shell_integration(tmp_path, shell="zsh", install=True) # noqa: S604 + + # Check installation + content = zshrc.read_text() + assert "# ccproxy shell integration" in content + assert "ccproxy_check_running()" in content + assert "precmd_functions" in content + + # Check output + captured = capsys.readouterr() + assert "✓ ccproxy shell integration installed" in captured.out + assert str(zshrc) in captured.out + + +def test_generate_shell_integration_install_bash(tmp_path: Path, capsys): + """Test installing integration to bash config.""" + # Create a fake .bashrc + bashrc = tmp_path / ".bashrc" + bashrc.write_text("# Existing bash config\n") + + with patch("pathlib.Path.home", return_value=tmp_path): + generate_shell_integration(tmp_path, shell="bash", install=True) # noqa: S604 + + # Check installation + content = bashrc.read_text() + assert "# ccproxy shell integration" in content + assert "ccproxy_check_running()" in content + assert "PROMPT_COMMAND" in content + + # Check output + captured = capsys.readouterr() + assert "✓ ccproxy shell integration installed" in captured.out + assert str(bashrc) in captured.out + + +def test_generate_shell_integration_already_installed(tmp_path: Path): + """Test handling of already installed integration.""" + # Create a fake .zshrc with existing integration + zshrc = tmp_path / ".zshrc" + zshrc.write_text("# Existing config\n# ccproxy shell integration\n# Already installed\n") + + with patch("pathlib.Path.home", return_value=tmp_path): + with pytest.raises(SystemExit) as exc_info: + generate_shell_integration(tmp_path, shell="zsh", install=True) # noqa: S604 + assert exc_info.value.code == 0 + + +def test_generate_shell_integration_creates_config_if_missing(tmp_path: Path): + """Test that shell config file is created if it doesn't exist.""" + with patch("pathlib.Path.home", return_value=tmp_path): + generate_shell_integration(tmp_path, shell="zsh", install=True) # noqa: S604 + + # Check that .zshrc was created + zshrc = tmp_path / ".zshrc" + assert zshrc.exists() + assert "# ccproxy shell integration" in zshrc.read_text() + + +def test_shell_integration_script_content(tmp_path: Path, capsys): + """Test the generated shell integration script content.""" + generate_shell_integration(tmp_path, shell="bash", install=False) # noqa: S604 + + captured = capsys.readouterr() + + # Check key components + assert str(tmp_path) in captured.out # Path is included + assert "litellm.lock" in captured.out.replace("\n", "") # Handle line breaks + assert 'kill -0 "$pid"' in captured.out # Process check + assert "alias claude='ccproxy run claude'" in captured.out + assert "unalias claude 2>/dev/null || true" in captured.out + assert "ccproxy_setup_alias" in captured.out diff --git a/tests/test_utils.py b/tests/test_utils.py index a0e99160..2cc856cf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,12 @@ """Tests for ccproxy utilities.""" +from datetime import timedelta from pathlib import Path from unittest.mock import Mock, patch import pytest -from ccproxy.utils import get_template_file, get_templates_dir +from ccproxy.utils import calculate_duration_ms, get_template_file, get_templates_dir class TestGetTemplatesDir: @@ -30,22 +31,20 @@ def test_templates_dir_development_mode(self, tmp_path: Path) -> None: assert result == templates_dir def test_templates_dir_installed_mode(self, tmp_path: Path) -> None: - """Test finding templates in sys.path.""" + """Test finding templates in installed package mode.""" # Create a fake module location fake_module = tmp_path / "fake" / "location" / "ccproxy" fake_module.mkdir(parents=True) fake_utils = fake_module / "utils.py" fake_utils.touch() - # Create site-packages structure - site_packages = tmp_path / "site-packages" - site_packages.mkdir() - templates_dir = site_packages / "templates" + # Create templates inside the package + templates_dir = fake_module / "templates" templates_dir.mkdir() (templates_dir / "ccproxy.yaml").touch() - # Mock sys.path and __file__ - with patch("sys.path", [str(site_packages), "/other/path"]), patch("ccproxy.utils.__file__", str(fake_utils)): + # Mock __file__ + with patch("ccproxy.utils.__file__", str(fake_utils)): result = get_templates_dir() assert result == templates_dir @@ -90,3 +89,69 @@ def test_get_nonexistent_template(self, mock_get_templates: Mock, tmp_path: Path get_template_file("missing.yaml") assert "Template file not found: missing.yaml" in str(exc_info.value) + + +class TestCalculateDurationMs: + """Test suite for calculate_duration_ms function.""" + + def test_calculate_duration_with_floats(self) -> None: + """Test duration calculation with float timestamps.""" + start_time = 1000.0 + end_time = 1002.5 + + result = calculate_duration_ms(start_time, end_time) + + assert result == 2500.0 # 2.5 seconds = 2500 ms + + def test_calculate_duration_with_timedelta(self) -> None: + """Test duration calculation with timedelta objects.""" + start_time = timedelta(seconds=0) + end_time = timedelta(seconds=1, milliseconds=500) + + result = calculate_duration_ms(start_time, end_time) + + assert result == 1500.0 # 1.5 seconds = 1500 ms + + def test_calculate_duration_with_mixed_types(self) -> None: + """Test that mixed types are handled gracefully.""" + # Mixed types that don't support subtraction should return 0.0 + start_time = 0 + end_time = timedelta(seconds=2) + + # This will fail because int - timedelta is not supported + result = calculate_duration_ms(start_time, end_time) + + # Should return 0.0 due to TypeError + assert result == 0.0 + + def test_calculate_duration_with_invalid_types(self) -> None: + """Test that invalid types return 0.0.""" + # String types should cause TypeError + result = calculate_duration_ms("start", "end") + assert result == 0.0 + + # None types should cause TypeError + result = calculate_duration_ms(None, None) + assert result == 0.0 + + # Object without subtraction support + result = calculate_duration_ms({"time": 1}, {"time": 2}) + assert result == 0.0 + + def test_calculate_duration_rounding(self) -> None: + """Test that results are rounded to 2 decimal places.""" + start_time = 1000.0 + end_time = 1000.0012345 + + result = calculate_duration_ms(start_time, end_time) + + assert result == 1.23 # Should be rounded to 2 decimal places + + def test_calculate_duration_negative(self) -> None: + """Test calculation when end time is before start time.""" + start_time = 2000.0 + end_time = 1000.0 + + result = calculate_duration_ms(start_time, end_time) + + assert result == -1000000.0 # Negative duration is allowed diff --git a/uv.lock b/uv.lock index da5f11cd..a0b232a0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version < '3.12'", +] [[package]] name = "aiohappyeyeballs" @@ -85,7 +90,8 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.12.*'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -112,7 +118,8 @@ dependencies = [ { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/03/3334921dc54ed822b3dd993ae72d823a7402588521bbba3e024b3333a1fd/anthropic-0.60.0.tar.gz", hash = "sha256:a22ba187c6f4fd5afecb2fc913b960feccf72bc0d25c1b7ce0345e87caede577", size = 425983, upload-time = "2025-07-28T19:53:47.685Z" } wheels = [ @@ -126,7 +133,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.12.*'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } wheels = [ @@ -170,7 +178,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "six" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/89/f53968635b1b2e53e4aad2dd641488929fef4ca9dfb0b97927fa7697ddf3/azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c", size = 339689, upload-time = "2025-07-03T00:55:23.496Z" } wheels = [ @@ -186,7 +195,8 @@ dependencies = [ { name = "cryptography" }, { name = "msal" }, { name = "msal-extensions" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/29/1201ffbb6a57a16524dd91f3e741b4c828a70aaba436578bdcb3fbcb438c/azure_identity-1.23.1.tar.gz", hash = "sha256:226c1ef982a9f8d5dcf6e0f9ed35eaef2a4d971e7dd86317e9b9d52e70a035e4", size = 266185, upload-time = "2025-07-15T19:16:38.077Z" } wheels = [ @@ -201,7 +211,8 @@ dependencies = [ { name = "azure-core" }, { name = "cryptography" }, { name = "isodate" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/95/3e3414491ce45025a1cde107b6ae72bf72049e6021597c201cd6a3029b9a/azure_storage_blob-12.26.0.tar.gz", hash = "sha256:5dd7d7824224f7de00bfeb032753601c982655173061e242f13be6e26d78d71f", size = 583332, upload-time = "2025-07-16T21:34:07.644Z" } wheels = [ @@ -217,6 +228,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "beautysh" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "types-colorama" }, + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/96/0b7545646b036d7fa8c27fa6239ad6aeed4e83e22c1d3e408a036fb3d430/beautysh-6.2.1.tar.gz", hash = "sha256:423e0c87cccf2af21cae9a75e04e0a42bc6ce28469c001ee8730242e10a45acd", size = 9800, upload-time = "2021-10-12T08:37:18.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/a7/542307bd25bf5af7b6a71fa32b89915023a8e18c87327a644b2ed3635d60/beautysh-6.2.1-py3-none-any.whl", hash = "sha256:8c7d9c4f2bd02c089194218238b7ecc78879506326b301eba1d5f49471a55bac", size = 9986, upload-time = "2021-10-12T08:37:17.696Z" }, +] + [[package]] name = "boto3" version = "1.34.34" @@ -245,96 +270,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049, upload-time = "2024-08-15T19:25:18.301Z" }, ] -[[package]] -name = "ccproxy" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "anthropic" }, - { name = "attrs" }, - { name = "fasteners" }, - { name = "httpx" }, - { name = "litellm", extra = ["proxy"] }, - { name = "prometheus-client" }, - { name = "psutil" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "structlog" }, - { name = "types-psutil" }, - { name = "watchdog" }, -] - -[package.optional-dependencies] -dev = [ - { name = "coverage", extra = ["toml"] }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, - { name = "types-pyyaml" }, - { name = "types-requests" }, -] - -[package.dev-dependencies] -dev = [ - { name = "coverage" }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, - { name = "types-psutil" }, - { name = "types-pyyaml" }, - { name = "types-requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "anthropic", specifier = ">=0.39.0" }, - { name = "attrs", specifier = ">=23.0.0" }, - { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "fasteners", specifier = ">=0.19.0" }, - { name = "httpx", specifier = ">=0.27.0" }, - { name = "litellm", extras = ["proxy"], specifier = ">=1.13.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, - { name = "prometheus-client", specifier = ">=0.18.0" }, - { name = "psutil", specifier = ">=5.9.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "structlog", specifier = ">=24.0.0" }, - { name = "types-psutil", specifier = ">=7.0.0.20250601" }, - { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, - { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.31.0" }, - { name = "watchdog", specifier = ">=3.0.0" }, -] -provides-extras = ["dev"] - -[package.metadata.requires-dev] -dev = [ - { name = "coverage", specifier = ">=7.10.1" }, - { name = "mypy", specifier = ">=1.17.0" }, - { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "pytest", specifier = ">=8.4.1" }, - { name = "pytest-asyncio", specifier = ">=1.1.0" }, - { name = "pytest-cov", specifier = ">=6.2.1" }, - { name = "ruff", specifier = ">=0.12.6" }, - { name = "types-psutil", specifier = ">=7.0.0.20250601" }, - { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, - { name = "types-requests", specifier = ">=2.32.4.20250611" }, -] - [[package]] name = "certifi" version = "2025.7.14" @@ -446,6 +381,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "claude-ccproxy" +version = "1.2.0" +source = { editable = "." } +dependencies = [ + { name = "anthropic" }, + { name = "attrs" }, + { name = "fasteners" }, + { name = "httpx" }, + { name = "langfuse" }, + { name = "litellm", extra = ["proxy"] }, + { name = "prisma" }, + { name = "prometheus-client" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "structlog" }, + { name = "tiktoken" }, + { name = "types-psutil" }, + { name = "tyro" }, + { name = "watchdog" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "beautysh" }, + { name = "coverage" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "setuptools" }, + { name = "types-psutil" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.39.0" }, + { name = "attrs", specifier = ">=23.0.0" }, + { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "fasteners", specifier = ">=0.19.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "langfuse", specifier = ">=2.0.0,<3.0.0" }, + { name = "litellm", extras = ["proxy"], specifier = ">=1.13.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, + { name = "prisma", specifier = ">=0.15.0" }, + { name = "prometheus-client", specifier = ">=0.18.0" }, + { name = "psutil", specifier = ">=5.9.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.7.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "structlog", specifier = ">=24.0.0" }, + { name = "tiktoken", specifier = ">=0.5.0" }, + { name = "types-psutil", specifier = ">=7.0.0.20250601" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.31.0" }, + { name = "tyro", specifier = ">=0.7.0" }, + { name = "watchdog", specifier = ">=3.0.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "beautysh", specifier = ">=6.2.1" }, + { name = "coverage", specifier = ">=7.10.1" }, + { name = "mypy", specifier = ">=1.17.0" }, + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.1.0" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, + { name = "ruff", specifier = ">=0.12.6" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "types-psutil", specifier = ">=7.0.0.20250601" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, + { name = "types-requests", specifier = ">=2.32.4.20250611" }, +] + [[package]] name = "click" version = "8.2.1" @@ -603,6 +642,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + [[package]] name = "email-validator" version = "2.2.0" @@ -623,7 +671,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } wheels = [ @@ -761,10 +810,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, +] + [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -785,13 +850,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/55/ef77a85ee443ae05a9e9cba1c9f0dd9241eb42da2aeba1dc50f51154c81a/hf_xet-1.1.5-cp37-abi3-win_amd64.whl", hash = "sha256:73e167d9807d166596b4b2f0b585c6d5bd84a26dea32843665a8b58f6edba245", size = 2738931, upload-time = "2025-06-20T21:48:39.482Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385, upload-time = "2025-04-11T14:42:46.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732, upload-time = "2025-04-11T14:42:44.896Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -805,7 +890,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore", version = "1.0.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } @@ -834,7 +920,8 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/b4/e6b465eca5386b52cf23cb6df8644ad318a6b0e12b4b96a7e0be09cbfbcc/huggingface_hub-0.34.3.tar.gz", hash = "sha256:d58130fd5aa7408480681475491c0abd7e835442082fbc3ef4d45b6c39f83853", size = 456800, upload-time = "2025-07-29T08:38:53.885Z" } wheels = [ @@ -997,9 +1084,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, ] +[[package]] +name = "langfuse" +version = "2.60.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "idna" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/1a/2443e3715767f1bf9d8cf32d74ac59cfb60e1d9b84e99df13fd656639eb3/langfuse-2.60.9.tar.gz", hash = "sha256:040753346d7df4a0be6967dfc7efe3de313fee362524fe2f801867fcbbca3c98", size = 152684, upload-time = "2025-06-29T09:39:27.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/50/3aa93fc284ba5f81dcdd00b6414caee338fd45d77fa4959c3e4f838cebc6/langfuse-2.60.9-py3-none-any.whl", hash = "sha256:e4291a66bc579c66d7652da5603ca7f0409536700d7b812e396780b5d9a0685d", size = 275543, upload-time = "2025-06-29T09:39:26.234Z" }, +] + [[package]] name = "litellm" -version = "1.74.9.post1" +version = "1.74.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1014,9 +1120,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/39/60a16cfa5aa43498f35538aa2c4608f303eaa60396e862e38ecdc5c85681/litellm-1.74.9.post1.tar.gz", hash = "sha256:968cc4ef2afa701a3da78389d1fd1514ace1574c09e46785972c1e1d594547f1", size = 9660690, upload-time = "2025-07-29T00:53:32.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/fd/3e28fa5f362ae08ba895d509d701ec7fd0af274bcb16ea4dece6740b5764/litellm-1.74.12.tar.gz", hash = "sha256:d73bdc6beedfe9ca985ca0e78e27677a8725ca1100e4560d20ebef6e0f62204e", size = 9678136, upload-time = "2025-07-31T14:44:55.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/0b/3951fc38b726a1a72fa806ab46fc64bbf2b92cbed69be856dd768196e16a/litellm-1.74.9.post1-py3-none-any.whl", hash = "sha256:9247808f90247073cb63657fb23e00d8ec2c46af8792476f61d9517e7c9633ae", size = 8740465, upload-time = "2025-07-29T00:53:29.976Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5745632d7a8c7f9bd588a956421e4514ae98d1895eec7eaece99d15ffa7f/litellm-1.74.12-py3-none-any.whl", hash = "sha256:67d9067c27c1ea23606b8463ba72342b01d25594555d1aa97f2b783636948835", size = 8755400, upload-time = "2025-07-31T14:44:52.343Z" }, ] [package.optional-dependencies] @@ -1057,12 +1163,9 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.2.12" +version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/9d/a8b5cd56eb94ca737f8556fd0cf06c1e19b4b2b1d0c5ecfe2cf95d9e25db/litellm_proxy_extras-0.2.12.tar.gz", hash = "sha256:df3254d607ee7bcfe70d518f49f20e21e99862c3ea0930748bcf4f91d07c208b", size = 15399, upload-time = "2025-07-28T22:08:39.394Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/50/0b177162871623d301825d430fc285d232e34e0c3918b22c34d4f2cd82d8/litellm_proxy_extras-0.2.12-py3-none-any.whl", hash = "sha256:e6762ad9cc276b8ef5134e059bd7fbdcf08fb23601039e35b216bf7172c52924", size = 28321, upload-time = "2025-07-28T22:08:38.272Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/f7/6e/6e46bf6abaddc73973933334ec6761da556617c26e224fe06a1628f69f4a/litellm_proxy_extras-0.2.14.tar.gz", hash = "sha256:c05bacba2048130648e41287856c3ca5cdcf744708e19970679333b2fed96dfb", size = 15083, upload-time = "2025-07-30T23:05:00.051Z" } [[package]] name = "markdown-it-py" @@ -1126,7 +1229,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.10.0" +version = "1.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1136,13 +1239,14 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/d90e42be23a7e6dd35c03e35c7c63fe1036f082d3bb88114b66bd0f2467e/mcp-1.10.0.tar.gz", hash = "sha256:91fb1623c3faf14577623d14755d3213db837c5da5dae85069e1b59124cbe0e9", size = 392961, upload-time = "2025-06-26T13:51:19.025Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/19/9955e2df5384ff5dd25d38f8e88aaf89d2d3d9d39f27e7383eaf0b293836/mcp-1.12.3.tar.gz", hash = "sha256:ab2e05f5e5c13e1dc90a4a9ef23ac500a6121362a564447855ef0ab643a99fed", size = 427203, upload-time = "2025-07-31T18:36:36.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/52/e1c43c4b5153465fd5d3b4b41bf2d4c7731475e9f668f38d68f848c25c9a/mcp-1.10.0-py3-none-any.whl", hash = "sha256:925c45482d75b1b6f11febddf9736d55edf7739c7ea39b583309f6651cbc9e5c", size = 150894, upload-time = "2025-06-26T13:51:17.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8b/0be74e3308a486f1d127f3f6767de5f9f76454c9b4183210c61cc50999b6/mcp-1.12.3-py3-none-any.whl", hash = "sha256:5483345bf39033b858920a5b6348a303acacf45b23936972160ff152107b850e", size = 158810, upload-time = "2025-07-31T18:36:34.915Z" }, ] [[package]] @@ -1263,34 +1367,41 @@ wheels = [ [[package]] name = "mypy" -version = "1.17.0" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, - { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, - { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, - { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, - { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, - { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, - { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" }, - { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" }, - { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" }, - { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" }, - { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" }, - { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" }, - { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" }, - { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, ] [[package]] @@ -1322,7 +1433,7 @@ wheels = [ [[package]] name = "openai" -version = "1.97.1" +version = "1.98.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1332,11 +1443,12 @@ dependencies = [ { name = "pydantic" }, { name = "sniffio" }, { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/57/1c471f6b3efb879d26686d31582997615e969f3bb4458111c9705e56332e/openai-1.97.1.tar.gz", hash = "sha256:a744b27ae624e3d4135225da9b1c89c107a2a7e5bc4c93e5b7b5214772ce7a4e", size = 494267, upload-time = "2025-07-22T13:10:12.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/9d/52eadb15c92802711d6b6cf00df3a6d0d18b588f4c5ba5ff210c6419fc03/openai-1.98.0.tar.gz", hash = "sha256:3ee0fcc50ae95267fd22bd1ad095ba5402098f3df2162592e68109999f685427", size = 496695, upload-time = "2025-07-30T12:48:03.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/35/412a0e9c3f0d37c94ed764b8ac7adae2d834dbd20e69f6aca582118e0f55/openai-1.97.1-py3-none-any.whl", hash = "sha256:4e96bbdf672ec3d44968c9ea39d2c375891db1acc1794668d8149d5fa6000606", size = 764380, upload-time = "2025-07-22T13:10:10.689Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fe/f64631075b3d63a613c0d8ab761d5941631a470f6fa87eaaee1aa2b4ec0c/openai-1.98.0-py3-none-any.whl", hash = "sha256:b99b794ef92196829120e2df37647722104772d2a74d08305df9ced5f26eae34", size = 767713, upload-time = "2025-07-30T12:48:01.264Z" }, ] [[package]] @@ -1405,11 +1517,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] [[package]] @@ -1469,6 +1581,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "prisma" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "nodeenv" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tomlkit" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/55/d4e07cbf40d5f1ab6d1c42c23613d442bf0d06abf7f70bec280aefb28249/prisma-0.15.0.tar.gz", hash = "sha256:5cd6402aa8322625db3fc1152040404e7fc471fe7f8fa3a314fa8a99529ca107", size = 154975, upload-time = "2024-08-16T02:54:03.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/6d/84533aa3fcc395235d58c3412fb86013653b697d91fc53f379c83bbb0b79/prisma-0.15.0-py3-none-any.whl", hash = "sha256:de949cc94d3d91243615f22ff64490aa6e2d7cb81aabffce53d92bd3977c09a4", size = 173809, upload-time = "2024-08-16T02:54:02.326Z" }, +] + [[package]] name = "prometheus-client" version = "0.22.1" @@ -1582,7 +1714,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } @@ -1600,7 +1733,8 @@ name = "pydantic-core" version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -1789,6 +1923,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/6b/b60f47101ba2cac66b4a83246630e68ae9bbe2e614cbae5f4465f46dee13/python_multipart-0.0.18-py3-none-any.whl", hash = "sha256:efe91480f485f6a361427a541db4796f9e1591afc0fb8e7a4ba06bfbc6708996", size = 24389, upload-time = "2024-11-28T19:16:00.947Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1843,7 +1996,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.12.*'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ @@ -1852,41 +2006,66 @@ wheels = [ [[package]] name = "regex" -version = "2025.7.29" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/63/975c4989b97b2a757495ebe5c52d82970a5ef88fcdc5f4d95cfac369e20d/regex-2025.7.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:747fea7f98761ed25dbbffa10f3def9385b48e49badfc5e97fad6e3f4f2caf5f", size = 489347, upload-time = "2025-07-29T18:48:51.851Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/51e28ec89cdcfa2165be30a29123cd46c169b4ccfe3a778fc6221032ae7a/regex-2025.7.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5714cc58c6bfaff1204f592c52b6531c90a27bf2a70e296a863bae18c92ebd0c", size = 293052, upload-time = "2025-07-29T18:48:53.176Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/c029b72e3ae82c794aa65c26a5caa997341128ce1023aaafee946739298f/regex-2025.7.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf3ac6f5f9e280b7ae6da10bdabc7fc9c102d1bf9e47eb8d92db4c73b78842f9", size = 290097, upload-time = "2025-07-29T18:48:54.646Z" }, - { url = "https://files.pythonhosted.org/packages/17/87/7373079eb1e2f7b973e9c5435224e5bc8a90ae7d812a9eae93f99d59ea13/regex-2025.7.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3603c722d36d9ed013918a4b1687db6caa08fcaafb4ba3b296c9fc8bd31a53c9", size = 803690, upload-time = "2025-07-29T18:48:55.874Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/4bcb450fb04aa4dea495e6c574ba5f7c306e04a17d0a47d80cdcf273f667/regex-2025.7.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:529880c105ae9a1230ff6d1130207e4f3b7e64d71c487f242464695673231bea", size = 792224, upload-time = "2025-07-29T18:48:57.425Z" }, - { url = "https://files.pythonhosted.org/packages/2e/18/b13983ee37f7571413660df445bbb6851f6d3a5f7b4998461893ee147c45/regex-2025.7.29-cp311-cp311-win32.whl", hash = "sha256:612765d6a7e39e6a43751e9f4412334414027f31273cd742284b2ddbba75dbd3", size = 268740, upload-time = "2025-07-29T18:48:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/40/33/92f11c75965627bb93dc24990e1345b4021d60ef0cfc5acf261d4882d687/regex-2025.7.29-cp311-cp311-win_amd64.whl", hash = "sha256:fd4a6a80788661ad09db376828833b0fc26359655e4e77be7539fcbe82241bec", size = 280435, upload-time = "2025-07-29T18:49:00.369Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/e4a14d793fe1e853afa5ffcdeb97d3556c1f5e3429d5b980164404f4c9ca/regex-2025.7.29-cp311-cp311-win_arm64.whl", hash = "sha256:a5aaafafb0a1fec9258dcd87b4b12d3a9c6078daaa74524a2cc0e74691075585", size = 272885, upload-time = "2025-07-29T18:49:02.089Z" }, - { url = "https://files.pythonhosted.org/packages/e1/0a/571b277e81ed74af6ffc5f93bf62f202ba21438727c20806fc31a8e87530/regex-2025.7.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df26c13221124138ac6944d7d895c12673b09499a9d650c81790b025a0b1bb37", size = 490335, upload-time = "2025-07-29T18:49:04.273Z" }, - { url = "https://files.pythonhosted.org/packages/77/93/70e71743dda71a2100d0ddcde1d48f27cc19726cd789940e126b5661a862/regex-2025.7.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1efb631d67f5ed0a37c7102425e4ae6e7c60acc561a92aa9f983360568ba17e", size = 293734, upload-time = "2025-07-29T18:49:06.041Z" }, - { url = "https://files.pythonhosted.org/packages/a9/55/e57b02df5d37f551dce447899f600428b9cb1e7a57479e22227e16e1ecba/regex-2025.7.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c7ca42a898610d64bee82854085810b006bae647508e6ca44b58a6866b94932", size = 290268, upload-time = "2025-07-29T18:49:07.599Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/fa18558ceb768851a4e7bb930f7cf73c99ec23564a57295e70a38701d343/regex-2025.7.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81c3dbddee0de40bc5db9c093e97f12fec1cfc48ddc8be61699bd28e67cd477f", size = 804510, upload-time = "2025-07-29T18:49:08.918Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0a/b6150fec18920a324233360d3aaca074b32b01acae475f5a16450e15b831/regex-2025.7.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:72ded9020430d97bbc68a87e602b9f05f037c3a978d3ada2124af5a960b01721", size = 794272, upload-time = "2025-07-29T18:49:10.362Z" }, - { url = "https://files.pythonhosted.org/packages/52/b9/b69a16a8fbdc7c6ae0616bea3166c814c9bcfd8671589379329cba129790/regex-2025.7.29-cp312-cp312-win32.whl", hash = "sha256:1538bfae71d42f31232e36d4d45c5594d3cc6515b0a49897331367946f0fb32a", size = 269105, upload-time = "2025-07-29T18:49:12.099Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d4/ef00edfff55867ec95ff9f8af085c28e590c2c83379f63f0b126ec8795d1/regex-2025.7.29-cp312-cp312-win_amd64.whl", hash = "sha256:9d72d33903a3e2d93acaa9e24d11cf3688f1c20515e4f8ec1ea881eea24b92e6", size = 279788, upload-time = "2025-07-29T18:49:13.578Z" }, - { url = "https://files.pythonhosted.org/packages/33/61/6e652fe1fe164028b5a60d3b6c57cb05193515ab7453361d6bdf1c3957e8/regex-2025.7.29-cp312-cp312-win_arm64.whl", hash = "sha256:d0c5de6962e7d062a3c2e41347cfe6c2a26b0731ba2da3500884519eaab7ac08", size = 272990, upload-time = "2025-07-29T18:49:15.061Z" }, - { url = "https://files.pythonhosted.org/packages/b1/67/c81234a9e900cb9b62c9fe549e9f56a2f19718323cc826f77f472653deeb/regex-2025.7.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b22b2cc3402996c730f1dfd240be95108e8897192f82b8a01bcffcfeafaf0476", size = 490122, upload-time = "2025-07-29T18:49:16.358Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f2/41dd213a58e8d4a3b0db7a598602de7cbfb465f14139040ffb6710b7a0b1/regex-2025.7.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bc0d5d1c45ad2880afec2891716616f1bcb84ebfbd70767086e81656a219f70b", size = 293621, upload-time = "2025-07-29T18:49:17.784Z" }, - { url = "https://files.pythonhosted.org/packages/a9/55/942db711ae7f1a19686994468ceef654a35440ec77beab2f706fe5d72631/regex-2025.7.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c82c7ca3b6589573b48250ca59b0d17ad43884491a0c5c6b9ef9f868f68a0aa7", size = 290210, upload-time = "2025-07-29T18:49:19.396Z" }, - { url = "https://files.pythonhosted.org/packages/1b/25/c07c7a7a8bd4b2351139742de46704ddbcfe83e0ff03f68443819c2885d7/regex-2025.7.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a885ee2db12adfe3a96faa231fb61a0731ba74c90f5265cb1cb78a0d53463f", size = 804528, upload-time = "2025-07-29T18:49:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b1/8b5ff8e6b27e539d390287e8ab08f5a04deda5c8da6639aeda11a2c2e2b4/regex-2025.7.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd1b8732ce1fcf6b119a36edfdcd4fbb49e82ee86fe73b963a706c3ea19edc42", size = 794347, upload-time = "2025-07-29T18:49:22.767Z" }, - { url = "https://files.pythonhosted.org/packages/0f/20/44b4bf1cc0e460b889e0ac2b04faa618447f737d2dc804fb4bc2fc8a1aa3/regex-2025.7.29-cp313-cp313-win32.whl", hash = "sha256:534fbaa53bb9f8b5951a5a87efee9ef10cab1a282f60c3711f24a84fff7faa97", size = 269087, upload-time = "2025-07-29T18:49:24.475Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ca/4a615ed8a17046eef18a65f05d7e7d27e5ad1c6a472dfafddf1e6369c9ea/regex-2025.7.29-cp313-cp313-win_amd64.whl", hash = "sha256:136bcfb36b751d51eafe7f21458a5d35be3d568f9c70f9c0934005ee96d19253", size = 279764, upload-time = "2025-07-29T18:49:26.178Z" }, - { url = "https://files.pythonhosted.org/packages/a7/59/3225b28555f1f56545f18e9ce913aa11875bef960a7f5641b7f86056a2b9/regex-2025.7.29-cp313-cp313-win_arm64.whl", hash = "sha256:b6fad25e9189187ac9e81cb3cdb7dd73b8912cde8a56301aa49c803252b93ef5", size = 272985, upload-time = "2025-07-29T18:49:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/46/85/95db52d187d1d94a6f712dad8317b88a953b8e6aae949e64ba4a56f6f97e/regex-2025.7.29-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:571ecd5970615bb3b3257d1fd23e76889977123fc0f525c166d8607680ffff28", size = 490156, upload-time = "2025-07-29T18:49:29.546Z" }, - { url = "https://files.pythonhosted.org/packages/56/e4/74a9162c588a62a50aafa302d0a354f5007c079d01dde0ae0f23cee72c73/regex-2025.7.29-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3cf360a5b44bde2c32097c6303fbf11136e04ce7912b5368b7b04c84f52a939b", size = 293534, upload-time = "2025-07-29T18:49:31.219Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8c/4a7853fecb771ad80c0c01bb9e6991c5bfd36e50dd21025a1d3b6d6fd479/regex-2025.7.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e211412e1466d654806f10597695e70e562899be3a883cad3326803c8da39ee", size = 290327, upload-time = "2025-07-29T18:49:32.533Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9f/c6831493334a46285a9842da754ba2644fb543a354cccccc667f8a2fb53b/regex-2025.7.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0f8e976785376ff0ab67176d0cbf42c8a964663f10306e6620c3424c88120a2", size = 803973, upload-time = "2025-07-29T18:49:33.833Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d6/d165df45ac08572ed7ade0ee15a127724b964147008f52a97006a4a1456b/regex-2025.7.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc6f92c568e6dad6041e850bddbb7b6f9fed0d2d36e91e8313d0f0abb95ddcda", size = 793880, upload-time = "2025-07-29T18:49:35.651Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ec/bd2e90e33c539bda7c5b608937a55e58dcc9585ba39c3083f784af97a8d6/regex-2025.7.29-cp314-cp314-win32.whl", hash = "sha256:a91781c833c0f03f42821bc349de4197fd411ef9a4dd513b72abf74d3afb8634", size = 274477, upload-time = "2025-07-29T18:49:37.554Z" }, - { url = "https://files.pythonhosted.org/packages/52/b1/9eb1af06611ebbd399910630960b41c8e23c4f5804aa4be9f5e27aef3186/regex-2025.7.29-cp314-cp314-win_amd64.whl", hash = "sha256:5743ae64c22b6f7672a699260fef86ec84baf8f6ee21be1484f9cca880ba85ba", size = 283030, upload-time = "2025-07-29T18:49:38.88Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2f/7ac07ba3252b91fec0095b64d8084611bdd36207a6d1833f831a50bebc9c/regex-2025.7.29-cp314-cp314-win_arm64.whl", hash = "sha256:03c0eab5d3310968f19721930014b9735d3a61dbe719b04cfa57d0571fbb64ac", size = 276079, upload-time = "2025-07-29T18:49:40.265Z" }, +version = "2025.7.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714, upload-time = "2025-07-31T00:21:16.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594, upload-time = "2025-07-31T00:19:13.927Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294, upload-time = "2025-07-31T00:19:15.395Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933, upload-time = "2025-07-31T00:19:16.704Z" }, + { url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335, upload-time = "2025-07-31T00:19:18.561Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605, upload-time = "2025-07-31T00:19:20.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780, upload-time = "2025-07-31T00:19:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868, upload-time = "2025-07-31T00:19:23.222Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784, upload-time = "2025-07-31T00:19:24.59Z" }, + { url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837, upload-time = "2025-07-31T00:19:25.911Z" }, + { url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240, upload-time = "2025-07-31T00:19:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139, upload-time = "2025-07-31T00:19:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019, upload-time = "2025-07-31T00:19:31.129Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047, upload-time = "2025-07-31T00:19:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362, upload-time = "2025-07-31T00:19:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492, upload-time = "2025-07-31T00:19:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000, upload-time = "2025-07-31T00:19:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072, upload-time = "2025-07-31T00:19:38.612Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341, upload-time = "2025-07-31T00:19:40.119Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556, upload-time = "2025-07-31T00:19:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762, upload-time = "2025-07-31T00:19:43Z" }, + { url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892, upload-time = "2025-07-31T00:19:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551, upload-time = "2025-07-31T00:19:46.127Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457, upload-time = "2025-07-31T00:19:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902, upload-time = "2025-07-31T00:19:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038, upload-time = "2025-07-31T00:19:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417, upload-time = "2025-07-31T00:19:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387, upload-time = "2025-07-31T00:19:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482, upload-time = "2025-07-31T00:19:55.183Z" }, + { url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334, upload-time = "2025-07-31T00:19:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942, upload-time = "2025-07-31T00:19:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991, upload-time = "2025-07-31T00:19:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415, upload-time = "2025-07-31T00:20:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487, upload-time = "2025-07-31T00:20:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717, upload-time = "2025-07-31T00:20:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943, upload-time = "2025-07-31T00:20:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664, upload-time = "2025-07-31T00:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457, upload-time = "2025-07-31T00:20:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008, upload-time = "2025-07-31T00:20:11.823Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101, upload-time = "2025-07-31T00:20:13.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401, upload-time = "2025-07-31T00:20:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368, upload-time = "2025-07-31T00:20:16.711Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482, upload-time = "2025-07-31T00:20:18.189Z" }, + { url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385, upload-time = "2025-07-31T00:20:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788, upload-time = "2025-07-31T00:20:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136, upload-time = "2025-07-31T00:20:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753, upload-time = "2025-07-31T00:20:27.919Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263, upload-time = "2025-07-31T00:20:29.803Z" }, + { url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103, upload-time = "2025-07-31T00:20:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709, upload-time = "2025-07-31T00:20:33.323Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726, upload-time = "2025-07-31T00:20:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306, upload-time = "2025-07-31T00:20:37.12Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494, upload-time = "2025-07-31T00:20:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850, upload-time = "2025-07-31T00:20:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730, upload-time = "2025-07-31T00:20:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640, upload-time = "2025-07-31T00:20:44.42Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757, upload-time = "2025-07-31T00:20:46.355Z" }, ] [[package]] @@ -2033,26 +2212,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.12.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/a2/364031a095e0d50277813b61c98918b8e5057a232f3b97bd39c3050898ad/ruff-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:59b48d8581989e0527b64c3297e672357c03b78d58cf1b228037a49915316277", size = 11855193, upload-time = "2025-07-29T20:44:15.216Z" }, - { url = "https://files.pythonhosted.org/packages/84/4b/17060a0c01ff20329cb86aff0ec8ade03a033fb340a0e8276973395ba5d1/ruff-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:412518260394e8a6647a0c610062cac48ff230d39b9df57faae93aa77123e90c", size = 12522289, upload-time = "2025-07-29T20:44:18.341Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5b/ca87980044b163278eca24dc081a38101d3b2b5da3b57af28ca33f997f1e/ruff-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b56a3f51a27d0db8141d5b4b095c2849b24f639539a05d201f72f8d83f829a78", size = 11739924, upload-time = "2025-07-29T20:44:20.654Z" }, - { url = "https://files.pythonhosted.org/packages/57/d9/2004a5c099d96f75931b318138c5bb39df6af7d9035b02c188e5024d3a35/ruff-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ef9e292957bd6a868ce4e5f57931d0583814a363add2adedae3a1c9854b7ad9", size = 11952620, upload-time = "2025-07-29T20:44:22.635Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/5bcc44d63823331e93b585797576b7e5bc581cd7eaf73f782bb2031dba81/ruff-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c3fd9955d3009c33e60bb596ea7bc66832de34d621883061114bb3b6114d358", size = 11662270, upload-time = "2025-07-29T20:44:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/5c/c2c56b605666353c139235a598a2ea073d51e65f9b615f6eee71b19657d3/ruff-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e7456efef8dd6957843de60a245152e34a842210d8b13381d5f3e7540d17935", size = 13232207, upload-time = "2025-07-29T20:44:27.432Z" }, - { url = "https://files.pythonhosted.org/packages/ef/1d/301a4788986b9f31a12439503f643413f6188a6bd154ee11bd47ac5fd6c1/ruff-0.12.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c99e62bae20c7e1a8d4de84f96754e9732d0831614ed165415ed2c4f4aa83864", size = 14179966, upload-time = "2025-07-29T20:44:30.079Z" }, - { url = "https://files.pythonhosted.org/packages/36/b1/5723f4d8f227351005c6c7a1cda1680a5357536be99f4a74da3fa51ebd76/ruff-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d47ff2b300da87df8437e1b35291349faaceb666d8349edef733b6562d29264f", size = 13629620, upload-time = "2025-07-29T20:44:32.387Z" }, - { url = "https://files.pythonhosted.org/packages/62/a7/2f614b90698084b5d9985e741ae11d1581e90fdd7ffc37cb4730a0472725/ruff-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8883ab5e9506574a6a2abacb5da34d416fdd8434151b35421ba3f79ca9a14a11", size = 12667635, upload-time = "2025-07-29T20:44:34.752Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b3/2f71b72f47ea6d2352bafcc08ca02d5d80ace032dd5f0c43d30a49f2d02a/ruff-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3cfbd192c312669fb22cd4bf8c700e8b4b1dced7ce034e581459c0e375486fa", size = 12941871, upload-time = "2025-07-29T20:44:36.733Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fd/dd266e754d584a4f60652795bbc1ce0cffed83b9e897f6d479e5c73fca07/ruff-0.12.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c1d87f2b1abf330281b3972d6bf34d366ee84b3077df66a89169e2d81b291891", size = 11773663, upload-time = "2025-07-29T20:44:38.68Z" }, - { url = "https://files.pythonhosted.org/packages/e6/15/9532fa52ac7a9c9c088ae77a60a626a4fb2a2d1e1e1fcca5ea082f1a9615/ruff-0.12.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3f32aaa9b5ed69de80693abeecf9961cd97851cadf7850081461261d0e6551b6", size = 11610539, upload-time = "2025-07-29T20:44:41.205Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a2/83dfcdec877bfba16589ed8c0463cb40c28e01cb52381af495146cf7b83b/ruff-0.12.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:de5185f19289a800c16d6ec8a9ba0b8b911b4640a4927b487f48fb51634ce315", size = 12485468, upload-time = "2025-07-29T20:44:43.598Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a7/e47be7e51e54945fdedcc10b43f819c3dffbd12a0378d7854fa43da7f9e8/ruff-0.12.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80f9d56205f6f6c4a1039c79d9acc0a9c104915f4fc0fc0385170decc72f6e4c", size = 12998871, upload-time = "2025-07-29T20:44:45.617Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6d/1b121d75ad74cb4e16b9f6e1e2493b178e64a84a8b57a3189fcf3dcce329/ruff-0.12.6-py3-none-win32.whl", hash = "sha256:b553271d6ed5611fcbe5f6752852eef695f2a77c0405b3a16fd507e5a057f5b0", size = 11747804, upload-time = "2025-07-29T20:44:47.725Z" }, - { url = "https://files.pythonhosted.org/packages/2b/55/935b38ca28fd550a81b758743f66dfb060428b0c5e1995833865644f4d9d/ruff-0.12.6-py3-none-win_amd64.whl", hash = "sha256:48b73d4acef6768bfe9912e8f623ec87677bcfb6dc748ac406ebff06a84a6d70", size = 12906253, upload-time = "2025-07-29T20:44:49.777Z" }, - { url = "https://files.pythonhosted.org/packages/55/68/0454d21dbc251e45da45c0cf0fd6db1253ec80d5888db0c1e11b25f21d5a/ruff-0.12.6-py3-none-win_arm64.whl", hash = "sha256:cd2c9c898a11f1441778d1cf9e358244cf5f4f2f11e93ff03c1a6c6759f4b15d", size = 11978598, upload-time = "2025-07-29T20:44:52.127Z" }, +version = "0.12.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" }, + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" }, + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687, upload-time = "2025-07-29T22:32:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365, upload-time = "2025-07-29T22:32:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, ] [[package]] @@ -2067,6 +2247,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shtab" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/3e/837067b970c1d2ffa936c72f384a63fdec4e186b74da781e921354a94024/shtab-1.7.2.tar.gz", hash = "sha256:8c16673ade76a2d42417f03e57acf239bfb5968e842204c17990cae357d07d6f", size = 45751, upload-time = "2025-04-12T20:28:03.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/03/3271b7bb470fbab4adf5bd30b0d32143909d96f3608d815b447357f47f2b/shtab-1.7.2-py3-none-any.whl", hash = "sha256:858a5805f6c137bb0cda4f282d27d08fd44ca487ab4a6a36d2a400263cd0b5c1", size = 14214, upload-time = "2025-04-12T20:28:01.82Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -2212,6 +2410,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -2224,6 +2431,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "typeguard" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a9/e3aee762739c1d7528da1c3e06d518503f8b6c439c35549b53735ba52ead/typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e", size = 34874, upload-time = "2025-06-18T09:56:05.999Z" }, +] + +[[package]] +name = "types-colorama" +version = "0.4.15.20240311" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/0fb0b9fe4964b45b2a06ed41b60c352752626db46aa0fb70a49a9e283a75/types-colorama-0.4.15.20240311.tar.gz", hash = "sha256:a28e7f98d17d2b14fb9565d32388e419f4108f557a7d939a66319969b2b99c7a", size = 5608, upload-time = "2024-03-11T02:15:51.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/83/6944b4fa01efb2e63ac62b791a8ddf0fee358f93be9f64b8f152648ad9d3/types_colorama-0.4.15.20240311-py3-none-any.whl", hash = "sha256:6391de60ddc0db3f147e31ecb230006a6823e81e380862ffca1e4695c13a0b8e", size = 5840, upload-time = "2024-03-11T02:15:50.43Z" }, +] + [[package]] name = "types-psutil" version = "7.0.0.20250601" @@ -2254,10 +2483,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" }, ] +[[package]] +name = "types-setuptools" +version = "57.4.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/5e/3d46cd143913bd51dde973cd23b1d412de9662b08a3b8c213f26b265e6f1/types-setuptools-57.4.18.tar.gz", hash = "sha256:8ee03d823fe7fda0bd35faeae33d35cb5c25b497263e6a58b34c4cfd05f40bcf", size = 16654, upload-time = "2022-06-26T12:32:07.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/45/b8368a8c2d1dc4fa47eb4db980966e23edecbda16fab7a38186b076bbd4d/types_setuptools-57.4.18-py3-none-any.whl", hash = "sha256:9660b8774b12cd61b448e2fd87a667c02e7ec13ce9f15171f1d49a4654c4df6a", size = 27357, upload-time = "2022-06-26T12:32:06.008Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, @@ -2268,13 +2522,32 @@ name = "typing-inspection" version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tyro" +version = "0.9.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "shtab" }, + { name = "typeguard" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/4b/c2b5e9b497bdd03fbf78f1fb83da621e6609d6a764ea0c34f9486dcc3e95/tyro-0.9.27.tar.gz", hash = "sha256:f7b16340bc07b1eeb0a06880c9fcdddf0cfd084fbad40baf3072361c5a63b268", size = 307477, upload-time = "2025-07-29T22:29:50.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ef/98b2700c6a262a9d78eaec5b16916a75a63f7c1e642cfce0717c440d2f9b/tyro-0.9.27-py3-none-any.whl", hash = "sha256:f51655c45be6ba297af47cfc04622287422177448a060ffbec0f5fa905046f41", size = 129003, upload-time = "2025-07-29T22:29:48.629Z" }, +] + [[package]] name = "tzdata" version = "2025.2" @@ -2311,7 +2584,8 @@ version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "h11" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/8d/5005d39cd79c9ae87baf7d7aafdcdfe0b13aa69d9a1e3b7f1c984a2ac6d2/uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0", size = 40894, upload-time = "2024-03-20T06:43:25.747Z" } wheels = [ @@ -2427,6 +2701,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload-time = "2024-09-21T17:34:19.904Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "yarl" version = "1.20.1"