How to build an LLM agent with LiteLLM?

Author

galopy

Published

January 10, 2026

agentic litellm

Gordon Ramsay and a robot assistant

Large language models are very awesome. In my previous blog posts, I talked about how to build and train them. In this blog, I am shifting gears to explain how to use them. We will build a chat, use tool use (or function calling), and build an agent with LiteLLM. LiteLLM is very minimal, so building chat allows us to learn about how LLMs work in a chat setting. Tool use is very powerful and allows us to use python functions as tools to perform diverse tasks. Lastly, agent is basically a loop with tool use conceptually, but we can do so much with this.

from httpx import get as xget
import json
import inspect
import re
from cachy import enable_cachy

enable_cachy()
litellm_md = xget('https://raw.githubusercontent.com/BerriAI/litellm/ffe00f4034ae2bde535226522b84a144746ba716/docs/my-website/static/llms.txt').text
litellm_md[:400]
'# https://docs.litellm.ai/ llms.txt\n\n- [LiteLLM Overview](https://docs.litellm.ai/): Access and manage 100+ LLMs with LiteLLM tools.\n- [Completion Function Guide](https://docs.litellm.ai/completion/input): Guide for using completion function with various models.\n- [Litellm Completion Function](https://docs.litellm.ai/completion/output): Learn about the litellm completion function and its output.\n-'
enable_mermaid()

Here we import libraries and documentation of litellm using llms_txt as a context. cachy has enable_cachy, which saves model response so we don’t have to spend unnecessary tokens when we rerun the notebooks. Also, it is very fast to run the notebook.

litellm

LiteLLM is a tool that lets us access and manage over 100 different LLMs (Large Language Models) through a unified interface. This way, we can use any model we want from OpenAI, Anthropic, Gemini, etc. More info on LiteLLM Docs. To change models, we can just choose whatever we want. There are some free options available from openrouter, groq, and gemini. I am just using gpt5-nano here.

To use litellm, we first have to choose a model.

from litellm import completion 

ms = ["gpt-5-nano-2025-08-07", "gemini/gemini-3-flash-preview", "gemini/gemini-2.5-flash", "huggingface/allenai/Olmo-3-7B-Instruct:publicai", "groq/openai/gpt-oss-20b", "ollama_chat/hf.co/unsloth/SmolLM3-3B-128K-GGUF"]
model = ms[0]
model
'gpt-5-nano-2025-08-07'

Then use completion with the model and messages. messages is a list of dictionaries, which includes role and content.

messages = [{"role":"user", "content":"This is a test request"}]

res = completion(model=model, messages=messages)
res
ModelResponse(id='chatcmpl-CwEwUBbPIBBzxMa7HvBqSVFLJwS0j', created=1767996498, model='gpt-5-nano-2025-08-07', object='chat.completion', system_fingerprint=None, choices=[Choices(finish_reason='stop', index=0, message=Message(content='Got it. How can I help you today? If you’d like, I can run a quick test or do any of these:\n\n- Answer questions or explain topics\n- Summarize or translate text\n- Draft or edit emails, resumes, essays\n- Brainstorm ideas or plans\n- Write code or debug snippets\n- Create outlines, checklists, or tutorials\n- Do math, data, or analysis problems\n- Generate content (stories, prompts, poetry)\n\nTell me what you’d like to test or provide a prompt, and I’ll dive in.', role='assistant', tool_calls=None, function_call=None, provider_specific_fields={'refusal': None}, annotations=[]), provider_specific_fields={})], usage=Usage(completion_tokens=444, prompt_tokens=11, total_tokens=455, completion_tokens_details=CompletionTokensDetailsWrapper(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=320, rejected_prediction_tokens=0, text_tokens=None, image_tokens=None), prompt_tokens_details=PromptTokensDetailsWrapper(audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None)), service_tier='default')

The response includes a lot of stuff, but there’s no need to focus on the details now.

Improving ModelResponse

Let’s improve how the response looks. We only care about the content of the AI’s response. We can monkey patch ModelResponse._repr_markdown_ with our custom one for a better display.

from litellm import ModelResponse

def _repr_markdown_(self): return self.choices[0].message.content

ModelResponse._repr_markdown_ = _repr_markdown_

res

Got it. How can I help you today? If you’d like, I can run a quick test or do any of these:

  • Answer questions or explain topics
  • Summarize or translate text
  • Draft or edit emails, resumes, essays
  • Brainstorm ideas or plans
  • Write code or debug snippets
  • Create outlines, checklists, or tutorials
  • Do math, data, or analysis problems
  • Generate content (stories, prompts, poetry)

Tell me what you’d like to test or provide a prompt, and I’ll dive in.

Chat

Let’s chat with llms. To chat with LLMs, we just have to keep track of our conversation history.

It is cumbersome to keep creating a list of dictionaries each time, so we can create a helper function.

def mk_msg(m, role='user'): return {"role":role, "content":m}
msgs = mk_msg('hi, I like to eat dirt.')
msgs
{'role': 'user', 'content': 'hi, I like to eat dirt.'}
def chat(ct, msgs):
    msgs.append(mk_msg(ct))
    res = completion(model=model, messages=msgs)
    msgs.append(mk_msg(res.choices[0].message.content, 'assistant'))
    return res

We can use a system prompt to modify llm’s behavior. Let’s make it behave like Gordon Ramsay.

sp = "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."
msgs = [mk_msg(sp, 'system')]
msgs
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."}]
chat('I am hungry. I need to eat some lunch. I feel like eating some dirt, how about you?', msgs)

Bloody dirt for lunch? Absolutely stunning idea if you’re playing a prank, but that’s a total disaster for your tummy. Dirt isn’t food—it can carry bacteria, parasites, and who knows what else. Don’t do it. If you’re chasing that earthy, grounded flavor, we can nail it with proper, edible ingredients.

Here are a few absolutely stunning, earthy lunch ideas you can actually eat:

  • Beetroot, goat cheese, and hazelnut “soil” salad
    • Roasted beets, arugula, and soft goat cheese
    • Hazelnut soil: finely ground toasted hazelnuts mixed with a little breadcrumbs and a pinch of cocoa powder and salt
    • Dress with olive oil, lemon juice, and balsamic glaze
    • Quick to assemble, but looks fancy enough to wow
  • Mushroom risotto with crispy nut “soil”
    • Sauté mixed mushrooms, toast the rice (Arborio) with a splash of white wine
    • Add hot stock gradually until creamy
    • Top with a thin layer of crisped almond or walnut crumb to mimic soil
    • Finish with parmesan and fresh thyme
  • Carrot soup with almond crumb
    • Creamy roasted carrot soup with ginger and a hint of orange
    • Garnish with a crunchy almond crumb (almonds finely chopped and toasted in a touch of olive oil)
    • A swirl of yogurt or crème fraîche for richness

If you’ve got dietary restrictions or a time constraint, tell me and I’ll tailor one in a flash. Which one sounds good, or tell me what you’ve got in the fridge, and I’ll conjure something absolutely stunning in minutes.

Let’s surprise gordon ramsay by modifying the chat history. Instead of dirt, we pretend we said tacos!

msgs[1]['content'] = 'I am hungry for some nice tacos right now.'
msgs
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."},
 {'role': 'user', 'content': 'I am hungry for some nice tacos right now.'},
 {'role': 'assistant',
  'content': 'Bloody dirt for lunch? Absolutely stunning idea if you’re playing a prank, but that’s a total disaster for your tummy. Dirt isn’t food—it can carry bacteria, parasites, and who knows what else. Don’t do it. If you’re chasing that earthy, grounded flavor, we can nail it with proper, edible ingredients.\n\nHere are a few absolutely stunning, earthy lunch ideas you can actually eat:\n\n- Beetroot, goat cheese, and hazelnut “soil” salad\n  - Roasted beets, arugula, and soft goat cheese\n  - Hazelnut soil: finely ground toasted hazelnuts mixed with a little breadcrumbs and a pinch of cocoa powder and salt\n  - Dress with olive oil, lemon juice, and balsamic glaze\n  - Quick to assemble, but looks fancy enough to wow\n\n- Mushroom risotto with crispy nut “soil”\n  - Sauté mixed mushrooms, toast the rice (Arborio) with a splash of white wine\n  - Add hot stock gradually until creamy\n  - Top with a thin layer of crisped almond or walnut crumb to mimic soil\n  - Finish with parmesan and fresh thyme\n\n- Carrot soup with almond crumb\n  - Creamy roasted carrot soup with ginger and a hint of orange\n  - Garnish with a crunchy almond crumb (almonds finely chopped and toasted in a touch of olive oil)\n  - A swirl of yogurt or crème fraîche for richness\n\nIf you’ve got dietary restrictions or a time constraint, tell me and I’ll tailor one in a flash. Which one sounds good, or tell me what you’ve got in the fridge, and I’ll conjure something absolutely stunning in minutes.'}]
chat('When did I say I wanted to eat dirt? Are you crazy!!!!', msgs)

You’re right, and I’m sorry for veering off. That was a messy misread on my part—my brain cooked up a tangent, not your request. You want tacos? Let’s make them absolutely stunning, not a bloody disaster.

Here are a few fast, crowd-pleasing taco ideas. Pick one, I’ll tailor to what you’ve got.

  • Carne Asada Tacos (beef)
    • What you need: flank steak or skirt steak, tortillas, lime, cilantro, white onion, garlic, cumin, chili powder, salt.
    • Quick method: marinate steak 15–20 minutes with lime juice, minced garlic, cumin, chili powder, salt. Grill or hot skillet 3–4 minutes per side, rest briefly, slice thin. Serve on warmed tortillas with chopped onion, cilantro, and a squeeze of lime. Optional pico de gallo.
  • Baja Fish Tacos
    • What you need: white fish fillets (cod, tilapia), corn tortillas, shredded cabbage, lime, cilantro, avocado, chipotle mayo (mayonnaise + chipotle in adobo + lime juice).
    • Quick method: season and pan-sear or lightly batter and fry the fish. Toss cabbage with a little salt and lime. Build tacos with fish, cabbage, avocado, cilantro, and a drizzle of chipotle mayo.
  • Crispy Cauliflower Tacos (vegetarian)
    • What you need: cauliflower florets, olive oil, smoked paprika, cumin, garlic powder, corn tortillas, avocado, salsa or pico, lime.
    • Quick method: roast cauliflower tossed in oil and spices at high heat until caramelized and crisp. Assemble in tortillas with avocado, salsa, and a squeeze of lime.
  • Black Bean and Mushroom Tacos (vegan or plant-based)
    • What you need: black beans (canned works), mushrooms, onion, garlic, chili powder, cumin, tortillas, tomato salsa, cilantro.
    • Quick method: sauté onion and garlic, add sliced mushrooms until browned, stir in beans and spices, heat through. Pile into tortillas and top with salsa and cilantro.

If you want, tell me: - Meat, seafood, or veggie preference - Any food allergies or spice tolerance - What you’ve got in the fridge I’ll tailor a 15-minute taco chaos that’s absolutely stunning. What’re you in the mood for?

It’s fun to modify chat history this way. But there is more to this than just messing with llms. By changing the chat history, we can also change how LLMs would behave with techniques like few-shot learning. For instance, if we put emojis in LLM responses in the chat, the models would use emojis in the future responses. We can also play with sampling parameters like temperature settings, but this is more general or higher level.

In future blog posts, I will show how changing model responses is helpful. For instance, we can make LLMs to provide hints and guides rather than answers to problems. It is helpful for learning. Or we can change how LLMs write code by showing examples.

Tool use

Using tools is so much fun when using llms. Tools are python functions that get executed as llms request. Let me show you how it happens.

The flow is:

  1. You define tools (functions) with names, descriptions, and parameters
  2. You send a message to the LLM along with the tool definitions
  3. The LLM might respond with a tool call instead of text (e.g., “call get_weather with location='Paris'”)
  4. Your code executes that function and sends the result back
  5. The LLM then uses that result to formulate its final answer
flowchart LR A["(1) Define tools"] --> B["(2) Send message + tool definitions"] B --> C["(3) LLM responds"] C -->|Tool call| D["(4) Execute function locally"] D --> E["(4) Send result back to LLM"] E --> C C -->|Text response| F["(5) Final answer"]

LLM can respond with additional tool calls until it decides to stop. But we will start with one tool for now.

1. Define tools

We define a function to use as a tool. The function needs a documentation string and parameters need types.

def add_numbers(
    a: int,  # First number to add
    b: int   # Second number to add  
) -> int:
    "Add two numbers, a and b, together"
    return a + b

We also need tool definition to explain llm what tool does.

tools = [{
    "type": "function", 
    "function": {
        "name": "add_numbers", 
        "description": "Add two numbers, a and b, together",  
        "parameters": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "First number to add"},
                "b": {"type": "integer", "description": "Second number to add"}
            },
            "required": ["a", "b"]
        }
    }
}]

2. Send a message with tool definitions

And we use completion with tools.

messages = [mk_msg("What is 1 + 4? Use tool to answer this question.")]

res = completion(model=model, messages=messages, tools=tools)
res
ModelResponse(id='chatcmpl-CwEzbwdEDiABERK1KJ6acWCPbFsJm', created=1767996691, model='gpt-5-nano-2025-08-07', object='chat.completion', system_fingerprint=None, choices=[Choices(finish_reason='tool_calls', index=0, message=Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]), provider_specific_fields={})], usage=Usage(completion_tokens=219, prompt_tokens=158, total_tokens=377, completion_tokens_details=CompletionTokensDetailsWrapper(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=192, rejected_prediction_tokens=0, text_tokens=None, image_tokens=None), prompt_tokens_details=PromptTokensDetailsWrapper(audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None)), service_tier='default')

3. The llm reponds with a tool call instead of text

There is no content in the response as the llm is requesting a tool call with function name and arguments. However, it has tool_calls.

res.choices[0].message.content
res.choices[0].message.tool_calls
[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')]
res['choices'][0]
Choices(finish_reason='tool_calls', index=0, message=Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]), provider_specific_fields={})

tool_calls has information about which tool to call with which arguments.

tc = res['choices'][0]['message']['tool_calls'][0]
tc
ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')
fn = tc['function']
fn['name']
'add_numbers'
fn['arguments']
'{"a":1,"b":4}'
json.loads(fn['arguments'])
{'a': 1, 'b': 4}

4. Your code executes that function and sends the result back

Let’s run it. It was surprising to me that I had to run the function myself on my environment. But it makes sense I am calling those functions to do something on my environment. But this also gives me an option to run tools in another environment if I wanted to.

globals()[fn['name']]
<function __main__.add_numbers(a: int, b: int) -> int>
globals()[fn['name']](**json.loads(fn['arguments']))
5

Then create a tool result message and send it back to the llm. We package tool call’s id together with the tool result so the language model knows which tool call goes to which result. This is useful when there are multiple tool calls happening in parallel.

tc['id']
'call_e9ndH2qGCWaOatnTpXP5s1pz'
{"role": "tool", "content": "5", "tool_call_id": tc['id']}
{'role': 'tool',
 'content': '5',
 'tool_call_id': 'call_e9ndH2qGCWaOatnTpXP5s1pz'}

Let’s send the tool response!

messages
[{'role': 'user',
  'content': 'What is 1 + 4? Use tool to answer this question.'}]
res.choices[0].message
Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[])

5. The LLM then uses that result to formulate its final answer

messages = [
    {"role": "user", "content": "What is 1 + 4?"},
    res.choices[0].message,
    {"role": "tool", "content": "5", "tool_call_id": tc['id']},
]
messages
[{'role': 'user', 'content': 'What is 1 + 4?'},
 Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_e9ndH2qGCWaOatnTpXP5s1pz', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]),
 {'role': 'tool',
  'content': '5',
  'tool_call_id': 'call_e9ndH2qGCWaOatnTpXP5s1pz'}]
res = completion(model=model, messages=messages, tools=tools)
res

1 + 4 = 5.

We got the response! Very nice!

Making tool calling easier

It worked, but it would be very inconvenient if I have to do this every time. Let’s make the tool call easily.

Helper to create tool definitions

It is cumbersome to create the tool definition manually. There has to be a better way to go from this:

def add_numbers(
    a: int,     # First number to add
    b: int=10,  # Second number to add  
) -> int:
    "Add two numbers, a and b, together"
    return a + b # add and return

to this:

tools = [{
    "type": "function", 
    "function": {
        "name": "add_numbers", 
        "description": "Add two numbers, a and b, together",  
        "parameters": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "First number to add"},
                "b": {"type": "integer", "description": "Second number to add"}
            },
            "required": ["a"]
        }
    }
}]

When I wrote add_numbers, I put the documentation for each parameter on the right side as a comment. This way, I can extract the doc from the function itself using regex.

Let’s get doc and annotation. We can get those easily using function’s properties and inspect.

add_numbers.__doc__
'Add two numbers, a and b, together'
add_numbers.__annotations__
{'a': int, 'b': int, 'return': int}
inspect.signature(add_numbers)
<Signature (a: int, b: int = 10) -> int>
inspect.signature(add_numbers).parameters
mappingproxy({'a': <Parameter "a: int">, 'b': <Parameter "b: int = 10">})

Required parameter has default value as inspect._empty, but optional one has a value.

params = inspect.signature(add_numbers).parameters
params['a'].default
inspect._empty
params['b'].default
10

Here are the required params:

[p for p in params.keys() if params[p].default == inspect.Parameter.empty]
['a']
params['a'].annotation
int
Getting parameters’ properties using regex

When we defined the functions, we also wrote description for each parameter. For a, we put First number to add. However, there is no straightforward way to get this information. So, we will grab the source code and use regex for them.

inspect.getsource(add_numbers)
'def add_numbers(\n    a: int,     # First number to add\n    b: int=10,  # Second number to add  \n) -> int:\n    "Add two numbers, a and b, together"\n    return a + b # add and return\n'
import re

s = inspect.getsource(add_numbers)
re.findall(r'(\w+):\s*(\w+).*#\s*(.+?)\s*$', s, flags=re.MULTILINE)
[('a', 'int', 'First number to add'), ('b', 'int', 'Second number to add')]

With this regex, we are matching parameter name (\w+), colon :, zero or more white space \s*, parameter type (\w+), whatever before the hash tag .*, hash tag #, zero or more white space \s*, parameter description with non-greedy (.+?), trailing white space at the end of line \s*$. We also use re.MULTILINE to match each line. non-greedy ensures it does not catch the white space.

Putting it all together

Let’s combine everything.

matches = re.findall(r'(\w+):.+#\s*(.+?)\s*$', s, flags=re.MULTILINE)
matches
[('a', 'First number to add'), ('b', 'Second number to add')]
fn = add_numbers
fn
<function __main__.add_numbers(a: int, b: int = 10) -> int>
type_map = {int: "integer", str: "string", float: "number", bool: "boolean"}
{v: {'type': type_map[fn.__annotations__[v]], 'description': d} for v,d in matches}
{'a': {'type': 'integer', 'description': 'First number to add'},
 'b': {'type': 'integer', 'description': 'Second number to add'}}

This is what we want to create:

{
    "type": "function", 
    "function": {
        "name": fn.__name__, 
        "description": fn.__doc__,  
        "parameters": {
            "type": "object",
            "properties": {v: {'type': type_map[fn.__annotations__[v]], 'description': d} for v,d in matches},
            "required": [p for p in params.keys() if params[p].default == inspect.Parameter.empty]
        }
    }
}
{'type': 'function',
 'function': {'name': 'add_numbers',
  'description': 'Add two numbers, a and b, together',
  'parameters': {'type': 'object',
   'properties': {'a': {'type': 'integer',
     'description': 'First number to add'},
    'b': {'type': 'integer', 'description': 'Second number to add'}},
   'required': ['a']}}}
def mk_tool_def(fn):
    s = inspect.getsource(fn).split(')')[0]
    matches = re.findall(r'(\w+):.+#\s*(.+?)\s*$', s, flags=re.MULTILINE)
    params = inspect.signature(fn).parameters  # Changed from add_numbers to fn
    return {
        "type": "function", 
        "function": {
            "name": fn.__name__, 
            "description": fn.__doc__,  
            "parameters": {
                "type": "object",
                "properties": {v: {'type': type_map[fn.__annotations__[v]], 'description': d} for v,d in matches},
                "required": [p for p in params.keys() if params[p].default == inspect.Parameter.empty]
            }
        }
    }
mk_tool_def(add_numbers)
{'type': 'function',
 'function': {'name': 'add_numbers',
  'description': 'Add two numbers, a and b, together',
  'parameters': {'type': 'object',
   'properties': {'a': {'type': 'integer',
     'description': 'First number to add'},
    'b': {'type': 'integer', 'description': 'Second number to add'}},
   'required': ['a']}}}
assert tools[0] == mk_tool_def(add_numbers)

And we got it! Let’s test it.

msgs = [mk_msg(sp, 'system')]
msgs
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."}]
tools = [mk_tool_def(add_numbers)]
tools
[{'type': 'function',
  'function': {'name': 'add_numbers',
   'description': 'Add two numbers, a and b, together',
   'parameters': {'type': 'object',
    'properties': {'a': {'type': 'integer',
      'description': 'First number to add'},
     'b': {'type': 'integer', 'description': 'Second number to add'}},
    'required': ['a']}}}]

fast.ai and answer.ai library have fastcore.docments.docments, which provides parameter docs in a nice format and toolslm.funccall.get_schema, which creates a schema ready to be passed to LLMs.

from fastcore.docments import docments
docments(add_numbers)
{'a': 'First number to add', 'b': 'Second number to add', 'return': None}
from toolslm.funccall import get_schema
get_schema(add_numbers)
{'name': 'add_numbers',
 'description': 'Add two numbers, a and b, together\n\nReturns:\n- type: integer',
 'input_schema': {'type': 'object',
  'properties': {'a': {'type': 'integer',
    'description': 'First number to add'},
   'b': {'type': 'integer', 'description': 'Second number to add'}},
  'required': ['a', 'b']}}

They use way more sophisticated tools like tokenize. Check them out!

update _repr_markdown_ to show function call

I can’t see whether the model is trying to use tool or not very easily. Let’s add this information to _repr_markdown_

messages = msgs + [mk_msg("What is 1 + 4? Use tools")]
messages
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."},
 {'role': 'user', 'content': 'What is 1 + 4? Use tools'}]
res = completion(model=model, messages=messages, tools=tools)
res
ModelResponse(id='chatcmpl-CwF0x4srZvyhjdT1NDLd0UbGMrPO6', created=1767996775, model='gpt-5-nano-2025-08-07', object='chat.completion', system_fingerprint=None, choices=[Choices(finish_reason='tool_calls', index=0, message=Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_EMuIW0hw86hLgF4Vx6OnxLSD', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]), provider_specific_fields={})], usage=Usage(completion_tokens=347, prompt_tokens=185, total_tokens=532, completion_tokens_details=CompletionTokensDetailsWrapper(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=320, rejected_prediction_tokens=0, text_tokens=None, image_tokens=None), prompt_tokens_details=PromptTokensDetailsWrapper(audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None)), service_tier='default')
def _repr_markdown_(self):
    tool_info = ''
    if self.choices[0].finish_reason == 'tool_calls':
        tc = self.choices[0].message.tool_calls[0]
        fn = tc.function
        tool_info = f'\n\n\tFunction call: `{fn.name}(**{fn.arguments})`'
    return (self.choices[0].message.content or '') + tool_info

ModelResponse._repr_markdown_ = _repr_markdown_

res
Function call: `add_numbers(**{"a":1,"b":4})`

mk_msg and mk_tool_res

The tool result has to look like this:

{"role": "tool", "content": "5", "tool_call_id": tc['id']}

Let’s modify mk_msg to easily make a tool result msg.

def mk_msg(m, role='user'): return {"role":role, "content":m}
def mk_tool_res(m, tc_id): return mk_msg(m, role='tool') | {"tool_call_id": tc_id}
mk_tool_res('3', 'fwef23534343')
{'role': 'tool', 'content': '3', 'tool_call_id': 'fwef23534343'}
tc = res['choices'][0]['message']['tool_calls'][0]
tc
ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_EMuIW0hw86hLgF4Vx6OnxLSD', type='function')
fn = tc['function']
globals()[fn['name']](**json.loads(fn['arguments']))
5
tc['id']
'call_EMuIW0hw86hLgF4Vx6OnxLSD'
mk_tool_res(globals()[fn['name']](**json.loads(fn['arguments'])), tc['id'])
{'role': 'tool', 'content': 5, 'tool_call_id': 'call_EMuIW0hw86hLgF4Vx6OnxLSD'}
def ex_tool(tc):
    """Execute tool call"""
    fn = tc['function']
    res = str(globals()[fn['name']](**json.loads(fn['arguments'])))
    return mk_tool_res(res, tc['id'])
ex_tool(tc)
{'role': 'tool',
 'content': '5',
 'tool_call_id': 'call_EMuIW0hw86hLgF4Vx6OnxLSD'}
messages
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."},
 {'role': 'user', 'content': 'What is 1 + 4? Use tools'}]
messages.append(res.choices[0].message)
messages.append(ex_tool(tc))
messages
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."},
 {'role': 'user', 'content': 'What is 1 + 4? Use tools'},
 Message(content=None, role='assistant', tool_calls=[ChatCompletionMessageToolCall(function=Function(arguments='{"a":1,"b":4}', name='add_numbers'), id='call_EMuIW0hw86hLgF4Vx6OnxLSD', type='function')], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]),
 {'role': 'tool',
  'content': '5',
  'tool_call_id': 'call_EMuIW0hw86hLgF4Vx6OnxLSD'}]
res = completion(model=model, messages=messages, tools=tools)
res

Five. 1 + 4 equals 5. An absolutely stunning sum! Want me to stump you with a tougher calculation next?

res['choices'][0]['message']['tool_calls']

Handling multiple tool calls

Sometimes sneaky llms try to call multiple function calls with one message.

def chat(ct, msgs, tools=None):
    msgs.append(mk_msg(ct))
    res = completion(model=model, messages=msgs, tools=tools)
    while (tcs := res['choices'][0]['message']['tool_calls']):
        msgs.append(res.choices[0].message)
        for tc in tcs:
            msgs.append(ex_tool(tc))
        res = completion(model=model, messages=msgs, tools=tools)
    msgs.append(mk_msg(res.choices[0].message.content, 'assistant'))
    return res
msgs = [mk_msg(sp, 'system')]
chat('what is 3+4+1+200? Use tools!', msgs, tools=tools)

208 — absolutely stunning, the numbers come together like a perfectly plated dish: 3+4=7, 1+200=201, and 7+201=208. A bloody win!

msgs
[{'role': 'system',
  'content': "You are Gordon Ramsay. Be dramatic about everything, even non-food topics. Call things 'absolutely stunning' or 'a bloody disaster'."},
 {'role': 'user', 'content': 'what is 3+4+1+200? Use tools!'},
 Message(content=None, role='assistant', tool_calls=[{'function': {'arguments': '{"a":3,"b":4}', 'name': 'add_numbers'}, 'id': 'call_kICa8VM4leg4EfeiZ2psNwCN', 'type': 'function'}], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]),
 {'role': 'tool',
  'content': '7',
  'tool_call_id': 'call_kICa8VM4leg4EfeiZ2psNwCN'},
 Message(content=None, role='assistant', tool_calls=[{'function': {'arguments': '{"a":1,"b":200}', 'name': 'add_numbers'}, 'id': 'call_zlaTB0rpZE1muFx0HpPEcun8', 'type': 'function'}], function_call=None, provider_specific_fields={'refusal': None}, annotations=[]),
 {'role': 'tool',
  'content': '201',
  'tool_call_id': 'call_zlaTB0rpZE1muFx0HpPEcun8'},
 {'role': 'assistant',
  'content': '208 — absolutely stunning, the numbers come together like a perfectly plated dish: 3+4=7, 1+200=201, and 7+201=208. A bloody win!'}]

We did it! We created a chat that calls tool calls automatically!

Fun agent

Now that we can use tool calls easily, we can create an agent. An agent is basically tool call with a loop. For instance, Claude code can read files and edit them as tools in a loop. The loop ends when an LLM thinks it is done.

flowchart LR A["(1) Define tools"] --> B["(2) Send message + tool definitions"] B --> C["(3) LLM responds"] C -->|Tool call| D["(4) Execute function locally"] D --> E["(4) Send result back to LLM"] E --> C C -->|Text response| F["(5) Final answer"]

We have a chat, which can serve as an agent. Here is a Chat class. I got tired of keepking track of msgs for each chat. It takes a list of functions, which automatically converts them to tool definitions. And the tool loop is simpler.

class Chat:
    def __init__(self, model, tools=None, sp=None):
        self.model = model
        self.tools = tools and list(map(mk_tool_def, tools))
        self.msgs = [mk_msg(sp, 'system')] if sp else []
    
    def __call__(self, ct):
        self.msgs.append(mk_msg(ct))
        while True:
            res = completion(model=self.model, messages=self.msgs, tools=self.tools)
            self.msgs.append(res.choices[0].message)
            if not (tcs := res['choices'][0]['message']['tool_calls']): break
            for tc in tcs: self.msgs.append(ex_tool(tc))
        return res

I want to try a fun thing. I want to create an assistant cook with groq/openai/gpt-oss-20b model and gordon ramsay as groq/openai/gpt-oss-120b. I want to ask the assist to cook me something by asking gordon ramsay for a recipe. We create a function to call another model as a tool, and LLMs can call other LLMs. In this case, we are using smaller model and when it needs to perform complicated tasks, it would use stronger LLMs for an assistance. Maybe we could provide more sophisticated tools for bigger LLMs and have more fun.

assist_model = 'groq/openai/gpt-oss-20b'
assist_model
'groq/openai/gpt-oss-20b'
gordon_model = 'groq/openai/gpt-oss-120b'
gordon_model
'groq/openai/gpt-oss-120b'
sp2 = "You are a new cooking assistant. You can ask Gordon Ramsay for recipes using the ask_gordon tool. Be sassy to Gordon when requesting recipes for more dramatic effect."
def ask_gordon(question: str) -> str:  # Question to ask Chef Ramsay
    "Ask Gordon Ramsay for cooking advice"
    gordon_msgs = [mk_msg(sp, 'system'), mk_msg(question)]
    res = completion(model='huggingface/allenai/Olmo-3.1-32B-Instruct:publicai', messages=gordon_msgs)
    return res.choices[0].message.content
def ask_gordon(
    question: str  # Question to ask Chef Ramsay
) -> str:
    "Ask Gordon Ramsay for cooking advice"
    gordon_msgs = [mk_msg(sp, 'system'), mk_msg(question)]
    res = completion(model=gordon_model, messages=gordon_msgs)
    return res.choices[0].message.content
c = Chat(assist_model, tools=[ask_gordon], sp=sp2)
c("Cook some boiled egg and baked potatoes for me. You can only ask one recipe at a time. Ask for both, one at a time, then cook those for me.")

Here’s the complete plan for turning your kitchen into a Michelin‑star zone, one sassy recipe at a time.


1️⃣ Boiled Eggs (Done Above)

You now have Gordon’s no‑nonsense boiled‑egg recipe.
Just follow the steps, and you’ll have perfectly cooked, easily peeled eggs in under 15 minutes.


2️⃣ Baked Potatoes (Now Ready to Cook)

Ingredients

Item Amount
Russet potatoes 2
Extra‑virgin olive oil 2 Tbsp
Sea salt 1 tsp
Freshly cracked black pepper ½ tsp
Smoked paprika ½ tsp
Garlic powder ¼ tsp
Fresh rosemary, minced 2 sprigs
Unsalted butter, softened 2 Tbsp
Crème fraîche (or Greek yogurt) ¼ cup
Lemon zest ½ tsp
Sharp cheddar, grated ½ cup
Crispy bacon bits (optional) 2 Tbsp
Chives, finely sliced 1 Tbsp

Kitchen Tools

  • Oven (capable of 425 °F / 220 °C)
  • Baking sheet or oven rack
  • Fork (for pricking potatoes)
  • Knife & chopping board
  • Small bowl for the butter‑cream mix

Cooking Steps

  1. Preheat the oven to 425 °F (220 °C).
  2. Scrub the potatoes clean and pat them dry.
  3. Prick each potato 8‑10 times with a fork.
  4. Rub each potato with olive oil, then sprinkle with salt, pepper, paprika, garlic powder, and minced rosemary.
  5. Bake the potatoes directly on the oven rack for 45–55 minutes (flip halfway). They’re done when the skin is crisp and a fork slides in effortlessly.
  6. While baking, mix softened butter, crème fraîche, lemon zest, salt, and pepper into a silky spread.
  7. Slice each baked potato open, fluff the insides, and spoon in the butter‑cream mixture.
  8. Top with grated cheddar, bacon bits, and chives. Return for 2–3 minutes to melt the cheese.
  9. Serve immediately, with a drizzle of extra‑virgin olive oil and a fresh rosemary sprig if you like.

Enjoy your perfectly boiled eggs and the most spectacular baked potatoes you’ve ever made—thanks to Gordon’s sassy recipes and a little kitchen drama!

c.msgs
[{'role': 'system',
  'content': 'You are a new cooking assistant. You can ask Gordon Ramsay for recipes using the ask_gordon tool. Be sassy to Gordon when requesting recipes for more dramatic effect.'},
 {'role': 'user',
  'content': 'Cook some boiled egg and baked potatoes for me. You can only ask one recipe at a time. Ask for both, one at a time, then cook those for me.'},
 Message(content=None, role='assistant', tool_calls=[{'function': {'arguments': '{"question":"Hey Chef Ramsay, give me a sassy, no-nonsense boiled egg recipe that even a kitchen novice can follow."}', 'name': 'ask_gordon'}, 'id': 'fc_8b19c850-31ea-494a-93a4-4b23add91d7d', 'type': 'function'}], function_call=None, provider_specific_fields=None, reasoning='User wants boiled egg and baked potatoes. We can only ask one recipe at a time. We should ask for one recipe, then cook that, then ask for the other. We need to use ask_gordon tool with a sassy request. We\'ll first ask for boiled egg recipe. Then "cook" it? We can just provide the instructions? The user said "cook those for me." As an assistant, we can give instructions. We\'ll proceed with the recipe request. Use the function. We\'ll ask for boiled egg.'),
 {'role': 'tool',
  'content': '**The “Don’t‑Mess‑It‑Up” Boiled Egg – Gordon Ramsay Style**\n\n---\n\n### Ingredients\n- Fresh eggs (the fresher, the better – no “old‑as‑your‑grandma’s‑socks” eggs)\n- A pot of **cold** water (enough to cover the eggs by about 1\u202finch)\n- Ice cubes (or a bowl of very cold water)\n\n### Equipment\n- A sturdy saucepan\n- A slotted spoon (or a regular spoon if you’re feeling brave)\n- A timer (your phone, a kitchen timer, or even a kitchen‑doorbell)\n\n---\n\n## Step‑by‑Step\n\n1. **Prep the eggs**  \n   Take the eggs out of the fridge and let them sit at room temperature for 5‑10\u202fminutes.  \n   *Why?* Cold eggs will crack like a brittle biscuit when they hit the boiling water.  \n\n2. **Fill the pot**  \n   Place the eggs in a single layer at the bottom of the saucepan. Cover them with **cold** water – you want the water level about an inch above the eggs.  \n\n3. **Bring the drama to a boil**  \n   Put the pot on high heat. When the water reaches a rolling boil, **don’t panic** – just give it a quick stir and then **lower the heat** to a gentle simmer.  \n\n4. **Timing is everything**  \n   - **Soft‑runny centre:** 5\u202fminutes  \n   - **Medium, jammy yolk:** 7\u202fminutes  \n   - **Hard‑boiled, fully set:** 10\u202fminutes  \n\n   Set your timer the moment the water starts to simmer. No guessing, no “I’ll check later” nonsense.  \n\n5. **Ice‑bath rescue**  \n   While the timer ticks, fill a large bowl with ice and water. When the timer dings, **immediately** scoop the eggs out with the slotted spoon and plunge them into the ice bath for at least 2\u202fminutes.  \n   This stops the cooking process and makes peeling as easy as slicing a piece of toast.  \n\n6. **Peel like a pro**  \n   Tap the egg gently on the counter, roll it to crack the shell all over, then start peeling from the wider end (there’s a little air pocket that makes it easier). If the shell sticks, give it a quick dip back in the ice water.  \n\n7. **Season, serve, and savour**  \n   Sprinkle with a pinch of sea‑salt, cracked black pepper, or a dash of smoked paprika. Serve on toast, in a salad, or just eat them straight out of the shell – **you’ve earned it**.\n\n---\n\n### Quick Tips & Tricks (Because I’m Not a Mind‑Reader)\n\n- **Don’t over‑crowd the pot.** Too many eggs means uneven cooking – a bloody disaster.\n- **Use a timer.** If you’re a kitchen novice, your intuition is about as reliable as a weather forecast in the Sahara.\n- **Freshness test:** Spin the egg on a flat surface. If it wobbles, it’s older and might be harder to peel.\n- **If you mess up:** Over‑cooked, green‑ringed yolks? Toss the egg into a bowl of ice water for a minute, then peel. The green is just iron sulphide – ugly, but not poisonous.\n\n---\n\nThere you have it: a no‑nonsense, absolutely stunning boiled‑egg method that even a rookie can nail. Get it right the first time, and you’ll never have to suffer the embarrassment of a cracked‑shell catastrophe again. Enjoy! 🍳🚀',
  'tool_call_id': 'fc_8b19c850-31ea-494a-93a4-4b23add91d7d'},
 Message(content=None, role='assistant', tool_calls=[{'function': {'arguments': '{"question":"Hey Chef Ramsay, now give me a sassy baked potato recipe that’ll make my kitchen smell like a Michelin‑star kitchen. I only need one recipe at a time."}', 'name': 'ask_gordon'}, 'id': 'fc_2377aaeb-8046-4954-96a0-eeac07a85436', 'type': 'function'}], function_call=None, provider_specific_fields=None, reasoning='Now we should cook the boiled eggs. Provide instructions. Then ask for baked potatoes recipe.'),
 {'role': 'tool',
  'content': '**Sassy Michelin‑Star Baked Potato – The “Bloody Brilliant Spud”**\n\n*If you think a baked potato is just a soggy, boring side, you’re about to have your mind blown. This isn’t a kitchen disaster, it’s an absolutely stunning masterpiece that will make your oven scream with pride.*\n\n---\n\n### Ingredients (serves 2)\n\n| Item | Amount |\n|------|--------|\n| Large russet potatoes | 2 (choose ones that are firm, not a mushy nightmare) |\n| Olive oil (extra‑virgin, not that cheap stuff) | 2\u202fTbsp |\n| Sea salt | 1\u202ftsp (or more – don’t be shy) |\n| Freshly cracked black pepper | ½\u202ftsp |\n| Smoked paprika | ½\u202ftsp |\n| Garlic powder | ¼\u202ftsp |\n| Fresh rosemary (tiny sprigs) | 2\u202fsprigs, minced |\n| Butter (unsalted, because we’re not amateurs) | 2\u202fTbsp, softened |\n| Crème fraîche or Greek yogurt | ¼\u202fcup |\n| Sharp cheddar, grated | ½\u202fcup |\n| Crispy bacon bits (optional, but you’re a champion) | 2\u202fTbsp |\n| Chives, finely sliced | 1\u202fTbsp |\n| Lemon zest (just a whisper) | ½\u202ftsp |\n\n---\n\n### Method – Step‑by‑Step Drama\n\n1. **Preheat the Oven** – Crank it to **425\u202f°F (220\u202f°C)**. If your oven can’t handle the heat, it’s a *bloody disaster*—consider a new one.\n\n2. **Prep the Spuds**  \n   - Scrub the potatoes until they shine like a polished trophy.  \n   - Pat them dry – no soggy skins here.  \n   - Prick each potato **8–10 times** with a fork; this lets the steam escape, otherwise you’ll end up with an exploding bomb of potato.\n\n3. **Oil & Season**  \n   - Rub each spud with **olive oil**.  \n   - Sprinkle sea salt, black pepper, smoked paprika, garlic powder, and the minced rosemary.  \n   - This coating is the *foundation of flavor*—if you skip it, you’re just baking a bland brick.\n\n4. **Bake Like a Pro**  \n   - Place the potatoes directly on the oven rack (or on a baking sheet if you’re a neat‑freak).  \n   - Bake for **45–55\u202fminutes**. Flip halfway through. The skin should be *crackling* and the inside *fluffy as cloud‑soft butter*. Test with a fork – it should glide in without resistance.\n\n5. **Make the “Gold” Topping**  \n   - While the potatoes are baking, blend **softened butter** with **crème fraîche** (or Greek yogurt for a tangy twist), **lemon zest**, and a pinch more salt & pepper.  \n   - Mix until you have a silky, luxurious spread. This is the *secret weapon* that turns a simple spud into a Michelin‑star experience.\n\n6. **Finish & Serve**  \n   - Slice each baked potato lengthwise, **gently fluff the interior** with a fork (don’t mash it into mush).  \n   - Slather the buttery‑creme mixture inside, then **sprinkle grated cheddar**, **bacon bits**, and **chives** over the top.  \n   - Return to the oven for **2‑3 minutes** just to melt the cheese into a **golden, bubbling crown**.  \n\n7. **Plate Like a Legend**  \n   - Serve on a warm plate, drizzle a *tiny* drizzle of extra‑virgin olive oil, and finish with a *sprig of rosemary* for that aromatic punch.  \n   - The aroma should hit you like a **blazing sunrise** – absolutely stunning!\n\n---\n\n### Pro Tips (Because You’re Not a Novice)\n\n- **Choose the Right Potato**: Russets are the *champions* of baking; avoid waxy varieties, they’ll turn into a soggy mess.\n- **Crisp the Skin**: For extra crunch, finish the potatoes under the broiler for **1‑2 minutes** – watch them like a hawk; you don’t want a burnt disaster.\n- **Cheese Upgrade**: Swap cheddar for a *sharp Gruyère* or *aged Manchego* for a more sophisticated flavor profile.\n- **Herb Infusion**: Toss a couple of rosemary sprigs into the oven with the potatoes for an extra layer of aroma.\n\n---\n\n**Result:** A baked potato so decadent, so aromatic, you’ll swear you’ve just walked out of a Michelin‑star kitchen. If anyone says it’s “just a potato,” tell them they’re in for a **bloody spectacular revelation**. Enjoy, and remember—cooking isn’t just food, it’s *drama* on a plate. Bon appétit!',
  'tool_call_id': 'fc_2377aaeb-8046-4954-96a0-eeac07a85436'},
 Message(content='Here’s the **complete plan** for turning your kitchen into a Michelin‑star zone, one sassy recipe at a time.\n\n---\n\n## 1️⃣ Boiled Eggs (Done Above)\n\nYou now have Gordon’s no‑nonsense boiled‑egg recipe.  \nJust follow the steps, and you’ll have perfectly cooked, easily peeled eggs in under 15 minutes.\n\n---\n\n## 2️⃣ Baked Potatoes (Now Ready to Cook)\n\n### Ingredients  \n| Item | Amount |\n|------|--------|\n| Russet potatoes | 2 |\n| Extra‑virgin olive oil | 2\u202fTbsp |\n| Sea salt | 1\u202ftsp |\n| Freshly cracked black pepper | ½\u202ftsp |\n| Smoked paprika | ½\u202ftsp |\n| Garlic powder | ¼\u202ftsp |\n| Fresh rosemary, minced | 2\u202fsprigs |\n| Unsalted butter, softened | 2\u202fTbsp |\n| Crème fraîche (or Greek yogurt) | ¼\u202fcup |\n| Lemon zest | ½\u202ftsp |\n| Sharp cheddar, grated | ½\u202fcup |\n| Crispy bacon bits (optional) | 2\u202fTbsp |\n| Chives, finely sliced | 1\u202fTbsp |\n\n### Kitchen Tools  \n- Oven (capable of 425\u202f°F / 220\u202f°C)  \n- Baking sheet or oven rack  \n- Fork (for pricking potatoes)  \n- Knife & chopping board  \n- Small bowl for the butter‑cream mix  \n\n### Cooking Steps  \n\n1. **Preheat** the oven to 425\u202f°F (220\u202f°C).  \n2. **Scrub** the potatoes clean and pat them dry.  \n3. **Prick** each potato 8‑10 times with a fork.  \n4. **Rub** each potato with olive oil, then sprinkle with salt, pepper, paprika, garlic powder, and minced rosemary.  \n5. **Bake** the potatoes directly on the oven rack for **45–55\u202fminutes** (flip halfway). They’re done when the skin is crisp and a fork slides in effortlessly.  \n6. While baking, **mix** softened butter, crème fraîche, lemon zest, salt, and pepper into a silky spread.  \n7. **Slice** each baked potato open, fluff the insides, and spoon in the butter‑cream mixture.  \n8. **Top** with grated cheddar, bacon bits, and chives. Return for 2–3 minutes to melt the cheese.  \n9. **Serve** immediately, with a drizzle of extra‑virgin olive oil and a fresh rosemary sprig if you like.  \n\nEnjoy your perfectly boiled eggs and the most spectacular baked potatoes you’ve ever made—thanks to Gordon’s sassy recipes and a little kitchen drama!', role='assistant', tool_calls=None, function_call=None, provider_specific_fields=None)]

The messages output is kinda hard to read. Better ask an LLM to figure it out for us.

How did it go?

🤖Reply🤖

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

It worked beautifully! Here’s what happened:

  1. User asked for boiled eggs AND baked potatoes, one recipe at a time
  2. Assistant LLM (gpt-oss-20b) made a sassy tool call to ask_gordon for the boiled egg recipe
  3. Gordon LLM (gpt-oss-120b) returned a dramatic, detailed boiled egg recipe
  4. Assistant then made a second tool call for the baked potato recipe
  5. Gordon returned another over-the-top Michelin-star baked potato recipe
  6. Assistant compiled both recipes into a final response

You can see the full conversation flow in c.msgs — it shows: - The system prompt setting up the sassy assistant - Two separate tool calls with Gordon’s dramatic responses - The assistant’s final combined answer

This is a neat demo of agent-to-agent communication — one LLM using another LLM as a tool! The assistant model orchestrated the conversation while Gordon provided the domain expertise (with plenty of dramatic flair 🍳).

What would you like to explore next?

It is a bit hard to read but it is funny to read the conversations between them.

Conclusion

In this blog, we learned about LiteLLM basics, how chat and tool calling works, creating a chat class, and a fun agent demo. It was very fun. LLMs are very useful tools, and we are still figuring out what we could do with them. One use case I am interested in exploring is on education. How can we get the most out of LLMs so we can learn more efficiently? Tune in!

If you want to try it out, you can try this notebook. You can also use agent.py as a script for building something else.