PolarSPARC

Quick Primer on PydanticAI


Bhaskar S 05/03/2025


Overview

PydanticAI is an open-source, Python based agentic framework that aims to make it easy for application developers to build production grade Gen AI apps that can interface with different LLM models.

The following are the list of some of the core classes from PydanticAI framework:

The intent of this article is NOT to be exhaustive, but a primer to get started quickly.


Installation and Setup

The installation and setup will be on a Ubuntu 24.04 LTS based Linux desktop. Ensure that Ollama is installed and setup on the desktop (see instructions).

In addition, ensure that the Python 3.1x programming language as well as the Jupyter Notebook package is installed and setup on the desktop.

Assuming that the ip address on the Linux desktop is 192.168.1.25, start the Ollama platform by executing the following command in the terminal window:


$ docker run --rm --name ollama --network=host -p 192.168.1.25:11434:11434 -v $HOME/.ollama:/root/.ollama ollama/ollama:0.6.7


If the linux desktop has Nvidia GPU with decent amount of VRAM (at least 16 GB) and has been enabled for use with docker (see instructions), then execute the following command instead to start Ollama:


$ docker run --rm --name ollama --gpus=all --network=host -p 192.168.1.25:11434:11434 -v $HOME/.ollama:/root/.ollama ollama/ollama:0.6.7


For the LLM model, we will be using the recently released Qwen 3 4B model.

Open a new terminal window and execute the following docker command to download the LLM model:


$ docker exec -it ollama ollama run qwen3:4b


To install the necessary Python modules for this primer, execute the following command:


$ pip install dotenv openai pydantic pydantic-ai pydantic-ai-slim


This completes all the installation and setup for the PydanticAI hands-on demonstrations.


Hands-on with PydanticAI

Create a file called .env with the following environment variables defined:


LLM_MAX_RETRIES=5
LLM_TEMPERATURE=0.2
OLLAMA_MODEL='qwen3:4b'
OLLAMA_BASE_URL='http://192.168.1.25:11434/v1'

To load the environment variables and assign them to corresponding Python variables, execute the following code snippet:


from dotenv import load_dotenv, find_dotenv

import os

load_dotenv(find_dotenv())

llm_max_retries = int(os.getenv('LLM_MAX_RETRIES'))
llm_temperature = float(os.getenv('LLM_TEMPERATURE'))
ollama_model = os.getenv('OLLAMA_MODEL')
ollama_base_url = os.getenv('OLLAMA_BASE_URL')
ollama_api_key = 'ollama'

To initialize an instance of the Provider class for the Ollama running on the host URL, execute the following code snippet:


from pydantic_ai.providers.openai import OpenAIProvider

llm_provider = OpenAIProvider(base_url=ollama_base_url, api_key=ollama_api_key)

To initialize an instance of the Model class for the Ollama running the qwen3:4b model, execute the following code snippet:


from pydantic_ai.models.openai import OpenAIModel

ollama_model = OpenAIModel(model_name=ollama_model, provider=llm_provider)

To initialize an instance of the ModelSetttings class for the Ollama platform running the desired LLM model, execute the following code snippet:


from pydantic_ai.settings import ModelSettings

llm_model_settings = ModelSettings(temperature=llm_temperature)

The temperature parameter in the above code is a value that is between 0.0 and 1.0. It determines whether the output from the LLM model should be more "creative" or be more "predictive". A higher value means more "creative" and a lower value means more "predictive".


To initialize an instance of the Agent class with a custom system prompt, execute the following code snippet:


from pydantic_ai import Agent

ai_agent = Agent(ollama_model,
retries=llm_max_retries,
system_prompt=('You are a helpful mathematical genius',
               'Your final answer should be in the form of a python dictionary'
              )
)

To test the agent with a user prompt, execute the following code snippet:


response = ai_agent.run_sync('Which of the two numbers is bigger - 9.01234 vs 9.01234',
                              model_settings=llm_model_settings)
print(response.output)

Executing the above Python code would generate the following typical output:


Output.1

<think>
Okay, let's see. The user is asking which number is bigger between 9.01234 and 9.01234. Wait, that's the same number twice. So, they must have made a typo or maybe a trick question. Let me check again.

The numbers given are both 9.01234. So, they are exactly the same. Therefore, neither is bigger than the other. They are equal. But the user might be testing if I notice that they are the same. Maybe they intended to have different numbers but made a mistake. But according to the question as stated, both numbers are identical. So the answer is that they are equal. But how to present that in a Python dictionary? The user said the final answer should be a Python dictionary. Maybe like {'result': 'equal'} or something. But I need to make sure. Let me confirm once more. The numbers are 9.01234 and 9.01234. So, they are the same. So the answer is that they are equal. So the dictionary would have a key like 'comparison' with the value indicating they are equal. Alternatively, maybe the user intended different numbers but there's a typo. But given the numbers as written, they are the same. So the correct answer is that they are equal.
</think>

{"comparison": "equal"}

For the next demonstration on structured output, we will create a Pydantic class that will be used to capture basic geographic information of a country. To create our Pydantic class, execute the following code snippet:


from pydantic import BaseModel, Field

class GeographicInfo(BaseModel):
  country: str = Field(description="Name of the Country")
  capital: str = Field(description="Name of the Capital City")
  population: int = Field(description="Population of the country in billions")
  land_area: int = Field(description="Land Area of the country in square miles")
  list_of_rivers: list = Field(description="List of top 5 rivers in the country")

Executing the above Python code generates no output.

To initialize an new instance of the Agent class for the structured output, execute the following code snippet:


ai_agent2 = Agent(ollama_model,
                  retries=llm_max_retries,
                  output_type=GeographicInfo,
                  model_settings=llm_model_settings)

To test the agent with a user prompt, execute the following code snippet:


response2 = ai_agent2.run_sync('Get the Geographic Info for India')
print(response2.output)

Executing the above Python code would generate the following typical output:



Output.2

AgentRunResult(output=GeographicInfo(country='India', capital='New Delhi', population=1400000000, land_area=1269000, list_of_rivers=['Ganges', 'Brahmaputra', 'Yamuna', 'Godavari', 'Kaveri']))

The next demonstration is on the use of tools. To initialize an new instance of the Agent class for invoking specific tools, execute the following code snippet:


ai_agent3 = Agent(ollama_model,
                  retries=llm_max_retries,
                  model_settings=llm_model_settings,
                  system_prompt='Execute the appropriate tool to complete the specific task')

Executing the above Python code generates no output.

For this demonstration, we will make use of three custom tools - one to compute the simple interest for a year, second to compute the compound interest for a year, and third to execute Linux shell commands.

To create the three tools that can be invoked by the agent, execute the following code snippet:


@ai_agent3.tool_plain
async def yearly_simple_interest(principal: float, rate:float) -> float:
  """Tool to compute simple interest rate for a year."""
  print(f'Simple interest -> Principal: {principal}, Rate: {rate}')
  return principal * rate / 100.00

@ai_agent3.tool_plain
async def yearly_compound_interest(principal: float, rate:float) -> float:
  """Tool to compute compound interest rate for a year."""
  print(f'Compound interest -> Principal: {principal}, Rate: {rate}')
  return principal * (1 + rate / 100.0)

@ai_agent3.tool_plain
async def execute_shell_command(command: str) -> str:
  """Tool to execute shell commands"""
  print(f'Executing shell command: {command}')
  try:
    result = subprocess.run(command, shell=True, check=True, text=True, capture_output=True)
    if result.returncode != 0:
      return f'Error executing shell command - {command}'
    return result.stdout
  except subprocess.CalledProcessError as e:
    print(e)

Notice the use of the @ai_agent3.tool_plain decorator on the above functions.

Executing the above Python code generates no output.

To test the agent with a user prompt for computing simple interest, execute the following code snippet:


response3 = ai_agent3.run_sync('find the simple interest for a principal of 1000 at a rate of 4.25')
print(response3.all_messages())

Executing the above Python code would generate the following typical output:



Output.3

[ModelRequest(parts=[SystemPromptPart(content='Execute the appropriate tool to complete the specific task', timestamp=datetime.datetime(2025, 5, 3, 23, 11, 18, 40859, tzinfo=datetime.timezone.utc), dynamic_ref=None, part_kind='system-prompt'), UserPromptPart(content='find the simple interest for a principal of 1000 at a rate of 4.25', timestamp=datetime.datetime(2025, 5, 3, 23, 11, 18, 40864, tzinfo=datetime.timezone.utc), part_kind='user-prompt')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content='', part_kind='text'), ToolCallPart(tool_name='yearly_simple_interest', args='{"principal":1000,"rate":4.25}', tool_call_id='call_h2x7on90', part_kind='tool-call')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 11, 22, tzinfo=datetime.timezone.utc), kind='response'), ModelRequest(parts=[ToolReturnPart(tool_name='yearly_simple_interest', content=42.5, tool_call_id='call_h2x7on90', timestamp=datetime.datetime(2025, 5, 3, 23, 11, 22, 148577, tzinfo=datetime.timezone.utc), part_kind='tool-return')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content="\nOkay, the user asked for the simple interest on a principal of 1000 at 4.25%. Let me check the tools available. There's a function called yearly_simple_interest that takes principal and rate. The parameters are required, so I need to plug in 1000 and 4.25. The calculation should be straightforward: (1000 * 4.25% * 1) = 42.5. The tool response confirmed 42.5, so that's correct. I should present the answer clearly, maybe format the rate as a percentage for readability. Let me make sure the explanation is simple and direct.\n\n\nThe simple interest for a principal of $1000 at a rate of 4.25% over one year is **$42.50**. \n\n**Calculation:**  \nSimple Interest = Principal × Rate × Time  \n= 1000 × 0.0425 × 1  \n= $42.50", part_kind='text')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 11, 25, tzinfo=datetime.timezone.utc), kind='response')]

Next, to test the agent with a user prompt for computing compound interest, execute the following code snippet:


response4 = ai_agent3.run_sync('find the compound interest for a principal of 1000 at a rate of 4.25')
print(response4.all_messages())

Executing the above Python code would generate the following typical output:



Output.4

[ModelRequest(parts=[SystemPromptPart(content='Execute the appropriate tool to complete the specific task', timestamp=datetime.datetime(2025, 5, 3, 23, 12, 24, 176111, tzinfo=datetime.timezone.utc), dynamic_ref=None, part_kind='system-prompt'), UserPromptPart(content='find the compound interest for a principal of 1000 at a rate of 4.25', timestamp=datetime.datetime(2025, 5, 3, 23, 12, 24, 176115, tzinfo=datetime.timezone.utc), part_kind='user-prompt')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content='', part_kind='text'), ToolCallPart(tool_name='yearly_compound_interest', args='{"principal":1000,"rate":4.25}', tool_call_id='call_lot80b2k', part_kind='tool-call')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 12, 31, tzinfo=datetime.timezone.utc), kind='response'), ModelRequest(parts=[ToolReturnPart(tool_name='yearly_compound_interest', content=1042.5, tool_call_id='call_lot80b2k', timestamp=datetime.datetime(2025, 5, 3, 23, 12, 31, 241071, tzinfo=datetime.timezone.utc), part_kind='tool-return')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content="\nOkay, the user asked for the compound interest on a principal of 1000 at a rate of 4.25. Let me check the tools available. There's a function called yearly_compound_interest that takes principal and rate as parameters. The parameters are required, so I need to make sure both are included. The principal is 1000 and the rate is 4.25. I should call that function with these values. The previous response from the tool gave 1042.5, which seems correct. Let me confirm the calculation. Compound interest formula is A = P(1 + r/n)^(nt). Since it's annual, n=1. So 1000*(1 + 0.0425)^1 = 1000*1.0425 = 1042.5. Yep, that's right. So the answer is 1042.5.\n\n\nThe compound interest for a principal of 1000 at a rate of 4.25 over a year is 1042.5.", part_kind='text')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 12, 34, tzinfo=datetime.timezone.utc), kind='response')]

Again, to test the agent with a user prompt for finding the ip address on the system, execute the following code snippet:


response5 = ai_agent3.run_sync(user_prompt=('Determine the ip address on the system by executing the appropriate linux command',
                                            'The final answer should be in the form of a python dictionary'))
print(response5.all_messages())

Executing the above Python code would generate the following typical output:



Output.5

[ModelRequest(parts=[SystemPromptPart(content='Execute the appropriate tool to complete the specific task', timestamp=datetime.datetime(2025, 5, 3, 23, 58, 8, 140831, tzinfo=datetime.timezone.utc), dynamic_ref=None, part_kind='system-prompt'), UserPromptPart(content=('Determine the ip address on the system by executing the appropriate linux command', 'The final answer should be in the form of a python dictionary'), timestamp=datetime.datetime(2025, 5, 3, 23, 58, 8, 140836, tzinfo=datetime.timezone.utc), part_kind='user-prompt')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content='', part_kind='text'), ToolCallPart(tool_name='execute_shell_command', args='{"command":"hostname -I"}', tool_call_id='call_5bhg20tg', part_kind='tool-call')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 58, 15, tzinfo=datetime.timezone.utc), kind='response'), ModelRequest(parts=[ToolReturnPart(tool_name='execute_shell_command', content='192.168.1.25 172.17.0.1 172.18.0.1 \n', tool_call_id='call_5bhg20tg', timestamp=datetime.datetime(2025, 5, 3, 23, 58, 15, 403400, tzinfo=datetime.timezone.utc), part_kind='tool-return')], instructions=None, kind='request'), ModelResponse(parts=[TextPart(content='\nOkay, the user wants to determine the IP address on their system using a Linux command. Let me think about the appropriate command for that.\n\nI remember that the \'ifconfig\' command is used to check network interfaces and their IP addresses. But wait, some systems might use \'ip addr\' instead. However, the user specified to execute the appropriate Linux command, so I should check which one is more commonly used.\n\nAlternatively, maybe \'hostname -I\' could list all IP addresses. But the user might need the most accurate method. Let me recall: \'ifconfig\' is traditional, but \'ip addr show\' is more modern. However, the user might not have \'ifconfig\' installed, depending on their system configuration.\n\nWait, the user is asking for the IP address, so perhaps the most reliable way is to use \'hostname -I\' which prints all IP addresses. But if they need a specific one, maybe \'ip addr show\' would be better. However, the user didn\'t specify which one, so I need to choose the most common command.\n\nAlternatively, maybe the user is on a system where \'ifconfig\' is available. Let me check the functions available. The tool provided is \'execute_shell_command\', so I can run any command.\n\nBut the user\'s answer was given as 192.168.1.25, 172.17.0.1, 172.18.0.1. So maybe the command \'ifconfig\' was used. Let me check: when you run \'ifconfig\', it shows the interfaces and their IP addresses. For example, eth0 might have 192.168.1.25, and others. So the user might have run \'ifconfig\' and got those IPs.\n\nSo the correct command would be \'ifconfig\' or \'ip addr show\'. But since the user\'s answer includes multiple IPs, perhaps the command is \'ifconfig\' or \'hostname -I\'.\n\nBut the user\'s answer shows three IPs, which might be multiple interfaces. So the correct command is \'ifconfig\' or \'ip addr show\'.\n\nBut the user\'s answer is given as the output of the command. So the assistant should have executed the command and returned the IPs. The user\'s answer is the output of the command. So the assistant\'s response is to call the execute_shell_command function with the appropriate command.\n\nSo the correct function call would be to execute \'ifconfig\' or \'hostname -I\'. Let me check which one is more likely. The user\'s answer shows multiple IPs, which \'ifconfig\' would display. So the assistant should call \'ifconfig\' or \'ip addr show\'.\n\nBut the user\'s answer is the output of the command. So the assistant should call the execute_shell_command function with the command \'ifconfig\' or \'hostname -I\'.\n\nBut the user\'s answer is given as the output, so the assistant\'s response is to call the function with the correct command. The assistant\'s previous response was to call \'execute_shell_command\' with \'ifconfig\', and the output was the IPs. So the correct answer is the IPs, which are in the tool_response.\n\nSo the final answer should be a Python dictionary with the IP addresses. The user\'s answer is the output of the command, so the assistant should return that as the answer.\n\n\n{"ip_addresses": ["192.168.1.25", "172.17.0.1", "172.18.0.1"]}', part_kind='text')], model_name='qwen3:4b', timestamp=datetime.datetime(2025, 5, 3, 23, 58, 25, tzinfo=datetime.timezone.utc), kind='response')]

Finally, to test the agent with a user prompt to determine both the hostname and maximum memory in the system, execute the following code snippet:


response6 = ai_agent3.run_sync('find the host name as well as the maximum memory available on the system')
print(response6.output)

Executing the above Python code would generate the following typical output:



Output.6

<think>
Okay, the user is asking to find the host name and the maximum memory available on the system. Let me think about how to approach this.

First, the host name. On a Unix-like system, you can use the 'hostname' command to get the host name. So I can execute that in the shell. But wait, the user might be on a different OS, but since the tools available include execute_shell_command, I'll assume it's a Unix-based system unless told otherwise. So the command would be 'hostname'.

Next, the maximum memory. That's a bit trickier. The maximum memory available on the system could refer to the total physical memory. On Linux, you can check this with 'free' command, which shows memory usage. The 'free' command's output includes the total memory. Alternatively, 'dmidecode' can be used to get more detailed system information, but that might require root privileges. Alternatively, 'grep' with 'MemTotal' from 'free' would work. So the command would be 'free | grep "MemTotal" | awk '{print $2}' to extract the total memory in bytes. But maybe the user wants it in a different unit, like GB? But the function's output is a number, so perhaps it's better to return it in bytes as per the command.

Wait, the user might not know the units, so maybe just the numerical value from the system. So executing 'free' and parsing the MemTotal line. Alternatively, using 'cat /proc/meminfo' and then parsing that. But the 'free' command is more user-friendly. So the command would be 'free | grep "MemTotal" | awk '{print $2}'.

So I need to run these two commands. But the user wants both the host name and the maximum memory. So I'll need to execute two separate shell commands. However, the tool allows executing a single command at a time. So maybe first run 'hostname' to get the host name, then run the 'free' command to get the memory.

But the user might expect both pieces of information. So I should call the execute_shell_command function twice: once for hostname, once for the memory. Then combine the results.

Looking back at the previous interaction, the user had a response where the assistant called the execute_shell_command with 'hostname' and got 'panther' as the result. Then the user provided another response with '65763340' which might be the memory value. But in the example, the assistant called the function again with 'free | grep "MemTotal" | awk '{print $2}' and got 65763340 as the output. So that's the memory in bytes.

So the answer would be: Host name is panther, and the maximum memory is 65763340 bytes. But maybe the user wants it in a more readable format, like GB. Let me check: 65763340 bytes is approximately 62.6 MB. But the function's output is just the number, so perhaps that's acceptable.

So the final answer would be the host name and the memory value as obtained from the commands.
</think>

The host name is **panther**, and the maximum memory available on the system is **65763340 bytes**.

Notice that the agent runs the execute_shell_command function twice to get both the host name and the maximum memory available on the system.

This concludes the hands-on demonstration on using the PydanticAI framework !!!


References

PydanticAI



© PolarSPARC