The gap between a demo LLM call and a production one is not the prompt — it is everything around it: schema-validated output your downstream code can trust, timeouts that fail fast, retry behavior that respects rate limits, a fallback path when the primary model is down, and PII hygiene before a single byte leaves your service.
Tier: Core · Workflow area: Building LLM Features
This playbook wraps a model API behind your own endpoint with all five, in runnable shape.
Not for agentic multi-step features (see pb-46) or streaming UX (see pb-45) — this is the request/response foundation both build on.
pip install fastapi anthropic pydantic.Define the contract as a schema, not a prompt instruction. Pydantic model = single source of truth for the model output and your response type:
from pydantic import BaseModel, Field
from typing import Literal
class TicketTriage(BaseModel):
category: Literal["billing", "bug", "how_to", "account", "other"]
severity: Literal["p0", "p1", "p2", "p3"]
summary: str = Field(description="One sentence, no PII")
needs_human: bool
Redact before you send. PII hygiene happens on your side of the wire — deterministically, before the SDK call:
import re
PATTERNS = [
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "<EMAIL>"),
(re.compile(r"\+?\d[\d\s()-]{8,}\d"), "<PHONE>"),
(re.compile(r"\b(?:\d[ -]*?){13,19}\b"), "<CARD>"),
]
def redact(text: str) -> str:
for pattern, token in PATTERNS:
text = pattern.sub(token, text)
return text
Make the call with schema enforcement, tight timeout, and bounded retries. The Anthropic SDK retries 429/5xx with backoff itself — configure it, don't reimplement it:
import anthropic
client = anthropic.Anthropic(timeout=20.0, max_retries=2)
SYSTEM = "You triage support tickets for a B2B SaaS. Be conservative: \
if unsure of severity, pick the higher one and set needs_human=true."
def triage(ticket_text: str) -> TicketTriage:
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
system=SYSTEM,
messages=[{"role": "user", "content": redact(ticket_text)}],
output_format=TicketTriage,
)
return response.parsed_output # validated TicketTriage instance
Map failures to an explicit error taxonomy — with a fallback model. Decide per error class: retry, fall back, or fail:
from fastapi import FastAPI, HTTPException
app = FastAPI()
FALLBACK_MODEL = "claude-sonnet-4-6"
@app.post("/triage", response_model=TicketTriage)
def triage_endpoint(body: dict):
try:
return triage(body["text"])
except anthropic.RateLimitError:
raise HTTPException(429, "Upstream busy, retry later") # surface, don't spin
except anthropic.APIStatusError as e:
if e.status_code >= 500: # provider incident: try the fallback once
return triage_with_model(body["text"], FALLBACK_MODEL)
raise HTTPException(502, "LLM upstream error")
except anthropic.APIConnectionError:
raise HTTPException(503, "LLM upstream unreachable")
Log what you will need at 2am. Per request: model, prompt version (tag your SYSTEM string with a version constant), input/output tokens (response.usage), latency, and whether fallback fired. This is the seed of pb-51's observability.
Load-test the failure paths, not just the happy path. Point the client at a mock that returns 429s and 500s; verify your endpoint degrades the way step 4 promises. Then a real-traffic soak: p95 latency, token cost per request.
Schema-validated output plus an explicit error taxonomy is what makes an LLM call a dependency other services can build on. Everything else is prompt tuning.
client.messages.parse(...) with a Pydantic output_format returns parsed_output pre-validated.client.responses.parse(model="gpt-5.6", input=[...], text_format=TicketTriage), read response.output_parsed. Chat Completions uses response_format={"type": "json_schema", ...}.AnthropicBedrockMantle(aws_region=...), Azure's OpenAI client) — the common route in enterprise/client tenancies. Check feature parity: not every structured-output mode exists on every platform.@anthropic-ai/sdk + Zod (zodOutputFormat), or openai + zod — same contract-first pattern.category/severity judgments are defensible.@ and 10+ digit runs; redaction holds.output_format/text_format with a strict schema is a guarantee. Never json.loads free text in production.max_tokens with no timeout. A pathological input can hold your worker for minutes. Timeout at the client, cap tokens at the request.