diff --git a/CLAUDE.md b/CLAUDE.md index d12ba867d..42945ec3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,39 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Code Style Guidelines - **Typing**: Use strict typing with annotations for all functions and variables + - **Modern Type Annotations**: Use built-in types (`list`, `dict`, `tuple`, `set`) instead of typing module (`List`, `Dict`, `Tuple`, `Set`) for Python 3.9+ + - Example: Use `list[str]` not `List[str]`, `dict[str, int]` not `Dict[str, int]` - **Imports**: Standard lib → third-party → local imports + - Remove unused imports (F401 Ruff error) - **Formatting**: Follow Black's formatting conventions (enforced by Ruff) - **Models**: Define structured outputs as Pydantic BaseModel subclasses - **Naming**: snake_case for functions/variables, PascalCase for classes - **Error Handling**: Use custom exceptions from exceptions.py, validate with Pydantic - **Comments**: Docstrings for public functions, inline comments for complex logic +## CI and Pre-commit Checks +Before committing or pushing code, always run these checks locally: + +1. **Ruff Linting**: `uv run ruff check .` + - Fix all linting errors (unused imports, deprecated type annotations, etc.) + - Auto-fix available: `uv run ruff check . --fix` + +2. **Ruff Formatting**: `uv run ruff format .` + - Ensures consistent code formatting + +3. **Type Checking**: `uv run ty check` + - Aim for zero type errors + - If errors are unavoidable, document with `# type: ignore[error-code]` and explanation + +4. **Tests**: `uv run pytest tests/ -k "not llm and not openai"` + - Run relevant tests for your changes + - No mocking - tests should make real API calls where needed + +**CI Workflow**: +- All PRs run Ruff, Type Check, and Core Tests +- Fix CI failures before requesting review +- Use `/gh-fix-ci` skill to diagnose and fix CI failures + ## Conventional Commits - **Format**: `type(scope): description` - **Types**: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert diff --git a/docs/integrations/claude_agent_sdk.md b/docs/integrations/claude_agent_sdk.md new file mode 100644 index 000000000..bc8b91dfb --- /dev/null +++ b/docs/integrations/claude_agent_sdk.md @@ -0,0 +1,259 @@ +# Claude Agent SDK + +This guide shows you how to use instructor with the Claude Agent SDK for structured outputs with agentic capabilities. + +## Installation + +```bash +pip install instructor claude-agent-sdk +``` + +Set your API key: +```bash +export ANTHROPIC_API_KEY=your-api-key +``` + +## Quick Start + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel +import anyio + +class User(BaseModel): + name: str + age: int + +async def main(): + client = from_claude_agent_sdk() + + user = await client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name) # John + print(user.age) # 25 + +anyio.run(main) +``` + +## Features + +### Guaranteed JSON Schema Compliance + +The Claude Agent SDK provides built-in JSON schema validation through its `output_format` option. This integration automatically: + +1. Converts your Pydantic model to a JSON schema +2. Passes the schema to the Claude Agent SDK +3. Validates the response with Pydantic +4. Returns a type-safe model instance + +### Automatic Message Conversion + +The integration accepts instructor's standard message format and automatically converts it to a prompt for the Claude Agent SDK: + +```python +# Standard instructor message format +user = await client.create( + response_model=User, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Extract user info from: John, age 25"} + ] +) +``` + +### Retry Support + +The integration includes retry support for validation errors: + +```python +user = await client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract user info..."}], + max_retries=3 # Retry up to 3 times on validation errors +) +``` + +## Examples + +### Contact Extraction + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel, Field +from typing import Optional +import anyio + +class ContactInfo(BaseModel): + name: str = Field(description="Full name of the person") + email: str = Field(description="Email address") + phone: Optional[str] = Field(default=None, description="Phone number") + company: Optional[str] = Field(default=None, description="Company name") + +async def main(): + client = from_claude_agent_sdk() + + email_text = """ + Hi, I'm Sarah Johnson from TechCorp Inc. + Reach me at sarah.johnson@techcorp.com or (555) 123-4567. + """ + + contact = await client.create( + response_model=ContactInfo, + messages=[ + {"role": "user", "content": f"Extract contact info:\n\n{email_text}"} + ] + ) + + print(f"Name: {contact.name}") + print(f"Email: {contact.email}") + print(f"Phone: {contact.phone}") + print(f"Company: {contact.company}") + +anyio.run(main) +``` + +### Sentiment Analysis + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel, Field +from typing import List +import anyio + +class SentimentAnalysis(BaseModel): + sentiment: str = Field(description="positive, negative, or neutral") + confidence: float = Field(ge=0.0, le=1.0) + key_phrases: List[str] = Field(description="Key phrases influencing sentiment") + +async def main(): + client = from_claude_agent_sdk() + + review = "This product is amazing! Best purchase I've ever made." + + result = await client.create( + response_model=SentimentAnalysis, + messages=[ + {"role": "user", "content": f"Analyze sentiment:\n\n{review}"} + ] + ) + + print(f"Sentiment: {result.sentiment}") + print(f"Confidence: {result.confidence:.2%}") + print(f"Key Phrases: {', '.join(result.key_phrases)}") + +anyio.run(main) +``` + +### Entity Extraction + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel, Field +from typing import List +import anyio + +class Entity(BaseModel): + name: str + type: str # person, organization, location, date, etc. + +class EntityExtraction(BaseModel): + entities: List[Entity] + summary: str + +async def main(): + client = from_claude_agent_sdk() + + text = """ + Apple CEO Tim Cook announced a meeting with French President Macron + in Paris on March 15, 2025. + """ + + result = await client.create( + response_model=EntityExtraction, + messages=[ + {"role": "user", "content": f"Extract entities:\n\n{text}"} + ] + ) + + for entity in result.entities: + print(f"- {entity.name} ({entity.type})") + +anyio.run(main) +``` + +## Key Differences from Other Providers + +1. **Async and Sync Support**: The integration supports both async (default) and sync modes via `use_async=False`. + +2. **Agentic Capabilities**: The Claude Agent SDK can perform agentic tasks with tool use, making it suitable for complex multi-step workflows. + +3. **Built-in Schema Validation**: The SDK validates JSON output against the schema before returning, providing an extra layer of reliability. + +4. **No Streaming Support**: Unlike other providers, Claude Agent SDK does not support streaming responses, `Partial` models, or `Iterable` models. + +## Configuration Options + +You can pass additional options when creating the client: + +```python +client = from_claude_agent_sdk( + model="claude-sonnet-4-20250514", # Optional: specify model + max_tokens=1024, # Optional: token limit + temperature=0.0, # Optional: temperature +) +``` + +## Sync vs Async + +By default, `from_claude_agent_sdk()` returns an async client. You can get a sync client by passing `use_async=False`: + +### Async (default) + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel +import anyio + +class User(BaseModel): + name: str + age: int + +async def main(): + client = from_claude_agent_sdk() # async by default + + user = await client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name, user.age) + +anyio.run(main) +``` + +### Sync + +```python +from instructor import from_claude_agent_sdk +from pydantic import BaseModel + +class User(BaseModel): + name: str + age: int + +client = from_claude_agent_sdk(use_async=False) # sync mode + +user = client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] +) +print(user.name, user.age) +``` + +Note: The sync version uses `anyio.run()` internally to execute the async Claude Agent SDK. + +## Limitations + +- No streaming support (`stream=True`, `Partial[Model]`, or `Iterable[Model]`) +- Use list fields in your model for multiple results diff --git a/examples/claude_agent_sdk/run.py b/examples/claude_agent_sdk/run.py new file mode 100644 index 000000000..127cb865a --- /dev/null +++ b/examples/claude_agent_sdk/run.py @@ -0,0 +1,254 @@ +""" +Example: Using Instructor with Claude Agent SDK +================================================ + +This example demonstrates how to use instructor's familiar interface +with the Claude Agent SDK for structured outputs. + +The Claude Agent SDK provides agentic capabilities with guaranteed +JSON schema compliance. This integration allows you to use instructor's +patterns while leveraging the Claude Agent SDK's structured output feature. + +Prerequisites: +- Set ANTHROPIC_API_KEY environment variable +- Install claude_agent_sdk: pip install claude-agent-sdk +- Install instructor: pip install instructor + +Key Benefits: +- Familiar instructor interface +- Guaranteed JSON schema compliance via Claude Agent SDK +- Automatic Pydantic validation +- Retry support with error context +""" + +import anyio +from typing import Optional +from pydantic import BaseModel, Field + +# Import instructor with Claude Agent SDK support +from instructor.providers.claude_agent_sdk import from_claude_agent_sdk + + +# ============================================================================= +# PYDANTIC MODELS +# ============================================================================= + + +class ContactInfo(BaseModel): + """Contact information extracted from text.""" + + name: str = Field(description="Full name of the person") + email: str = Field(description="Email address") + phone: Optional[str] = Field(default=None, description="Phone number if provided") + company: Optional[str] = Field( + default=None, description="Company name if mentioned" + ) + + +class SentimentAnalysis(BaseModel): + """Sentiment analysis result.""" + + sentiment: str = Field( + description="Overall sentiment: positive, negative, or neutral" + ) + confidence: float = Field( + description="Confidence score between 0 and 1", ge=0.0, le=1.0 + ) + key_phrases: list[str] = Field( + description="Key phrases that influenced the sentiment" + ) + + +class Entity(BaseModel): + """A named entity extracted from text.""" + + name: str = Field(description="The entity name") + type: str = Field( + description="Entity type: person, organization, location, date, etc." + ) + + +class EntityExtraction(BaseModel): + """Named entities extracted from text.""" + + entities: list[Entity] = Field(description="List of extracted entities") + summary: str = Field(description="Brief summary of the text content") + + +# ============================================================================= +# EXAMPLE FUNCTIONS +# ============================================================================= + + +async def example_contact_extraction(): + """Extract structured contact information from an email.""" + print("\n" + "=" * 60) + print("Example 1: Contact Information Extraction") + print("=" * 60) + + # Create instructor client with Claude Agent SDK + client = from_claude_agent_sdk() + + email_text = """ + Hi there, + + My name is Sarah Johnson and I work at TechCorp Inc. I'm really interested + in your Enterprise plan and would love to schedule a demo to see it in action. + + You can reach me at sarah.johnson@techcorp.com or call me at (555) 123-4567. + + Looking forward to hearing from you! + + Best regards, + Sarah + """ + + print(f"\nInput Email:\n{email_text}") + print("\nProcessing with Claude Agent SDK...") + + contact = await client.create( + response_model=ContactInfo, + messages=[ + { + "role": "user", + "content": f"Extract the contact information from this email:\n\n{email_text}", + } + ], + ) + + print("\nExtracted Contact Info:") + print(f" Name: {contact.name}") + print(f" Email: {contact.email}") + print(f" Phone: {contact.phone}") + print(f" Company: {contact.company}") + + +async def example_sentiment_analysis(): + """Analyze sentiment with confidence scores.""" + print("\n" + "=" * 60) + print("Example 2: Sentiment Analysis") + print("=" * 60) + + client = from_claude_agent_sdk() + + reviews = [ + "This product exceeded all my expectations! The quality is outstanding.", + "Terrible experience. The product broke after just 2 days.", + "It's okay. Does what it's supposed to do, nothing special.", + ] + + for i, review in enumerate(reviews, 1): + print(f"\nReview {i}: {review}") + + sentiment = await client.create( + response_model=SentimentAnalysis, + messages=[ + { + "role": "user", + "content": f"Analyze the sentiment of this review:\n\n{review}", + } + ], + ) + + print(f" Sentiment: {sentiment.sentiment.upper()}") + print(f" Confidence: {sentiment.confidence:.2%}") + print(f" Key Phrases: {', '.join(sentiment.key_phrases)}") + + +async def example_entity_extraction(): + """Extract named entities from text.""" + print("\n" + "=" * 60) + print("Example 3: Named Entity Extraction") + print("=" * 60) + + client = from_claude_agent_sdk() + + article_text = """ + Apple Inc. announced today that CEO Tim Cook will visit Paris next month to meet + with French President Emmanuel Macron. The meeting, scheduled for March 15, 2025, + will focus on expanding Apple's operations in Europe and discussing the new iPhone 16 + and MacBook Pro models that are set to launch this spring. + """ + + print(f"\nArticle:\n{article_text}") + print("\nExtracting entities...") + + extraction = await client.create( + response_model=EntityExtraction, + messages=[ + { + "role": "user", + "content": f"Extract all named entities from this text:\n\n{article_text}", + } + ], + ) + + print("\nExtracted Entities:") + for entity in extraction.entities: + print(f" - {entity.name} ({entity.type})") + print(f"\nSummary: {extraction.summary}") + + +async def example_with_prompt(): + """Demonstrate using direct prompt instead of messages.""" + print("\n" + "=" * 60) + print("Example 4: Direct Prompt Usage") + print("=" * 60) + + client = from_claude_agent_sdk() + + # You can also use a direct prompt instead of messages + contact = await client.create( + response_model=ContactInfo, + messages=[ + { + "role": "user", + "content": "Extract contact info: John Doe, john@example.com, works at Acme Corp", + } + ], + ) + + print("\nExtracted from direct prompt:") + print(f" Name: {contact.name}") + print(f" Email: {contact.email}") + print(f" Company: {contact.company}") + + +# ============================================================================= +# MAIN +# ============================================================================= + + +async def main(): + """Run all examples.""" + print("\n" + "=" * 60) + print("INSTRUCTOR + CLAUDE AGENT SDK DEMO") + print("=" * 60) + print("\nUsing instructor's familiar interface with Claude Agent SDK") + print("for guaranteed JSON schema compliance and agentic capabilities") + + try: + await example_contact_extraction() + await example_sentiment_analysis() + await example_entity_extraction() + await example_with_prompt() + + print("\n" + "=" * 60) + print("All examples completed successfully!") + print("=" * 60) + + except ImportError as e: + print(f"\nError: {e}") + print("\nMake sure you have the required packages installed:") + print(" pip install claude-agent-sdk instructor") + + except Exception as e: + print(f"\nError: {e}") + print("\nTroubleshooting:") + print(" - Verify your ANTHROPIC_API_KEY is set correctly") + print(" - Ensure Claude Code CLI is installed") + print(" - Check your Claude Code Max subscription") + + +if __name__ == "__main__": + anyio.run(main) diff --git a/instructor/__init__.py b/instructor/__init__.py index 2ca9f4f6e..a34a97289 100644 --- a/instructor/__init__.py +++ b/instructor/__init__.py @@ -145,3 +145,8 @@ from .providers.genai.client import from_genai __all__ += ["from_genai"] + +if importlib.util.find_spec("claude_agent_sdk") is not None: + from .providers.claude_agent_sdk.client import from_claude_agent_sdk + + __all__ += ["from_claude_agent_sdk"] diff --git a/instructor/core/client.py b/instructor/core/client.py index 0cb64d60d..c162c9b12 100644 --- a/instructor/core/client.py +++ b/instructor/core/client.py @@ -633,6 +633,7 @@ async def create( # type: ignore[override] instructor.Mode.PARALLEL_TOOLS, instructor.Mode.VERTEXAI_PARALLEL_TOOLS, instructor.Mode.ANTHROPIC_PARALLEL_TOOLS, + instructor.Mode.CLAUDE_AGENT_SDK, } ): return self.create_iterable( diff --git a/instructor/mode.py b/instructor/mode.py index e45fa9d0d..0166d3ab3 100644 --- a/instructor/mode.py +++ b/instructor/mode.py @@ -71,6 +71,9 @@ class Mode(enum.Enum): PERPLEXITY_JSON = "perplexity_json" OPENROUTER_STRUCTURED_OUTPUTS = "openrouter_structured_outputs" + # Claude Agent SDK modes + CLAUDE_AGENT_SDK = "claude_agent_sdk" + # Classification helpers @classmethod def tool_modes(cls) -> set["Mode"]: diff --git a/instructor/processing/response.py b/instructor/processing/response.py index 482bffe7c..bde311461 100644 --- a/instructor/processing/response.py +++ b/instructor/processing/response.py @@ -161,6 +161,12 @@ class User(BaseModel): reask_xai_tools, ) +# Claude Agent SDK utils +from ..providers.claude_agent_sdk.utils import ( + handle_claude_agent_sdk, + reask_claude_agent_sdk, +) + logger = logging.getLogger("instructor") T_Model = TypeVar("T_Model", bound=BaseModel) @@ -464,6 +470,8 @@ def handle_response_model( Mode.RESPONSES_TOOLS_WITH_INBUILT_TOOLS: handle_responses_tools_with_inbuilt_tools, Mode.XAI_JSON: handle_xai_json, Mode.XAI_TOOLS: handle_xai_tools, + # Claude Agent SDK mode + Mode.CLAUDE_AGENT_SDK: handle_claude_agent_sdk, } if mode in mode_handlers: @@ -662,6 +670,8 @@ def handle_reask_kwargs( # XAI modes Mode.XAI_JSON: reask_xai_json, Mode.XAI_TOOLS: reask_xai_tools, + # Claude Agent SDK modes + Mode.CLAUDE_AGENT_SDK: reask_claude_agent_sdk, } if mode in REASK_HANDLERS: diff --git a/instructor/providers/claude_agent_sdk/__init__.py b/instructor/providers/claude_agent_sdk/__init__.py new file mode 100644 index 000000000..855eecb03 --- /dev/null +++ b/instructor/providers/claude_agent_sdk/__init__.py @@ -0,0 +1,69 @@ +"""Claude Agent SDK integration for Instructor. + +This module provides integration with the Claude Agent SDK, enabling structured +outputs using instructor's familiar interface with the Claude Agent SDK's +agentic capabilities. + +Supports both sync and async interfaces. + +Example (async - default): + ```python + from instructor import from_claude_agent_sdk + from pydantic import BaseModel + import anyio + + class User(BaseModel): + name: str + age: int + + async def main(): + client = from_claude_agent_sdk() # async by default + + user = await client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name) # John + print(user.age) # 25 + + anyio.run(main) + ``` + +Example (sync): + ```python + from instructor import from_claude_agent_sdk + from pydantic import BaseModel + + class User(BaseModel): + name: str + age: int + + client = from_claude_agent_sdk(use_async=False) # sync mode + + user = client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name) # John + print(user.age) # 25 + ``` +""" + +from .client import ( + from_claude_agent_sdk, + ClaudeAgentSDKClient, + claude_agent_sdk_create, + claude_agent_sdk_create_async, + claude_agent_sdk_create_sync, +) +from .utils import handle_claude_agent_sdk, reask_claude_agent_sdk + +__all__ = [ + "from_claude_agent_sdk", + "ClaudeAgentSDKClient", + "claude_agent_sdk_create", + "claude_agent_sdk_create_async", + "claude_agent_sdk_create_sync", + "handle_claude_agent_sdk", + "reask_claude_agent_sdk", +] diff --git a/instructor/providers/claude_agent_sdk/client.py b/instructor/providers/claude_agent_sdk/client.py new file mode 100644 index 000000000..c0e1213bb --- /dev/null +++ b/instructor/providers/claude_agent_sdk/client.py @@ -0,0 +1,499 @@ +"""Claude Agent SDK integration for Instructor. + +This module provides integration with the Claude Agent SDK, enabling structured +outputs using instructor's familiar interface with the Claude Agent SDK's +agentic capabilities. + +The Claude Agent SDK already guarantees JSON schema compliance, so this integration +directly leverages that capability while providing the familiar instructor interface. + +Supports both sync and async interfaces. +""" + +from __future__ import annotations + +import instructor +from typing import Any, TypeVar, overload +from pydantic import BaseModel + +T = TypeVar("T", bound=BaseModel) + + +class ClaudeAgentSDKClient: + """Wrapper client for Claude Agent SDK that provides an instructor-compatible interface. + + This client wraps the Claude Agent SDK's query function to work with instructor's + response model pattern. It automatically handles: + - Converting Pydantic models to JSON schemas for output_format + - Iterating through the async generator to extract structured outputs + - Validating responses against the provided response model + """ + + def __init__(self, **kwargs: Any): + """Initialize the Claude Agent SDK client wrapper. + + Args: + **kwargs: Default keyword arguments to pass to ClaudeAgentOptions + """ + self.default_options = kwargs + + +def _merge_default_options( + default_options: dict[str, Any], + kwargs: dict[str, Any], +) -> dict[str, Any]: + if not default_options: + return kwargs + return {**default_options, **kwargs} + + +def _convert_messages_to_prompt(messages: list[dict[str, Any]]) -> str: + """Convert instructor message format to a single prompt string. + + Args: + messages: List of message dicts with 'role' and 'content' keys + + Returns: + Combined prompt string + """ + prompt_parts = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + if role == "system": + prompt_parts.append(f"System: {content}") + elif role == "user": + prompt_parts.append(content) + elif role == "assistant": + prompt_parts.append(f"Assistant: {content}") + return "\n\n".join(prompt_parts) + + +def _prepare_options( + response_model: type[T] | None, + kwargs: dict[str, Any], +) -> tuple[type[T] | None, dict[str, Any]]: + """Prepare ClaudeAgentOptions kwargs. + + Args: + response_model: Pydantic model class for structured output + kwargs: Additional kwargs to process + + Returns: + Tuple of (actual_model, option_kwargs) + """ + from typing import get_origin + from instructor.dsl.partial import PartialBase + import collections.abc + + option_kwargs = {} + + # Pass through supported options + for key in ["model", "max_tokens", "temperature", "working_directory"]: + if key in kwargs: + option_kwargs[key] = kwargs[key] + + # Get the actual response model class for schema generation + actual_model = response_model + if response_model is not None: + # Check for Partial models (which inherit from PartialBase) + if isinstance(response_model, type) and issubclass(response_model, PartialBase): + raise ValueError( + "Claude Agent SDK does not support Partial models. " + "Provide a regular BaseModel response_model and avoid create_partial." + ) + + # Check for Iterable models + if get_origin(response_model) is collections.abc.Iterable: + raise ValueError( + "Claude Agent SDK does not support Iterable models. " + "Provide a regular BaseModel response_model and avoid create_iterable." + ) + + # Validate that we have a proper BaseModel + if not hasattr(response_model, "model_json_schema"): + raise ValueError( + "Claude Agent SDK requires a Pydantic BaseModel as response_model." + ) + + # Set up output_format for structured output + option_kwargs["output_format"] = { + "type": "json_schema", + "schema": response_model.model_json_schema(), + } + + actual_model = response_model + + return actual_model, option_kwargs + + +async def _execute_query_async( + prompt: str, + option_kwargs: dict[str, Any], +) -> dict[str, Any] | None: + """Execute the Claude Agent SDK query asynchronously. + + Args: + prompt: The prompt to send + option_kwargs: Options for ClaudeAgentOptions + + Returns: + Structured output dict or None + """ + from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage + + options = ClaudeAgentOptions(**option_kwargs) + structured_output = None + + # Iterate through all messages to ensure proper cleanup + # The ResultMessage with structured_output comes at the end + async for message in query(prompt=prompt, options=options): + if isinstance(message, ResultMessage): + if hasattr(message, "structured_output") and message.structured_output: + structured_output = message.structured_output + # Don't break - let the generator complete naturally + + return structured_output + + +def _execute_query_sync( + prompt: str, + option_kwargs: dict[str, Any], +) -> dict[str, Any] | None: + """Execute the Claude Agent SDK query synchronously. + + Uses anyio to run the async query in a sync context. + + Args: + prompt: The prompt to send + option_kwargs: Options for ClaudeAgentOptions + + Returns: + Structured output dict or None + """ + import anyio + + async def _run(): + return await _execute_query_async(prompt, option_kwargs) + + return ( + anyio.from_thread.run_sync(_run) # type: ignore + if anyio.get_current_task() + else anyio.run(_run) + ) + + +async def claude_agent_sdk_create_async( + messages: list[dict[str, Any]] | None = None, + response_model: type[T] | None = None, + prompt: str | None = None, + max_retries: int = 1, + validation_context: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, + strict: bool = True, # noqa: ARG001 + hooks: Any = None, # noqa: ARG001 + **kwargs: Any, +) -> T: + """Execute a Claude Agent SDK query with structured output (async version). + + Args: + messages: List of message dicts (instructor format) - will be converted to prompt + response_model: Pydantic model class for structured output + prompt: Direct prompt string (alternative to messages) + max_retries: Maximum number of retry attempts + validation_context: Additional context for validation (deprecated) + context: Additional context for validation + strict: Whether to enforce strict validation + hooks: Instructor hooks for events + **kwargs: Additional arguments passed to ClaudeAgentOptions + + Returns: + Validated Pydantic model instance + + Raises: + ValueError: If neither prompt nor messages are provided + ValidationError: If the response doesn't match the expected schema + """ + # Convert messages to prompt if not provided directly + if kwargs.get("stream"): + raise ValueError( + "Claude Agent SDK does not support streaming/iterable responses. " + "Remove stream=True or avoid create_iterable/create_partial." + ) + + if prompt is None and messages: + prompt = _convert_messages_to_prompt(messages) + + if prompt is None: + raise ValueError("Either 'prompt' or 'messages' must be provided") + + if response_model is None: + raise ValueError("response_model is required for Claude Agent SDK") + + actual_model, option_kwargs = _prepare_options(response_model, kwargs) + assert ( + actual_model is not None + ) # Should never be None if response_model is not None + + # Execute with retries + last_exception: Exception | None = None + current_prompt = prompt + + for attempt in range(max_retries): + try: + structured_output = await _execute_query_async( + current_prompt, option_kwargs + ) + + if structured_output is None: + raise ValueError("No structured output received from Claude Agent SDK") + + # Validate the response with Pydantic + validated = actual_model.model_validate( + structured_output, + context=context or validation_context, + ) + # Attach raw response for compatibility + validated._raw_response = structured_output # type: ignore + return validated + + except Exception as e: + last_exception = e + if attempt < max_retries - 1: + # Prepare for retry with error context + current_prompt = f"{current_prompt}\n\nPrevious attempt failed with error: {str(e)}\nPlease correct the response." + else: + raise + + # This should never be reached due to raise in the except block, + # but we need it for type checking + assert last_exception is not None + raise last_exception + + +def claude_agent_sdk_create_sync( + messages: list[dict[str, Any]] | None = None, + response_model: type[T] | None = None, + prompt: str | None = None, + max_retries: int = 1, + validation_context: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, + strict: bool = True, + hooks: Any = None, + **kwargs: Any, +) -> T: + """Execute a Claude Agent SDK query with structured output (sync version). + + This is a synchronous wrapper around the async implementation. + Uses anyio.run() internally to execute the async code. + + Args: + messages: List of message dicts (instructor format) - will be converted to prompt + response_model: Pydantic model class for structured output + prompt: Direct prompt string (alternative to messages) + max_retries: Maximum number of retry attempts + validation_context: Additional context for validation (deprecated) + context: Additional context for validation + strict: Whether to enforce strict validation + hooks: Instructor hooks for events + **kwargs: Additional arguments passed to ClaudeAgentOptions + + Returns: + Validated Pydantic model instance + + Raises: + ValueError: If neither prompt nor messages are provided + ValidationError: If the response doesn't match the expected schema + """ + import anyio + + if kwargs.get("stream"): + raise ValueError( + "Claude Agent SDK does not support streaming/iterable responses. " + "Remove stream=True or avoid create_iterable/create_partial." + ) + + return anyio.run( + claude_agent_sdk_create_async, + messages, + response_model, + prompt, + max_retries, + validation_context, + context, + strict, + hooks, + **kwargs, + ) + + +# Keep the original name for backwards compatibility +claude_agent_sdk_create = claude_agent_sdk_create_async + + +@overload +def from_claude_agent_sdk( + client: ClaudeAgentSDKClient | None = None, + mode: instructor.Mode = instructor.Mode.CLAUDE_AGENT_SDK, + use_async: bool = True, + **kwargs: Any, +) -> instructor.AsyncInstructor: ... + + +@overload +def from_claude_agent_sdk( + client: ClaudeAgentSDKClient | None = None, + mode: instructor.Mode = instructor.Mode.CLAUDE_AGENT_SDK, + use_async: bool = False, + **kwargs: Any, +) -> instructor.Instructor: ... + + +def from_claude_agent_sdk( + client: ClaudeAgentSDKClient | None = None, + mode: instructor.Mode = instructor.Mode.CLAUDE_AGENT_SDK, + use_async: bool = True, + **kwargs: Any, +) -> instructor.AsyncInstructor | instructor.Instructor: + """Create an Instructor instance for Claude Agent SDK. + + This function creates an instructor-compatible client that uses the Claude Agent SDK + for structured outputs. The Claude Agent SDK provides agentic capabilities with + guaranteed JSON schema compliance. + + Args: + client: Optional ClaudeAgentSDKClient instance. If None, a new one is created. + mode: The instructor mode to use. Defaults to CLAUDE_AGENT_SDK. + use_async: If True (default), returns AsyncInstructor. If False, returns sync Instructor. + **kwargs: Additional keyword arguments passed to ClaudeAgentSDKClient + + Returns: + AsyncInstructor or Instructor instance configured for Claude Agent SDK + + Example (async): + ```python + from instructor import from_claude_agent_sdk + from pydantic import BaseModel + import anyio + + class User(BaseModel): + name: str + age: int + + async def main(): + client = from_claude_agent_sdk() # async by default + + user = await client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name) # John + print(user.age) # 25 + + anyio.run(main) + ``` + + Example (sync): + ```python + from instructor import from_claude_agent_sdk + from pydantic import BaseModel + + class User(BaseModel): + name: str + age: int + + client = from_claude_agent_sdk(use_async=False) # sync mode + + user = client.create( + response_model=User, + messages=[{"role": "user", "content": "Extract: John is 25 years old"}] + ) + print(user.name) # John + print(user.age) # 25 + ``` + """ + valid_modes = {instructor.Mode.CLAUDE_AGENT_SDK} + + if mode not in valid_modes: + from ...core.exceptions import ModeError + + raise ModeError( + mode=str(mode), + provider="ClaudeAgentSDK", + valid_modes=[str(m) for m in valid_modes], + ) + + if client is None: + client = ClaudeAgentSDKClient(**kwargs) + + # Merge client default_options with any additional kwargs provided + merged_defaults = _merge_default_options( + client.default_options if client else {}, kwargs + ) + + if use_async: + + async def _create_async( + response_model: type[T] | None, + messages: list[dict[str, Any]] | None = None, + max_retries: int = 1, + validation_context: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, + strict: bool = True, + hooks: Any = None, + **call_kwargs: Any, + ) -> T: + merged_kwargs = _merge_default_options( + merged_defaults, + call_kwargs, + ) + return await claude_agent_sdk_create_async( + messages=messages, + response_model=response_model, + max_retries=max_retries, + validation_context=validation_context, + context=context, + strict=strict, + hooks=hooks, + **merged_kwargs, + ) + + return instructor.AsyncInstructor( + client=client, + create=_create_async, + provider=instructor.Provider.CLAUDE_AGENT_SDK, + mode=mode, + ) + else: + + def _create_sync( + response_model: type[T] | None, + messages: list[dict[str, Any]] | None = None, + max_retries: int = 1, + validation_context: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, + strict: bool = True, + hooks: Any = None, + **call_kwargs: Any, + ) -> T: + merged_kwargs = _merge_default_options( + merged_defaults, + call_kwargs, + ) + return claude_agent_sdk_create_sync( + messages=messages, + response_model=response_model, + max_retries=max_retries, + validation_context=validation_context, + context=context, + strict=strict, + hooks=hooks, + **merged_kwargs, + ) + + return instructor.Instructor( + client=client, + create=_create_sync, + provider=instructor.Provider.CLAUDE_AGENT_SDK, + mode=mode, + ) diff --git a/instructor/providers/claude_agent_sdk/utils.py b/instructor/providers/claude_agent_sdk/utils.py new file mode 100644 index 000000000..f7f557c7e --- /dev/null +++ b/instructor/providers/claude_agent_sdk/utils.py @@ -0,0 +1,97 @@ +"""Claude Agent SDK utilities for instructor integration. + +This module provides response handling and reask utilities for the Claude Agent SDK +integration with instructor. +""" + +from __future__ import annotations + +import json +from typing import Any, TypeVar +from pydantic import BaseModel + +T = TypeVar("T", bound=BaseModel) + + +def handle_claude_agent_sdk( + response_model: type[T] | None, + new_kwargs: dict[str, Any], +) -> tuple[type[T] | None, dict[str, Any]]: + """Handle Claude Agent SDK response model preparation. + + The Claude Agent SDK handles JSON schema validation internally through its + output_format option. This handler prepares the kwargs to be compatible + with the Claude Agent SDK's query function. + + Args: + response_model: The Pydantic model class for structured output + new_kwargs: The keyword arguments for the API call + + Returns: + Tuple of (response_model, updated_kwargs) + """ + # The Claude Agent SDK uses 'prompt' instead of 'messages' + # The actual conversion happens in the client wrapper + # We just need to pass through the response_model information + return response_model, new_kwargs + + +def reask_claude_agent_sdk( + kwargs: dict[str, Any], + response: Any, + exception: Exception, +) -> dict[str, Any]: + """Handle reask/retry for Claude Agent SDK validation errors. + + When a response fails validation, this function prepares a new request + that includes error context to help the model correct its output. + + Args: + kwargs: The original request kwargs + response: The response that failed validation + exception: The validation error that occurred + + Returns: + Updated kwargs for retry with error context + """ + # Get the current messages + messages = kwargs.get("messages", []) + + # Extract error details + error_message = str(exception) + if hasattr(exception, "errors"): + try: + error_details = exception.errors() + error_message = json.dumps(error_details, indent=2) + except Exception: + pass + + # Get the previous response content if available + previous_response = "" + if response is not None: + if hasattr(response, "content"): + previous_response = str(response.content) + elif hasattr(response, "model_dump"): + previous_response = json.dumps(response.model_dump()) + else: + previous_response = str(response) + + # Add retry context to messages + retry_message = { + "role": "user", + "content": f"""Your previous response did not match the expected schema. + +Previous response: +{previous_response} + +Validation error: +{error_message} + +Please correct your response to match the required JSON schema exactly. +Ensure all required fields are present and have the correct types.""" + } + + messages = list(messages) + [retry_message] + kwargs["messages"] = messages + + return kwargs diff --git a/instructor/utils/providers.py b/instructor/utils/providers.py index 9bc47a489..1093026a3 100644 --- a/instructor/utils/providers.py +++ b/instructor/utils/providers.py @@ -27,6 +27,7 @@ class Provider(Enum): BEDROCK = "bedrock" PERPLEXITY = "perplexity" OPENROUTER = "openrouter" + CLAUDE_AGENT_SDK = "claude_agent_sdk" def get_provider(base_url: str) -> Provider: diff --git a/tests/v2/test_provider_modes.py b/tests/v2/test_provider_modes.py deleted file mode 100644 index f23a18b94..000000000 --- a/tests/v2/test_provider_modes.py +++ /dev/null @@ -1,272 +0,0 @@ -""" -Comprehensive parametrized tests for all provider modes. - -Tests all registered modes for each provider with actual API calls to ensure complete coverage. -""" - -from __future__ import annotations - -import pytest -from collections.abc import Iterable -from typing import Literal, Union -from pydantic import BaseModel - -import instructor -from instructor import Mode -from instructor.v2 import Provider, mode_registry - - -class Answer(BaseModel): - """Simple answer model.""" - - answer: float - - -class Weather(BaseModel): - """Weather tool.""" - - location: str - units: Literal["imperial", "metric"] - - -class GoogleSearch(BaseModel): - """Search tool.""" - - query: str - - -# Provider-specific configurations -PROVIDER_CONFIGS = { - Provider.ANTHROPIC: { - "provider_string": "anthropic/claude-3-5-haiku-latest", - "modes": [ - Mode.TOOLS, - Mode.JSON_SCHEMA, - Mode.PARALLEL_TOOLS, - Mode.ANTHROPIC_REASONING_TOOLS, - ], - "basic_modes": [Mode.TOOLS, Mode.JSON_SCHEMA], - "async_modes": [Mode.TOOLS, Mode.JSON_SCHEMA], - }, - Provider.GENAI: { - "provider_string": "google/gemini-2.0-flash", - "modes": [Mode.TOOLS, Mode.JSON], - "basic_modes": [Mode.TOOLS, Mode.JSON], - "async_modes": [Mode.TOOLS, Mode.JSON], - }, -} - - -@pytest.mark.parametrize( - "provider,mode", - [ - (Provider.ANTHROPIC, Mode.TOOLS), - (Provider.ANTHROPIC, Mode.JSON_SCHEMA), - (Provider.ANTHROPIC, Mode.PARALLEL_TOOLS), - (Provider.ANTHROPIC, Mode.ANTHROPIC_REASONING_TOOLS), - (Provider.GENAI, Mode.TOOLS), - (Provider.GENAI, Mode.JSON), - ], -) -def test_mode_is_registered(provider: Provider, mode: Mode): - """Verify each mode is registered in the v2 registry.""" - assert mode_registry.is_registered(provider, mode) - - handlers = mode_registry.get_handlers(provider, mode) - assert handlers.request_handler is not None - assert handlers.reask_handler is not None - assert handlers.response_parser is not None - - -@pytest.mark.parametrize( - "provider,mode", - [ - (Provider.ANTHROPIC, Mode.TOOLS), - (Provider.ANTHROPIC, Mode.JSON_SCHEMA), - (Provider.GENAI, Mode.TOOLS), - (Provider.GENAI, Mode.JSON), - ], -) -@pytest.mark.requires_api_key -def test_mode_basic_extraction(provider: Provider, mode: Mode): - """Test basic extraction with each mode.""" - config = PROVIDER_CONFIGS[provider] - - # All providers now use from_provider() - client = instructor.from_provider( - config["provider_string"], - mode=mode, - ) - - response = client.chat.completions.create( - response_model=Answer, - messages=[ - { - "role": "user", - "content": "What is 2 + 2? Reply with a number.", - }, - ], - max_tokens=1000, - ) - - assert isinstance(response, Answer) - assert response.answer == 4.0 - - -@pytest.mark.parametrize( - "provider,mode", - [ - (Provider.ANTHROPIC, Mode.TOOLS), - (Provider.ANTHROPIC, Mode.JSON_SCHEMA), - (Provider.GENAI, Mode.TOOLS), - (Provider.GENAI, Mode.JSON), - ], -) -@pytest.mark.asyncio -@pytest.mark.requires_api_key -async def test_mode_async_extraction(provider: Provider, mode: Mode): - """Test async extraction with each mode.""" - config = PROVIDER_CONFIGS[provider] - - # All providers now use from_provider() - client = instructor.from_provider( - config["provider_string"], - mode=mode, - async_client=True, - ) - - response = await client.chat.completions.create( - response_model=Answer, - messages=[ - { - "role": "user", - "content": "What is 4 + 4? Reply with a number.", - }, - ], - max_tokens=1000, - ) - - assert isinstance(response, Answer) - assert response.answer == 8.0 - - -@pytest.mark.requires_api_key -def test_anthropic_parallel_tools_extraction(): - """Test PARALLEL_TOOLS mode extraction (Anthropic-specific).""" - client = instructor.from_provider( - "anthropic/claude-3-5-haiku-latest", - mode=Mode.PARALLEL_TOOLS, - ) - response = client.chat.completions.create( - response_model=Iterable[Union[Weather, GoogleSearch]], - messages=[ - { - "role": "system", - "content": "You must always use tools. Use them simultaneously when appropriate.", - }, - { - "role": "user", - "content": "Get weather for San Francisco and search for Python tutorials.", - }, - ], - max_tokens=1000, - ) - - result = list(response) - assert len(result) >= 1 - assert all(isinstance(r, (Weather, GoogleSearch)) for r in result) - - -@pytest.mark.parametrize( - "mode", - [ - Mode.TOOLS, - Mode.ANTHROPIC_REASONING_TOOLS, - ], -) -@pytest.mark.requires_api_key -def test_anthropic_tools_with_thinking(mode: Mode): - """Test tools modes with thinking parameter (Anthropic-specific).""" - # Note: Thinking requires Claude 3.7 Sonnet or later - client = instructor.from_provider( - "anthropic/claude-3-7-sonnet-20250219", - mode=mode, - ) - # Note: max_tokens must be greater than thinking.budget_tokens - response = client.chat.completions.create( - response_model=Answer, - messages=[ - { - "role": "user", - "content": "What is 5 + 5? Reply with a number.", - }, - ], - max_tokens=2048, # Must be > budget_tokens - thinking={"type": "enabled", "budget_tokens": 1024}, - ) - - assert isinstance(response, Answer) - assert response.answer == 10.0 - - -@pytest.mark.requires_api_key -def test_anthropic_reasoning_tools_deprecation(): - """Test that ANTHROPIC_REASONING_TOOLS shows deprecation warning.""" - import warnings - - import instructor.mode as mode_module - - mode_module._reasoning_tools_deprecation_shown = False # type: ignore[attr-defined] - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - - # Trigger deprecation by accessing the handler - from instructor.v2.providers.anthropic.handlers import ( - AnthropicReasoningToolsHandler, - ) - - handler = AnthropicReasoningToolsHandler() - handler.prepare_request(Answer, {"messages": []}) - - # Verify deprecation warning was issued - deprecation_warnings = [ - warning - for warning in w - if issubclass(warning.category, DeprecationWarning) - and "ANTHROPIC_REASONING_TOOLS" in str(warning.message) - ] - assert len(deprecation_warnings) >= 1 - - # Also test that it works - client = instructor.from_provider( - "anthropic/claude-3-5-haiku-latest", - mode=Mode.ANTHROPIC_REASONING_TOOLS, - ) - response = client.chat.completions.create( - response_model=Answer, - messages=[ - { - "role": "user", - "content": "What is 6 + 6? Reply with a number.", - }, - ], - max_tokens=1000, - ) - - assert isinstance(response, Answer) - assert response.answer == 12.0 - - -@pytest.mark.parametrize("provider", [Provider.ANTHROPIC, Provider.GENAI]) -@pytest.mark.requires_api_key -def test_all_modes_covered(provider: Provider): - """Verify we're testing all registered modes for each provider.""" - config = PROVIDER_CONFIGS[provider] - tested_modes = set(config["modes"]) - registered_modes = set(mode_registry.get_modes_for_provider(provider)) - - # All registered modes should be tested - assert tested_modes.issubset(registered_modes), ( - f"Tested modes {tested_modes} should be subset of registered modes {registered_modes}" - )