Pydantic AIPydantic AI 2.51 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
29 small wins to finish your pathNext lesson →

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:

Example
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)

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 ModelResponse holding one TextPart, the same shape a real model produces.

Reading what the agent offers with AgentInfo

Example
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")

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:

Exampleshop_model.py
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_ticket guesses 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 a ToolCallPart asking 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

Example
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)

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

FunctionModelTest model
Reads the messagesYes, you decide the replyNo, fixed answer
Tool argumentsWhatever your function sendsMade-up, type-valid
Good forA genuine, scripted demoA 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.
What the stand-in is for
Watch out. A keyword rule gets "I want my money back" wrong, and the evals part catches it doing so. The stand-in is for the code around the model, not for the sorting itself.
Try it yourself
  • Add a rule to sort_ticket for password tickets, with the category account.
  • Make reply answer in capital letters.
  • In peek, print len(messages).

This is what real progress feels like.