Combining Web Search with Structured Outputs
Why Free-Text Answers Are Not Enough for Application Logic
Every example so far in this unit has produced a free-text answer — a paragraph of prose that a human can read, but that application code cannot reliably parse. If you want to store search-backed findings in a database, render them in a structured UI component, or feed them into another step of a pipeline, you need the model's output as data with a defined shape, not as an essay you have to regex apart.
Unit 6 covered structured outputs in depth: defining a schema (typically through a Pydantic model) and having the API return a response that conforms to it exactly, rather than hoping the model's free text happens to be parseable. This lesson combines that capability with the web search tool, so you get an answer that is both grounded in current information and shaped as clean, typed data your code can use directly.
This combination is genuinely useful in practice. A price-comparison feature does not want "the current prices are roughly..." — it wants a list of typed records, each with a product name, a price, a currency, and a source URL. A news digest does not want one long paragraph — it wants an array of headline objects, each with a title, a summary, and a publication date. Structured outputs give you that shape; web search gives you the current, grounded content to fill it with.
Defining the Schema
As in Unit 6, the schema is defined as a Pydantic model, which the Responses API uses to constrain the model's output.
from pydantic import BaseModel
from openai import OpenAI
class PriceFinding(BaseModel):
product_name: str
price_usd: float
source_url: str
as_of_description: str
class PriceReport(BaseModel):
findings: list[PriceFinding]
notes: str
PriceFinding represents a single grounded fact: a product, its price, and where that price came from. price_usd is typed as float rather than str, which matters — if you left it as a string, your application would need to parse currency formatting downstream ("$129.99" versus "129.99" versus "129.99 USD"), reintroducing exactly the kind of brittle parsing that structured outputs are meant to eliminate. source_url keeps the citation attached to the specific finding it supports, rather than as a separate, disconnected list, which solves a problem noted back in Lesson 4 — the risk of losing the link between a claim and its source.
PriceReport wraps a list of findings plus a free-text notes field, giving the model a place to mention anything relevant that does not fit the structured fields — for example, noting that prices vary by region, or that one product was discontinued. This is a common and useful pattern in schema design: keep the fields you need to process programmatically strict and typed, while leaving one small, clearly-scoped field for genuinely unstructured context, rather than trying to force every possible nuance into rigid fields.
Making the Combined Call
from pydantic import BaseModel
from openai import OpenAI
class PriceFinding(BaseModel):
product_name: str
price_usd: float
source_url: str
as_of_description: str
class PriceReport(BaseModel):
findings: list[PriceFinding]
notes: str
client = OpenAI()
response = client.responses.parse(
model="gpt-5.6-terra",
tools=[{"type": "web_search"}],
input=(
"Search the web for the current listed prices of the base models of three "
"popular wireless noise-cancelling headphones. For each one, report the "
"product name, price in US dollars, the source URL you found it on, and a "
"short description of what 'current' means for that price (e.g. 'as listed "
"on the manufacturer's site today')."
),
text_format=PriceReport,
)
report: PriceReport = response.output_parsed
for finding in report.findings:
print(f"{finding.product_name}: ${finding.price_usd} (source: {finding.source_url})")
print("Notes:", report.notes)
This uses client.responses.parse() rather than client.responses.create() — the same distinction introduced in Unit 6 — because parse() is the method that enforces the schema and gives you back a validated, typed object through response.output_parsed, instead of a raw string you would need to parse yourself. The tools parameter works identically to every other example in this unit; enabling web search and requesting structured output are independent, composable configuration choices, not alternatives to each other.
Note: The exact parameter name for specifying the output schema (
text_formathere, matching the convention used earlier in this course) and the exact behavior of combining it with tool use are details that can vary between SDK versions. Confirm the current parameter name and any constraints on combining structured outputs with built-in tools against the official documentation before relying on this in production.
Note the prompt itself does real work here: it explicitly asks for exactly the fields the schema expects — product name, price, source URL, and an "as of" description — in plain language. Structured outputs constrain the shape of the response, but they do not by themselves guarantee the model gathers all the right content to fill that shape well. A schema with a source_url field will still get filled in with something if you forget to ask for it explicitly, but that something might be a low-quality guess rather than an actual URL from a real search result. Writing your prompt to explicitly ask for every important field, even when a schema already declares it, meaningfully improves output quality.
Handling the Interaction Between Search and Schema Validation
One subtlety worth understanding: structured output validation happens on the model's final answer, after any tool use has already completed. The web search step itself is not schema-constrained — it is an intermediate action the model takes on its way to producing the final structured message. This means a malformed or unhelpful search does not itself cause a schema validation failure; instead, it can cause the model to produce a schema-valid but low-quality answer, for example filling source_url with a URL that does not actually support the claim, simply because the field requires some string and the model needs to produce a complete, valid object.
This is an important distinction from validation errors you may already be familiar with from Unit 6: a PriceReport object can pass schema validation perfectly while still being factually ungrounded in a genuinely useful source. Schema validation checks shape, not truthfulness. This is precisely why Lesson 8, on reducing unsupported claims, and Lesson 7, on handling low-quality sources, matter even after you have adopted structured outputs — a clean schema is necessary for building reliable application logic, but it is not sufficient on its own for building trustworthy application logic.
A Practical Validation Layer on Top of the Schema
Because schema validation alone cannot catch a hollow or fabricated source_url, it is worth adding a lightweight application-level check as a second line of defense.
def validate_price_report(report: PriceReport) -> list[str]:
"""Return a list of problems found in a PriceReport. Empty list means it looks sound."""
problems = []
if not report.findings:
problems.append("No findings were returned.")
for finding in report.findings:
if not finding.source_url.startswith("http"):
problems.append(f"Suspicious source_url for {finding.product_name!r}: {finding.source_url!r}")
if finding.price_usd <= 0:
problems.append(f"Non-positive price for {finding.product_name!r}: {finding.price_usd}")
return problems
def test_validate_price_report_flags_bad_url_and_bad_price():
good = PriceFinding(
product_name="Model A",
price_usd=199.99,
source_url="https://example.com/model-a",
as_of_description="as listed today",
)
bad_url = PriceFinding(
product_name="Model B",
price_usd=149.99,
source_url="not a real url",
as_of_description="as listed today",
)
bad_price = PriceFinding(
product_name="Model C",
price_usd=0,
source_url="https://example.com/model-c",
as_of_description="as listed today",
)
report = PriceReport(findings=[good, bad_url, bad_price], notes="test report")
problems = validate_price_report(report)
assert len(problems) == 2, f"expected exactly 2 problems, got {len(problems)}: {problems}"
assert any("Model B" in p for p in problems)
assert any("Model C" in p for p in problems)
print("PASS: validate_price_report flags a bad URL and a non-positive price without flagging a good entry")
test_validate_price_report_flags_bad_url_and_bad_price()
validate_price_report is a plain function operating on an already-parsed PriceReport object — it does not call the API at all, which is exactly why it can be tested with plainly constructed PriceFinding instances rather than fake API objects. It checks two simple but meaningful things: that source_url at least looks like a URL (a cheap sanity check, not a guarantee of validity — genuinely confirming a URL is reachable and relevant would require an actual HTTP request, which is a heavier check you might add in a production pipeline) and that price_usd is a plausible positive number. Neither check can be expressed inside the Pydantic schema itself in a way that catches a model producing a shape-valid but semantically hollow value, which is exactly the gap this function closes.
Common Mistakes
Assuming a valid schema means a valid answer, which causes ungrounded or fabricated field values to pass silently through your application simply because they satisfy the type checker. Schema validation and factual validation are different concerns; you generally need both, especially for fields like source_url that are supposed to represent real, checkable evidence.
Under-specifying the prompt and relying entirely on the schema's field names to communicate intent, which causes the model to fill fields with a best guess rather than genuinely searched content. Field names like source_url are documentation for your code, not necessarily strong instructions to the model — write the prompt to explicitly ask for what each important field should contain.
Using an overly rigid schema for an inherently variable answer, such as forcing exactly three findings when the real number of comparable products found in a search varies. Use a list[...] field, as shown here, rather than a fixed number of individual named fields, so the schema can flex to however many items the search actually turned up.
Best Practices
Add a lightweight, application-level validation function alongside any schema used with web search, specifically checking the fields that represent grounding — like a source URL — since schema validation alone cannot verify that a value is genuinely well-supported.
Write the prompt to name every field explicitly, even when the schema already declares it, since the schema constrains shape but the prompt drives what content actually goes into that shape.
Keep one small free-text field, like notes, in an otherwise strict schema to give the model a place for legitimate caveats or context that does not fit cleanly into your typed fields, rather than forcing that nuance awkwardly into a structured field it does not belong in.