# Structured Output AbstractCore implements structured output generation using Pydantic models with automatic schema validation and provider-specific optimizations. The system employs a dual-strategy architecture that adapts to provider capabilities, delivering reliable schema compliance across all supported LLM providers. --- ## Table of Contents 1. [Overview](#overview) 2. [Architecture](#architecture) 3. [Provider Implementation](#provider-implementation) 4. [Usage Guide](#usage-guide) 5. [Schema Design](#schema-design) 6. [Performance Characteristics](#performance-characteristics) 7. [Error Handling](#error-handling) 8. [Production Deployment](#production-deployment) 9. [API Reference](#api-reference) --- ## Overview ### What is Structured Output? Structured output constrains LLM responses to conform to predefined schemas, enabling direct deserialization into typed objects. AbstractCore uses Pydantic BaseModel classes to define schemas and validate responses. Over HTTP, the [server](server.md#structured-outputs-response_format) exposes the same handler through OpenAI's `response_format` (`json_object` or `json_schema`) on `POST /v1/chat/completions`, for every provider. ### Basic Example ```python from abstractcore import create_llm from pydantic import BaseModel class Person(BaseModel): name: str age: int email: str llm = create_llm("openai", model="gpt-4o-mini") person = llm.generate( "Extract: John Doe, 35 years old, john@example.com", response_model=Person ) # person is a validated Person instance assert isinstance(person, Person) assert person.name == "John Doe" assert person.age == 35 ``` ### Key Benefits - **Type Safety**: Responses are validated Pydantic instances with full IDE support - **Schema Compliance**: Automatic validation ensures data conforms to defined structure - **Provider Agnostic**: Identical API across OpenAI, Anthropic, Ollama, LMStudio, HuggingFace, MLX - **Automatic Strategy Selection**: Framework selects optimal implementation based on provider capabilities - **Test Coverage**: Supported strategies are exercised by the repository test suite (see `tests/structured/`) --- ## Architecture ### Dual-Strategy Design AbstractCore implements two distinct strategies for structured output generation: #### Strategy 1: Native Structured Output (Server-Side Enforcement) **Mechanism**: Provider API accepts JSON schema and enforces compliance before returning response. **Providers**: - OpenAI (via `response_format` parameter) - Anthropic (via tool-calling mechanism) - Ollama (via `format` parameter) - LMStudio (via `response_format` parameter) - HuggingFace GGUF models (via `response_format` parameter with llama-cpp-python) **Characteristics**: - Server-side schema validation - Backend schema constraints where supported - Client-side validation, with fallback/retry for invalid native results - Success and latency depend on backend, model and schema **Validation**: - Structured output behavior is covered by automated tests in this repo (see `tests/structured/`). - Exact success rates and latency depend on provider/model/schema complexity. #### Strategy 2: Prompted with Validation (Client-Side Enforcement) **Mechanism**: Schema embedded in system prompt; response extracted, validated, and retried if necessary. **Providers**: - HuggingFace (Transformers models) - MLX - Any provider without native support **Characteristics**: - Schema injected into enhanced prompt - Client-side Pydantic validation - Automatic retry with error feedback (up to 3 attempts) - Fallback for providers without native support ### Automatic Strategy Selection The `StructuredOutputHandler` selects the appropriate strategy automatically: ```python def _has_native_support(self, provider) -> bool: """Detect native structured output capability""" provider_name = provider.__class__.__name__ # Ollama and LMStudio always have native support if provider_name in ['OllamaProvider', 'LMStudioProvider']: return True # HuggingFace GGUF models (via llama-cpp-python) if provider_name == 'HuggingFaceProvider': if hasattr(provider, 'model_type') and provider.model_type == 'gguf': return True # Check model capabilities for other providers capabilities = getattr(provider, 'model_capabilities', {}) return capabilities.get("structured_output") == "native" ``` No configuration required—the framework handles strategy selection transparently. --- ## Provider Implementation ### OpenAI **Implementation**: Native support via `response_format` parameter ```python # AbstractCore implementation (simplified) payload["response_format"] = { "type": "json_schema", "json_schema": { "name": response_model.__name__, "schema": response_model.model_json_schema() } } ``` **Models with Native Support**: - gpt-4o, gpt-4o-mini - gpt-4-turbo - gpt-3.5-turbo **Reference**: [OpenAI Structured Outputs Documentation](https://platform.openai.com/docs/guides/structured-outputs) --- ### Anthropic **Implementation**: Native support via tool-calling mechanism The provider forces execution of a tool whose input schema matches the desired output structure. **Models with Native Support**: - claude-haiku-4-5 - claude-sonnet-4-5 - claude-opus-4-5 **Reference**: [Anthropic API Documentation](https://docs.anthropic.com/) --- ### Ollama **Implementation**: Native support via `format` parameter ```python # AbstractCore implementation (abstractcore/providers/ollama_provider.py:147-152) if response_model and PYDANTIC_AVAILABLE: json_schema = response_model.model_json_schema() payload["format"] = json_schema # Full schema, server-side validation ``` **Mechanism**: 1. Full JSON schema passed to Ollama API 2. Server-side constrained sampling enforces schema compliance 3. Response is expected to follow the schema (provider/model dependent) **Notes**: - Native structured output depends on the Ollama server/build and the selected model. - For example coverage, see `tests/structured/`. **Supported Models**: Many models, including: - Llama 3.1, 3.2, 3.3 family - Qwen 2.5, 3, 3-coder family - Gemma 2b, 7b, gemma2, gemma3 - Mistral, Phi-3, Phi-4, GLM-4, DeepSeek-R1 **Reference**: [Ollama API Documentation](https://github.com/ollama/ollama/blob/main/docs/api.md) --- ### LMStudio **Implementation**: Native support via OpenAI-compatible `response_format` parameter ```python # AbstractCore implementation (abstractcore/providers/lmstudio_provider.py:211-222) if response_model and PYDANTIC_AVAILABLE: json_schema = response_model.model_json_schema() payload["response_format"] = { "type": "json_schema", "json_schema": { "name": response_model.__name__, "schema": json_schema } } ``` **Mechanism**: 1. OpenAI-compatible format passed to LMStudio server 2. Server-side schema enforcement via underlying inference engine 3. Response is expected to follow the schema (server/model dependent) **Notes**: - Behavior depends on the LMStudio server version and underlying model/runtime. - For example coverage, see `tests/structured/`. **Reference**: [LMStudio Documentation](https://lmstudio.ai/docs) --- ### HuggingFace **Implementation**: Dual strategy based on model type #### GGUF Models (Native Support) **Backend**: llama-cpp-python with native structured output ```python # AbstractCore implementation (abstractcore/providers/huggingface_provider.py:669-680) if response_model and PYDANTIC_AVAILABLE: json_schema = response_model.model_json_schema() generation_kwargs["response_format"] = { "type": "json_schema", "json_schema": { "name": response_model.__name__, "schema": json_schema } } ``` **Notes**: - GGUF structured output support depends on the llama-cpp-python backend and model. - For example coverage, see `tests/structured/`. #### Transformers Models (Native via Outlines) **Backend**: Hugging Face Transformers library with Outlines **Implementation**: Native support via Outlines constrained generation ```python # AbstractCore implementation (abstractcore/providers/huggingface_provider.py:514-548) if response_model and PYDANTIC_AVAILABLE and OUTLINES_AVAILABLE: # Cache Outlines model wrapper if not hasattr(self, '_outlines_model'): self._outlines_model = outlines.from_transformers( self.model_instance, self.tokenizer ) # Generate with constrained decoding generator = self._outlines_model( input_text, outlines.json_schema(response_model), max_tokens=max_tokens ) # Return validated instance validated_obj = response_model.model_validate(generator) ``` **Mechanism**: 1. Outlines wraps transformers model and tokenizer 2. JSON schema passed to constrained generator 3. Server-side logit filtering ensures only valid tokens are sampled 4. Schema compliance is enforced via constrained decoding (provider/model dependent) 5. Automatic fallback to prompted approach if Outlines unavailable **Installation**: ```bash pip install "abstractcore[apple]" # Apple silicon; includes Outlines pip install "abstractcore[gpu]" # NVIDIA / AMD; includes Outlines ``` **Characteristics**: - Schema compliance via constrained decoding (still validated client-side) - Zero or minimal validation retries when supported - Works with many transformers-compatible models - Automatic detection and activation when Outlines is installed - Graceful fallback to prompted approach if Outlines is missing **Fallback behavior**: - If Outlines isn't available (or a backend doesn't support constrained decoding), AbstractCore falls back to prompted structured output with validation and retries. - Exact success rates and latency depend on provider/model/hardware/schema complexity. --- ### MLX (Apple Silicon) **Implementation**: Native via Outlines **Backend**: MLX with Outlines constrained generation ```python # AbstractCore implementation (abstractcore/providers/mlx_provider.py:165-197) if response_model and PYDANTIC_AVAILABLE and OUTLINES_AVAILABLE: # Cache Outlines MLX model wrapper if not hasattr(self, '_outlines_model'): self._outlines_model = outlines_models.mlxlm(self.model) # Generate with constrained decoding generator = self._outlines_model( full_prompt, outlines.json_schema(response_model), max_tokens=max_tokens ) # Return validated instance validated_obj = response_model.model_validate(generator) ``` **Mechanism**: 1. Outlines MLX backend wraps mlx-lm model 2. JSON schema converted to token constraints 3. Constrained sampling on Apple Silicon hardware 4. Server-side schema enforcement 5. Automatic fallback to prompted approach if Outlines unavailable **Installation**: ```bash pip install "abstractcore[apple]" # Includes Outlines automatically ``` **Models**: - mlx-community/Qwen2.5-Coder-7B-Instruct-4bit - mlx-community/Meta-Llama-3.1-8B-Instruct-4bit - All MLX-compatible models **Characteristics**: - Schema compliance via constrained decoding (still validated client-side) - Zero or minimal validation retries when supported - Optimized for Apple M-series processors - Automatic detection and activation when Outlines installed - Graceful fallback to prompted approach if Outlines missing **Performance notes**: - Prompted structured output (validation + retry) is the default fallback and is often the simplest to run. - Constrained decoding can be slower or faster depending on backend/model/schema; benchmark on your hardware if it matters. --- ## Usage Guide ### Basic Usage ```python from abstractcore import create_llm from pydantic import BaseModel class ExtractedData(BaseModel): name: str age: int email: str llm = create_llm("ollama", model="qwen3:4b") result = llm.generate( "Extract: Alice Johnson, 28, alice@example.com", response_model=ExtractedData, temperature=0 # Recommended for deterministic output ) print(f"{result.name} ({result.age}): {result.email}") ``` ### Tools + Structured Output (2-pass hybrid) If you pass both `tools=[...]` and `response_model=...` in the same `generate()` call, AbstractCore uses a 2-pass hybrid flow: 1) a tool-capable call, then 2) a structured-output call using the tool-context. Notes: - Streaming is not supported in this hybrid mode. - Tool execution is pass-through by default (`execute_tools=False`): your host/runtime should execute tool calls and feed results back as messages when building multi-step workflows. ### Using Enums Enums provide type-safe categorical values: ```python from enum import Enum from pydantic import BaseModel class Priority(str, Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical" class Task(BaseModel): title: str priority: Priority estimated_hours: float llm = create_llm("lmstudio", model="qwen/qwen3-4b-2507") task = llm.generate( "Create task: Fix authentication bug, critical priority, 8 hours estimated", response_model=Task ) assert isinstance(task.priority, Priority) print(f"Priority: {task.priority.value}") # "critical" ``` **Notes**: Enums are supported and exercised by tests; exact behavior depends on provider/model. ### Nested Objects ```python from typing import List from pydantic import BaseModel class Address(BaseModel): street: str city: str postal_code: str class Person(BaseModel): name: str email: str address: Address llm = create_llm("openai", model="gpt-4o-mini") person = llm.generate( """Extract: John Smith, john@example.com Address: 123 Main St, Boston, MA 02101""", response_model=Person ) assert isinstance(person.address, Address) ``` ### Complex Hierarchies Complex schemas with multiple nesting levels are supported: ```python from enum import Enum from typing import List, Optional from pydantic import BaseModel class Department(str, Enum): ENGINEERING = "engineering" SALES = "sales" MARKETING = "marketing" class EmployeeLevel(str, Enum): JUNIOR = "junior" MID = "mid" SENIOR = "senior" class Skill(BaseModel): name: str proficiency: int # 1-10 scale years_experience: float class Employee(BaseModel): name: str email: str department: Department level: EmployeeLevel skills: List[Skill] manager_email: Optional[str] = None class Team(BaseModel): name: str department: Department lead: Employee members: List[Employee] class Organization(BaseModel): company_name: str founded_year: int teams: List[Team] total_employees: int llm = create_llm("anthropic", model="claude-haiku-4-5") org = llm.generate( """Create organization: TechCorp, founded 2020 Team: Platform (engineering) Lead: Sarah Chen (sarah@tech.com, senior, Python-9/10-5yrs, AWS-8/10-4yrs) Member: Bob Lee (bob@tech.com, mid, JavaScript-7/10-3yrs, manager: sarah@tech.com) Total employees: 2""", response_model=Organization ) ``` **Notes**: Deeply nested schemas are supported; validate against your target provider/model and see `tests/structured/` for examples. ### Direct Handler Usage For advanced use cases requiring custom retry configuration: ```python from abstractcore.structured import StructuredOutputHandler, FeedbackRetry # Configure custom retry strategy handler = StructuredOutputHandler( retry_strategy=FeedbackRetry(max_attempts=5) ) result = handler.generate_structured( provider=llm, prompt="Extract complex data from document...", response_model=ComplexSchema, temperature=0 ) ``` --- ## Schema Design ### Design Principles Well-designed schemas improve validation success rates and reduce response times. #### 1. Clear Field Naming Use descriptive, unambiguous field names: ```python # Recommended class Employee(BaseModel): employee_id: str hire_date: str department: str annual_salary: float # Avoid class Employee(BaseModel): id: str # Ambiguous date: str # What date? dept: str # Abbreviation unclear salary: float # Currency? Period? ``` #### 2. Leverage Enums for Categorical Data Enums provide validation and type safety: ```python # Recommended class Status(str, Enum): ACTIVE = "active" INACTIVE = "inactive" PENDING = "pending" class User(BaseModel): status: Status # Only valid enum values accepted # Avoid class User(BaseModel): status: str # Any string accepted, no validation ``` #### 3. Use Optional Fields Appropriately Distinguish required from optional fields: ```python from typing import Optional, List class Task(BaseModel): # Required fields title: str created_at: str # Optional with defaults description: str = "" tags: List[str] = [] # Truly optional (may be None) assigned_to: Optional[str] = None due_date: Optional[str] = None ``` #### 4. Logical Hierarchy Group related fields into nested objects: ```python # Recommended class ContactInfo(BaseModel): email: str phone: str address: str class Person(BaseModel): name: str contact: ContactInfo # Logical grouping # Avoid flat structure class Person(BaseModel): name: str email: str phone: str address: str ``` ### Complexity Guidelines Schema complexity affects latency and cost; keep schemas as small as practical. #### Simple Schemas (< 10 fields, 1 level) **Example**: ```python class PersonInfo(BaseModel): name: str age: int email: str occupation: str ``` **Recommended for**: User profiles, data extraction, form processing #### Medium Schemas (10-30 fields, 1-2 levels) **Example**: ```python class Project(BaseModel): name: str description: str start_date: str tasks: List[Task] # Nested objects total_hours: float ``` **Recommended for**: Project management, task tracking, structured data extraction #### Complex Schemas (30+ fields, 3+ levels) **Example**: ```python class Organization(BaseModel): company_name: str teams: List[Team] # Level 2 # Team contains: # lead: Employee # Level 3 # members: List[Employee] # Level 3 # # Employee contains: # # skills: List[Skill] # Level 4 ``` **Recommended for**: Organizational hierarchies, knowledge graphs, complex data models ### Anti-Patterns Avoid these patterns that can degrade performance or reliability: #### 1. Excessive Nesting Depth (>4 levels) ```python # Avoid class Level1(BaseModel): level2: Level2 # Level2 -> Level3 -> Level4 -> Level5 (too deep) ``` **Impact**: Increased token usage, longer response times #### 2. Ambiguous Enum Values ```python # Avoid class Status(str, Enum): ONE = "1" TWO = "2" THREE = "3" # Recommended class Status(str, Enum): PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" ``` #### 3. Overly Long Field Names ```python # Avoid class Data(BaseModel): very_long_and_descriptive_field_name_that_uses_many_tokens: str # Recommended class Data(BaseModel): user_email: str # Clear but concise ``` **Impact**: Increases token count, affecting cost and context window --- ## Performance Characteristics Structured output performance is highly dependent on: - Provider/backend strategy (native constrained decoding vs prompted validation/retry) - Schema complexity (field count + nesting depth) - Model choice, server configuration, and hardware - Sampling settings (use `temperature=0` when you care about schema fidelity) If performance matters, benchmark on your target provider/model/hardware. Historical benchmark notes (non-authoritative) may exist under `docs/reports/`. ### Temperature Settings **Recommendation**: Use `temperature=0` for structured outputs **Rationale**: - Deterministic responses - Consistent schema compliance - Reduced sampling overhead **When to increase temperature**: - Creative content generation within schema constraints - Diverse response generation for the same prompt - Exploratory data generation --- ## Error Handling ### Error Categories #### 1. Infrastructure errors and retry ownership Provider-level retry and structured-output validation retry are separate budgets. Handle `AbstractCoreError` and transport/backend errors at the application boundary as described in [API error handling](api.md#errors-and-retry-boundaries). Authentication errors require fixing configuration before another call. Do not add an unconditional outer retry around a provider that already retries. Use `RetryConfig.single_attempt()` when your host owns infrastructure retries, and `FeedbackRetry(max_attempts=1)` when it also needs one schema attempt. The default `FeedbackRetry` permits 3 attempts including the first. A length-cutoff response is rejected by default even if its JSON validates; the handler may retry with a larger output budget. See [bounded typed extraction](agent-usage.md#typed-extraction) for a complete example. Known issue (2.26.4): a native response with `content=None` can raise `TypeError` while decoding JSON. Catch it at the extraction boundary and treat the extraction as failed. Do not replace missing model output with an empty object or invented fields. #### 2. Validation Errors (Non-Retriable) Schema validation failures indicate schema or prompt issues—do not retry: ```python from pydantic import ValidationError try: result = llm.generate( "Extract user data...", response_model=UserModel ) except ValidationError as e: # Log validation errors print("Schema validation failed:") for error in e.errors(): field = " -> ".join(str(loc) for loc in error['loc']) print(f" {field}: {error['msg']}") # Fix schema or prompt—do not retry raise ``` **Common validation errors**: - Missing required fields: Schema too strict or prompt unclear - Type mismatches: Field type incompatible with LLM output - Enum validation failures: LLM returned invalid enum value **Resolution**: Revise schema or improve prompt clarity #### 3. Token Limit Errors Context window exceeded—simplify schema or split request: ```python try: result = llm.generate(prompt, response_model=ComplexModel) except Exception as e: if "token" in str(e).lower() or "context" in str(e).lower(): print("Token limit exceeded. Options:") print("1. Simplify schema (reduce fields or nesting)") print("2. Split into multiple requests") print("3. Use model with larger context window") raise ``` ### Retry Strategy Details The default `FeedbackRetry` strategy: 1. **Maximum attempts**: 3 (configurable) 2. **Retry condition**: Only `ValidationError` exceptions 3. **Feedback mechanism**: Provides detailed error descriptions to LLM **Example error feedback**: ``` Your previous response had validation errors: • Missing required field: 'department' • Field 'employee_level': Expected one of: junior, mid, senior • Field 'age': Expected integer, received string ``` The LLM uses this feedback to self-correct on subsequent attempts. **Configuration**: ```python from abstractcore.structured import StructuredOutputHandler, FeedbackRetry handler = StructuredOutputHandler( retry_strategy=FeedbackRetry(max_attempts=5) ) ``` --- ## Production Deployment ### Pre-Deployment Checklist Before deploying structured outputs to production: - [ ] Schema validated locally with Pydantic: `Model.model_validate(test_data)` - [ ] Success rate measured with target model (target: >95%) - [ ] Response time benchmarked under expected load - [ ] Error handling implemented for infrastructure failures - [ ] Logging configured for validation errors and retries - [ ] Monitoring configured for success rates and latencies - [ ] Fallback strategy defined for structured output failures - [ ] Token limits verified: `len(prompt) + len(schema) + len(response) < context_window` ### Monitoring Metrics Track these metrics in production: **Success Metrics**: - Validation success rate (target: >95%) - First-attempt success rate - Average retry count **Performance Metrics**: - p50, p95, p99 response times - Response time by schema complexity - Token usage statistics **Error Metrics**: - Validation error rate by field - Infrastructure error rate - Token limit exceeded rate ### Configuration Best Practices **Temperature**: Set to 0 for deterministic structured outputs ```python llm.generate(prompt, response_model=Model, temperature=0) ``` **Timeout**: Configure appropriate timeouts based on schema complexity ```python # Simple schemas: 30s # Medium schemas: 60s # Complex schemas: 120s ``` **Provider Selection**: - Development: Use local providers (Ollama, LMStudio) for cost efficiency - Production: Select based on performance requirements and budget ### Schema Versioning Maintain schema version compatibility: ```python from pydantic import BaseModel, Field class UserV1(BaseModel): name: str email: str class UserV2(BaseModel): name: str email: str department: str = Field(default="unassigned") # Backward compatible ``` Use optional fields with defaults for backward-compatible schema evolution. --- ## API Reference ### Core Function ```python llm.generate( prompt: str, response_model: Type[BaseModel], temperature: float = 0.0, **kwargs ) -> BaseModel ``` **Parameters**: - `prompt` (str): Input prompt describing extraction/generation task - `response_model` (Type[BaseModel]): Pydantic model class defining output schema - `temperature` (float): Sampling temperature (0.0 = deterministic, 1.0 = creative) - `**kwargs`: Additional provider-specific parameters **Returns**: - Instance of `response_model`, validated and type-safe **Raises**: - `ValidationError`: Schema validation failed after all retry attempts - `ConnectionError`: Network/infrastructure error - `TimeoutError`: Request timeout **Example**: ```python person = llm.generate( "Extract: John Doe, age 35", response_model=Person, temperature=0 ) ``` ### StructuredOutputHandler Advanced handler for custom retry strategies: ```python from abstractcore.structured import StructuredOutputHandler handler = StructuredOutputHandler(retry_strategy=None) ``` **Methods**: ```python handler.generate_structured( provider: LLMProvider, prompt: str, response_model: Type[BaseModel], **kwargs ) -> BaseModel ``` Generates structured output with automatic strategy selection (native or prompted). ### Retry Strategies ```python from abstractcore.structured import FeedbackRetry retry = FeedbackRetry(max_attempts=3) ``` **Parameters**: - `max_attempts` (int): Maximum retry attempts including initial attempt **Methods**: - `should_retry(attempt, error)`: Returns True if retry should occur - `prepare_retry_prompt(prompt, error, attempt)`: Creates retry prompt with validation feedback --- ## Related Documentation - [Getting Started](getting-started.md#structured-output) - Quick introduction - [API Reference](api-reference.md) - Complete API documentation - [Examples](examples.md#structured-output-examples) - Real-world usage patterns - [Server: structured outputs](server.md#structured-outputs-response_format) - `response_format` over the OpenAI-compatible API - [Response Model Parameter Analysis](archive/structured-response-keyword.md) - Why `response_model` - [Native Implementation Test Results](archive/improved-structured-response.md) - Detailed test data --- ## References - [OpenAI Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) - [Anthropic API Documentation](https://docs.anthropic.com/) - [Ollama API Documentation](https://github.com/ollama/ollama/blob/main/docs/api.md) - [Pydantic Documentation](https://docs.pydantic.dev/) --- --- ## Testing and Validation Structured output behavior is exercised by automated tests under `tests/structured/`. ### Running tests From this repository: ```bash pip install -e ".[test]" pytest tests/structured -q ``` Some provider-specific tests require additional extras: - MLX, HuggingFace / Outlines: `pip install -e ".[apple,test]"` (Apple silicon) - vLLM, HuggingFace / Outlines: `pip install -e ".[gpu,test]"` (NVIDIA / AMD) For the light install plus the test tooling: ```bash pip install -e ".[test]" pytest -q ``` ### Notes - Performance and success rates vary widely by provider/model/schema complexity and are not guaranteed. - If performance matters, benchmark on your target hardware/provider setup.