CrewAICrewAI 1.15 · Python 3.10 to 3.13
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
35 small wins to finish your pathNext lesson →

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

python
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

python
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

Example
from shop_llm import ShopLLM

llm = ShopLLM(model="shop")
print(llm.call("Where is my order A17?"))
print(llm.call("Hello"))

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

Example
from shop_llm import ShopLLM

llm = ShopLLM()

BaseLLM 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.

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

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

AspectShopLLMA hosted model
call doesa few lines of Pythona request to the provider
keynonerequired
messages readthe last oneall of them
costnoneper 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.
A hosted model works the same way
A hosted model's 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.
Watch out
call has to return a value. A method that only prints and returns None makes the agent see an empty reply, and the run stalls with nothing to show.
Try it yourself
  • 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.