LLM clients¶
Provider-neutral LLM access (docs/design.md §3, ADR 0002).
One small LLMClient protocol plus request/response models, so judges,
example agents and replay can swap providers (mock, OpenAI-compatible,
Anthropic) without code changes:
from agentlens.llm import LLMRequest, Message, MockLLM
llm = MockLLM(["Hi! How can I help?"])
response = llm.complete(LLMRequest(messages=[Message.user("Hello")]))
assert response.content == "Hi! How can I help?"
Real providers: OpenAICompatClient (OpenAI, Ollama, vLLM, LM Studio via
base_url) and AnthropicClient (Anthropic Messages API).
Adapter failures raise subclasses of LLMError.
get_client builds any of them from a "provider:model" spec (or
$AGENTLENS_MODEL), with usage.cost_usd filled from the Pricing table:
from agentlens.llm import get_client
llm = get_client("ollama:llama3.1") # or "openai:gpt-4o-mini", "anthropic:...", "mock"
ANTHROPIC_VERSION
module-attribute
¶
Value of the anthropic-version header sent with every request.
DEFAULT_ANTHROPIC_BASE_URL
module-attribute
¶
The base_url of the official Anthropic API (the client appends /v1/messages).
DEFAULT_MAX_TOKENS
module-attribute
¶
max_tokens sent when neither the request nor the client sets one.
MODEL_ENV
module-attribute
¶
Environment variable read by get_client when no spec is given.
PROVIDERS
module-attribute
¶
PROVIDERS: dict[str, _Builder] = {'openai': _openai, 'anthropic': _anthropic, 'ollama': _local('ollama', 'OLLAMA_BASE_URL', _OLLAMA_DEFAULT, None), 'vllm': _local('vllm', 'VLLM_BASE_URL', _VLLM_DEFAULT, 'VLLM_API_KEY'), 'lmstudio': _local('lmstudio', 'LMSTUDIO_BASE_URL', _LMSTUDIO_DEFAULT, None), 'openai-compat': _openai_compat}
Provider name -> builder (model, overrides) -> LLMClient. mock is handled
separately (it needs no model and is not priced).
ScriptItem
module-attribute
¶
ScriptItem: TypeAlias = LLMResponse | str | ToolCall | list[ToolCall]
What a script may produce: a full response, plain text, or tool call(s).
DEFAULT_OPENAI_BASE_URL
module-attribute
¶
The base_url of the official OpenAI API.
PRICING_ENV
module-attribute
¶
Environment variable naming a YAML pricing file merged over the packaged table.
Role
module-attribute
¶
Who authored a Message.
RetryPolicy
dataclass
¶
RetryPolicy(*, max_retries: int = 2, initial_delay: float = 0.5, max_delay: float = 8.0, max_retry_after: float = 60.0, jitter: Callable[[float], float] = equal_jitter, sleep: Callable[[float], None] = sleep, async_sleep: Callable[[float], Awaitable[None]] = sleep)
How an adapter retries failed requests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_retries
|
int
|
Retries after the first attempt ( |
2
|
initial_delay
|
float
|
Backoff before the first retry, in seconds. |
0.5
|
max_delay
|
float
|
Upper bound of the exponential backoff, in seconds. |
8.0
|
max_retry_after
|
float
|
Upper bound applied to a server's |
60.0
|
jitter
|
Callable[[float], float]
|
Maps the computed backoff to the actual delay; |
equal_jitter
|
sleep
|
Callable[[float], None]
|
Blocking sleep used by sync calls (inject a fake in tests). |
sleep
|
async_sleep
|
Callable[[float], Awaitable[None]]
|
Sleep used by async calls. |
sleep
|
AnthropicClient ¶
AnthropicClient(model: str | None = None, *, base_url: str = DEFAULT_ANTHROPIC_BASE_URL, api_key: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS, timeout: float | Timeout = 60.0, max_retries: int = 2, retry_policy: RetryPolicy | None = None, headers: Mapping[str, str] | None = None, provider: str = 'anthropic', http_client: Client | None = None, async_http_client: AsyncClient | None = None)
An LLMClient for the Anthropic Messages API.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str | None
|
Default model (e.g. |
None
|
base_url
|
str
|
API root without |
DEFAULT_ANTHROPIC_BASE_URL
|
api_key
|
str | None
|
Sent as |
None
|
max_tokens
|
int
|
|
DEFAULT_MAX_TOKENS
|
timeout
|
float | Timeout
|
Per-request timeout in seconds (or an |
60.0
|
max_retries
|
int
|
Retries on HTTP 408/429/5xx (including Anthropic's 529
"overloaded") and transport errors, with exponential backoff
( |
2
|
retry_policy
|
RetryPolicy | None
|
Full control over retries/backoff/jitter (see |
None
|
headers
|
Mapping[str, str] | None
|
Extra HTTP headers sent with every request (e.g. |
None
|
provider
|
str
|
Value of the |
'anthropic'
|
http_client
|
Client | None
|
An |
None
|
async_http_client
|
AsyncClient | None
|
An |
None
|
The API key is never logged, never part of repr and never copied into
LLMResponse.raw or error messages. Use the client as a context manager
(or call close) to release its connection pool.
complete ¶
complete(request: LLMRequest) -> LLMResponse
Send request to /v1/messages and return the normalised response.
Raises:
| Type | Description |
|---|---|
LLMError
|
Subclasses for HTTP errors ( |
build_payload ¶
build_payload(request: LLMRequest) -> dict[str, Any]
The JSON body sent for request (useful for debugging and tests).
LLMClient ¶
Bases: Protocol
A minimal, provider-neutral LLM client.
Implementations translate LLMRequest to a provider call and the
provider's answer back to LLMResponse. The protocol is structural:
any object with these two methods is a client, no subclassing needed.
complete ¶
complete(request: LLMRequest) -> LLMResponse
Run request and block until the response is available.
LLMConfigError ¶
Bases: LLMError, ValueError
Invalid client configuration: a bad get_client spec or pricing file.
Also a ValueError, since it is raised for bad input before any call.
LLMConnectionError ¶
Bases: LLMError
The provider could not be reached (DNS, refused connection, broken stream...).
Raised after the retries are exhausted; the underlying httpx error is the
__cause__.
LLMError ¶
Bases: RuntimeError
Base class for failures of an LLM adapter call.
LLMHTTPError ¶
Bases: LLMError
The provider answered with a non-success HTTP status.
Attributes:
| Name | Type | Description |
|---|---|---|
status_code |
The HTTP status. |
|
body |
The response body (text, truncated to a few KB) for debugging. |
LLMRateLimitError ¶
LLMResponseError ¶
Bases: LLMError
A success response that cannot be understood (not JSON, no choices, bad tool arguments).
LLMTimeoutError ¶
Bases: LLMConnectionError
The request timed out (connect, read, write or pool timeout).
MockExhaustedError ¶
Bases: MockLLMError
A list script ran out of responses (and cycle is off).
MockLLM ¶
MockLLM(script: Script, *, model: str = 'mock', cycle: bool = False, default: ScriptItem | None = None)
A deterministic LLM client driven by a script.
script is one of:
- a sequence of items, returned in order, one per call. When exhausted it
raises
MockExhaustedError(loud failure: an agent making more calls than scripted is usually a bug), or starts over ifcycle=True; - a callable
(request) -> item, for responses computed from the request; - a mapping
pattern -> item: the first pattern (in insertion order) thatre.search-matches the last user message wins; otherwisedefaultis returned, orMockNoMatchErroris raised if there is no default.
With a sequence script, default (if given) also answers every call after
the script is exhausted, instead of raising.
Items are an LLMResponse, plain text, a ToolCall or a list of
them. Every response gets model (the request's, else this client's) and,
unless the item set its own, deterministic usage: whitespace word counts of
the request messages (input) and of the response (output), at cost_usd=0.
Every request is recorded in requests for assertions in tests.
complete ¶
complete(request: LLMRequest) -> LLMResponse
Return the next scripted response for request.
acomplete
async
¶
acomplete(request: LLMRequest) -> LLMResponse
Async variant of complete (no I/O, returns immediately).
MockLLMError ¶
Bases: RuntimeError
Base class for scripting errors raised by MockLLM.
MockNoMatchError ¶
Bases: MockLLMError
No pattern of a regex script matched and no default was given.
OpenAICompatClient ¶
OpenAICompatClient(model: str | None = None, *, base_url: str = DEFAULT_OPENAI_BASE_URL, api_key: str | None = None, timeout: float | Timeout = 60.0, max_retries: int = 2, retry_policy: RetryPolicy | None = None, headers: Mapping[str, str] | None = None, provider: str | None = None, http_client: Client | None = None, async_http_client: AsyncClient | None = None)
An LLMClient for OpenAI-compatible /chat/completions APIs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str | None
|
Default model, used when a request has no |
None
|
base_url
|
str
|
API root, e.g. |
DEFAULT_OPENAI_BASE_URL
|
api_key
|
str | None
|
Sent as |
None
|
timeout
|
float | Timeout
|
Per-request timeout in seconds (or an |
60.0
|
max_retries
|
int
|
Retries on HTTP 408/429/5xx and transport errors, with
exponential backoff ( |
2
|
retry_policy
|
RetryPolicy | None
|
Full control over retries/backoff/jitter (see |
None
|
headers
|
Mapping[str, str] | None
|
Extra HTTP headers sent with every request (e.g. |
None
|
provider
|
str | None
|
Value of the |
None
|
http_client
|
Client | None
|
An |
None
|
async_http_client
|
AsyncClient | None
|
An |
None
|
The API key is never logged, never part of repr and never copied into
LLMResponse.raw or error messages. Use the client as a context manager
(or call close) to release its connection pool.
complete ¶
complete(request: LLMRequest) -> LLMResponse
Send request to /chat/completions and return the normalised response.
Raises:
| Type | Description |
|---|---|
LLMError
|
Subclasses for HTTP errors ( |
build_payload ¶
build_payload(request: LLMRequest) -> dict[str, Any]
The JSON body sent for request (useful for debugging and tests).
ModelPrice ¶
Bases: SchemaModel
Price of one model, in USD per 1M tokens.
PricedClient ¶
Wrap an LLMClient so responses carry usage.cost_usd.
Costs the provider already reported are kept; otherwise the cost is computed from
pricing using the response's model (else the request's, else the client's
model). Unknown models keep cost_usd=None. Attributes not defined here
(model, provider, close...) are looked up on the wrapped client, so
agentlens.wrap_llm still sees the provider and default model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
LLMClient
|
The client to wrap. |
required |
pricing
|
Pricing | None
|
The table to use (default: |
None
|
provider
|
str | None
|
Provider name for the free-provider check; defaults to the client's
|
None
|
pricing
instance-attribute
¶
The pricing table in use.
provider
instance-attribute
¶
Provider used for the free-provider check (None if unknown).
complete ¶
complete(request: LLMRequest) -> LLMResponse
Call the wrapped client and fill in the response's cost.
Pricing ¶
Pricing(models: Mapping[str, ModelPrice | Mapping[str, float]] | None = None, *, aliases: Mapping[str, str] | None = None, free_providers: Iterable[str] = (), updated: str | None = None)
A table of model prices with alias and prefix matching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
models
|
Mapping[str, ModelPrice | Mapping[str, float]] | None
|
Model id -> |
None
|
aliases
|
Mapping[str, str] | None
|
Model id -> the priced id it costs the same as. |
None
|
free_providers
|
Iterable[str]
|
Providers whose models all cost 0 (local servers). |
()
|
updated
|
str | None
|
Free-form date of the prices, for display. |
None
|
Lookup (case-insensitive) of a model id: exact key, then alias, then the longest key
that is a prefix of the id followed by -, : or @ — so dated or tagged ids
(gpt-4o-mini-2024-07-18, claude-3-5-haiku-latest) use their base model's price.
from_dict
classmethod
¶
from_dict(data: Mapping[str, Any]) -> Pricing
Build a table from parsed YAML/JSON (keys models, aliases,
free_providers, updated).
Raises:
| Type | Description |
|---|---|
LLMConfigError
|
The data does not have that shape. |
from_yaml
classmethod
¶
from_yaml(text: str, *, source: str = '<string>') -> Pricing
Parse a YAML pricing table (an empty document is an empty table).
Raises:
| Type | Description |
|---|---|
LLMConfigError
|
Invalid YAML or an unexpected shape. |
from_file
classmethod
¶
from_file(path: str | PathLike[str]) -> Pricing
Read a YAML pricing table from path.
Raises:
| Type | Description |
|---|---|
LLMConfigError
|
The file is missing, unreadable or invalid. |
packaged
classmethod
¶
packaged() -> Pricing
The table shipped with AgentLens (approximate prices, see its updated date).
load
classmethod
¶
load(path: str | PathLike[str] | None = None) -> Pricing
The packaged table with path (else $AGENTLENS_PRICING, if set) merged over it.
merged ¶
A new table: this one with other's models, aliases and free providers on top.
lookup ¶
lookup(model: str) -> ModelPrice | None
The price of model (exact, alias, then prefix match), or None if unknown.
cost ¶
cost(model: str | None, input_tokens: int, output_tokens: int, *, provider: str | None = None) -> float | None
USD cost of a call, 0.0 for a free provider, None for an unknown model.
apply ¶
apply(response: LLMResponse, *, model: str | None = None, provider: str | None = None) -> LLMResponse
response with usage.cost_usd filled in, when it can be.
The response's own model (the one that actually answered) wins over
model. Returned unchanged when there is no usage, the cost is already set,
or the model is unknown.
LLMRequest ¶
Bases: SchemaModel
Everything needed to make one LLM call, independent of the provider.
messages also accepts a plain string, shorthand for a single user message.
None values mean "provider/client default".
model
class-attribute
instance-attribute
¶
None lets the client use its configured model.
response_format
class-attribute
instance-attribute
¶
E.g. {"type": "json_object"} or {"type": "json_schema", "json_schema": {...}}.
last_user_message
property
¶
Content of the most recent user message, or None if there is none.
canonical_json ¶
Stable, compact JSON encoding: sorted keys, no whitespace, unicode kept.
Two requests that are equal as models always encode to the same string,
whatever the dict insertion order of tool schemas or response_format.
LLMResponse ¶
Bases: SchemaModel
The normalised result of one LLM call.
content
class-attribute
instance-attribute
¶
Text output; None when the model only returned tool calls.
usage
class-attribute
instance-attribute
¶
usage: Usage | None = None
None when the provider did not report usage.
raw
class-attribute
instance-attribute
¶
The provider's raw JSON payload, kept for debugging; not used for logic.
model
class-attribute
instance-attribute
¶
The model that actually answered, as reported by the provider.
Message ¶
Bases: SchemaModel
One chat message.
Invariants: only assistant messages carry tool_calls; tool messages
must name the call they answer in tool_call_id (and only they may);
every role except assistant needs content (an assistant message may be
tool calls only).
assistant
classmethod
¶
An assistant turn: text, tool calls, or both.
ToolCall ¶
Bases: SchemaModel
A request from the model to call one tool.
ToolSpec ¶
Bases: SchemaModel
A tool the model may call, described by a JSON Schema for its arguments.
parameters
class-attribute
instance-attribute
¶
JSON Schema of the arguments object.
get_client ¶
get_client(spec: str | None = None, *, pricing: Pricing | str | PathLike[str] | None = None, **overrides: Any) -> LLMClient
Build an LLM client from "<provider>:<model>" (default: $AGENTLENS_MODEL).
Providers and the environment variables they read (overrides win):
openai:<model>—OpenAICompatClient;OPENAI_API_KEY,OPENAI_BASE_URL(defaulthttps://api.openai.com/v1).anthropic:<model>—AnthropicClient;ANTHROPIC_API_KEY,ANTHROPIC_BASE_URL.ollama:<model>—OLLAMA_BASE_URL(defaulthttp://localhost:11434/v1), no key.vllm:<model>—VLLM_BASE_URL(defaulthttp://localhost:8000/v1), optionalVLLM_API_KEY.lmstudio:<model>—LMSTUDIO_BASE_URL(defaulthttp://localhost:1234/v1).openai-compat:<model>— any other compatible server:OPENAI_COMPAT_BASE_URL(required, orbase_url=), optionalOPENAI_COMPAT_API_KEY.mock/mock:<name>—MockLLMreporting model<name>(default"mock") that echoes the last user message; passscript=(andcycle=,default=) to script it. Costs 0 and needs no network.
Local servers never receive OPENAI_API_KEY. Every client except the mock is
wrapped in a PricedClient, which fills usage.cost_usd from pricing:
a Pricing, a YAML file merged over the packaged table, or None for
Pricing.load (packaged table + $AGENTLENS_PRICING).
Raises:
| Type | Description |
|---|---|
LLMConfigError
|
No spec and no |
parse_spec ¶
Split "provider:model" into a normalised (provider, model).
The provider is lower-cased and aliases resolved (openai_compat ->
openai-compat, lm-studio -> lmstudio); "mock" alone means
("mock", "mock").
Raises:
| Type | Description |
|---|---|
LLMConfigError
|
Empty spec, missing |
count_tokens ¶
Deterministic stand-in for a tokenizer: the number of whitespace-separated words.