Structured output
Get answers as validated Pydantic objects or JSON, from an agent run, a single model call or a stream.
2 min read
Ask for a shape and get it back validated. Pass a Pydantic model or a JSON Schema, and the answer arrives in result.parsed. An answer that does not fit is sent back to the model with the error, in the same conversation, until it does.
From an agent run
from pydantic import BaseModel
from shipit_agent import Agent
from shipit_agent.llms import build_llm_from_env
class Finding(BaseModel):
file: str
line: int
problem: str
severity: str
class Review(BaseModel):
summary: str
findings: list[Finding]
agent = Agent.with_builtins(llm=build_llm_from_env(), project_root=".")
result = agent.run("Review src/payments for bugs.", output_schema=Review)
for finding in result.parsed.findings:
print(f"{finding.file}:{finding.line} [{finding.severity}] {finding.problem}")The agent still uses its tools to do the work; only the final answer has to fit the schema. A JSON Schema dict works the same way, and parsed is then a dict.
max_validation_retries (2 by default) is how many corrections the model gets. If the answer still does not validate, parsed is None and output holds the last text.
With the SDK API, the schema goes in output_format, and the result is ResultMessage.structured_output:
from shipit_agent.sdk import AgentOptions
options = AgentOptions(output_format={"type": "json_schema", "schema": Review.model_json_schema()})How a correction works
user: Review src/payments for bugs. <the schema>
assistant: I found two problems: the refund path... ← not JSON
user: That response could not be parsed: Invalid JSON. Answer with JSON matching the schema.
assistant: {"summary": "Two bugs...", "findings": [...]} ← validatesThe correction stays in the same conversation, so the model fixes its own answer with everything it already found, instead of starting over.
Without an agent
For extraction or classification that needs no tools, StructuredOutput calls the model directly:
from pydantic import BaseModel
from shipit_agent import StructuredOutput
from shipit_agent.llms import build_llm_from_env
class Ticket(BaseModel):
category: str
priority: str
customer_email: str
extract = StructuredOutput(llm=build_llm_from_env(), schema=Ticket, max_retries=2)
result = extract.run("From: [email protected]. Checkout charges me twice since this morning!!")
print(result.value) # Ticket(category='billing', priority='high', customer_email='[email protected]')
print(result.attempts) # 1 when the first answer validatedresult.raw_text is the model's final text and result.history the failed attempts. coerce=True (the default) lets Pydantic convert compatible types, such as "3" for an int field.
Building blocks
StructuredOutput and output_schema= are built from three helpers you can use directly, for example with a model call you make yourself:
from pydantic import BaseModel
from shipit_agent import build_schema_prompt, parse_structured_output, schema_to_response_format
class Ticket(BaseModel):
category: str
priority: str
instructions = build_schema_prompt(Ticket) # text that asks for JSON in this shape
response_format = schema_to_response_format(Ticket) # a provider-native response_format
ticket = parse_structured_output('{"category": "billing", "priority": "high"}', Ticket)
print(ticket)category='billing' priority='high'parse_structured_output returns a model instance for a Pydantic schema, or a dict or list for a JSON Schema, and raises when the text doesn't fit.
Streaming partial JSON
stream() yields the object as it fills in, so an interface can show fields as they arrive. Each yield has at least what the one before had; the last is the validated object.
for partial in extract.stream("Classify: the app crashes when I upload a photo."):
print(partial){}
{'category': 'bug'}
{'category': 'bug', 'priority': 'med'}
{'category': 'bug', 'priority': 'medium', 'customer_email': ''}
Ticket(category='bug', priority='medium', customer_email='')parse_partial_json() does the same for any text stream:
from shipit_agent.parsers import parse_partial_json
parse_partial_json('{"name": "Al') # {'name': 'Al'}
parse_partial_json('{"items": [1, 2, ') # {'items': [1, 2]}
parse_partial_json('Sure! Here: {"a": 1}') # {'a': 1}Provider-native formats
Calling a model adapter directly, response_format passes the provider's own structured-output setting through, such as OpenAI's {"type": "json_schema", ...}. Prefer output_schema and StructuredOutput, which validate the answer and work with every provider.
See also
- Parsers: JSON, Markdown and Pydantic parsers for text you already have.