Module 1: Models and Providers
Structured Output
Capsule overview
So far, every time you invoke a model you get free-form text. The model answers with a string of characters that you, as the developer, have to interpret by hand. If you want to pull a title, a rating and a summary out of a review, you'd have to parse the text with regular expressions, split on delimiters, or ask the model to "please answer in JSON" — and pray that it does.
Structured Output removes that problem entirely. Instead of getting text and parsing it, you tell the model exactly which structure you want — with types, fields and descriptions — and you get back a typed Python object. No regex, no brittle parsing, no surprises. You define a schema with Pydantic, TypedDict or JSON Schema, and the with_structured_output method makes sure the model responds in exactly that format.
This capability is fundamental for any serious application. APIs that return data, pipelines that process information, agents that make decisions — all of them need structured data, not free-form text. In this module's project, you'll use Structured Output to extract metadata from every chat response: which provider was used, how many tokens it consumed, and the latency of the call.
Why Structured Output?
The problem: parsing free-form text is brittle
Imagine you ask a model to analyze a movie:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke("Analyze the movie Inception. Give me a title, a rating from 1 to 10, and a short summary.")
print(response.content)
# Possible output 1: "Title: Inception\nRating: 9/10\nSummary: A thief who steals secrets..."
# Possible output 2: "**Inception** - 9 stars. A thriller about dreams within dreams..."
# Possible output 3: "The movie Inception, directed by Christopher Nolan, deserves a 9..."
Three runs, three different formats. Now try extracting the rating programmatically. You'd need regex, heuristics, and it would still break on unexpected formats.
The solution: typed responses
With Structured Output, you define the structure once and the model always respects it:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class MovieReview(BaseModel):
title: str = Field(description="The movie's title")
rating: int = Field(description="Rating from 1 to 10")
summary: str = Field(description="A one-sentence summary")
model = init_chat_model("openai:gpt-4.1-mini")
structured_model = model.with_structured_output(MovieReview)
result = structured_model.invoke("Analyze the movie Inception")
print(type(result)) # <class 'MovieReview'>
print(result.title) # Inception
print(result.rating) # 9
print(result.summary) # A thief skilled at extracting secrets through dreams...
The result is no longer free-form text — it's a MovieReview object with typed attributes. result.rating is always an int, not a string you have to parse.
Concrete benefits
- ✅ Type safety — Every field has a defined type. No manual conversions
- ✅ Parse reliability — The model respects the structure 100% of the time
- ✅ No regex — Never write a regular expression to extract data again
- ✅ Automatic validation — Pydantic validates types and constraints for you
- ✅ IDE autocomplete — Your editor knows which fields exist
- ✅ Composability — Structured objects plug straight into the rest of your code
with_structured_output with Pydantic
Pydantic is the most recommended approach. It gives you type validation, field descriptions that guide the model, and native Python objects as the result.
Basic example
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Sentiment(BaseModel):
label: str = Field(description="Sentiment: positive, negative or neutral")
confidence: float = Field(description="Confidence level between 0.0 and 1.0")
explanation: str = Field(description="A brief explanation of the analysis")
model = init_chat_model("openai:gpt-4.1-mini")
analyzer = model.with_structured_output(Sentiment)
result = analyzer.invoke("I love this product, it's incredible")
print(result.label) # positive
print(result.confidence) # 0.95
print(result.explanation) # The text expresses enthusiasm and clear satisfaction...
How it works
- You define a Pydantic model with typed fields and descriptions
- You call
model.with_structured_output(YourModel)— this returns a new model that always produces objects of the type you specified - You invoke the structured model normally with
.invoke() - The result is an instance of your Pydantic model, not text
Field(description=...) matters: those descriptions tell the model what you expect in each field. Think of them as mini-instructions for the LLM.
Supported field types
| Type | Example | Description |
|---|---|---|
str | name: str | Free text |
int | count: int | Whole number |
float | score: float | Decimal number |
bool | is_valid: bool | True or false |
list[str] | tags: list[str] | A list of strings |
Optional[str] | note: Optional[str] | Can be None |
Enum | status: MyEnum | A value from a closed set |
Literal["a", "b"] | choice: Literal["a", "b"] | A restricted literal value |
Extracting multiple items
A very common pattern is extracting a list of objects from a text:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Person(BaseModel):
name: str = Field(description="The person's full name")
role: str = Field(description="The role or relationship mentioned")
class PeopleExtraction(BaseModel):
people: list[Person] = Field(description="List of people mentioned in the text")
total_count: int = Field(description="Total number of people found")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(PeopleExtraction)
text = """
The meeting was attended by Carlos (CTO), Ana María (product manager)
and the new developer Luis García.
"""
result = extractor.invoke(f"Extract the people mentioned: {text}")
print(result.total_count) # 3
for person in result.people:
print(f" - {person.name}: {person.role}")
# Output:
# - Carlos: CTO
# - Ana María: Product Manager
# - Luis García: Developer
with_structured_output with TypedDict
If you don't need the validation Pydantic offers and you'd rather work with dictionaries, you can use TypedDict. The result will be a Python dict instead of a Pydantic object.
Basic example
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from typing import TypedDict, Annotated
class MovieReview(TypedDict):
title: Annotated[str, "The movie's title"]
rating: Annotated[int, "Rating from 1 to 10"]
summary: Annotated[str, "A one-sentence summary"]
model = init_chat_model("openai:gpt-4.1-mini")
structured_model = model.with_structured_output(MovieReview)
result = structured_model.invoke("Analyze the movie The Matrix")
print(type(result)) # <class 'dict'>
print(result["title"]) # The Matrix
print(result["rating"]) # 9
print(result["summary"]) # A programmer discovers reality is a simulation...
The key difference from Pydantic
With TypedDict the result is a dictionary (result["title"]), not an object with attributes (result.title). There's no automatic type validation — if the model returns a string where you expected an int, no error gets raised.
When do you use TypedDict?
- ✅ When the output goes straight into a JSON response (API endpoints)
- ✅ When you don't need strict validation
- ✅ When you'd rather work with dictionaries out of familiarity
- ❌ When you need constraint validation (ranges, patterns, etc.)
- ❌ When you want custom methods on the result
with_structured_output with JSON Schema
The third approach uses a JSON Schema directly. This is useful when the schema is generated dynamically — for example, when the user defines the extraction structure at runtime.
Basic example
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
json_schema = {
"title": "MovieReview",
"description": "Analysis of a movie",
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The movie's title"
},
"rating": {
"type": "integer",
"description": "Rating from 1 to 10"
},
"summary": {
"type": "string",
"description": "A one-sentence summary"
}
},
"required": ["title", "rating", "summary"]
}
model = init_chat_model("openai:gpt-4.1-mini")
structured_model = model.with_structured_output(json_schema)
result = structured_model.invoke("Analyze the movie Interstellar")
print(type(result)) # <class 'dict'>
print(result["title"]) # Interstellar
print(result["rating"]) # 9
print(result["summary"]) # A group of explorers travels through a wormhole...
When do you use JSON Schema?
- ✅ Schemas generated at runtime (dynamic forms, per-user configuration)
- ✅ When you receive the schema from an external source (an API, a database)
- ✅ When you need compatibility with systems that already use JSON Schema
- ❌ When the schema is fixed — Pydantic is more ergonomic and safer
Nested structures
Simple schemas cover a lot of cases, but real applications need hierarchical structures. Pydantic lets you nest models inside models.
Example: a report with sections
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Section(BaseModel):
title: str = Field(description="The section's title")
content: str = Field(description="The section's content, in 2-3 sentences")
class Report(BaseModel):
topic: str = Field(description="The report's topic")
executive_summary: str = Field(description="A one-sentence executive summary")
sections: list[Section] = Field(description="3 sections of the report")
conclusion: str = Field(description="A one-sentence conclusion")
model = init_chat_model("openai:gpt-4.1-mini")
reporter = model.with_structured_output(Report)
result = reporter.invoke("Write a brief report on the impact of AI in education")
print(f"Topic: {result.topic}")
print(f"Summary: {result.executive_summary}")
print(f"Sections: {len(result.sections)}")
for section in result.sections:
print(f" - {section.title}: {section.content[:80]}...")
print(f"Conclusion: {result.conclusion}")
# Output:
# Topic: The Impact of Artificial Intelligence on Education
# Summary: AI is transforming education through personalization...
# Sections: 3
# - Personalized learning: AI systems can adapt...
# - Administrative automation: Tasks like grading and reporting...
# - Challenges and ethical considerations: The digital divide and privacy...
# Conclusion: AI has the potential to democratize education...
Nesting can go several levels deep, but keep the complexity reasonable. Schemas more than 3 levels deep tend to produce less reliable results.
include_raw: getting the original response
By default, with_structured_output returns only the parsed object. But sometimes you need access to the model's original response — for debugging, logging, or to pull metadata like tokens used.
Turning on include_raw
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Sentiment(BaseModel):
label: str = Field(description="Sentiment: positive, negative or neutral")
confidence: float = Field(description="Confidence between 0.0 and 1.0")
model = init_chat_model("openai:gpt-4.1-mini")
analyzer = model.with_structured_output(Sentiment, include_raw=True)
result = analyzer.invoke("This restaurant has the worst food I have ever tried")
print(type(result)) # <class 'dict'>
print(result.keys()) # dict_keys(['raw', 'parsed', 'parsing_error'])
With include_raw=True, the result changes: instead of getting the Pydantic object directly, you get a dictionary with three keys:
| Key | Type | Contents |
|---|---|---|
raw | AIMessage | The model's original response, with complete metadata |
parsed | Your Pydantic model (or None) | The parsed object, same as without include_raw |
parsing_error | Exception or None | The parsing error, if there was one |
Using include_raw for debugging
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Sentiment(BaseModel):
label: str = Field(description="Sentiment: positive, negative or neutral")
confidence: float = Field(description="Confidence between 0.0 and 1.0")
model = init_chat_model("openai:gpt-4.1-mini")
analyzer = model.with_structured_output(Sentiment, include_raw=True)
result = analyzer.invoke("This restaurant has the worst food I have ever tried")
if result["parsing_error"]:
print(f"Error: {result['parsing_error']}")
print(f"Raw: {result['raw'].content}")
else:
parsed = result["parsed"]
raw = result["raw"]
print(f"Sentiment: {parsed.label} (confidence: {parsed.confidence})")
print(f"Token usage: {raw.usage_metadata}")
# Output:
# Sentiment: negative (confidence: 0.95)
# Token usage: {'input_tokens': 45, 'output_tokens': 12, 'total_tokens': 57}
When do you use include_raw?
- ✅ Debugging during development — seeing what the model actually answered
- ✅ Logging in production — storing raw responses for auditing
- ✅ Token tracking — pulling
usage_metadatato monitor costs - ✅ Error handling — catching and handling parsing errors without crashing your app
Comparison: Pydantic vs TypedDict vs JSON Schema
| Feature | Pydantic | TypedDict | JSON Schema |
|---|---|---|---|
| Result type | Object with attributes (result.field) | Dictionary (result["field"]) | Dictionary (result["field"]) |
| Type validation | ✅ Automatic | ❌ No | ❌ No |
| Field descriptions | ✅ Field(description=...) | ✅ Annotated[type, "desc"] | ✅ "description": "..." |
| Nested structures | ✅ Natural | ⚠️ Limited | ✅ With $ref |
| Constraints (min, max, pattern) | ✅ Field(ge=1, le=10) | ❌ No | ✅ "minimum": 1 |
| IDE autocomplete | ✅ Full | ⚠️ Partial | ❌ No |
| Dynamic schemas | ❌ Static | ❌ Static | ✅ Can be generated at runtime |
| Learning curve | Medium | Low | High |
| Recommendation | Use by default | Quick prototypes | Dynamic schemas |
Rule: Use Pydantic unless you have a specific reason not to. TypedDict for quick prototypes where you don't want to install Pydantic (though it already ships with LangChain). JSON Schema only when the schema is generated dynamically.
Connection to the project
In the Multi-Provider Chat with Fallback, you'll use Structured Output for two things:
-
Response metadata: Every chat response will include structured metadata — which provider generated it, how long it took, and how many tokens it used. This lets you monitor the system without parsing logs by hand.
-
Formatted responses: When the user asks for analysis or data extraction, the chat will use Structured Output to return consistent results regardless of which provider generated them.
The metadata schema will look like this:
from pydantic import BaseModel, Field
class ResponseMetadata(BaseModel):
provider: str = Field(description="The provider that generated the response")
model_name: str = Field(description="The name of the model used")
latency_ms: float = Field(description="Latency in milliseconds")
tokens_used: int = Field(description="Total tokens consumed")
This pattern — combining include_raw to pull metadata out of the AIMessage with Pydantic for the structured response — is exactly what you'll implement in the project capsule.
Troubleshooting
Problem 1: The model doesn't respect the structure
Symptom: You get None or a parsing error instead of the expected object.
Cause: The prompt is ambiguous or contradicts the schema's structure.
Fix: Make sure the prompt is compatible with the schema. The descriptions in Field() help the model understand what to generate.
# Bad — the prompt asks for free-form output, but the schema expects structure
result = structured_model.invoke("Write whatever you want about AI")
# Good — the prompt is compatible with the structure
result = structured_model.invoke("Analyze the impact of AI on education")
Problem 2: Pydantic raises ValidationError
Symptom: pydantic.ValidationError: 1 validation error for MyModel.
Cause: The model generated a value that doesn't pass Pydantic's validation (for example, a string where you expected an int).
Fix: Use include_raw=True to inspect what the model actually generated, and tighten up your field descriptions so they're more explicit.
analyzer = model.with_structured_output(MyModel, include_raw=True)
result = analyzer.invoke("...")
if result["parsing_error"]:
print(f"Error: {result['parsing_error']}")
print(f"Raw: {result['raw'].content}")
Problem 3: with_structured_output isn't available
Symptom: AttributeError: 'ChatModel' object has no attribute 'with_structured_output'.
Cause: An old LangChain version, or a provider that doesn't support structured output.
Fix:
pip install --upgrade langchain langchain-core langchain-openai
Check that you're on LangChain v1.2+. The main providers (OpenAI, Anthropic, Google) support structured output.
Problem 4: Optional fields always come back as None
Symptom: The optional fields never get filled even though the information is in the text. Cause: The descriptions of the optional fields aren't clear enough. Fix: Improve the descriptions and spell out when the field should be filled:
# Bad
assignee: Optional[str] = Field(description="Assigned person")
# Good
assignee: Optional[str] = Field(
description="Name of the person assigned to the task. None only if nobody is mentioned."
)
Exercises
Exercise 1: Contact extraction (Easy)
Define a Pydantic model ContactInfo with fields for name, email and phone (the last two optional). Use with_structured_output to extract contact information from natural-language text.
Test text: "Hi, I'm Laura Martínez. My email is laura@example.com and you can call me at 555-1234."
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
from typing import Optional
class ContactInfo(BaseModel):
name: str = Field(description="The person's full name")
email: Optional[str] = Field(description="Email address, if mentioned")
phone: Optional[str] = Field(description="Phone number, if mentioned")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(ContactInfo)
text = "Hi, I'm Laura Martínez. My email is laura@example.com and you can call me at 555-1234."
result = extractor.invoke(f"Extract the contact information: {text}")
print(f"Name: {result.name}") # Laura Martínez
print(f"Email: {result.email}") # laura@example.com
print(f"Phone: {result.phone}") # 555-1234
Explanation: The model automatically identifies each piece of information and assigns it to the right field. The Optional fields get filled when the information is present and stay None when it isn't.
Exercise 2: Classification with an Enum (Easy)
Build a support-ticket classifier that sorts each ticket into one of these categories: bug, feature_request, question, complaint. Use an Enum to restrict the possible values. Also include an urgency field (1-5) and a summary.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
from enum import Enum
class TicketCategory(str, Enum):
BUG = "bug"
FEATURE_REQUEST = "feature_request"
QUESTION = "question"
COMPLAINT = "complaint"
class TicketClassification(BaseModel):
category: TicketCategory = Field(description="The ticket's category")
urgency: int = Field(description="Urgency level from 1 (low) to 5 (critical)")
summary: str = Field(description="A one-sentence summary of the ticket")
model = init_chat_model("openai:gpt-4.1-mini")
classifier = model.with_structured_output(TicketClassification)
tickets = [
"The checkout page crashes when I use Safari. I can't complete my purchase.",
"Could you add dark mode? It would be great for working at night.",
"How do I export my data to CSV?",
]
for ticket in tickets:
result = classifier.invoke(f"Classify this support ticket: {ticket}")
print(f"Category: {result.category.value} | Urgency: {result.urgency} | {result.summary}")
# Output:
# Category: bug | Urgency: 4 | Checkout page failure on Safari...
# Category: feature_request | Urgency: 2 | Request for dark mode...
# Category: question | Urgency: 1 | Question about exporting data to CSV...
Explanation: The Enum guarantees the category will always be one of the 4 valid values. The model can't invent new categories. Combined with the int-typed urgency field, you get data that's ready to process programmatically.
Exercise 3: Nested structure with TypedDict (Medium)
Recreate the Report-with-sections example, but this time using TypedDict instead of Pydantic. Compare the development experience of both approaches.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from typing import TypedDict, Annotated
class Section(TypedDict):
title: Annotated[str, "The section's title"]
content: Annotated[str, "The section's content, in 2-3 sentences"]
class Report(TypedDict):
topic: Annotated[str, "The report's topic"]
executive_summary: Annotated[str, "A one-sentence executive summary"]
sections: Annotated[list[Section], "3 sections of the report"]
conclusion: Annotated[str, "A one-sentence conclusion"]
model = init_chat_model("openai:gpt-4.1-mini")
reporter = model.with_structured_output(Report)
result = reporter.invoke("Write a brief report on the impact of AI in healthcare")
print(f"Topic: {result['topic']}")
print(f"Summary: {result['executive_summary']}")
for section in result["sections"]:
print(f" - {section['title']}: {section['content'][:60]}...")
print(f"Conclusion: {result['conclusion']}")
Explanation: With TypedDict you reach fields via result["key"] instead of result.key. The structure works the same, but you lose automatic validation and IDE autocomplete. For nested schemas, Pydantic is usually more ergonomic.
Exercise 4: Dynamic schema with JSON Schema (Medium)
Write a function extract_from_text(text, fields) that takes a text and a dictionary of fields with their descriptions, generates a JSON Schema dynamically, and extracts the information. Try it with at least two different sets of fields over the same text.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
def extract_from_text(text: str, fields: dict[str, str]) -> dict:
"""Extracts information from a text using a dynamically generated schema."""
schema = {
"title": "Extraction",
"type": "object",
"properties": {
name: {"type": "string", "description": desc}
for name, desc in fields.items()
},
"required": list(fields.keys())
}
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(schema)
return extractor.invoke(f"Extract the following information from the text: {text}")
article = """
Apple unveiled the iPhone 16 in September 2024 with a base price of $799.
CEO Tim Cook highlighted the new AI capabilities built into the device.
Apple's stock rose 2% after the announcement.
"""
business_fields = {
"company": "The company's name",
"product": "The product unveiled",
"price": "The price mentioned",
}
print("Business extraction:", extract_from_text(article, business_fields))
# Output: {'company': 'Apple', 'product': 'iPhone 16', 'price': '$799'}
market_fields = {
"stock_movement": "The stock movement mentioned",
"executive": "The name of the executive mentioned",
"date": "The date of the event",
}
print("Market extraction:", extract_from_text(article, market_fields))
# Output: {'stock_movement': 'Rose 2%', 'executive': 'Tim Cook', 'date': 'September 2024'}
Explanation: The same function extracts completely different information from the same text, depending on the fields you hand it. This pattern is powerful for applications where users define what they want to extract.
Exercise 5: include_raw for monitoring (Advanced)
Write a function analyze_with_metrics that uses include_raw=True to return both the structured analysis and usage metrics (tokens, model). If there's a parsing error, it should return the error and the raw response instead of crashing.
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
class Analysis(BaseModel):
topic: str = Field(description="The topic analyzed")
sentiment: str = Field(description="Overall sentiment: positive, negative or neutral")
key_points: list[str] = Field(description="3 key points from the analysis")
def analyze_with_metrics(text: str) -> dict:
"""Analyzes text and returns the result together with usage metrics."""
model = init_chat_model("openai:gpt-4.1-mini")
analyzer = model.with_structured_output(Analysis, include_raw=True)
result = analyzer.invoke(f"Analyze the following text: {text}")
raw = result["raw"]
parsed = result["parsed"]
error = result["parsing_error"]
metrics = {
"tokens": raw.usage_metadata if hasattr(raw, "usage_metadata") else None,
"model": raw.response_metadata.get("model_name", "unknown"),
}
if error:
return {
"success": False,
"error": str(error),
"raw_content": raw.content,
"metrics": metrics,
}
return {
"success": True,
"analysis": {
"topic": parsed.topic,
"sentiment": parsed.sentiment,
"key_points": parsed.key_points,
},
"metrics": metrics,
}
result = analyze_with_metrics(
"The new Python framework has gained popularity fast. "
"Developers are adopting it for its simplicity and performance."
)
if result["success"]:
print(f"Topic: {result['analysis']['topic']}")
print(f"Sentiment: {result['analysis']['sentiment']}")
for point in result["analysis"]["key_points"]:
print(f" - {point}")
print(f"Tokens: {result['metrics']['tokens']}")
print(f"Model: {result['metrics']['model']}")
else:
print(f"Error: {result['error']}")
print(f"Raw: {result['raw_content']}")
# Output:
# Topic: A new Python framework
# Sentiment: positive
# - Gained popularity fast
# - Developers are adopting it
# - Praised for simplicity and performance
# Tokens: {'input_tokens': 85, 'output_tokens': 45, 'total_tokens': 130}
# Model: gpt-4.1-mini
Explanation: include_raw=True gives you access to the model's complete response, including token metadata. The pattern of checking parsing_error before touching parsed makes your code robust against parsing errors without reaching for try/except.
Summary
In this capsule you learned:
- Structured Output removes manual parsing of free-form text — you define a schema and get typed objects back
- Pydantic is the recommended approach: it gives you validation, field descriptions, and objects with attributes
- TypedDict is the lightweight alternative when you'd rather have dictionaries and don't need validation
- JSON Schema is for dynamic schemas generated at runtime
- Nested structures let you model hierarchical data with Pydantic models inside models
include_raw=Truegives you access to the original response for debugging, logging, and token tracking- Field descriptions are instructions for the model — write them clearly
Next capsule: Multimodal and Reasoning — how to process images, audio and video with models, and how to make the model show its reasoning steps.
Additional resources
- Structured Output — LangChain Docs — The official conceptual guide to structured output
- How to return structured data from a model — A step-by-step tutorial with examples
- Pydantic v2 Documentation — Complete Pydantic reference for advanced schemas
- OpenAI Structured Outputs — OpenAI's implementation of the standard
- Anthropic Tool Use for Structured Output — How Anthropic implements structured output via tools
- JSON Schema Specification — Reference for writing JSON Schemas by hand
- TypedDict — Python Docs — Official TypedDict documentation
- LangChain Chat Models API Reference — Reference for the
with_structured_outputmethod
Module 1 — LangChain & LangGraph: From Chains to Agents