A custom LLM with BaseLLM
BaseLLM is the class every CrewAI model subclasses; write a call method that returns text and an agent treats your object as a real model.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The desk needs a model to read messages. Rather than a hosted one with a key, write a small model class, so every lesson runs offline and prints the same thing each time.
CrewAI's own classes for OpenAI, Anthropic, Gemini and the rest all subclass BaseLLM. A subclass has to supply one method, call, which receives the conversation as a list of messages and returns the model's reply. Everything else has a default.
The imports and three settings
import os
import re
from crewai import BaseLLM
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
os.environ["CREWAI_TRACING_ENABLED"] = "false"
os.environ["CREWAI_DISABLE_VERSION_CHECK"] = "true"The three environment variables from the setup lesson are set here, first thing, so that every script that imports the model also keeps CrewAI offline. CrewAI reads them when it is about to send something, so they work even when crewai was imported earlier.
Deciding on a reply
class ShopLLM(BaseLLM):
def call(self, messages, tools=None, **kwargs):
if isinstance(messages, str):
messages = [{"role": "user", "content": messages}]
text = messages[-1]["content"]
orders = re.findall(r"\b[A-Z]\d+\b", text)
if orders:
return f"I have no way to look up {orders[0]} yet."
return "Hello. Which order is this about?"A message is a dictionary with a role, who is speaking, and content, what they said. call reads the last one and looks for an order id, a capital letter followed by digits. With one, it admits it cannot look the order up yet. Without one, it asks which order the customer means. It also accepts a plain string, which it wraps as a user message.
Save both pieces as shop_llm.py. Every lesson from here on imports it.
Calling the model
from shop_llm import ShopLLM
llm = ShopLLM(model="shop")
print(llm.call("Where is my order A17?"))
print(llm.call("Hello"))I have no way to look up A17 yet. Hello. Which order is this about?
model is a name for the model. A hosted model uses it to pick which model to run; yours only stores it.
A model with no name
from shop_llm import ShopLLM
llm = ShopLLM()Traceback (most recent call last):
File "main.py", line 3, in <module>
llm = ShopLLM()
pydantic_core._pydantic_core.ValidationError: 1 validation error for ShopLLM
Value error, Model name is required and cannot be empty [type=value_error, input_value={}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.12/v/value_errorBaseLLM is a Pydantic model, and it rejects an empty model. Some examples pass the model through a constructor; in 1.15.22 fields are declared on the class instead, as the tool-calls lesson does for its own field.
from shop_llm import ShopLLM
llm = ShopLLM(model="shop")
conversation = [
{"role": "system", "content": "You help customers of a small online shop."},
{"role": "user", "content": "Is C40 in stock?"},
]
print(llm.call(conversation))I have no way to look up C40 yet.
A list of messages works the same way. The model reads only the last one, so the system message changes nothing here. A hosted model reads them all.
Reading what the model did
- The order id drives the reply: C40 in, C40 named back, because call reads the last message.
- An empty model name is refused: BaseLLM is a Pydantic model and validates its fields when you build it.
- A list of messages works like a string: this model reads the last one, while a hosted model reads them all.
Your model vs a hosted one
| Aspect | ShopLLM | A hosted model |
|---|---|---|
| call does | a few lines of Python | a request to the provider |
| key | none | required |
| messages read | the last one | all of them |
| cost | none | per token |
When a stand-in model fits
- Teaching or a demo, where a key would be in the way.
- Tests, where the same input must give the same output.
- Trying the framework's shape before paying for a provider.
call sends the messages to the provider's servers and returns what comes back. Yours decides with a few lines of Python. The agents in the next lesson cannot tell the difference, which is why no key appears anywhere below.Related
- Previous: A support desk in plain Python
- Next: Your first crew: agents and tasks
- Call it with "Hi, is B22 on its way?" and check which order it names.
- Change the reply for an order id so it names every id it found.
- Pass
model=""and compare the error with the one above.
Slow is fine. Stopping is the only problem.