diff --git a/.env.example b/.env.example index 7dfeca8..d84440b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # --- LLM Configuration --- -# Select the LLM provider: "deepseek" (default), "groq", "openrouter", "github", or "ollama" +# Select the LLM provider: "claude-agent-sdk", "deepseek" (default), "groq", "openrouter", "github", "anthropic", or "ollama" LLM_PROVIDER="deepseek" # Provide the API key for the chosen provider: @@ -7,6 +7,8 @@ LLM_PROVIDER="deepseek" DEEPSEEK_API_KEY="your_deepseek_api_key" # OPENROUTER_API_KEY="your_openrouter_api_key" # GITHUB_TOKEN="ghp_your_github_personal_access_token" +# ANTHROPIC_API_KEY="your_anthropic_api_key" +# Note: Claude Agent SDK requires NO API KEY - uses local Claude Code installation # Note: Ollama requires no API key but needs local installation # Note: GitHub Models requires a GitHub Personal Access Token with appropriate scopes @@ -15,12 +17,18 @@ LLM_BASE_URL="your_base_url_if_needed" # Optional: Specify different models for Enhanced (Complex Synthesis) and Standard (Individual Processing) # Defaults are set within the code based on the provider if these are not set. +# Example for Claude Agent SDK (uses local Claude Code - no API key needed): +# CLAUDE_AGENT_SDK_ENHANCED_MODEL_ID="claude-sonnet-4-5" # For complex synthesis +# CLAUDE_AGENT_SDK_STANDARD_MODEL_ID="claude-sonnet-4-5" # For individual processing # Example for Groq: # GROQ_ENHANCED_MODEL_ID="openai/gpt-oss-120b" # For complex synthesis # GROQ_STANDARD_MODEL_ID="openai/gpt-oss-20b" # For individual processing # Example for DeepSeek: # DEEPSEEK_ENHANCED_MODEL_ID="deepseek-chat" # For complex synthesis # DEEPSEEK_STANDARD_MODEL_ID="deepseek-chat" # For individual processing +# Example for Anthropic: +# ANTHROPIC_ENHANCED_MODEL_ID="claude-3-5-sonnet-20241022" # For complex synthesis +# ANTHROPIC_STANDARD_MODEL_ID="claude-3-5-haiku-20241022" # For individual processing # Example for GitHub Models: # GITHUB_ENHANCED_MODEL_ID="openai/gpt-5" # For complex synthesis # GITHUB_STANDARD_MODEL_ID="openai/gpt-5-min" # For individual processing @@ -103,4 +111,26 @@ MAX_RETRIES="3" TIMEOUT="30.0" PERFORMANCE_MONITORING="true" # Enable real-time performance monitoring PERFORMANCE_BASELINE_TIME="30.0" # Baseline time per thought in seconds -PERFORMANCE_BASELINE_EFFICIENCY="0.8" # Target efficiency score \ No newline at end of file +PERFORMANCE_BASELINE_EFFICIENCY="0.8" # Target efficiency score + +# --- Claude Agent SDK Advanced Configuration --- +# These settings are ONLY used when LLM_PROVIDER="claude-agent-sdk" +# Claude Agent SDK uses local Claude Code - no API key required + +# Quick Setup Example: +# LLM_PROVIDER="claude-agent-sdk" +# CLAUDE_AGENT_SDK_ENHANCED_MODEL_ID="claude-sonnet-4-5" +# CLAUDE_AGENT_SDK_STANDARD_MODEL_ID="claude-sonnet-4-5" + +# Advanced Features (all optional): +# CLAUDE_SDK_PERMISSION_MODE="bypassPermissions" # Options: "default", "acceptEdits", "plan", "bypassPermissions" +# CLAUDE_SDK_CWD="/path/to/working/directory" # Working directory for file operations +# CLAUDE_SDK_ADD_DIRS="/path/to/extra/context,/another/path" # Comma-separated additional directories for context + +# Note: The following advanced features are available but configured programmatically: +# - Structured outputs support (automatic via Agno response_format) +# - Tool choice strategies (automatic via Agno tool_choice) +# - Session continuation (automatic via Agno session_id) +# - Usage tracking (automatic - tokens, cache, stop_reason) +# - User context (automatic via Agno user_id) +# See CLAUDE.md for detailed documentation on these features \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7d99a7d..eb615e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,8 @@ External LLM → sequentialthinking tool → ThoughtProcessor → WorkflowExecut ### Configuration & Data Flow **Environment Variables:** -- `LLM_PROVIDER`: Provider selection (deepseek, groq, openrouter, ollama, github, anthropic) -- `{PROVIDER}_API_KEY`: API keys (e.g., `DEEPSEEK_API_KEY`, `GITHUB_TOKEN`) +- `LLM_PROVIDER`: Provider selection (deepseek, groq, openrouter, ollama, github, anthropic, claude-agent-sdk) +- `{PROVIDER}_API_KEY`: API keys (e.g., `DEEPSEEK_API_KEY`, `GITHUB_TOKEN`) - **Not required for claude-agent-sdk** - `{PROVIDER}_ENHANCED_MODEL_ID`: Enhanced model for complex synthesis (Blue Hat) - `{PROVIDER}_STANDARD_MODEL_ID`: Standard model for individual hat processing - `EXA_API_KEY`: Research capabilities (if using research agents) @@ -122,6 +122,13 @@ External LLM → sequentialthinking tool → ThoughtProcessor → WorkflowExecut **Examples:** ```bash +# Claude Agent SDK (uses local Claude Code - no API key needed!) +LLM_PROVIDER="claude-agent-sdk" +# No API key required - uses locally installed Claude Code +# Model IDs are informational - Claude Code uses its internal models +CLAUDE_AGENT_SDK_ENHANCED_MODEL_ID="claude-sonnet-4-5" # Both synthesis and processing +CLAUDE_AGENT_SDK_STANDARD_MODEL_ID="claude-sonnet-4-5" + # GitHub Models GITHUB_ENHANCED_MODEL_ID="openai/gpt-5" # Blue Hat synthesis GITHUB_STANDARD_MODEL_ID="openai/gpt-5-min" # Individual hats @@ -155,6 +162,189 @@ ANTHROPIC_STANDARD_MODEL_ID="claude-3-5-haiku-20241022" # Processing **For Agno Documentation**: Use deepwiki MCP reference with repoName: `agno-agi/agno` +## Claude Agent SDK Advanced Features + +The Claude Agent SDK provider supports comprehensive Agno integration with advanced features: + +### Structured Outputs Support + +**Response Format Configuration:** +The SDK supports structured outputs through Agno's `response_format` parameter: + +```python +from pydantic import BaseModel + +class ThinkingResult(BaseModel): + """Structured thinking output""" + analysis: str + confidence: float + key_insights: list[str] + +# Agent automatically uses this schema +agent = Agent( + model=claude_sdk_model, + output_schema=ThinkingResult # Converted to system prompt instructions +) +``` + +**Supported Formats:** +- **Pydantic BaseModel**: Full schema with validation (converted to JSON schema in system prompt) +- **JSON Mode**: `{"type": "json_object"}` for generic JSON output +- **Custom Schemas**: Dict-based schemas with properties + +**How it Works:** +- `supports_native_structured_outputs()` returns `True` for Agno compatibility +- Schema is converted to detailed system prompt instructions +- Claude follows the schema structure via system prompt guidance +- Reliable structured output without native SDK support + +### Tool Choice Strategies + +**Fine-grained Tool Control:** +```python +agent = Agent( + model=claude_sdk_model, + tools=[ReasoningTools], + tool_choice="required" # Force tool usage +) + +# Available strategies: +# - "none": Disable all tools +# - "auto": Model decides (default) +# - "required" / "any": Model must use tools +# - {"type": "tool", "name": "Think"}: Specific tool only +``` + +**Automatic Mapping:** +- `tool_choice="none"` → All tools added to `disallowed_tools` +- `tool_choice="required"` → Keep `allowed_tools` as-is +- Specific tool selection → Only that tool in `allowed_tools`, rest in `disallowed_tools` + +### Session Continuation and User Context + +**Automatic Session Tracking:** +```python +# Agno automatically provides session_id and user_id +agent.arun( + "Continue our previous discussion", + session_id="abc123", # Extracted automatically + user_id="user_456" # Used for personalization +) + +# SDK automatically: +# - Sets continue_conversation=True +# - Passes user context: {"id": "user_456"} +# - Maintains conversation continuity +``` + +**Benefits:** +- Multi-Thinking sequences maintain context across hat switches +- User-specific personalization +- Session history preservation + +### Usage and Cost Tracking + +**Automatic Metadata Extraction:** +Every response includes comprehensive usage data: + +```python +response = await agent.arun("Analyze this problem") + +# Available in response.provider_data: +{ + "usage": { + "input_tokens": 1500, + "output_tokens": 800, + "cache_creation_input_tokens": 200, + "cache_read_input_tokens": 1000, + "stop_reason": "end_turn", + "model_used": "claude-sonnet-4-5" + }, + "run_metadata": { + "session_id": "abc123", + "user_id": "user_456", + "run_id": "run_789" + }, + "response_format_used": True, + "tool_choice_used": "auto", + "session_continuation": True +} +``` + +**Use Cases:** +- Cost analysis and budget tracking +- Performance monitoring +- Cache efficiency optimization +- Debugging and troubleshooting + +### Advanced Configuration + +**All Supported ClaudeAgentOptions:** +```python +from pathlib import Path + +model = ClaudeAgentSDKModel( + model_id="claude-sonnet-4-5", + + # Permission control + permission_mode="bypassPermissions", # default, acceptEdits, plan, bypassPermissions + + # File system access + cwd="/path/to/project", + add_dirs=[Path("/extra/context")], + + # MCP servers + mcp_servers={ + "filesystem": {"path": "/path/to/mcp/config"} + }, + + # Environment + env={"DEBUG": "1", "CUSTOM_VAR": "value"}, + + # Event hooks + hooks={ + "PreToolUse": [lambda ctx: print(f"Using {ctx.tool_name}")], + "PostToolUse": [lambda ctx: print(f"Completed {ctx.tool_name}")] + }, + + # Permission callback + can_use_tool=async_permission_checker +) +``` + +**Automatic Agno Integration:** +When used with Agno Agent: +- ✅ `response_format` → System prompt schema instructions +- ✅ `tool_choice` → Allowed/disallowed tools mapping +- ✅ `tools` → Automatic tool name mapping (ReasoningTools → Think) +- ✅ `tool_call_limit` → max_turns parameter +- ✅ `run_response.session_id` → continue_conversation +- ✅ `run_response.user_id` → user context +- ✅ Usage metadata → Extracted and tracked + +### Multi-Thinking Integration Benefits + +**For Multi-Thinking Workflow:** +1. **Structured Outputs**: Each hat can return structured JSON for reliable parsing +2. **Tool Control**: Fine-tune which hats use Think tool vs direct responses +3. **Session Context**: Maintain context across all 6 thinking agents +4. **Cost Tracking**: Monitor token usage per hat for optimization +5. **User Personalization**: Context-aware responses based on user history + +**Example Multi-Thinking Flow:** +``` +Factual Hat → (session_id: abc123) + ↓ usage: 500 input, 200 output tokens +Emotional Hat → (session continues, uses cache) + ↓ usage: 100 input (400 cached), 150 output +Critical Hat → (session continues) + ↓ usage: 100 input (400 cached), 250 output +... +Synthesis Hat → (full context, structured output) + ✓ Total cost tracked across all hats + ✓ Cache efficiency: 80% cache hit rate +``` + ## AI-Powered Complexity Analysis **Key Innovation**: The system uses AI instead of rule-based pattern matching for complexity analysis: diff --git a/README.md b/README.md index 4b81d9d..23efe0b 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,11 @@ Research is **optional** - requires `EXA_API_KEY` environment variable. The syst - **AI Selection**: System automatically chooses the right model based on task complexity ### Supported Providers: +- **Claude Agent SDK** - Use local Claude Code (no API key required!) + - ✨ **New: Advanced Agno Integration** - Structured outputs, tool choice strategies, session continuation + - ✅ Automatic usage tracking (tokens, cache efficiency, stop_reason) + - ✅ User context and session continuity across Multi-Thinking sequences + - 📖 See [CLAUDE.md: Claude Agent SDK Advanced Features](CLAUDE.md#claude-agent-sdk-advanced-features) for detailed usage - **DeepSeek** (default) - High performance, cost-effective - **Groq** - Ultra-fast inference - **OpenRouter** - Access to multiple models @@ -339,9 +344,12 @@ Create a `.env` file or set these variables: ```bash # LLM Provider (required) -LLM_PROVIDER="deepseek" # deepseek, groq, openrouter, github, anthropic, ollama +LLM_PROVIDER="deepseek" # deepseek, groq, openrouter, github, anthropic, ollama, claude-agent-sdk DEEPSEEK_API_KEY="sk-..." +# Or use Claude Agent SDK (no API key needed!) +# LLM_PROVIDER="claude-agent-sdk" # Requires Claude Code installed locally + # Optional: Enhanced/Standard Model Selection # DEEPSEEK_ENHANCED_MODEL_ID="deepseek-chat" # For synthesis # DEEPSEEK_STANDARD_MODEL_ID="deepseek-chat" # For other agents diff --git a/pyproject.toml b/pyproject.toml index f35a5d1..6812700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "openrouter", "httpx[socks]>=0.28.1", "sqlalchemy", + "claude-agent-sdk", ] [project.optional-dependencies] diff --git a/src/mcp_server_mas_sequential_thinking/config/modernized_config.py b/src/mcp_server_mas_sequential_thinking/config/modernized_config.py index a3ca071..4cd2f3c 100644 --- a/src/mcp_server_mas_sequential_thinking/config/modernized_config.py +++ b/src/mcp_server_mas_sequential_thinking/config/modernized_config.py @@ -5,10 +5,9 @@ import os from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Protocol, runtime_checkable -from agno.models.anthropic import Claude from agno.models.base import Model from agno.models.deepseek import DeepSeek from agno.models.groq import Groq @@ -16,6 +15,12 @@ from agno.models.openai import OpenAIChat from agno.models.openrouter import OpenRouter +from mcp_server_mas_sequential_thinking.models.claude_agent_sdk import ( + ClaudeAgentSDKModel, +) + +# Note: Claude is imported lazily in AnthropicStrategy to avoid anthropic dependency + class GitHubOpenAI(OpenAIChat): """OpenAI provider configured for GitHub Models API with enhanced validation.""" @@ -83,7 +88,7 @@ def _validate_github_token(token: str) -> None: "use a real GitHub token." ) - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 # Set GitHub Models configuration kwargs.setdefault("base_url", "https://models.github.ai/inference") @@ -114,24 +119,49 @@ class ModelConfig: def create_enhanced_model(self) -> Model: """Create enhanced model instance (used for complex synthesis like Blue Hat).""" # Enable prompt caching for Anthropic models - if self.provider_class == Claude: + if self.provider_class.__name__ == "Claude": return self.provider_class( id=self.enhanced_model_id, - # Note: cache_system_prompt removed - not available in current Agno version + # Note: cache_system_prompt removed - not available in + # current Agno version ) return self.provider_class(id=self.enhanced_model_id) def create_standard_model(self) -> Model: """Create standard model instance (used for individual hat processing).""" # Enable prompt caching for Anthropic models - if self.provider_class == Claude: + if self.provider_class.__name__ == "Claude": return self.provider_class( id=self.standard_model_id, - # Note: cache_system_prompt removed - not available in current Agno version + # Note: cache_system_prompt removed - not available in + # current Agno version ) return self.provider_class(id=self.standard_model_id) +@dataclass(frozen=True) +class ClaudeAgentSDKModelConfig(ModelConfig): + """Extended configuration for Claude Agent SDK with additional parameters.""" + + sdk_kwargs: dict[str, Any] = field(default_factory=dict) + + def create_enhanced_model(self) -> Model: + """Create enhanced Claude Agent SDK model with custom configuration.""" + return ClaudeAgentSDKModel( + model_id=self.enhanced_model_id, + name="Claude Agent SDK Enhanced", + **self.sdk_kwargs, + ) + + def create_standard_model(self) -> Model: + """Create standard Claude Agent SDK model with custom configuration.""" + return ClaudeAgentSDKModel( + model_id=self.standard_model_id, + name="Claude Agent SDK Standard", + **self.sdk_kwargs, + ) + + @runtime_checkable class ConfigurationStrategy(Protocol): """Protocol defining configuration strategy interface.""" @@ -274,6 +304,9 @@ class AnthropicStrategy(BaseProviderStrategy): @property def provider_class(self) -> type[Model]: + # Lazy import to avoid anthropic dependency unless needed + from agno.models.anthropic import Claude # noqa: PLC0415 + return Claude @property @@ -289,6 +322,87 @@ def api_key_name(self) -> str: return "ANTHROPIC_API_KEY" +class ClaudeAgentSDKStrategy(BaseProviderStrategy): + """Claude Agent SDK provider strategy (uses local Claude Code). + + This provider uses the Claude Agent SDK to communicate with locally installed + Claude Code, eliminating the need for API keys and enabling the use of + Claude Code's capabilities within the Multi-Thinking framework. + """ + + @property + def provider_class(self) -> type[Model]: + return ClaudeAgentSDKModel + + @property + def default_enhanced_model(self) -> str: + # Claude Code uses internal models, but we specify sonnet as default + return "claude-sonnet-4-5" + + @property + def default_standard_model(self) -> str: + # Both enhanced and standard use the same model in Claude Code + return "claude-sonnet-4-5" + + @property + def api_key_name(self) -> str | None: + # Claude Agent SDK doesn't require API keys - uses local Claude Code + return None + + def get_config(self) -> ModelConfig: + """Get Claude Agent SDK configuration with environment variable overrides.""" + # Get model IDs from environment or defaults + prefix = self.__class__.__name__.replace("Strategy", "").upper() + + enhanced_model = self._get_env_with_fallback( + f"{prefix}_ENHANCED_MODEL_ID", self.default_enhanced_model + ) + standard_model = self._get_env_with_fallback( + f"{prefix}_STANDARD_MODEL_ID", self.default_standard_model + ) + + # Read Claude SDK specific environment variables + permission_mode = os.environ.get( + "CLAUDE_SDK_PERMISSION_MODE", "bypassPermissions" + ) + cwd = os.environ.get("CLAUDE_SDK_CWD") + add_dirs_str = os.environ.get("CLAUDE_SDK_ADD_DIRS", "") + + # Parse add_dirs (comma-separated list) + add_dirs = ( + [d.strip() for d in add_dirs_str.split(",") if d.strip()] + if add_dirs_str + else None + ) + + # Create kwargs for ClaudeAgentSDKModel + sdk_kwargs: dict[str, Any] = {} + + # Only add parameters if they're set + if permission_mode and permission_mode in ( + "default", + "acceptEdits", + "plan", + "bypassPermissions", + ): + sdk_kwargs["permission_mode"] = permission_mode + + if cwd: + sdk_kwargs["cwd"] = cwd + + if add_dirs: + sdk_kwargs["add_dirs"] = add_dirs + + # Return ClaudeAgentSDKModelConfig with SDK-specific kwargs + return ClaudeAgentSDKModelConfig( + provider_class=self.provider_class, + enhanced_model_id=enhanced_model, + standard_model_id=standard_model, + api_key=None, # No API key for Claude SDK + sdk_kwargs=sdk_kwargs, + ) + + class ConfigurationManager: """Manages configuration strategies with dependency injection.""" @@ -300,6 +414,7 @@ def __init__(self) -> None: "ollama": OllamaStrategy(), "github": GitHubStrategy(), "anthropic": AnthropicStrategy(), + "claude-agent-sdk": ClaudeAgentSDKStrategy(), } self._default_strategy = "deepseek" @@ -342,7 +457,7 @@ def validate_environment(self, provider_name: str | None = None) -> dict[str, st exa_key = os.environ.get("EXA_API_KEY") if not exa_key: # Don't fail startup - just log warning that research will be disabled - import logging + import logging # noqa: PLC0415 logging.getLogger(__name__).warning( "EXA_API_KEY not found. Research tools will be disabled." diff --git a/src/mcp_server_mas_sequential_thinking/models/__init__.py b/src/mcp_server_mas_sequential_thinking/models/__init__.py new file mode 100644 index 0000000..f772f08 --- /dev/null +++ b/src/mcp_server_mas_sequential_thinking/models/__init__.py @@ -0,0 +1,7 @@ +"""Models module for custom model implementations.""" + +from mcp_server_mas_sequential_thinking.models.claude_agent_sdk import ( + ClaudeAgentSDKModel, +) + +__all__ = ["ClaudeAgentSDKModel"] diff --git a/src/mcp_server_mas_sequential_thinking/models/claude_agent_sdk.py b/src/mcp_server_mas_sequential_thinking/models/claude_agent_sdk.py new file mode 100644 index 0000000..9dbbb12 --- /dev/null +++ b/src/mcp_server_mas_sequential_thinking/models/claude_agent_sdk.py @@ -0,0 +1,987 @@ +"""Claude Agent SDK Model Wrapper for Agno Framework. + +This module provides integration between Claude Agent SDK and Agno framework, +allowing the use of local Claude Code as a model provider within Multi-Thinking. +""" + +import asyncio +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path +from typing import Any, Literal + +from agno.models.base import Model +from agno.models.message import Message +from agno.models.response import ModelResponse +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class ClaudeAgentSDKModel(Model): + """Claude Agent SDK model wrapper that implements Agno Model interface. + + This class bridges Claude Agent SDK (local Claude Code) with the Agno framework, + enabling it to be used as a model provider in the Multi-Thinking architecture. + + Features: + - Converts Agno messages to Claude Agent SDK query format + - Executes queries through local Claude Code + - Returns responses in Agno ModelResponse format + - Supports reasoning tools (Think tool in Claude Agent SDK) + - Tool permission management (allowed_tools, disallowed_tools, can_use_tool) + - MCP server integration + - Environment variables and working directory control + - Event hooks (PreToolUse, PostToolUse, UserPromptSubmit, etc.) + - Additional directory access for context + - Structured outputs support (BaseModel schemas and JSON mode) + - Tool choice strategies (none, auto, required, specific tool selection) + - Session continuation and user context tracking + - Usage and timing metadata extraction (tokens, cache, stop_reason) + + Example: + Basic usage: + >>> model = ClaudeAgentSDKModel( + ... model_id="claude-sonnet-4-5", + ... permission_mode="bypassPermissions" + ... ) + + With MCP servers: + >>> model = ClaudeAgentSDKModel( + ... mcp_servers={"filesystem": {...}}, + ... env={"DEBUG": "1"}, + ... add_dirs=["/path/to/project"] + ... ) + + With hooks: + >>> hooks = { + ... "PreToolUse": [lambda ctx: print(f"Using {ctx.tool_name}")], + ... "PostToolUse": [lambda ctx: print(f"Completed {ctx.tool_name}")] + ... } + >>> model = ClaudeAgentSDKModel(hooks=hooks) + + With permission callback: + >>> async def check_permission(tool_name, args, context): + ... if tool_name == "dangerous_tool": + ... return {"allow": False, "reason": "Not allowed"} + ... return {"allow": True} + >>> model = ClaudeAgentSDKModel(can_use_tool=check_permission) + """ + + def __init__( + self, + model_id: str = "claude-sonnet-4-5", # Default model ID + name: str | None = None, + permission_mode: Literal[ + "default", "acceptEdits", "plan", "bypassPermissions" + ] = "bypassPermissions", + cwd: str | None = None, + mcp_servers: dict[str, Any] | str | Path | None = None, + env: dict[str, str] | None = None, + add_dirs: list[str | Path] | None = None, + hooks: dict[str, list[Any]] | None = None, + can_use_tool: Callable[[str, dict[str, Any], Any], Awaitable[Any]] + | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialize Claude Agent SDK model. + + Args: + model_id: Model identifier (e.g., "claude-sonnet-4-5") + name: Optional human-readable name + permission_mode: Permission mode for Claude Code operations + - 'default': Standard permissions with prompts + - 'acceptEdits': Auto-accept file edits + - 'plan': Plan mode for reviewing actions + - 'bypassPermissions': Bypass all permission checks (default) + cwd: Working directory for Claude Code (default: current directory) + mcp_servers: MCP servers configuration (dict, path to config, or None) + env: Environment variables to pass to Claude Code + add_dirs: Additional directories for context/file access + hooks: Event hooks (PreToolUse, PostToolUse, UserPromptSubmit, etc.) + can_use_tool: Runtime callback for tool permission checks + **kwargs: Additional arguments passed to base Model class + """ + super().__init__( + id=model_id, + name=name or "Claude Agent SDK", + provider="claude-agent-sdk", + **kwargs, + ) + + # Structured outputs support (via system prompt instructions) + self.supports_native_structured_outputs = True + self.supports_json_schema_outputs = True + + # Store configuration + self.permission_mode = permission_mode + self.cwd = cwd or str(Path.cwd()) + self.mcp_servers = mcp_servers + self.env = env or {} + self.add_dirs = [str(d) for d in add_dirs] if add_dirs else [] + self.hooks = hooks + self.can_use_tool = can_use_tool + + # Lazy import to avoid issues if SDK not installed + try: + # Import both classes from claude_agent_sdk + from claude_agent_sdk import ( # noqa: PLC0415, I001 + ClaudeAgentOptions, + query as claude_query, + ) + + self._claude_query = claude_query + self._claude_options_class = ClaudeAgentOptions + except ImportError as e: + logger.exception( + "claude-agent-sdk not installed. Please install it: " + "pip install claude-agent-sdk" + ) + raise ImportError( + "claude-agent-sdk is required for ClaudeAgentSDKModel. " + "Install it with: pip install claude-agent-sdk" + ) from e + + logger.info( + "Initialized Claude Agent SDK model: %s (provider: claude-agent-sdk, " + "permission_mode: %s, cwd: %s, mcp_servers: %s, env_vars: %d, " + "add_dirs: %d, hooks: %s, can_use_tool: %s)", + model_id, + permission_mode, + self.cwd, + "configured" if self.mcp_servers else "none", + len(self.env), + len(self.add_dirs), + list(self.hooks.keys()) if self.hooks else "none", + "configured" if self.can_use_tool else "none", + ) + + def _extract_system_and_messages( + self, messages: list[Message] + ) -> tuple[str | None, str]: + """Extract system prompt and convert messages to prompt string. + + System messages are separated and used for ClaudeAgentOptions.system_prompt. + Other messages are converted to the main prompt. + + Args: + messages: List of Agno Message objects + + Returns: + Tuple of (system_prompt, user_prompt) + """ + system_parts = [] + prompt_parts = [] + + for msg in messages: + role = msg.role if hasattr(msg, "role") else "user" + content = msg.content if hasattr(msg, "content") else str(msg) + + # Separate system messages for ClaudeAgentOptions + if role == "system": + system_parts.append(content) + elif role == "user": + prompt_parts.append(f"User: {content}") + elif role == "assistant": + prompt_parts.append(f"Assistant: {content}") + else: + prompt_parts.append(str(content)) + + system_prompt = "\n\n".join(system_parts) if system_parts else None + user_prompt = "\n\n".join(prompt_parts) if prompt_parts else "" + + return system_prompt, user_prompt + + def _map_tools_to_allowed_tools(self, tools: list[Any] | None) -> list[str]: + """Map Agno tools to Claude Agent SDK allowed_tools list. + + Args: + tools: List of Agno tools (ReasoningTools, ExaTools, Functions, etc.) + + Returns: + List of tool names for Claude Agent SDK + """ + if not tools: + return [] + + allowed_tools = [] + + for tool in tools: + # Handle tool classes + if hasattr(tool, "__name__"): + tool_name = tool.__name__ + # Map known Agno tools to Claude Agent SDK tools + if "reasoning" in tool_name.lower(): + allowed_tools.append("Think") + elif "exa" in tool_name.lower(): + allowed_tools.append("search_exa") + else: + # For other tools, use class name as-is + allowed_tools.append(tool_name) + # Handle Function objects + elif hasattr(tool, "name"): + allowed_tools.append(tool.name) + # Handle dict-based tools + elif isinstance(tool, dict) and "name" in tool: + allowed_tools.append(tool["name"]) + + logger.debug("Mapped tools to allowed_tools: %s", allowed_tools) + return allowed_tools + + def _extract_tool_calls(self, message: Any) -> list[dict[str, Any]]: # noqa: ANN401 + """Extract tool calls from Claude Agent SDK message. + + Args: + message: Message object from Claude Agent SDK + + Returns: + List of tool call dictionaries + """ + tool_calls = [] + + # Check if message has tool_use blocks + if hasattr(message, "content") and isinstance(message.content, list): + for block in message.content: + # Handle tool_use blocks + if hasattr(block, "type") and block.type == "tool_use": + tool_call = { + "id": getattr(block, "id", "unknown"), + "type": "tool_use", + "name": getattr(block, "name", "unknown"), + "input": getattr(block, "input", {}), + } + tool_calls.append(tool_call) + elif isinstance(block, dict) and block.get("type") == "tool_use": + tool_calls.append( + { + "id": block.get("id", "unknown"), + "type": "tool_use", + "name": block.get("name", "unknown"), + "input": block.get("input", {}), + } + ) + + return tool_calls + + async def ainvoke( # noqa: PLR0912, PLR0915 + self, + messages: list[Message], + assistant_message: Message, + response_format: dict[str, Any] | type | None = None, + tools: list[Any] | None = None, + tool_choice: str | dict[str, Any] | None = None, + run_response: Any = None, # noqa: ANN401 + ) -> ModelResponse: + """Generate async response using Claude Agent SDK. + + This is the core method that Agno Agent uses to get model responses. + + Args: + messages: List of conversation messages + assistant_message: Message object to track metrics (Agno framework) + response_format: Optional response format specification (BaseModel or dict) + tools: Optional list of tools (ReasoningTools mapped to Think tool) + tool_choice: Optional tool selection strategy + run_response: Optional run response object with session/user metadata + + Returns: + ModelResponse with generated content + """ + try: + # Extract system prompt and convert messages + system_prompt, prompt = self._extract_system_and_messages(messages) + + # Add response_format instructions to system prompt + response_format_prompt = self._format_response_format_prompt( + response_format + ) + if response_format_prompt and system_prompt: + system_prompt += response_format_prompt + elif response_format_prompt: + system_prompt = response_format_prompt.strip() + + # Map Agno tools to Claude Agent SDK allowed_tools + allowed_tools = self._map_tools_to_allowed_tools(tools) + + # Process tool_choice to get final allowed/disallowed tools + final_allowed_tools, disallowed_tools = self._process_tool_choice( + tool_choice, allowed_tools + ) + + # Extract metadata from run_response + run_metadata = self._extract_metadata_from_run_response(run_response) + + # Start metrics tracking + assistant_message.metrics.start_timer() + + # Create Claude Agent SDK options + options_kwargs: dict[str, Any] = { + "system_prompt": system_prompt, + "max_turns": 10, # Default max_turns + "model": self.id, + "permission_mode": self.permission_mode, + "cwd": self.cwd, + } + + # Add optional parameters if provided + if final_allowed_tools: + options_kwargs["allowed_tools"] = final_allowed_tools + + if disallowed_tools: + options_kwargs["disallowed_tools"] = disallowed_tools + + if self.mcp_servers is not None: + options_kwargs["mcp_servers"] = self.mcp_servers + + if self.env: + options_kwargs["env"] = self.env + + if self.add_dirs: + options_kwargs["add_dirs"] = self.add_dirs + + if self.hooks is not None: + options_kwargs["hooks"] = self.hooks + + if self.can_use_tool is not None: + options_kwargs["can_use_tool"] = self.can_use_tool + + # Add session continuation support + session_id = run_metadata.get("session_id") + if session_id: + # Use continue_conversation for session continuity + options_kwargs["continue_conversation"] = True + logger.debug( + "Enabling session continuation with session_id: %s", session_id + ) + + # Add user information if available + user_id = run_metadata.get("user_id") + if user_id: + options_kwargs["user"] = {"id": user_id} + logger.debug("Adding user context: %s", user_id) + + options = self._claude_options_class(**options_kwargs) + + logger.debug( + "Claude Agent SDK query - prompt: %d chars, system: %d chars, " + "allowed_tools: %s, disallowed_tools: %s, response_format: %s, " + "tool_choice: %s, session_id: %s", + len(prompt), + len(system_prompt) if system_prompt else 0, + final_allowed_tools, + disallowed_tools, + "configured" if response_format else "none", + tool_choice, + session_id, + ) + + # Collect response from Claude Agent SDK + full_response = "" + collected_tool_calls = [] + accumulated_usage: dict[str, Any] = {} + + async for message in self._claude_query(prompt=prompt, options=options): + # Extract tool calls from message + tool_calls = self._extract_tool_calls(message) + if tool_calls: + collected_tool_calls.extend(tool_calls) + + # Extract usage metadata + usage_data = self._extract_usage_metadata(message) + if usage_data: + # Accumulate usage data + for key, value in usage_data.items(): + if key in ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ): + current = accumulated_usage.get(key, 0) + accumulated_usage[key] = current + value + else: + accumulated_usage[key] = value + + # Claude Agent SDK returns message objects + # Extract text content + if hasattr(message, "content"): + # Handle different content formats + if isinstance(message.content, str): + full_response += message.content + elif isinstance(message.content, list): + # Handle content blocks + for block in message.content: + if hasattr(block, "text"): + full_response += block.text + elif isinstance(block, dict) and "text" in block: + full_response += block["text"] + else: + # Fallback: convert to string + full_response += str(message) + + logger.debug( + "Claude Agent SDK response - length: %d chars, tool_calls: %d, " + "usage: %s", + len(full_response), + len(collected_tool_calls), + accumulated_usage, + ) + + # Build comprehensive provider_data + provider_data: dict[str, Any] = { + "model_id": self.id, + "provider": "claude-agent-sdk", + "permission_mode": self.permission_mode, + "cwd": self.cwd, + "mcp_servers_configured": self.mcp_servers is not None, + "env_vars_count": len(self.env), + "add_dirs_count": len(self.add_dirs), + "hooks_configured": list(self.hooks.keys()) if self.hooks else [], + "can_use_tool_configured": self.can_use_tool is not None, + "response_format_used": response_format is not None, + "tool_choice_used": tool_choice, + "session_continuation": session_id is not None, + } + + # Add usage data to provider_data + if accumulated_usage: + provider_data["usage"] = accumulated_usage + + # Add run metadata + if run_metadata: + provider_data["run_metadata"] = run_metadata + + # Stop metrics tracking + assistant_message.metrics.stop_timer() + + # Create Agno ModelResponse with all metadata + return ModelResponse( + role="assistant", + content=full_response, + tool_calls=collected_tool_calls if collected_tool_calls else [], + provider_data=provider_data, + ) + + except Exception as e: + logger.exception("Claude Agent SDK query failed") + assistant_message.metrics.stop_timer() + # Return error response + return ModelResponse( + role="assistant", + content=f"Error querying Claude Agent SDK: {e!s}", + provider_data={ + "error": str(e), + "model_id": self.id, + "provider": "claude-agent-sdk", + }, + ) + + async def ainvoke_stream( # noqa: PLR0912, PLR0915 + self, + messages: list[Message], + assistant_message: Message, + response_format: dict[str, Any] | type | None = None, + tools: list[Any] | None = None, + tool_choice: str | dict[str, Any] | None = None, + run_response: Any = None, # noqa: ANN401 + ) -> AsyncIterator[ModelResponse]: + """Generate streaming async response using Claude Agent SDK. + + Args: + messages: List of conversation messages + assistant_message: Message object to track metrics (Agno framework) + response_format: Optional response format specification (BaseModel or dict) + tools: Optional list of tools + tool_choice: Optional tool selection strategy + run_response: Optional run response object with session/user metadata + + Yields: + ModelResponse objects as they arrive + """ + try: + # Extract system prompt and convert messages + system_prompt, prompt = self._extract_system_and_messages(messages) + + # Add response_format instructions to system prompt + response_format_prompt = self._format_response_format_prompt( + response_format + ) + if response_format_prompt and system_prompt: + system_prompt += response_format_prompt + elif response_format_prompt: + system_prompt = response_format_prompt.strip() + + # Map Agno tools to Claude Agent SDK allowed_tools + allowed_tools = self._map_tools_to_allowed_tools(tools) + + # Process tool_choice to get final allowed/disallowed tools + final_allowed_tools, disallowed_tools = self._process_tool_choice( + tool_choice, allowed_tools + ) + + # Extract metadata from run_response + run_metadata = self._extract_metadata_from_run_response(run_response) + + # Start metrics tracking + assistant_message.metrics.start_timer() + + # Create Claude Agent SDK options + options_kwargs: dict[str, Any] = { + "system_prompt": system_prompt, + "max_turns": 10, # Default max_turns + "model": self.id, + "permission_mode": self.permission_mode, + "cwd": self.cwd, + } + + # Add optional parameters if provided + if final_allowed_tools: + options_kwargs["allowed_tools"] = final_allowed_tools + + if disallowed_tools: + options_kwargs["disallowed_tools"] = disallowed_tools + + if self.mcp_servers is not None: + options_kwargs["mcp_servers"] = self.mcp_servers + + if self.env: + options_kwargs["env"] = self.env + + if self.add_dirs: + options_kwargs["add_dirs"] = self.add_dirs + + if self.hooks is not None: + options_kwargs["hooks"] = self.hooks + + if self.can_use_tool is not None: + options_kwargs["can_use_tool"] = self.can_use_tool + + # Add session continuation support + session_id = run_metadata.get("session_id") + if session_id: + options_kwargs["continue_conversation"] = True + logger.debug( + "Enabling session continuation (stream) with session_id: %s", + session_id, + ) + + # Add user information if available + user_id = run_metadata.get("user_id") + if user_id: + options_kwargs["user"] = {"id": user_id} + logger.debug("Adding user context (stream): %s", user_id) + + options = self._claude_options_class(**options_kwargs) + + logger.debug( + "Claude Agent SDK streaming - prompt: %d chars, system: %d chars, " + "allowed_tools: %s, disallowed_tools: %s, response_format: %s, " + "tool_choice: %s, session_id: %s", + len(prompt), + len(system_prompt) if system_prompt else 0, + final_allowed_tools, + disallowed_tools, + "configured" if response_format else "none", + tool_choice, + session_id, + ) + + async for message in self._claude_query(prompt=prompt, options=options): + # Extract tool calls from message + tool_calls = self._extract_tool_calls(message) + + # Extract usage metadata for each chunk + usage_data = self._extract_usage_metadata(message) + + # Extract content from message + content = "" + if hasattr(message, "content"): + if isinstance(message.content, str): + content = message.content + elif isinstance(message.content, list): + for block in message.content: + if hasattr(block, "text"): + content += block.text + elif isinstance(block, dict) and "text" in block: + content += block["text"] + else: + content = str(message) + + # Build provider_data for this chunk + provider_data: dict[str, Any] = { + "model_id": self.id, + "provider": "claude-agent-sdk", + "streaming": True, + "permission_mode": self.permission_mode, + "response_format_used": response_format is not None, + "tool_choice_used": tool_choice, + "session_continuation": session_id is not None, + } + + # Add usage data if available + if usage_data: + provider_data["usage"] = usage_data + + # Add run metadata to first chunk + if run_metadata: + provider_data["run_metadata"] = run_metadata + + # Yield response with content or tool calls + if content or tool_calls: + yield ModelResponse( + role="assistant", + content=content, + tool_calls=tool_calls if tool_calls else [], + provider_data=provider_data, + ) + + except Exception as e: + logger.exception("Claude Agent SDK streaming query failed") + assistant_message.metrics.stop_timer() + yield ModelResponse( + role="assistant", + content=f"Error in Claude Agent SDK streaming: {e!s}", + provider_data={ + "error": str(e), + "model_id": self.id, + "provider": "claude-agent-sdk", + }, + ) + finally: + # Always stop timer when streaming ends + assistant_message.metrics.stop_timer() + + def invoke( + self, + messages: list[Message], + assistant_message: Message, + response_format: dict[str, Any] | type | None = None, + tools: list[Any] | None = None, + tool_choice: str | dict[str, Any] | None = None, + run_response: Any = None, # noqa: ANN401 + ) -> ModelResponse: + """Synchronous invoke (wraps async ainvoke). + + Claude Agent SDK is async-only, so this method wraps the async version. + + Args: + messages: List of conversation messages + assistant_message: Message object to track metrics + response_format: Optional response format specification + tools: Optional list of tools + tool_choice: Optional tool selection strategy + run_response: Optional run response object + + Returns: + ModelResponse with generated content + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + return loop.run_until_complete( + self.ainvoke( + messages=messages, + assistant_message=assistant_message, + response_format=response_format, + tools=tools, + tool_choice=tool_choice, + run_response=run_response, + ) + ) + + def invoke_stream( + self, + messages: list[Message], + assistant_message: Message, + response_format: dict[str, Any] | type | None = None, + tools: list[Any] | None = None, + tool_choice: str | dict[str, Any] | None = None, + run_response: Any = None, # noqa: ANN401 + ) -> AsyncIterator[ModelResponse]: + """Synchronous stream (wraps async ainvoke_stream). + + Claude Agent SDK is async-only, so this method wraps the async version. + + Args: + messages: List of conversation messages + assistant_message: Message object to track metrics + response_format: Optional response format specification + tools: Optional list of tools + tool_choice: Optional tool selection strategy + run_response: Optional run response object + + Yields: + ModelResponse objects as they arrive + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _async_generator() -> AsyncIterator[ModelResponse]: + async for response in self.ainvoke_stream( + messages=messages, + assistant_message=assistant_message, + response_format=response_format, + tools=tools, + tool_choice=tool_choice, + run_response=run_response, + ): + yield response + + # Return the async generator (caller must await it) + return _async_generator() + + def _parse_provider_response( + self, + response: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401, ARG002 + ) -> ModelResponse: + """Parse Claude Agent SDK response into ModelResponse. + + This method is required by the Model base class but isn't directly used + in our implementation since we parse responses inline in ainvoke(). + + Args: + response: Raw response from Claude Agent SDK + **kwargs: Additional keyword arguments + + Returns: + ModelResponse object + """ + # Extract content from message + content = "" + if hasattr(response, "content"): + if isinstance(response.content, str): + content = response.content + elif isinstance(response.content, list): + for block in response.content: + if hasattr(block, "text"): + content += block.text + elif isinstance(block, dict) and "text" in block: + content += block["text"] + else: + content = str(response) + + # Extract tool calls + tool_calls = self._extract_tool_calls(response) + + # Extract usage metadata + usage_data = self._extract_usage_metadata(response) + + return ModelResponse( + role="assistant", + content=content, + tool_calls=tool_calls if tool_calls else [], + provider_data={ + "model_id": self.id, + "provider": "claude-agent-sdk", + "usage": usage_data if usage_data else {}, + }, + ) + + def _parse_provider_response_delta( + self, + response_delta: Any, # noqa: ANN401 + ) -> ModelResponse: + """Parse Claude Agent SDK streaming response chunk into ModelResponse. + + This method is required by the Model base class for streaming responses. + + Args: + response_delta: Streaming response chunk from Claude Agent SDK + + Returns: + ModelResponse object for this chunk + """ + # For streaming, we parse each message chunk + return self._parse_provider_response(response_delta) + + def response( + self, + messages: list[Message], + response_format: dict[str, Any] | type | None = None, + tools: list[Any] | None = None, + tool_choice: str | dict[str, Any] | None = None, + tool_call_limit: int | None = None, + run_response: Any = None, # noqa: ANN401 + send_media_to_model: bool = True, + ) -> ModelResponse: + """Synchronous response (not implemented for async-only SDK). + + Claude Agent SDK is async-only, so this method raises NotImplementedError. + Use aresponse() instead. + """ + raise NotImplementedError( + "Claude Agent SDK only supports async operations. Use aresponse() instead." + ) + + def get_provider(self) -> str: + """Get provider name. + + Returns: + Provider name string + """ + return "claude-agent-sdk" + + def _format_response_format_prompt( + self, response_format: dict[str, Any] | type | None + ) -> str: + """Convert response_format to system prompt instructions. + + Args: + response_format: Response format specification (BaseModel or dict) + + Returns: + Formatted prompt instructions for response format + """ + if response_format is None: + return "" + + # Handle Pydantic BaseModel (structured outputs) + if isinstance(response_format, type) and issubclass(response_format, BaseModel): + schema = response_format.model_json_schema() + schema_str = json.dumps(schema, indent=2) + return ( + f"\n\n## Response Format\n" + f"Return your response as valid JSON matching this schema:\n" + f"```json\n{schema_str}\n```\n" + f"Ensure all required fields are present and types match the schema." + ) + + # Handle JSON mode (dict with type: json_object) + if isinstance(response_format, dict): + if response_format.get("type") == "json_object": + return ( + "\n\n## Response Format\n" + "Return your response as a valid JSON object. " + "Use proper JSON syntax with quoted keys and appropriate " + "data types." + ) + # Handle other dict-based schemas + schema_str = json.dumps(response_format, indent=2) + return ( + f"\n\n## Response Format\n" + f"Return your response matching this format:\n" + f"```json\n{schema_str}\n```" + ) + + return "" + + def _process_tool_choice( + self, tool_choice: str | dict[str, Any] | None, allowed_tools: list[str] + ) -> tuple[list[str] | None, list[str] | None]: + """Process tool_choice parameter into allowed_tools and disallowed_tools. + + Args: + tool_choice: Tool selection strategy from Agno + allowed_tools: Current list of allowed tools + + Returns: + Tuple of (allowed_tools, disallowed_tools) - either can be None + """ + if tool_choice is None: + # No preference - use allowed_tools as-is + return (allowed_tools if allowed_tools else None, None) + + # Handle string tool_choice + if isinstance(tool_choice, str): + if tool_choice == "none": + # Disable all tools + return (None, allowed_tools if allowed_tools else None) + if tool_choice in ("required", "any"): + # Keep allowed_tools as-is (model must use tools) + return (allowed_tools if allowed_tools else None, None) + if tool_choice == "auto": + # Model decides - keep allowed_tools as-is + return (allowed_tools if allowed_tools else None, None) + + # Handle dict tool_choice (specific tool selection) + if isinstance(tool_choice, dict): + tool_type = tool_choice.get("type") + if tool_type in ("tool", "function"): + # Specific tool required + tool_name = tool_choice.get("name") + if tool_name: + # Only allow this specific tool + # Disallow all others + disallowed = [t for t in allowed_tools if t != tool_name] + return ([tool_name], disallowed if disallowed else None) + + # Default: use allowed_tools as-is + return (allowed_tools if allowed_tools else None, None) + + def _extract_metadata_from_run_response( + self, + run_response: Any, # noqa: ANN401 + ) -> dict[str, Any]: + """Extract useful metadata from Agno run_response. + + Args: + run_response: RunResponse object from Agno + + Returns: + Dictionary with extracted metadata + """ + metadata: dict[str, Any] = {} + + if run_response is None: + return metadata + + # Extract session_id for resume support + if hasattr(run_response, "session_id"): + metadata["session_id"] = run_response.session_id + + # Extract user_id + if hasattr(run_response, "user_id"): + metadata["user_id"] = run_response.user_id + + # Extract run_id + if hasattr(run_response, "run_id"): + metadata["run_id"] = run_response.run_id + + # Extract metadata dict if present + if hasattr(run_response, "metadata"): + metadata["agno_metadata"] = run_response.metadata + + return metadata + + def _extract_usage_metadata(self, message: Any) -> dict[str, Any]: # noqa: ANN401 + """Extract usage and timing metadata from Claude SDK message. + + Args: + message: Message object from Claude Agent SDK + + Returns: + Dictionary with usage metadata + """ + usage_data: dict[str, Any] = {} + + # Try to extract usage information + if hasattr(message, "usage"): + usage = message.usage + if hasattr(usage, "input_tokens"): + usage_data["input_tokens"] = usage.input_tokens + if hasattr(usage, "output_tokens"): + usage_data["output_tokens"] = usage.output_tokens + if hasattr(usage, "cache_creation_input_tokens"): + usage_data["cache_creation_input_tokens"] = ( + usage.cache_creation_input_tokens + ) + if hasattr(usage, "cache_read_input_tokens"): + usage_data["cache_read_input_tokens"] = usage.cache_read_input_tokens + + # Try to extract stop_reason + if hasattr(message, "stop_reason"): + usage_data["stop_reason"] = message.stop_reason + + # Try to extract model + if hasattr(message, "model"): + usage_data["model_used"] = message.model + + return usage_data diff --git a/uv.lock b/uv.lock index 3fd0417..0c25656 100644 --- a/uv.lock +++ b/uv.lock @@ -171,6 +171,20 @@ 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-agent-sdk" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/b6/b73279eb875333fcc3e14c28fc080f815abaf35d2a65b132be0c8b05851c/claude_agent_sdk-0.1.6.tar.gz", hash = "sha256:3090a595896d65a5d951e158e191b462759aafc97399e700e4f857d5265a8f23", size = 49328, upload-time = "2025-10-31T05:15:55.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/12/38e4e9f7f79f2c04c7be34cd995ef4a56681f8266e382a5288ce815ede71/claude_agent_sdk-0.1.6-py3-none-any.whl", hash = "sha256:54227b096e8c7cfb60fc8b570082fce1f91ea060413092f65a08a2824cb9cb4b", size = 36369, upload-time = "2025-10-31T05:15:54.767Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -548,6 +562,7 @@ source = { editable = "." } dependencies = [ { name = "agno" }, { name = "asyncio" }, + { name = "claude-agent-sdk" }, { name = "exa-py" }, { name = "groq" }, { name = "httpx", extra = ["socks"] }, @@ -577,6 +592,7 @@ requires-dist = [ { name = "agno", specifier = ">=2.0.5" }, { name = "asyncio" }, { name = "black", marker = "extra == 'dev'" }, + { name = "claude-agent-sdk" }, { name = "exa-py" }, { name = "groq" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, @@ -692,15 +708,16 @@ wheels = [ [[package]] name = "openrouter" -version = "1.0" +version = "0.0.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, - { name = "urllib3" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/3c/e2471e2bf3de74cb1832e52706a8c7288378c03f6060bda8b6f86e6ca2f3/openrouter-1.0.tar.gz", hash = "sha256:1f120ba67d85fa8ef7d4f47d10aeff8bea1d657f02a05b3cbad4e3a60382fff2", size = 3855, upload-time = "2024-08-28T20:30:40.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d5/650560397cd9a1d9f40c561e1b584d5a794fc17936613f48f2c9565c9ccd/openrouter-0.0.16.tar.gz", hash = "sha256:7c5a5ed7f6f046d6db3fc5886a4e7971945f82fa3e12bf26e0ca3693f59bb245", size = 110272, upload-time = "2025-11-15T18:52:37.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/91/60b826a2499c81dec10642182e8531d4588d059d91f145d1923ca2bf6a6b/openrouter-1.0-py3-none-any.whl", hash = "sha256:4f3d50991dbf4b269d270622ab38be75e6162da9e09acdaead8697081ffe6167", size = 3791, upload-time = "2024-08-28T20:30:38.84Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/2fa3dc5db531d65e21e9c8538f748f2baafd79f91968386c50f443d98636/openrouter-0.0.16-py3-none-any.whl", hash = "sha256:b69bcb5a401d6932ef80740b449859601e5371ce9184052a5cd7c56a0f03c770", size = 234484, upload-time = "2025-11-15T18:52:36.065Z" }, ] [[package]]