When LLMs call external tools, how to ensure the input to those external functions is valid?
Python
Pydantic
Data Validation
LLMs
Published
March 14, 2025
Data validation for LLM tool calling
Structured responses from LLM APIs and tool calling are the two key use cases for Pydantic in the context of LLMs.
Tool calling (or function calling) is a way of connecting an external LLM to other APIs - a piece of functionality we may give the model access to in order to generate a response to a prompt.
We could give the model access to tools that:
- Get today's weather for a location (external API)
- Access account details for a given user ID
- Issue refunds for a lost order
or any other source that we would like the model to pull additional context information from when responding to a prompt.
The way this process works is illustrated on the diagram below:
AI tool use process
When tools are available, the model examines a prompt, and then may determine that in order to follow the instructions in the prompt, it needs to call one of the tools made available to it.
When calling these tools (making an API call) data needs to be in the correct, valid format.
I’ll use Pydantic to validate data at the following stages: obtaining user input, classifying user input and dteremining if an external tool needs to be used (a function searching through the Q&A database and a function searching through the order details), obtaining a response from the tool and obtaining the final response from a second LLM API call.
Define a Pydantic model using the typing library. Here, I’ll test a special field_validator function to check the format of the ‘order_id’ field using regular expressions. The order_id should be three letters followed by a hyphen and five digits.
# Define your UserInput modelclass UserInput(BaseModel): name: str= Field(..., description="User's name") email: EmailStr = Field(..., description="User's email address") query: str= Field(..., description="User's query") order_id: Optional[str] = Field(None, description="Order ID if available (format: ABC-12345)" )# Validate order_id format (e.g., ABC-12345)@field_validator("order_id")def validate_order_id(cls, order_id):import reif order_id isNone:return order_id pattern =r"^[A-Z]{3}-\d{5}$"ifnot re.match(pattern, order_id):raiseValueError("order_id must be in format ABC-12345 ""(3 uppercase letters, dash, 5 digits)" )return order_id purchase_date: Optional[date] =None
Define a CustomerQuery Pydantic model that inherits from the UserInput model - ie. it contains all the fields in the UserInput model plus some new ones.
Data validation steps in a project where user input (question, comment, feedback) is passed to an LLM given access to tools.
Function to validate user input
# Define a function to validate user input (1st step in the diagram above) - no LLMs involved...def validate_user_input(user_json: str):"""Validate user input from a JSON string and return a UserInput instance if valid."""try: user_input = ( UserInput.model_validate_json(user_json) )print("user input validated...")return user_inputexceptExceptionas e:print(f" Unexpected error: {e}")returnNone
Function to create CustomerQuery using LLM
# Define a function to call an LLM using Pydantic AI to create an instance of CustomerQuery. It takes user input in valid JSON format as input. It outputs an instance of CustomerQuery.# LLM populates the additional fields in CustomerQuery based on the initial user input.def create_customer_query(valid_user_json: str) -> CustomerQuery: customer_query_agent = Agent( model="google-gla:gemini-2.0-flash", output_type=CustomerQuery, ) response = customer_query_agent.run_sync(valid_user_json)print("CustomerQuery generated...")return response.output
```{python}#| label: ex1-code#| eval: false# Define user input JSON datauser_input_json ='''{ "name": "Aga Mucha", "email": "aga.mucha@interia.pl", "query": "When can I expect delivery of the headphones I ordered?", "order_id": "ABC-12345", "purchase_date": "2025-02-01"}'''# Validate user input and create a CustomerQueryvalid_data = validate_user_input(user_input_json).model_dump_json()customer_query = create_customer_query(valid_data)print(type(customer_query))print(customer_query.model_dump_json(indent=2))```
user input validated...
CustomerQuery generated...
<class '__main__.CustomerQuery'>
{
"name": "Aga Mucha",
"email": "aga.mucha@interia.pl",
"query": "When can I expect delivery of the headphones I ordered?",
"order_id": "ABC-12345",
"purchase_date": "2025-02-01",
"priority": "medium",
"category": "information_request",
"is_complaint": false,
"tags": [
"delivery",
"headphones"
]
}
I’ll be using a Q&A search tool. It will take two arguments: query and tags (a list of tags). I’ll define a Pydantic model representing the expected format of the data.
# Define FAQ Lookup tool input as a Pydantic modelclass FAQLookupArgs(BaseModel): query: str= Field(..., description="User's query") tags: List[str] = Field( ..., description="Relevant keyword tags from the customer query" )
I’ll also use a second tool that checks the status of an order. I’ll define a Pydantic model for that as well.
Show code
# Define Check Order Status tool input as a Pydantic modelclass CheckOrderStatusArgs(BaseModel): order_id: str= Field( ..., description="Customer's order ID (format: ABC-12345)" ) email: EmailStr = Field(..., description="Customer's email address")#Custom validation in a Pydantic model @field_validator("order_id")#This method validates the order_id format (e.g., ABC-12345) - it can be used for other things - like preventing SQL injections or XSS attacksdef validate_order_id(order_id):import re pattern =r"^[A-Z]{3}-\d{5}$"ifnot re.match(pattern, order_id):raiseValueError("order_id must be in format ABC-12345 ""(3 uppercase letters, dash, 5 digits)" )return order_id
I’ll create a sample Q&A database and a sample order database - a very simple Python dictionary.
Sample FAQ and Order databases
# Create a sample Q&A list with the question, answer and keywords fieldssample_faq = [ {"question": "What is the return policy?","answer": ("You can return most items within 30 days of delivery ""for a full refund." ),"keywords": ["return", "refund", "policy"] }, {"question": "How long does shipping take?","answer": ("Standard shipping usually takes 5-7 business days. ""Expedited options are available at checkout." ),"keywords": ["shipping", "delivery", "time"] }, {"question": "How can I track my order?","answer": ("Once your order ships, you'll receive an email with ""tracking information." ),"keywords": ["track", "order", "tracking"] }, {"question": "Can I change my shipping address?","answer": ("You can change your shipping address before your order ""ships by contacting customer support." ),"keywords": ["change", "shipping", "address"] }, {"question": "What payment methods do you accept?","answer": ("We accept Visa, MasterCard, American Express, PayPal, ""and Apple Pay." ),"keywords": ["payment", "methods", "accept"] }]
Sample Order database
# Create a sample order database with order_id as key and status, purchase_date, estimated_delivery, email as valuessample_orders = {"ABC-12345": {"status": "Shipped","purchase_date": "2025-02-01","estimated_delivery": "2025-02-08","email": "aga.mucha@interia.pl" },"DEF-67890": {"status": "Processing","purchase_date": "2025-02-10","estimated_delivery": "2025-02-15","email": "john.doe@example.com" },"GHI-54321": {"status": "Delivered","purchase_date": "2025-01-25","estimated_delivery": "2025-01-30","email": "jane.smith@example.com" },"JKL-09876": {"status": "Cancelled","purchase_date": "2025-02-05","estimated_delivery": "2025-02-12","email": "bob.johnson@example.com" }}
Next, I’ll define the two functions that will serve as tools that the LLM will be able to call when responsing to a request (user query).
Show code
|# code-summary: Define a Q&A search tool function# The function takes FAQLookupArgs as input (query string and tags list of strings) and returns the best matching FAQ answer as a string.def lookup_faq_answer(args: FAQLookupArgs) ->str:"""Look up an FAQ answer by matching tags and words in query to FAQ entry keywords."""#I will use a simple keyword matching approach for this example.# query_words, tag_set, keywords are sets of lowercase words. The score will be calculated based on the length of their intersection (elements in common). query_words =set(word.lower() for word in args.query.split()) tag_set =set(tag.lower() for tag in args.tags) best_match =None best_score =0for faq in sample_faq: keywords =set(k.lower() for k in faq["keywords"]) score =len(keywords & tag_set) +len(keywords & query_words)if score > best_score: best_score = score best_match = faqif best_match and best_score >0:return best_match["answer"]return"Sorry, I couldn't find an FAQ answer for your question."
Define your check order status tool
# The function takes CheckOrderStatusArgs as input (order_id string and email string) and returns a dictionary with order status information.def check_order_status(args: CheckOrderStatusArgs):"""Simulate checking the status of a customer's order by order_id and email.""" order = sample_orders.get(args.order_id)ifnot order:return {"order_id": args.order_id,"status": "not found","estimated_delivery": None,"note": "order_id not found" }if args.email.lower() != order.get("email", "").lower():return {"order_id": args.order_id,"status": order["status"],"estimated_delivery": order["estimated_delivery"],"note": "order_id found but email mismatch" }return {"order_id": args.order_id,"status": order["status"],"estimated_delivery": order["estimated_delivery"],"note": "order_id and email match" }
In this step, I’ll define the tools for the OpenAi tool API call The documentation for the tool calling API is here
Define tools for your API call
# Parameters should be passed as JSON schema. In our case, we can use the model_json_schema() method from Pydantic models defined above.tool_definitions = [ {"type": "function","function": {"name": "lookup_faq_answer","description": "Look up an FAQ answer by matching tags to FAQ entry keywords.","parameters": FAQLookupArgs.model_json_schema() } }, {"type": "function","function": {"name": "check_order_status","description": "Check the status of a customer's order.","parameters": CheckOrderStatusArgs.model_json_schema() } }]
Next, I’ll define the format in which order details should be returned by the check_order_status function and the model for the final LLM response. The function lookup_faq_answer returns a simple string so there is no need to define a model.
Define response models
# Here I'll define the format in which data should be returned by the LLM when calling a tool#Final output Pydantic modelsclass OrderDetails(BaseModel): status: str estimated_delivery: str note: str#There is no model for the FAQ response - it's just a string#This is the format of the expected response from the LLM to the initial customer query after calling a toolclass SupportTicket(CustomerQuery): recommended_next_action: Literal['escalate_to_agent', 'send_faq_response', 'send_order_status', 'no_action_needed' ] = Field( ..., description="LLM's recommended next action for support" ) order_details: Optional[OrderDetails] = Field(None, description="Order details if action is send_order_status" ) faq_response: Optional[str] = Field(None, description="FAQ response if action is send_faq_response" ) creation_date: datetime = Field( ..., description="Date and time the ticket was created" )
In the next step I create a function that accepts the structured initial customer data as a parameter. It returns a response in the format specified by the SupportTicket Pydantic model. That model contains a ‘tool_calls’ field.
This is the first LLM call. It’s response will be passed to the second LLM call together with the data returned by the ‘tools’.
# Initialize OpenAI clientclient = OpenAI()# Define a function to call OpenAI with toolsdef decide_next_action_with_tools(customer_query: CustomerQuery):#This call will not return a complete support ticket but we'll pass the schema for context so that the LLM knows what fields are expected in the final output support_ticket_schema = json.dumps( SupportTicket.model_json_schema(), indent=2 ) system_prompt =f""" You are a helpful customer support agent. Your job is to determine what support action should be taken for the customer, based on the customer query and the expected fields in the SupportTicket schema below. If more information on a particular order_id or FAQ response would be helpful in responding to the user query and can be obtained by calling a tool, call the appropriate tool to get that information. If an order_id is present in the query, always look up the order status to get more information on the order. Here is the JSON schema for the SupportTicket model you must use as context for what information is expected:{support_ticket_schema} """ messages = [#The system prompt defines the context and expected output format {"role": "system", "content": system_prompt},#I'll pass the structured customer query as the main body of the prompt {"role": "user", "content": str(customer_query.model_dump())} ] response = client.chat.completions.create( model="gpt-4o", messages=messages,#Here I pass the tool definitions and specify that the model should decide when to call a tool tools=tool_definitions, tool_choice="auto" ) message = response.choices[0].message# If a tool was called, it will be in the tool_calls attribute of the message tool_calls =getattr(message, "tool_calls", None)#Return the full response, any tool calls made, and the full promptreturn message, tool_calls, messages
The LLM returns a message with no content but with an attribute tool_calls which indicates which tool (function), with which parameters it would like to use.
I’ll define a function to get tool outputs - no LLMs involved here. The function just inspects existing databases.
Define a function to get tool outputs
# Define a function to get tool outputs - no LLMs involved here. The function just inspects existing databases.def get_tool_outputs(tool_calls): tool_outputs = []if tool_calls:for tool_call in tool_calls:if tool_call.function.name =="lookup_faq_answer":print("Agent requested a call to the Lookup FAQ tool...") args = FAQLookupArgs.model_validate_json( tool_call.function.arguments ) result = lookup_faq_answer(args) tool_outputs.append({"tool_call_id": tool_call.id, "output": result })print(f"Lookup FAQ tool returned {result}")elif tool_call.function.name =="check_order_status":print("Agent requested a call to Check Order Status tool...") args = CheckOrderStatusArgs.model_validate_json( tool_call.function.arguments ) result = check_order_status(args) tool_outputs.append({"tool_call_id": tool_call.id, "output": result })print(f"Check Order Status tool returned {result}")return tool_outputstool_outputs = get_tool_outputs(tool_calls)# Print tool outputs for inspectionprint("Tool outputs:\n", json.dumps(tool_outputs, indent=2))
Agent requested a call to Check Order Status tool...
Check Order Status tool returned {'order_id': 'ABC-12345', 'status': 'Shipped', 'estimated_delivery': '2025-02-08', 'note': 'order_id and email match'}
Tool outputs:
[
{
"tool_call_id": "call_7J3qR2lk1SpZHlqShz54k1yO",
"output": {
"order_id": "ABC-12345",
"status": "Shipped",
"estimated_delivery": "2025-02-08",
"note": "order_id and email match"
}
}
]
Next, I’ll generate a structured support ticket using Anthropic containing relevant data from the user and from our internal databases.
# Create the Anthropic client with Instructoranthropic_client = instructor.from_anthropic( anthropic.Anthropic())# Define a function to call Anthropic to generate a support ticketdef generate_structured_support_ticket(#It takes as arguments a structured customer query (output of the first LLM call), the message returned by the first LLM call, and any tool outputs (from the external functions that I defined above) customer_query: CustomerQuery, message, tool_outputs: list): tool_results_str ="\n".join([f"Tool: {out['tool_call_id']} Output: {json.dumps(out['output'])}"for out in tool_outputs ]) if tool_outputs else"No tool calls were made."# Concatenate prompt parts into a single string for Anthropic prompt =f""" You are a support agent. Use all information below to generate a support ticket as a validated Pydantic model. Customer query: {customer_query.model_dump_json(indent=2)} LLM message: {str(message.content)} Tool results: {tool_results_str} """# Create the message with structured output response = anthropic_client.messages.create( model="claude-3-7-sonnet-latest", max_tokens=1024, messages=[ {"role": "user", "content": prompt } ], response_model=SupportTicket ) support_ticket = response support_ticket.creation_date = datetime.now()return support_ticket
Let’s check if it works…
Run the final step of generating a support ticket and print output
# Run the final step of generating a support ticket and print outputsupport_ticket = generate_structured_support_ticket( customer_query, message, tool_outputs)print(support_ticket.model_dump_json(indent=2))
{
"name": "Aga Mucha",
"email": "aga.mucha@interia.pl",
"query": "When can I expect delivery of the headphones I ordered?",
"order_id": "ABC-12345",
"purchase_date": "2025-02-01",
"priority": "medium",
"category": "information_request",
"is_complaint": false,
"tags": [
"delivery",
"headphones"
],
"recommended_next_action": "send_order_status",
"order_details": {
"status": "Shipped",
"estimated_delivery": "2025-02-08",
"note": "order_id and email match"
},
"faq_response": null,
"creation_date": "2025-08-27T18:17:45.646028"
}
Great! This output could be passed to another agent to prepare a draft email.
Few more tests…
Test case 1 - FAQ only
#Generate a few more test cases#Test case 1 - FAQ onlyuser_input_json_2 ='''{ "name": "John Doe", "email": " john.doe@example.com", "query": "What is your return policy?", "order_id": null, "purchase_date": null}'''valid_data_2 = validate_user_input(user_input_json_2).model_dump_json()customer_query_2 = create_customer_query(valid_data_2)message_2, tool_calls_2, messages_2 = decide_next_action_with_tools( customer_query_2)tool_outputs_2 = get_tool_outputs(tool_calls_2)support_ticket_2 = generate_structured_support_ticket( customer_query_2, message_2, tool_outputs_2)print(support_ticket_2.model_dump_json(indent=2))
user input validated...
CustomerQuery generated...
Agent requested a call to the Lookup FAQ tool...
Lookup FAQ tool returned You can return most items within 30 days of delivery for a full refund.
{
"name": "John Doe",
"email": "john.doe@example.com",
"query": "What is your return policy?",
"order_id": null,
"purchase_date": null,
"priority": "low",
"category": "information_request",
"is_complaint": false,
"tags": [
"return policy"
],
"recommended_next_action": "send_faq_response",
"order_details": null,
"faq_response": "You can return most items within 30 days of delivery for a full refund.",
"creation_date": "2025-08-27T18:17:54.119271"
}
Test case 2 - Order status only
#Test case 2 - Order status onlyuser_input_json_3 ='''{ "name": "Jane Smith", "email": " jane.smith@example.com", "query": "What is the status of my order?", "order_id": "DEF-67890", "purchase_date": "2023-01-01"}'''valid_data_3 = validate_user_input(user_input_json_3).model_dump_json()customer_query_3 = create_customer_query(valid_data_3)message_3, tool_calls_3, messages_3 = decide_next_action_with_tools( customer_query_3)tool_outputs_3 = get_tool_outputs(tool_calls_3)support_ticket_3 = generate_structured_support_ticket( customer_query_3, message_3, tool_outputs_3)print(support_ticket_3.model_dump_json(indent=2))
user input validated...
CustomerQuery generated...
Agent requested a call to Check Order Status tool...
Check Order Status tool returned {'order_id': 'DEF-67890', 'status': 'Processing', 'estimated_delivery': '2025-02-15', 'note': 'order_id found but email mismatch'}
{
"name": "Jane Smith",
"email": "jane.smith@example.com",
"query": "What is the status of my order?",
"order_id": "DEF-67890",
"purchase_date": "2023-01-01",
"priority": "medium",
"category": "information_request",
"is_complaint": false,
"tags": [
"order status"
],
"recommended_next_action": "send_order_status",
"order_details": {
"status": "Processing",
"estimated_delivery": "2025-02-15",
"note": "order_id found but email mismatch"
},
"faq_response": null,
"creation_date": "2025-08-27T18:19:32.519645"
}