FunctionModel: write a stand-in model
FunctionModel is a model built from a Python function you write: the agent sends it the same messages a real model gets, and your function returns the response.
Last updated: 28 Sep, 2026 · Pydantic AI 2.51
The message list from Requests and responses in a run is exactly what a model function receives. Because your function reads those messages and writes the reply, it can stand in for a real model with no key, yet still respond to what was said.
Writing a model function
A model function takes the messages and an info object, and returns a ModelResponse with the parts a model would send:
from pydantic_ai import Agent, ModelResponse, TextPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
def reply(messages, info: AgentInfo) -> ModelResponse:
ticket = messages[-1].parts[-1].content
return ModelResponse(parts=[TextPart(f"You wrote {len(ticket.split())} words.")])
agent = Agent(FunctionModel(reply))
print(agent.run_sync("I was charged twice for one order").output)You wrote 7 words.
What the function received
- messages is the list from Requests and responses in a run, so
messages[-1].parts[-1]is the newest part: the ticket. - The reply is derived from that text, so a different prompt gives a different word count.
- The return value is a
ModelResponseholding oneTextPart, the same shape a real model produces.
Reading what the agent offers with AgentInfo
def peek(messages, info: AgentInfo) -> ModelResponse:
print("instructions:", info.instructions)
print("tools:", [tool.name for tool in info.function_tools])
print("text allowed:", info.allow_text_output)
return ModelResponse(parts=[TextPart("ok")])
agent = Agent(FunctionModel(peek), instructions="Be short.")
agent.run_sync("hello")instructions: Be short. tools: [] text allowed: True
info tells a stand-in what the agent wants: its instructions, the tools it registered and whether plain text is allowed. A real model gets the same facts as JSON in the API request.
The support desk stand-in
The rest of the course runs on one model function, kept in shop_model.py. It is a few keyword rules, not a language model, but it answers through the same parts a real model would, and every branch is driven by the actual messages:
import re
from pydantic_ai import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
def sort_ticket(text):
text = text.lower()
if "charged" in text or "refund" in text:
return "billing", 4
if "parcel" in text or "arrived" in text:
return "shipping", 3
return "other", 1
def shop_reply(messages, info: AgentInfo) -> ModelResponse:
prompts = [p.content for m in messages for p in m.parts if p.part_kind == "user-prompt"]
ticket = prompts[-1]
last = messages[-1].parts[-1]
order = re.search(r"A-\d{4}", ticket)
# 1. The ticket names an order and the agent has a tool: ask for it.
if order and info.function_tools and last.part_kind == "user-prompt":
tool = info.function_tools[0].name
return ModelResponse(parts=[ToolCallPart(tool, {"order_id": order.group()})])
# 2. A tool answered: write the reply from what it said.
if last.part_kind == "tool-return" and info.allow_text_output:
return ModelResponse(parts=[TextPart(f"Order {order.group()}: {last.content}.")])
# 3. The agent wants a typed answer: fill in its output tool.
category, priority = sort_ticket(ticket)
if info.output_tools:
args = {"category": category, "priority": priority}
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args)])
# 4. Otherwise, plain text.
return ModelResponse(parts=[TextPart(f"Sorted as {category}.")])
shop_model = FunctionModel(shop_reply, model_name="shop")sort_ticketguesses a category and priority from keywords.- Rule 1: a ticket that names an order like
A-1001, sent to an agent with a tool, returns aToolCallPartasking for that tool. - Rule 2: when the newest part is a
tool-return, it writes the answer from the tool's result. - Rule 3: an agent that wants typed output, from Structured output: a typed ticket, gets its output tool called with the category and priority.
- Rule 4: anything else gets a sentence.
Sorting three tickets with the stand-in
agent = Agent(shop_model)
for ticket in ["I was charged twice for one order", "My parcel never arrived", "How do I change my email?"]:
print(agent.run_sync(ticket).output)Sorted as billing. Sorted as shipping. Sorted as other.
Why the stand-in is honest
- Each line differs because the reply comes from the ticket's keywords, not a fixed string.
- Change a ticket and the category changes, the way a real model would respond to different input.
- The code around the model stays the same when a hosted model replaces the function in Real models: providers, keys and model names.
FunctionModel vs the test model
| FunctionModel | Test model | |
|---|---|---|
| Reads the messages | Yes, you decide the reply | No, fixed answer |
| Tool arguments | Whatever your function sends | Made-up, type-valid |
| Good for | A genuine, scripted demo | A quick wiring check |
When you write a model function
- Teaching or demonstrating a feature without a key or a bill.
- Driving a specific path in a test, covered in the testing part.
- Reproducing a model's reply exactly to debug the code around it.
Related
- Previous: Requests and responses in a run
- Next: Instructions: what the model is told
- Reference: FunctionModel API
- Add a rule to
sort_ticketfor password tickets, with the categoryaccount. - Make
replyanswer in capital letters. - In
peek, printlen(messages).
This is what real progress feels like.