LLM#
Language-model agents: the client, its cache, budgets, retries, providers and structured decisions. How to use them is in Language-model agents.
Language-model agents with runs that replay and costs that stay bounded.
Every call goes through a Client: it builds the request, answers from the
ResponseCache when it can, holds the call against the Budget, sends
it with retries and a concurrency limit, and caches the reply. Agents mix in
LLMAgent and call self.decide(prompt, schema); decide_all asks a whole
population at once and returns the decisions in agent order.
Adapters: AnthropicProvider (Messages API), OpenAICompatibleProvider
(OpenAI and local servers such as Ollama or llama.cpp) and FakeProvider
(tests and teaching). They use the standard library only; no provider SDK is needed.
Example
>>> from modelly import Agent, Model
>>> from modelly.llm import Client, FakeProvider, LLMAgent
>>> class Voter(LLMAgent, Agent):
... persona = "You are a voter."
>>> model = Model(rng=7)
>>> model.llm = Client(FakeProvider(lambda r: '{"vote": "yes"}'))
>>> Voter(model).decide("Vote?", {"properties": {"vote": {"enum": ["yes", "no"]}}})
{'vote': 'yes'}
See docs/llm_agents.md for reproducibility, budgets, keys and limitations.
Client and agents#
- class Client(provider: Provider, *, model: str | None = None, cache: ResponseCache | None = None, budget: Budget | None = None, retry: RetryPolicy | None = None, max_concurrency: int = 4, system: str = '', seed: int | None = None, **params: Any)[source]#
Language-model calls with a cache, a budget, retries and a concurrency limit.
- Parameters:
provider – The adapter:
AnthropicProvider,OpenAICompatibleProviderorFakeProvider.model – The model name; the provider’s default when None.
cache – Where replies are kept; a fresh in-memory cache when None.
budget – Token and cost limits; no limit when None.
retry – How failed calls are retried.
max_concurrency – The most calls in flight at once.
system – The system prompt used when a call sets none.
seed – Part of every cache key; agents pass their model’s seed instead.
**params – Default call parameters (
temperature,max_tokens, …).
Example
>>> from modelly.llm import FakeProvider >>> client = Client(FakeProvider()) >>> client.complete("Hello").cached False >>> client.usage.calls 1
Set the provider and the defaults of every call.
- next_index(scope: str) int[source]#
The next call counter of
scope, for calls not made by anLLMAgent.
- request(prompt: str | Sequence[Message], *, scope: str = 'model', call_index: int | None = None, system: str | None = None, seed: int | None = None, schema: dict | None = None, **params: Any) Request[source]#
Build a
Request;promptis a user message or a list of messages.
- complete(prompt: str | Sequence[Message], **options: Any) Response[source]#
One call: options are those of
request().
- complete_many(requests: Iterable[Request]) list[Response][source]#
Replies to
requests, in their order.Cached replies come first and cost nothing. The calls left are held against the budget in order; up to the first that could pass it, they are sent concurrently, counted and cached, then
BudgetExceededis raised for the rest.- Raises:
CacheMiss – In replay mode, for the first call the cache does not hold.
BudgetExceeded – Before the first call that could pass the budget.
ProviderError – The first call (in order) that failed after its retries.
- lookup(request: Request) Response | None[source]#
The cached reply of
request; a miss in replay mode raises.
- reserve(batch: list[Request], pending: list[int]) tuple[list[int], BudgetExceeded | None][source]#
The pending calls the budget allows, and the refusal that stopped the rest.
- dispatch(batch: list[Request], replies: list[Response | None], allowed: list[int]) None[source]#
Send the
allowedcalls, then count and cache their replies in order.
- class LLMAgent[source]#
Mix into an
Agent(class Voter(LLMAgent, Agent)) to askmodel.llm.- Variables:
Example
>>> from modelly import Agent, Model >>> from modelly.llm import Client, FakeProvider >>> class Voter(LLMAgent, Agent): ... persona = "You are a careful voter." >>> model = Model(rng=1) >>> model.llm = Client(FakeProvider()) >>> Voter(model).ask("Vote?").startswith("fake reply") True
- decide_all(agents: Sequence[LLMAgent], prompt: str | Callable[[Any], str], schema: dict, *, repairs: int = 1, **params: Any) list[Any][source]#
One decision per agent, sent concurrently, returned in the order of
agents.- Parameters:
agents –
LLMAgentinstances of one model.prompt – The same prompt for all, or a function of the agent.
schema – What each decision must match.
repairs – Repair rounds for invalid replies.
**params – Call parameters for every request.
Requests and replies#
- class Request(provider: str, model: str, messages: tuple[Message, ...], system: str = '', params: tuple[tuple[str, Any], ...] = (), scope: str = 'model', call_index: int = 0, seed: int | None = None, schema: dict | None = None)[source]#
Everything that decides a reply, and who asked.
scopeandcall_indexsay which caller made the call and how many it made before: an agent’s id and its own call counter. They are part of the cache key, so two agents asking the same question get their own replies, and the key of a call does not depend on which thread sent it.Example
>>> r = Request("fake", "m", (Message("user", "Hi"),), scope="a1", call_index=0) >>> len(r.key()) 64
- key() str[source]#
SHA-256 of the canonical JSON of
identity(): the cache key.
Cache#
- class ResponseCache(path: str | Path | None = None, mode: str = 'read-write')[source]#
Replies by cache key, in memory (
path=None) or in a SQLite file.- Parameters:
path – The SQLite file; created when missing. None keeps the cache in memory.
mode –
"read-write","read-only"or"off".
Open (or create) the store.
- classmethod from_file(path: str | Path, mode: str = 'read-only') ResponseCache[source]#
An in-memory cache filled from an exported file (JSON lines or SQLite).
The file itself is never written, so a cache shipped with a study bundle or a package stays as it was.
- put(request: Request, response: Response) None[source]#
Store
responseunder the key ofrequest, in one transaction.
- export(path: str | Path) Path[source]#
Write the cache to
path: JSON lines for.jsonl(.jsonl.gz), else SQLite.The file is written next to its target and renamed over it, so a reader never sees half a file.
Budgets and retries#
- class Budget(max_tokens: int | None = None, max_cost: float | None = None, prices: Mapping[str, ~modelly.llm.budget.Price]=<factory>, usage: Usage = <factory>)[source]#
Limits for one run and the usage counted against them.
- Parameters:
max_tokens – The most input plus output tokens the run may send for. None: no limit.
max_cost – The most the run may cost. Needs a price for every model it calls.
prices – Model name to
Price.
- reserve(request: Request) None[source]#
Hold the worst case of
request, or raiseBudgetExceeded.
- refuse_over(tokens: int, cost: float, request: Request) None[source]#
Raise when
tokensorcostpasses its limit.
- release(request: Request) None[source]#
Give back the reservation of
requestonce its reply is counted.
- class Price(input_per_million: float, output_per_million: float)[source]#
Price per million input tokens and per million output tokens, in any currency.
- class Usage(calls: int = 0, cache_hits: int = 0, retries: int = 0, input_tokens: int = 0, output_tokens: int = 0, cost: float | None = 0.0)[source]#
What a run used so far.
- Variables:
calls (int) – Calls sent to the provider (retries not included).
cache_hits (int) – Replies served from the cache.
retries (int) – Extra tries after a 429, a 5xx or a lost connection.
input_tokens (int) – Prompt tokens of the calls sent.
output_tokens (int) – Reply tokens of the calls sent.
cost (float | None) – The cost of the calls sent, or None when a model has no price.
- class RetryPolicy(max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0, sleeper: ~collections.abc.Callable[[float], None] = <built-in function sleep>)[source]#
How many times to retry a failed call and how long to wait in between.
- Parameters:
max_retries – Extra tries after the first; 0 never retries.
base_delay – Seconds before the first retry; doubled each time after.
max_delay – The longest wait, also capping a server’s
Retry-After.sleeper – Called with the seconds to wait;
time.sleepby default.
Example
>>> RetryPolicy(base_delay=1.0).delay(2, None) 4.0
- sleeper()#
sleep(seconds)
Delay execution for a given number of seconds. The argument may be a floating-point number for subsecond precision.
Providers#
- class Provider(*, api_key: str | None = None, base_url: str | None = None, timeout: float = 120.0)[source]#
The interface of every adapter: a
nameandcomplete().Subclasses set
name(part of every cache key),default_modelandkey_variable, the environment variable holding the API key.Keep the endpoint; the key stays in memory only.
- class AnthropicProvider(*, api_key: str | None = None, base_url: str | None = None, timeout: float = 120.0)[source]#
The Anthropic Messages API (
POST /v1/messages); key fromANTHROPIC_API_KEY.Example
>>> AnthropicProvider(api_key="sk-...") AnthropicProvider(base_url='https://api.anthropic.com')
Keep the endpoint; the key stays in memory only.
- class OpenAICompatibleProvider(*, name: str | None = None, **options: Any)[source]#
Any
POST /chat/completionsendpoint: OpenAI, or a local Ollama or llama.cpp.The key comes from
OPENAI_API_KEYand is optional, since local servers need none.nameis part of the cache key: give each endpoint its own when they serve different models under one name.Example
>>> OpenAICompatibleProvider(base_url="http://127.0.0.1:11434/v1", name="ollama") OpenAICompatibleProvider(base_url='http://127.0.0.1:11434/v1')
Set the endpoint, and a
namefor its cache keys.
- class FakeProvider(responder: ~collections.abc.Callable[[~modelly.llm.request.Request], str] = <function echo>, failures: ~collections.abc.Iterable[int] = ())[source]#
Replies come from
responder(request) -> str; tokens are counted from length.- Parameters:
responder – A module-level function (so a model holding it can be saved).
failures – HTTP statuses to raise, one per call, before answering normally:
failures=[429, 503]fails twice, then replies.
Example
>>> from modelly.llm import Client >>> client = Client(FakeProvider(lambda request: "yes")) >>> client.complete("Agree?").text 'yes'
Keep the responder and the failures to play back.
Schemas#
- validate(value: Any, schema: dict, path: str = '$') Any[source]#
Return
valuewhen it matchesschema; raiseSchemaErrorotherwise.- Parameters:
value – Parsed JSON.
schema – A schema using the supported keywords.
path – Where
valuesits, for the error message.
- parse_json(text: str) Any[source]#
The JSON value in a reply: plain, inside a code fence, or after some prose.
- Raises:
SchemaError – When the reply holds no JSON value.
Errors#
- exception ProviderError(message: str, *, status: int | None = None, retry_after: float | None = None, retryable: bool | None = None)[source]#
A provider refused or failed a call.
- Variables:
status – The HTTP status, or None when no response came back.
retryable – Whether the same call may succeed later (429, 5xx, a lost connection).
retry_after – Seconds the server asked to wait, when it said so.
Record the status and whether another try may succeed (from it, by default).
- exception CacheMiss(scope: str, call_index: int, key: str, excerpt: str)[source]#
A replay (
mode="read-only") asked for a response the cache does not hold.- Variables:
scope – Who made the call (an agent’s id, or the scope passed to the client).
call_index – The caller’s call counter for this call.
key – The cache key that was looked up.
Name the call, so the user can see which prompt was never recorded.
- exception BudgetExceeded[source]#
The next call could take the run past its token or cost budget.
Raised before that call is sent, so the money is never spent. Responses already received in the same batch are cached and counted first.