Architecture
On this page
Package layout
There is no plugin system, no dependency-injection container, no registry that dynamically looks up a provider by string — __llm_providers__ in __init__ is a plain list of provider name strings (["openai", "anthropic", "ollama", "gemini", "mistral", "groq"]) for reference only; nothing in the package reads it to instantiate a class. Selecting a provider means importing its class directly.
llm/tools.py is a standalone, provider-agnostic module with no dependency on BaseLLMInterface or any SDK — it exports the ToolCall dataclass, one _normalize_tool() helper, seven to_*_tools() schema converters (one per provider, with to_groq_tools/to_mistral_tools/to_ollama_tools being plain aliases of to_openai_tools since those three providers share OpenAI's function-calling dialect), five parse_*_tool_calls() response parsers, and tool_calls_to_dicts(). Every provider module imports only the specific converter/parser pair it needs from this module. See Tool Calling for the full contract.
The contract: BaseLLMInterface
Every provider class subclasses BaseLLMInterface, an ABC defined in base_llm_interface.py. It fixes:
- Constructor shape:
__init__(self, model_name: str, api_key: str, **kwargs)— subclasses callsuper().__init__(...)then do their own SDK client setup. - Two abstract methods that every subclass must implement:
generate(...)(sync) andgenerate_async(...)(async). Both are declared withtools: Optional[list] = Noneandtool_choice: Optional[Union[str, dict]] = Noneparameters — every one of the seven subclasses implements this same signature, convertingtoolsvia the matchingllm.tools.to_*_tools()helper and parsing the response via the matchingparse_*_tool_calls()helper. See Tool Calling. - One concrete method with a default implementation:
astream(...), an async generator. The base implementation callsgenerate_async()for the full response and then yields it word-by-word withawait asyncio.sleep(0)between words — a fallback, not real token streaming. Providers that support real streaming (OpenAI, Anthropic, Groq, AWS Bedrock) overrideastreamentirely. - Async context manager support:
__aenter__/__aexit__, where__aexit__callsacleanup(). The baseacleanup()is a no-oppass— subclasses override it to close SDK client sessions. validate_connection(...): a concrete helper that fires a small test prompt (sync or async) and returns adict(success,reason,response) instead of letting exceptions propagate — useful for health checks.
prompt vs messages — one signature, seven providers
BaseLLMInterface.generate (and generate_async, astream) is declared as generate(self, messages: Optional[list] = None, prompt: Optional[str] = None, max_tokens=..., temperature=..., **kwargs), and every one of the seven provider subclasses implements this same signature — messages is accepted as the first parameter, ahead of prompt, everywhere. Internally each provider resolves the pair through its own _build_messages (or, for Gemini, _build_contents) static helper: messages wins if both are given; if neither is given, the helper raises ValueError("Either 'prompt' or 'messages' must be provided.").
What still differs by provider is (a) the exact payload shape sent to the underlying SDK/HTTP call, and (b) whether that ValueError reaches the caller as an exception or gets folded into the provider's normal error return — see the note below.
Payload shape per provider:
- OpenAI, Anthropic, Groq, Mistral — pass the
messageslist straight through, unmodified: flat OpenAI-style{"role": ..., "content": "..."}dicts. - AWS Bedrock —
promptis auto-wrapped into Bedrock's Converse content-block shape ({"role": "user", "content": [{"text": prompt}]}); amessageslist you supply yourself must already be in that block shape. - Gemini —
_build_contentstranslates OpenAI-stylemessagesinto Gemini's shape:assistant→model(Gemini has noassistantrole), and anysystem-role entries are pulled out of the turn history and returned separately assystem_instructionrather than left incontents. - Ollama —
generate/generate_async/astreampost to{base_url}/api/chatwith themessageslist whenmessagesis given, and to{base_url}/api/generatewithpromptotherwise — it's the only provider that changes endpoint, not just payload shape, based on which argument you pass.
[!NOTE] Whether the missing-prompt/messages
ValueErrorpropagates depends on the call path On mostgenerate/generate_async/astreammethods, the_build_messages/_build_contentscall sits inside that method's owntry/except, so a missingprompt/messagesis caught there along with every other exception and turned into the provider's normal error return (adictfor OpenAI/Anthropic/Groq/AWS Bedrock; an error-prefixedstrfor Mistral, Gemini's async path, and Ollama'sastreamwhenaiohttpis installed) — it does not reach the caller as a raised exception. The exceptions to that areGeminiInterface.generate(sync) andOllamaInterface.generate/generate_async, which build the messages list before entering their retry/networktry/except, so on those two providers' sync-and-asyncgeneratemethods theValueErrorreally does propagate uncaught. See Troubleshooting for the full per-method breakdown.
Practically: always call with keyword arguments (llm.generate(prompt=...) or llm.generate(messages=...)) rather than positionally, since messages is the first positional parameter on every provider — passing a bare string positionally would silently be treated as messages rather than prompt.
Configuration flow
Each interface module creates its own llm_config() instance at import time (some name it config, others llm_config_ — see Configuration) and uses its fields as default parameter values in method signatures. Because Python evaluates default argument values once, at function-definition time, these defaults are fixed when the module is imported — mutating an llm_config instance's fields afterward will not retroactively change already-defined method defaults. See Configuration for the practical implication.
Client lifecycle
Most providers (OpenAI, Anthropic, Groq) create both a sync and an async SDK client eagerly in __init__. AWS Bedrock creates the sync boto3 client eagerly but the async aioboto3 client lazily, on first generate_async/astream call (_get_async_client), since aioboto3 is an optional dependency. Ollama does not use an SDK client at all — it talks to a local HTTP server via requests/aiohttp and can manage that server's process lifecycle itself (see OllamaInterface).
Cleanup is not automatic except for Ollama (which stops a self-started server in __del__). For the other providers, call cleanup() (sync) / await acleanup() (async), or use the async with context manager.