LLMAdapter
LLMAdapter is a unified Python client for Large Language Model (LLM) providers. It gives you one consistent interface for chat completions, embeddings, tool calling, streaming, context caching, and full agentic workflows — across Azure OpenAI, Azure Foundry, OpenAI, Vertex AI, and Anthropic — without changing your code when you switch providers.
New to LLMs or AI APIs? Start with Layer 1 — Core Adapter. Once you're comfortable, work your way through the layers below.
Start Here
The runnable example covers chat, embeddings, Responses, async requests, LangChain, agents, and orchestration. Install its optional dependencies, then try the credential-free resilience section first:
pip install -e ".[all,dev]"
python examples/llmadapter_example.py --section resilience
To exercise provider-backed sections, configure the
Azure OpenAI credentials used by the
example. Run everything with --section all, or choose one section:
python examples/llmadapter_example.py --section core
python examples/llmadapter_example.py --section responses
python examples/llmadapter_example.py --section async
python examples/llmadapter_example.py --section langchain
python examples/llmadapter_example.py --section agent
python examples/llmadapter_example.py --section orchestration
python examples/llmadapter_example.py --section all
The Responses section requires an Azure OpenAI deployment that supports that API; sections requiring credentials are skipped when the chat profile is missing. For exact signatures, capability boundaries, and return types, use the API reference.
Table of Contents
- Feature Guide
- Installation
- Environment Variables & Credentials
- Layer 1 — Core Adapter (
LLMAdapter/AsyncLLMAdapter)- Supported Providers & Configuration
- Chat Completions — Sync
- Chat Completions — Streaming
- Chat Completions — Async
- Async Streaming
- All Chat Parameters Reference
- Structured Outputs — JSON Object / JSON Schema / Typed Parse (
.parse()) - Tool Calling (Raw API)
- Multimodal Input (Images)
- Embeddings
- Context Caching
- Failover / Backup Config
- Rate Limiting
- Error Handling
- Response Shapes
- Responses API (
adapter.responses) - Layer 2 — LangChain Compatibility (
ChatLLMAdapter) - Layer 3 — Advanced Agent Runtime (
LLMAdapterAgent) - Layer 4 — Multi-Agent Orchestration
- Extending with a New Provider
- Project Structure
- API Reference
Feature Guide — What to Use, When, and Why
Use the simplest layer that meets your needs. Each layer builds on the one below it; provider and model support still depends on the operation.
| Need | Use | Why / important notes |
|---|---|---|
| Portable text, tools, and embeddings | LLMAdapter |
Lowest-level, provider-neutral API. Use for application services and batch jobs. |
| Non-blocking requests or streams | AsyncLLMAdapter |
Uses provider-native async chat/Responses clients where available. |
| New OpenAI/Azure response workflows | adapter.responses |
Supports typed output, background jobs, state, built-in tools, and structured parse. OpenAI/Azure OpenAI only. |
| Existing OpenAI-shaped integration | adapter.chat.completions |
Retains legacy Chat Completions behavior across all supported chat providers. |
| LangChain chains or agents | ChatLLMAdapter |
Choose api="chat" for broad provider support or api="responses" for OpenAI/Azure Responses features. |
| Tool-running autonomous workflows | create_llmadapter_agent |
Adds bounded tool concurrency, deadlines, retry policy, routing, reflection, and checkpoints. |
| Several specialist agents | invoke_specialists / aggregate_specialists |
Filters specialists by capability, attaches weights, and returns an explicit aggregation result. |
ChatLLMAdapter and all agent/orchestration exports require langchain-core, provided by the langchain extra.
One configuration shape for every provider
Pass the union of provider-specific fields without changing caller code when you switch providers. Unrelated fields are accepted and ignored by the selected provider. Default routing always uses:
$$ \text{deployment_name} \rightarrow \text{per-call model} \rightarrow \text{configured model} $$
Use deployment_name whenever your endpoint routes by deployment, including
Azure OpenAI native endpoints and Azure OpenAI-compatible /openai/v1/
endpoints. A configured deployment intentionally cannot be overridden by a
per-call model alias. Without a deployment, most providers accept a per-call
model; Anthropic fixes its model when the adapter is created and ignores
per-call model overrides.
Provider and capability boundaries
| Feature | OpenAI | Azure OpenAI | Azure Foundry | Anthropic | Vertex AI |
|---|---|---|---|---|---|
| Legacy chat | Yes | Yes | Yes | Yes | Yes, sync only |
| Embeddings | Yes | Yes | Yes | No | No |
| Native async chat | Yes | Yes | Yes | Yes | No |
| Responses API | Yes | Yes | No | No | No |
| Built-in Responses tools | Yes | Yes | No | No | No |
| Prompt caching | Usage telemetry | Usage telemetry | No adapter API | Explicit markers | Provider-level CachedContent only |
Call adapter.responses.capabilities() before relying on model/deployment
specific Responses features. A result of unknown means the provider may
support the feature, but it should be validated on the target deployment.
Production defaults
- Use
timeoutto set provider transport timeouts. - Use
share_clients=Trueonly when adapters are frequently created; shared clients are lease-safe during eviction. - Use
circuit_breaker=Truefor fail-fast behavior during provider outages. - Use
backup={...}for an explicit compatible fallback target. - Configure
rpm,tpm,context_output_reserve_tokens, and an explicit Redis outage policy for predictable cost and latency control. - Keep provider credentials in environment variables or a secret manager, never
in notebooks, examples, source files, or committed
.envfiles.
Installation
# Create and activate a virtual environment (recommended)
python -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows
# Python 3.11 or later is required.
# Install the base package: OpenAI and Azure OpenAI support.
pip install .
# Install one optional integration when needed.
pip install ".[redis]" # Redis-backed distributed rate limiting
pip install ".[anthropic]" # Anthropic chat
pip install ".[azure-foundry]" # Azure Foundry chat and embeddings
pip install ".[vertexai]" # Vertex AI sync chat
pip install ".[langchain]" # ChatLLMAdapter and agent/orchestration APIs
# Install every provider integration plus LangChain.
pip install ".[all]"
# Install the runnable example, tests, and development tooling.
pip install -e ".[all,dev]"
Environment Variables & Credentials
LLMAdapter does not load environment variables or .env files automatically. Load credentials in application code or
from a secret manager, then pass the resulting values to the adapter constructor. The runnable example loads a local
.env file for convenience. The live-test hook loads .env only when
LLMADAPTER_RUN_LIVE_TESTS=1 is already set in the shell.
Create a .env file:
# Azure OpenAI
AZURE_OPENAI_API_KEY=your-azure-openai-key
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o
AZURE_OPENAI_API_VERSION=2024-02-01
AZURE_OPENAI_MODEL=gpt-4o
# Azure OpenAI embeddings (optional for the runnable example)
AZURE_OPENAI_EMBEDDING_API_KEY=your-azure-openai-embedding-key
AZURE_OPENAI_EMBEDDING_ENDPOINT=https://your-embedding-resource.openai.azure.com/
AZURE_OPENAI_EMBEDDING_API_VERSION=2024-02-01
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-small
# Azure Foundry (optional)
AZURE_FOUNDRY_API_KEY=your-foundry-key
AZURE_FOUNDRY_ENDPOINT=https://your-foundry-endpoint.inference.ai.azure.com/
AZURE_FOUNDRY_DEPLOYMENT_NAME=Llama-4-Maverick
AZURE_FOUNDRY_API_VERSION=2024-05-01-preview
# OpenAI (optional)
OPENAI_API_KEY=sk-...
OPENAI_ORG=org-...
OPENAI_PROJECT=proj_...
# Vertex AI (optional)
VERTEXAI_CREDENTIALS_PATH=/path/to/service_account.json
VERTEXAI_LOCATION=us-central1
VERTEXAI_MODEL=gemini-2.5-pro
# Anthropic (optional)
ANTHROPIC_API_KEY=sk-ant-api03-...
Load in Python:
The following local-development convenience uses python-dotenv, which is
included in the dev extra. Install .[dev] (or install python-dotenv
separately) before using it; production applications can instead read
os.environ through their normal secret-management flow.
import os
from dotenv import load_dotenv
load_dotenv() # reads .env from the current directory
api_key = os.environ["AZURE_OPENAI_API_KEY"]
Run live tests explicitly:
LLMADAPTER_RUN_LIVE_TESTS=1 python -m pytest test/ -m live
Layer 1 — Core Adapter (LLMAdapter / AsyncLLMAdapter)
The core adapter is a thin, consistent wrapper around provider HTTP APIs. Think of it as a smarter version of the
official openai Python client that also supports Azure and other providers without any code changes on your side.
Supported Providers & Configuration
LLMAdapter and AsyncLLMAdapter accept the union of provider-specific
constructor fields. This means one shared configuration object may include
fields such as deployment_name, api_version, endpoint, location, and
credentials_path without changing when the provider changes. Each provider
uses the fields it understands and ignores unrelated fields. A configured
deployment_name takes precedence over a per-call model, followed by the configured model.
| Provider | Required constructor fields | Optional provider-specific fields |
|---|---|---|
openai |
api_key |
endpoint, organization, project |
azure_openai |
api_key, endpoint, api_version, deployment_name |
responses_api_version, region |
azure_foundry |
api_key, endpoint, api_version, deployment_name |
— |
anthropic |
api_key |
endpoint, deployment_name, enable_prompt_caching |
vertexai |
credentials_path, location |
scopes |
Shared operational fields include timeout, max_retries, retry_base_delay, rpm, tpm, embedding limits,
completion_context_window, context_output_reserve_tokens, context_overhead_tokens, backup, and user_id.
mcp_policy is required before remote MCP or connector tools can be sent. A backup must contain a provider and is
validated during adapter construction.
Azure OpenAI
import os
from llmadapter import LLMAdapter
adapter = LLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model="gpt-4o",
redis_url="redis://server_disabled", # disables all adapter rate-limit enforcement
user_id="my-app-user",
)
Azure Foundry
Used for open-weight models (Llama, Mistral, Phi, etc.) hosted on Azure AI Foundry.
adapter = LLMAdapter(
provider="azure_foundry",
api_key=os.environ["AZURE_FOUNDRY_API_KEY"],
endpoint=os.environ["AZURE_FOUNDRY_ENDPOINT"],
deployment_name=os.environ["AZURE_FOUNDRY_DEPLOYMENT_NAME"],
api_version="2024-05-01-preview",
model="Llama-4-Maverick",
redis_url="redis://server_disabled",
user_id="my-app-user",
)
OpenAI
adapter = LLMAdapter(
provider="openai",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o",
organization=os.environ.get("OPENAI_ORG"),
project=os.environ.get("OPENAI_PROJECT"),
timeout=60,
)
For OpenAI-compatible gateways or local proxies, set endpoint to the base URL.
Vertex AI
The Vertex AI provider requires an extra dependency:
pip install google-genai==1.49.0
adapter = LLMAdapter(
provider="vertexai",
credentials_path=os.environ["VERTEXAI_CREDENTIALS_PATH"],
location=os.environ.get("VERTEXAI_LOCATION", "us-central1"),
model=os.environ.get("VERTEXAI_MODEL", "gemini-2.5-pro"),
scopes=["https://www.googleapis.com/auth/cloud-platform"],
redis_url="redis://server_disabled",
user_id="my-app-user",
)
Vertex AI notes: Only synchronous chat is implemented. Async chat and embeddings are not available for the Vertex AI provider. Messages are converted to Gemini content parts, system instructions, tool calls, and tool responses. Common provider-specific kwargs:
system_instruction,temperature,top_p,top_k,candidate_count,seed,max_output_tokens,stop_sequences,presence_penalty,frequency_penalty.
Anthropic
adapter = LLMAdapter(
provider="anthropic",
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-opus-4-5", # or claude-3-7-sonnet, claude-haiku-3-5, etc.
enable_prompt_caching=True, # auto-inject cache_control on system + tools (default True)
redis_url="redis://server_disabled",
user_id="my-app-user",
)
Anthropic notes: Two caching modes are available:
- Explicit block-level (
enable_prompt_caching=True, default): injectscache_controlon the last system block > and last tool definition — caches the static prefix independently from the conversation.- Automatic caching: pass
cache_control={"type": "ephemeral"}as a kwarg tocreate()and Anthropic > automatically caches up to the last cacheable block. Best for multi-turn conversations.Cache eligibility is decided by Anthropic and varies by model and cache mode. The adapter applies markers but does not enforce a local token threshold.
Chat Completions — Sync
The basic request/response pattern. You send a list of messages and get a response back.
response = adapter.chat.completions.create(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
temperature=0.0, # 0 = deterministic, 1 = creative
max_tokens=64,
)
# Access the reply
print(response.choices[0].message.content)
# Access token usage
print(f"Prompt tokens: {response.usage.prompt_tokens}")
print(f"Completion tokens: {response.usage.completion_tokens}")
print(f"Total tokens: {response.usage.total_tokens}")
Understanding messages:
| Role | Purpose |
|---|---|
system |
Sets the AI's personality and instructions. Used once at the start. |
user |
Your question or prompt. |
assistant |
Previous AI responses (for multi-turn conversations). |
tool |
The result of a tool/function call (see Tool Calling). |
Multi-turn conversation:
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "My name is Alice."},
]
response = adapter.chat.completions.create(messages=messages)
reply = response.choices[0].message.content
# Add assistant's reply to history, then continue
messages.append({"role": "assistant", "content": reply})
messages.append({"role": "user", "content": "What's my name?"})
response2 = adapter.chat.completions.create(messages=messages)
print(response2.choices[0].message.content) # "Your name is Alice."
Chat Completions — Streaming
Streaming returns text token-by-token as it is generated, which feels faster to the end user.
stream = adapter.chat.completions.create(
messages=[{"role": "user", "content": "Tell me a short story about a robot."}],
stream=True,
)
for chunk in stream:
# The last chunk may carry usage statistics
if chunk.usage:
print(f"\n[Total tokens: {chunk.usage.total_tokens}]")
continue
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
The adapter automatically injects
stream_options={"include_usage": True}when the provider supports it, so the final chunk contains token counts.
Chat Completions — Async
Use AsyncLLMAdapter when you are in an async context (FastAPI, asyncio event loop, etc.).
import asyncio
import os
from llmadapter import AsyncLLMAdapter
async def main():
adapter = AsyncLLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model="gpt-4o",
)
response = await adapter.chat.completions.create(
messages=[{"role": "user", "content": "Say hello in French."}],
max_tokens=16,
)
print(response.choices[0].message.content)
asyncio.run(main())
Async Streaming
async def stream_example():
stream = await adapter.chat.completions.create(
messages=[{"role": "user", "content": "Explain Python generators briefly."}],
stream=True,
)
async for chunk in stream:
if chunk.usage:
print(f"\n[Tokens: {chunk.usage.total_tokens}]")
continue
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
All Chat Parameters Reference
Pass provider/model-dependent keyword arguments to adapter.chat.completions.create(...). Unsupported fields may be
ignored, translated, or rejected by the selected provider.
| Parameter | Type | Description |
|---|---|---|
messages |
list[dict] |
Required. The conversation history. |
model |
str |
Per-call model override when the provider supports it; ignored when a deployment is configured and by Anthropic. |
stream |
bool |
Return a streaming iterator instead of a full response. |
temperature |
float |
Randomness. 0 = deterministic, 1 = creative. |
top_p |
float |
Nucleus sampling probability. Alternative to temperature. |
max_tokens |
int |
Maximum tokens to generate in the response. |
max_completion_tokens |
int |
Use this instead of max_tokens for reasoning models (o1, o3). |
stop |
str | list[str] |
Stop sequences — generation halts when any of these appear. |
n |
int |
Number of completions to generate. Default 1. |
seed |
int |
Seed for reproducibility (provider support varies). |
presence_penalty |
float |
Penalizes new tokens already present in the response. |
frequency_penalty |
float |
Penalizes repeated tokens. |
user |
str |
A user identifier for abuse detection. |
response_format |
dict | type |
Raw JSON-object/schema dictionaries are provider-dependent; a Pydantic BaseModel subclass is the portable typed-parse route. |
tools |
list[dict] |
List of function/tool definitions. |
tool_choice |
str | dict |
"auto", "none", or {"type": "function", "function": {"name": "..."}}. |
parallel_tool_calls |
bool |
OpenAI-compatible control; support is provider/model dependent. |
logit_bias |
dict |
Token probability adjustments. |
logprobs |
bool |
Return log probabilities for output tokens. |
top_logprobs |
int |
Number of top log probability alternatives to return. |
modalities |
list[str] |
Output modalities, e.g. ["text", "audio"]. |
audio |
dict |
Audio output configuration. |
prediction |
dict |
Predicted output hint for latency optimization. |
store |
bool |
Store the conversation in the provider's storage. |
metadata |
dict |
Arbitrary metadata attached to the request. |
reasoning_effort |
str |
For reasoning models: "low", "medium", "high". |
extra_body |
dict |
Pass provider-specific extras (e.g., Azure RAG data_sources). |
live_logs |
bool |
Sync adapter only: enables redacted INFO request/response logging. |
truncate |
bool |
Automatically truncate messages to fit the context window. |
Structured Outputs
Force the model to return a specific JSON structure. There are three modes, in order of increasing ergonomics.
Mode 1 — JSON Object mode
The model returns valid JSON, but the schema is not enforced. Fastest, but you must parse and validate yourself.
import json
response = adapter.chat.completions.create(
messages=[
{"role": "system", "content": "Always respond with valid JSON."},
{"role": "user", "content": "Return a user profile with name and age."},
],
response_format={"type": "json_object"},
temperature=0,
)
data = json.loads(response.choices[0].message.content)
print(data)
Mode 2 — JSON Schema mode
The model is constrained to your exact schema. You still parse the raw JSON string yourself.
response = adapter.chat.completions.create(
messages=[
{"role": "system", "content": "Follow the schema exactly."},
{"role": "user", "content": "Classify the sentiment: 'This product is amazing!'"},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "sentiment_result",
"strict": True,
"schema": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": ["positive", "negative", "neutral"]},
"confidence": {"type": "number"},
},
"required": ["label", "confidence"],
"additionalProperties": False,
},
},
},
temperature=0,
)
Mode 3 — Typed Parse (recommended)
Pass a Pydantic BaseModel subclass as response_format, or use the dedicated .parse() method. This is the
portable structured-output route: each provider uses its supported native schema or forced-tool mechanism and the
adapter deserializes the result into message.parsed when available.
from pydantic import BaseModel
from typing import List
class MovieReview(BaseModel):
title: str
year: int
rating: float
pros: List[str]
cons: List[str]
# Option A — via .parse() (clearest intent)
response = adapter.chat.completions.parse(
messages=[
{"role": "system", "content": "You are a film critic."},
{"role": "user", "content": "Review Inception (2010)."},
],
response_format=MovieReview,
temperature=0,
)
review: MovieReview = response.choices[0].message.parsed
print(review.title) # "Inception"
print(review.rating) # e.g. 9.1
print(review.pros) # ["Complex narrative", ...]
# Option B — via .create() with stream=False (identical result)
response = adapter.chat.completions.create(
messages=[{"role": "user", "content": "Review Inception."}],
response_format=MovieReview,
temperature=0,
)
review = response.choices[0].message.parsed
How each provider implements typed parse:
| Provider | Mechanism | Notes |
|---|---|---|
| OpenAI / Azure OpenAI | beta.chat.completions.parse() — native SDK |
Full strict mode; message.parsed set by the SDK |
| Anthropic | Forced tool use — schema injected as a tool's input_schema |
Works across all Claude models/versions |
| Vertex AI | Native response_schema=<class> in GenerateContentConfig |
SDK sanitizes schema for Gemini; response.parsed read natively |
| Azure Foundry | JsonSchemaFormat with strict-compliant schema |
additionalProperties:false + all-required enforced |
Streaming + Pydantic class: Passing a Pydantic class with
stream=Trueautomatically converts it to a strictjson_schemadict. You get the structured text stream but no automaticparseddeserialization — iterate chunks and parse the final assembled content manually.
message.parsed is serialization-safe:
import json
dumped = json.loads(response.model_dump_json())
print(dumped["choices"][0]["message"]["parsed"]) # dict, not a Pydantic instance
When to use which? Raw
json_objectandjson_schemadictionaries are provider/model-specific. Use the Pydantic class /.parse()mode when you need the adapter's portable typed-parse behavior. Anthropic does not honor raw Chat Completionsresponse_formatdictionaries; its typed path uses forced tool calling.
Tool Calling (Raw API)
Tool calling lets you define Python functions as "tools" the model can invoke. The model decides when to call a tool, and your code actually executes it.
Step 1 — Define a tool:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Tokyo'"},
},
"required": ["city"],
},
},
}
]
Step 2 — Send the request with tools:
response = adapter.chat.completions.create(
messages=[{"role": "user", "content": "What is the weather in Tokyo?"}],
tools=tools,
tool_choice="auto", # let the model decide when to call a tool
)
message = response.choices[0].message
Step 3 — Execute the tool and send the result back:
import json
messages = [{"role": "user", "content": "What is the weather in Tokyo?"}]
# Append the assistant's tool_call message
messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": [tc.model_dump() for tc in (message.tool_calls or [])],
})
# Run your actual function
tool_call = message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
weather_result = {"city": args["city"], "temperature": 22, "condition": "Sunny"}
# Append the tool result
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(weather_result),
})
# Get the final answer
final = adapter.chat.completions.create(messages=messages, tools=tools)
print(final.choices[0].message.content)
Tip: For a managed agentic loop (instead of writing this pattern yourself), see Layer 3.
Multimodal Input (Images)
Send images alongside text by using a list of content parts in the user message:
response = adapter.chat.completions.create(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What objects do you see in this image?"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/photo.jpg",
"detail": "low", # "low", "high", or "auto"
},
},
],
}
],
max_tokens=256,
)
print(response.choices[0].message.content)
The provider endpoint must be able to fetch the image URL.
detail="low"/"high"is an OpenAI-compatible control; other providers may ignore it. Provider-specific image processing and pricing remain provider-managed.
Embeddings
Embeddings turn text into numerical vectors, useful for semantic search, clustering, and similarity comparisons.
Basic embeddings:
response = adapter.embeddings.create(
input=["Hello world", "Embeddings represent meaning"],
model="text-embedding-3-large",
)
for item in response.data:
print(f"Index {item.index}: vector length = {len(item.embedding)}")
print(f"Total tokens: {response.usage.total_tokens}")
Batched embeddings (for large document sets):
# Process in batches of 2 to stay within provider limits
response = adapter.embeddings.create(
input=["Doc 1", "Doc 2", "Doc 3", "Doc 4", "Doc 5"],
model="text-embedding-3-large",
batching=True,
batchsize=2, # send 2 texts per API call
restore_order=True, # reassemble in original order when done
)
Reduced dimensions (saves storage and speeds up similarity search):
response = adapter.embeddings.create(
input=["Compact vector test"],
model="text-embedding-3-large",
dimensions=256, # reduce from 3072 to 256 dimensions
)
All embedding parameters:
| Parameter | Type | Description |
|---|---|---|
input |
str | list[str] |
Text(s) to embed. |
model |
str |
Embedding model name. |
batching |
bool |
Process input in batches. |
batchsize |
int |
Texts per API call when batching. |
restore_order |
bool |
Reassemble batched results in input order. |
dimensions |
int |
Reduce output vector size (model must support it). |
encoding_format |
str |
"float" or "base64". |
live_logs |
bool |
Sync adapter only: enable redacted INFO request/response logs. |
Context Caching
Context caching lets you store a static prompt prefix on the provider's servers so repeated calls reuse it instead of re-billing the same tokens on every request.
Mechanism per provider
| Provider | Mechanism | Control |
|---|---|---|
| Azure OpenAI / OpenAI | Automatic — any static prefix ≥ 1 024 tokens is cached transparently | None needed |
| Anthropic | Explicit — cache_control: {type: ephemeral} markers |
enable_prompt_caching=True (default) |
| Vertex AI | Explicit — create a CachedContent resource, then attach it by name |
Direct Vertex provider integration; no stable adapter-level lifecycle API |
| Azure Foundry | None — backend does not currently expose cache signals | N/A |
Reading cache metrics
Normalized usage exposes cache-related fields, but non-zero telemetry depends on what the provider and model return:
response = adapter.chat.completions.create(
messages=[{"role": "user", "content": "Summarize the current cache telemetry."}],
)
usage = response.usage
print(usage.prompt_tokens) # total input tokens billed
print(usage.cached_tokens) # tokens served from cache (any provider)
print(usage.cache_creation_input_tokens) # cache creation telemetry when returned
print(usage.cache_read_input_tokens) # cache-read telemetry when returned
print(usage.cache_savings_pct) # percentage of prompt served from cache
cached_tokens is a unified read-only property that works for all providers:
- OpenAI / Azure OpenAI — reads
prompt_tokens_details.cached_tokens - Anthropic / Vertex AI — reads
cache_read_input_tokens
Azure OpenAI / OpenAI — automatic caching
No code changes required. Just send the same static system prompt (≥ 1 024 tokens) on repeated calls:
SYSTEM = "You are a detailed technical assistant..." * 20 # must exceed ~1024 tokens
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "What is a Merkle tree?"},
]
# First call — cold (cache miss)
r1 = adapter.chat.completions.create(messages=messages, model="gpt-4o", temperature=0)
print(r1.usage.cached_tokens) # 0 on first call
# Second call — hot (cache hit)
r2 = adapter.chat.completions.create(messages=messages, model="gpt-4o", temperature=0)
print(r2.usage.cached_tokens) # e.g. 1280 — 68% savings
print(r2.usage.cache_savings_pct) # e.g. 68.9
Streaming also reports cache tokens in the final usage chunk:
last_usage = None
for chunk in adapter.chat.completions.create(
messages=messages, model="gpt-4o", stream=True,
stream_options={"include_usage": True},
):
if chunk.usage:
last_usage = chunk.usage
print(last_usage.cached_tokens) # same field, same value
Anthropic — explicit cache_control
The provider supports two caching modes (both work via the same create() call):
Mode 1 — Explicit block-level (default, enable_prompt_caching=True):
cache_control is injected on the last system block and last tool definition.
Caches the static prefix independently from the growing conversation history.
adapter = LLMAdapter(
provider="anthropic",
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-opus-4-5",
enable_prompt_caching=True, # default — no further action needed
)
messages = [
{"role": "system", "content": SYSTEM}, # cache_control injected automatically
{"role": "user", "content": "Explain bloom filters."},
]
r1 = adapter.chat.completions.create(messages=messages, model="claude-opus-4-5")
print(r1.usage.cache_creation_input_tokens) # tokens written to cache on first call
r2 = adapter.chat.completions.create(messages=messages, model="claude-opus-4-5")
print(r2.usage.cache_read_input_tokens) # tokens read from cache (~10% cost)
print(r2.usage.cached_tokens) # convenience alias
Mode 2 — Automatic caching (top-level cache_control kwarg):
Anthropic automatically caches up to the last cacheable block and advances the
breakpoint as the conversation grows. Best for multi-turn conversations.
r = adapter.chat.completions.create(
messages=messages,
model="claude-opus-4-5",
cache_control={"type": "ephemeral"}, # top-level automatic caching
)
Minimum cacheable tokens: ≥ 4 096 for Opus 4.x and Haiku 4.5; ≥ 2 048 for Sonnet 4.6 and Haiku 3.5; ≥ 1 024 for Sonnet 4.5/4/3.7 and Opus 4.1/4. Requests below the threshold silently skip caching.
To opt out of block-level injection for a specific adapter:
adapter = LLMAdapter(
provider="anthropic",
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-opus-4-5",
enable_prompt_caching=False,
)
Vertex AI — CachedContent
VertexAIChatProvider implements provider-level CachedContent creation, and chat calls can pass cached_content when
the selected Gemini target supports it. LLMAdapter does not expose a stable adapter-level CachedContent lifecycle API;
do not access private provider caches to obtain an underlying client. Use the direct provider integration only when that
provider-specific lifecycle is required, and treat cache-read telemetry as provider-returned usage metadata.
Failover / Backup Config
Configure a backup provider that automatically takes over if the primary fails:
adapter = LLMAdapter(
provider="azure_openai",
api_key=os.environ["PRIMARY_KEY"],
endpoint=os.environ["PRIMARY_ENDPOINT"],
deployment_name="primary-gpt4o",
api_version="2024-02-01",
model="gpt-4o",
backup={
"provider": "azure_openai",
"api_key": os.environ["BACKUP_KEY"],
"endpoint": os.environ["BACKUP_ENDPOINT"],
"deployment_name": "backup-gpt4o",
"api_version": "2024-02-01",
"model": "gpt-4o",
},
)
# If the primary fails, the backup is tried automatically
response = adapter.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}]
)
When to use: Production systems where uptime matters — region failover, deployment quota exhaustion, or temporary provider outages. Chat and Responses creation fail over only for retryable errors (such as rate limits, connection failures, and server errors), not invalid requests. Each target's circuit breaker also guards backup calls.
Rate Limiting
LLMAdapter uses shared in-process counters by default. For distributed deployments, configure a Redis instance. Each quota is scoped to the provider target (provider, endpoint, deployment/model), user, request type, and time window:
adapter = LLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model="gpt-4o",
# Rate limits
rpm=100, # max requests per minute (chat)
tpm=100000, # max tokens per minute (chat)
embedding_rpm=60, # max requests per minute (embeddings)
embedding_tpm=60000, # max tokens per minute (embeddings)
completion_context_window=128000, # context window size for truncation
context_output_reserve_tokens=4096, # preserve room for generation
# Redis for distributed tracking
redis_url="redis://localhost:6379/0",
user_id="user-42", # per-user tracking key
)
When a configured Redis backend is unavailable, choose an explicit policy:
adapter = LLMAdapter(
provider="openai",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o",
redis_url="redis://localhost:6379/0",
rate_limit_failure_policy="memory_fallback", # default: keep local limits
redis_connect_timeout=1.0,
)
Supported policies are "memory_fallback", "fail_open", and "fail_closed".
"fail_closed" raises RateLimitBackendUnavailableError rather than sending an
unmetered request.
Disable rate limiting (development):
adapter = LLMAdapter(
provider="openai",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o",
redis_url="redis://server_disabled",
)
redis_url=None uses shared in-process limits. The redis://server_disabled sentinel disables all adapter rate-limit
enforcement and should only be used deliberately in local development or tests. The limiter uses fixed windows; denied
requests still reserve the current window. A single request larger than the configured TPM limit raises LLMAdapterError.
Context budgets
completion_context_window is enforced as a request-wide budget, not a
per-message limit. LLMAdapter reserves the greater of
context_output_reserve_tokens and any per-call generation limit
(max_tokens, max_completion_tokens, or max_output_tokens), then estimates
message framing and tool/schema overhead. If a request cannot fit, it raises
ContextLengthExceededError; pass truncate=True to retain system/developer
messages and the newest eligible text history within that budget.
Multimodal chat blocks and Responses item lists are never rewritten merely to
fit a budget. If they exceed capacity, the adapter raises instead of changing
their structure. Server-side state referenced by previous_response_id or a
Conversation is not locally measurable and remains provider-enforced.
Resilience & Performance Options
These optional LLMAdapter / AsyncLLMAdapter constructor kwargs tune resilience and
throughput. Each is off/default unless set.
| Option | Default | Effect |
|---|---|---|
token_counter |
None |
Custom (text, model_name) -> int counter used for rate-limit and context-budget estimation. It can change whether/how much input is retained; final text slicing still uses the tokenizer implementation. |
share_clients |
False |
Share provider SDK clients (and their HTTP connection pools) across adapter instances with the same config, via a bounded process-wide cache. Useful when adapters are created per request. |
circuit_breaker |
False |
True or {"failure_threshold": 5, "reset_timeout": 30.0} — opens a per-target circuit (provider, operation, endpoint, resolved model/deployment) after repeated retryable 5xx health failures. Chat and Responses create paths support it; embeddings do not. |
from llmadapter import LLMAdapter
adapter = LLMAdapter(
provider="anthropic",
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-opus-4-5",
share_clients=True, # reuse one client across adapters with this config
circuit_breaker=True, # fail fast when the provider is persistently unhealthy
)
Retries: the adapter is the single source of retry truth — provider SDK built-in retries are disabled, and
max_retries/retry_base_delay(or a per-callmax_retries=) control full-jitter exponential back-off.max_retries=3means up to four attempts. Providerretry_aftervalues are honored when available. Agent tool retries are separate: they use fixed backoff and retry only eligible failed tool results. Setmax_retries=0to disable core-request retries.
Streaming lifecycle: a streaming request opens its provider stream only when you begin iterating. The circuit breaker records success only after the stream is fully consumed. Pre-output events are buffered, so establishment and pre-visible failures can retry up to
max_retriestimes per target before backup failover. Once output is visible, the adapter never retries, fails over, or replays the stream. Provider-emitted terminal failure events are not replayed to a backup. Errors from lazy stream creation surface from the iterator rather than fromcreate(..., stream=True). Close a stream (stream.close()orawait stream.aclose()) if you stop consuming it early; the LangChain integration closes its underlying adapter stream when its stream iterator is closed.
Async embeddings:
AsyncLLMAdapterexposesawait adapter.embeddings.create(...)with the same signature as the sync adapter; the (synchronous) embedding providers run in a worker thread so the event loop stays free.
Error Handling
LLMAdapter raises typed exceptions so you can handle failures precisely:
from llmadapter.errors import (
LLMAdapterError, # Base class for all adapter errors
ConfigurationError, # Invalid adapter or backup configuration
ContextLengthExceededError, # Request cannot fit configured context budget
AuthenticationError, # Invalid API key
ModelNotFoundError, # Deployment/model name wrong
RateLimitExceededError, # Provider-side 429 response
RateLimitBackendUnavailableError, # Fail-closed Redis backend unavailable
APIConnectionError, # Network issues, timeouts
TimeOutError, # Request exceeded timeout
ChatCompletionError, # General completion failure
EmbeddingGenerationError, # Embedding request failed
ContentFilterError, # Response blocked by content policy
InvalidResponseError, # Malformed or unexpected response
InvalidResponseRequestError, # Invalid Responses create contract
UnsupportedCapabilityError, # Explicitly unsupported Responses feature
ResponsesError, # Responses API operation failed
ResponseNotFoundError, # Stored response was not found
)
try:
response = adapter.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}]
)
except AuthenticationError:
print("Check your API key.")
except RateLimitExceededError:
print("Slow down — you have hit your rate limit.")
except ContentFilterError as exc:
print(f"Content blocked: {exc.message}")
print(f"Filter details: {exc.context.get('filter_results')}")
except APIConnectionError as exc:
print(f"Network error: {exc.message}")
except LLMAdapterError as exc:
# Catch-all for any adapter error
print(f"Error [{exc.status_code}] {exc.error_code}: {exc.message}")
Error attributes:
| Attribute | Description |
|---|---|
message |
Human-readable description. |
status_code |
HTTP status code from the provider. |
error_code |
Adapter-defined error code string. |
context |
Dict with extra details (e.g., filter results). |
Response Shapes
Chat completion response:
response.id # request ID
response.model # model that was used
response.choices[0].message.content # text reply
response.choices[0].message.parsed # Pydantic model instance (set by .parse() — None otherwise)
response.choices[0].message.tool_calls # list of tool calls (if any)
response.choices[0].finish_reason # "stop", "tool_calls", "length", etc.
response.usage.prompt_tokens
response.usage.completion_tokens
response.usage.total_tokens
response.usage.cached_tokens # tokens served from cache (all providers)
response.usage.cache_creation_input_tokens # tokens written to cache (Anthropic mapping)
response.usage.cache_read_input_tokens # tokens read from cache (Anthropic/Vertex mappings)
response.usage.cache_savings_pct # % of prompt served from cache (0–100)
response.prompt_filter_results # Azure content filter results
response.metadata # extra metadata dict
response.provider_extensions # versioned provider provenance container
Streaming chunk:
chunk.choices[0].delta.content # text fragment (may be None)
chunk.choices[0].delta.tool_calls # tool call fragments
chunk.choices[0].finish_reason # set on the last content chunk
chunk.usage # set on the final usage chunk
Embedding response:
response.data[0].embedding # list[float] — the vector
response.data[0].index # position in input list
response.model # model used
response.usage.total_tokens
Responses API (adapter.responses)
Alongside the legacy chat.completions surface, the adapter exposes the OpenAI
Responses API (/v1/responses) at adapter.responses. Create operations use
token counting, rate limiting, retries with full-jitter backoff, optional circuit
breaking, and optional backup failover. Management operations are retried and
error-translated but are not metered, circuit-broken, or failed over.
For streams, provider creation is lazy and breaker success is recorded only after full consumption. Retry and failover can occur only before visible output is released; a partially visible stream is never replayed to a backup provider.
Provider support:
openaiandazure_openaionly. Other providers raiseResponsesErrorwithstatus_code=501.chat.completionsremains available for all five providers, so nothing about your existing code changes.
Methods
adapter.responses.create(input, stream=False, truncate=False, **kwargs) # -> Response | Iterator[ResponseStreamEvent]
adapter.responses.stream(input, **kwargs) # -> Iterator[ResponseStreamEvent]
adapter.responses.parse(input, text_format=Model, **kwargs) # -> Response (with .output_parsed)
adapter.responses.retrieve(response_id, **kwargs) # -> Response
adapter.responses.delete(response_id) # -> ResponseDeleted
adapter.responses.cancel(response_id) # -> Response
adapter.responses.compact(input=[...], **kwargs) # -> CompactedResponse
adapter.responses.input_items.list(response_id, **kwargs) # -> ResponseItemList
adapter.responses.input_tokens.count(**kwargs) # -> InputTokenCountResponse
adapter.responses.capabilities() # -> ResponseCapabilities
adapter.responses.create_background(input, **kwargs) # -> Response
adapter.responses.wait(response_id, timeout=..., **kwargs) # -> Response
adapter.responses.submit_mcp_approval(previous_response_id, approval) # -> Response
adapter.responses.conversations.create(items=None, **kwargs) # -> Conversation
adapter.responses.conversations.retrieve(conversation_id, **kwargs) # -> Conversation
adapter.responses.conversations.update(conversation_id, **kwargs) # -> Conversation
adapter.responses.conversations.delete(conversation_id, **kwargs) # -> ConversationDeleted
adapter.responses.conversations.list_items(conversation_id, **kwargs) # -> ResponseItemList
adapter.responses.conversations.retrieve_item(conversation_id, item_id) # -> ResponseItem
adapter.responses.conversations.delete_item(conversation_id, item_id) # -> Conversation
AsyncLLMAdapter exposes the same operations as awaitables or async streams;
responses.capabilities() remains synchronous because it resolves local
configuration only.
responses.compact accepts input items or previous_response_id to select
the history to compact.
Capability contract and strict requests
Responses requests are validated before token reservation, retry, failover, or
provider-client creation. Typos and incompatible state references raise
InvalidResponseRequestError; explicitly disabled target features raise
UnsupportedCapabilityError.
caps = adapter.responses.capabilities()
print(caps.status("tool.web_search")) # supported / unknown / unsupported
print(caps.supports("tool.web_search")) # True / None / False
adapter = LLMAdapter(
provider="azure_openai",
# ... normal Azure configuration ...
responses_capabilities={
"input_tokens.count": False, # deployment-specific known limitation
"tool.web_search": False,
},
)
With a custom openai endpoint or compatible gateway, Responses features
start as unknown rather than claiming native OpenAI support. Unknown features
can still be called; use responses_capabilities to record verified support or
known limitations for the configured target.
After adapter-only max_retries and live_logs controls are removed, the
strict provider request contract includes instructions, tools, tool_choice,
parallel_tool_calls, max_tool_calls, include, background, conversation,
previous_response_id, reasoning, text, store, metadata, prompt,
prompt_cache_key, prompt_cache_options, prompt_cache_retention,
truncation, safety_identifier, service_tier, user, context,
extra_headers, extra_query, and per-call timeout, plus generation fields
model, temperature, top_p, top_logprobs, and max_output_tokens, and
stream_options for streaming; structured-output
aliases text_format and response_format are also accepted as Pydantic model
classes. Use extra_body for a new provider field that has not yet been added
to the adapter contract.
previous_response_id and conversation cannot be combined. Unknown tool types
and unsupported include values are rejected before dispatch.
ResponseCapabilities.supports(feature) returns True for known support,
False for an explicit restriction, and None when support is target- or
deployment-dependent.
max_retries overrides the configured retry count for a call (subject to the
side-effecting request safeguards). In the synchronous adapter, live_logs
controls redacted INFO logging; the async adapter accepts and ignores it.
Basic Usage
from llmadapter import LLMAdapter
adapter = LLMAdapter(provider="openai", api_key="sk-...", model="gpt-5")
response = adapter.responses.create(
"Summarize the Apollo program in two sentences.",
instructions="You are concise and factual.",
max_output_tokens=200,
)
print(response.output_text)
print(response.usage.total_tokens, response.usage.cached_tokens, response.usage.reasoning_tokens)
input accepts either a plain string or a list of Responses input items:
response = adapter.responses.create([
{"role": "user", "content": [{"type": "input_text", "text": "Describe this chart."}]},
])
Streaming
Streaming yields ResponseStreamEvent objects rather than chat chunks. Branch on
event.type:
for event in adapter.responses.stream("Tell me a story."):
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.completed":
print("\nTokens:", event.usage.total_tokens)
Known event and output-item types are mapped to typed subclasses such as
ResponseOutputTextDeltaEvent, ResponseCompletedEvent,
ResponseFunctionCallItem, ResponseWebSearchCallItem, and
ResponseCodeInterpreterCallItem. New provider event/item types remain
available through the generic ResponseStreamEvent and ResponseOutputItem
fallback models, including their preserved extra fields.
Typed web and file search tools
Use WebSearchTool and FileSearchTool instead of manually building tool
dictionaries. Included results are exposed through response.web_sources and
response.file_search_results.
from llmadapter import FileSearchTool, WebSearchTool
response = adapter.responses.create(
"Compare internal notes with current public information.",
tools=[
WebSearchTool(search_context_size="medium"),
FileSearchTool(vector_store_ids=["vs_123"], max_num_results=5),
],
include=["web_search_call.action.sources", "file_search_call.results"],
)
for source in response.web_sources:
print(source.title, source.url)
for result in response.file_search_results:
print(result.filename, result.score)
Tool availability remains capability-gated by provider, API version, deployment, and model. Azure deployment support is frequently model-specific.
Typed execution tools
CodeInterpreterTool, ImageGenerationTool, and ComputerTool provide typed
configuration for Responses tools. Their results are available as
response.code_interpreter_outputs, response.generated_images, and
response.computer_actions.
from llmadapter import CodeInterpreterTool, ImageGenerationTool
response = adapter.responses.create(
"Analyze the spreadsheet and create a chart image.",
tools=[
CodeInterpreterTool(container={"type": "auto", "file_ids": ["file_123"]}),
ImageGenerationTool(size="1024x1024"),
],
)
Computer use is a separate, application-controlled workflow:
from llmadapter import ComputerTool, ResponseComputerCallItem
response = adapter.responses.create(
"Inspect the isolated test browser.",
tools=[ComputerTool()],
)
for call in response.output:
if isinstance(call, ResponseComputerCallItem):
print(call.call_id, call.computer_actions, call.pending_safety_checks)
ComputerTool() sends {"type": "computer"}; it does not operate a browser.
Each computer_call holds ordered actions and a call_id needed to return
a screenshot. response.computer_actions is flattened for inspection only.
An application must review safety checks, authorize and execute permitted
actions in order in a persistent isolated UI, capture its actual screenshot,
and send it back using previous_response_id=response.id and an input item
with {"type": "computer_call_output", "call_id": call.call_id, "output": {"type": "computer_screenshot", "image_url": screenshot_data_url, "detail": "original"}}. Do not execute untrusted page instructions or
consequential actions without user confirmation; bound and verify each run.
Computer tool availability depends on the configured model and endpoint.
Remote MCP and connectors
McpTool and ConnectorTool use the Responses MCP transport. They are rejected
unless the adapter has an explicit allowlist policy. Match allowed_origins to a
server URL's netloc (host and optional port), not its full URL.
import os
from llmadapter import McpApprovalDecision, McpTool
adapter = LLMAdapter(
provider="openai",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-5",
mcp_policy={
"enabled": True,
"allowed_server_labels": ["trusted"],
"allowed_origins": ["mcp.example.com"],
"allowed_connector_ids": ["connector_123"],
},
)
response = adapter.responses.create(
"Use the approved remote tool.",
tools=[McpTool(server_label="trusted", server_url="https://mcp.example.com")],
)
for approval in response.pending_mcp_approvals:
adapter.responses.submit_mcp_approval(
previous_response_id=response.id,
approval=McpApprovalDecision(approval_request_id=approval.approval_request_id, approve=True),
)
require_approval defaults to "always". computer, code_interpreter,
image_generation, remote MCP/connector tools, and background Responses creates
suppress automatic adapter retries because they can produce side effects.
Structured Outputs
Pass a Pydantic model class as text_format; the deserialized instance lands on
response.output_parsed.
from pydantic import BaseModel
class TripPlan(BaseModel):
city: str
daily_budget_eur: int
plan = adapter.responses.parse("Return city=Paris and a daily budget.", text_format=TripPlan)
print(plan.output_parsed.city, plan.output_parsed.daily_budget_eur)
Multi-Turn State
The Responses API can hold conversation state server-side, so you don't have to resend the whole history:
first = adapter.responses.create("My favourite colour is teal.", store=True)
second = adapter.responses.create("What is my favourite colour?", previous_response_id=first.id)
print(second.output_text)
For provider targets that expose the Conversations resource, use the capability- gated namespace:
conversation = adapter.responses.conversations.create(
items=[{"role": "user", "content": "Remember that I prefer teal."}],
metadata={"topic": "preferences"},
)
adapter.responses.conversations.update(conversation.id, metadata={"topic": "profile"})
items = adapter.responses.conversations.list_items(conversation.id)
adapter.responses.conversations.delete(conversation.id)
Background workflows and reasoning continuity
Use create_background and wait for stored long-running Responses. Polling
uses bounded full-jitter backoff and can optionally cancel a response when its
local deadline expires.
job = adapter.responses.create_background("Run the long analysis.")
final = adapter.responses.wait(job.id, timeout=300, cancel_on_timeout=True)
create_background sets background=True and defaults store=True without
overriding an explicit store=False. wait(..., on_poll=callback) invokes
callback(response, attempts) on each poll; the async variant also awaits an
async callback. Cancellation on timeout is best effort.
Async callers can use await adapter.responses.create_background(...) and
await adapter.responses.wait(...). If
include=["reasoning.encrypted_content"] is requested, encrypted continuity
payloads are available through response.encrypted_reasoning_content for
provider-supported stateless follow-up workflows.
Managing Stored Responses
fetched = adapter.responses.retrieve(first.id)
page = adapter.responses.input_items.list(first.id, limit=20)
for item in page:
print(item.type, item.id)
if page.has_more:
page = adapter.responses.input_items.list(first.id, after=page.last_id)
background = adapter.responses.create("Long job...", background=True, store=True)
adapter.responses.cancel(background.id)
compacted = adapter.responses.compact(previous_response_id=first.id)
adapter.responses.delete(first.id)
ResponseItemList is a cursor page with data, first_id, last_id, and
has_more; it also supports direct iteration and len(page).
Pre-Flight Token Counting
input_tokens.count asks the server how many input tokens a request would
consume, before you pay for it:
count = adapter.responses.input_tokens.count(
input="How many tokens is this prompt?",
instructions="You are a helpful assistant.",
)
print(count.input_tokens)
Async
from llmadapter import AsyncLLMAdapter
adapter = AsyncLLMAdapter(provider="openai", api_key="sk-...", model="gpt-5")
response = await adapter.responses.create("Hello!")
async for event in await adapter.responses.create("Stream this.", stream=True):
if event.type == "response.output_text.delta":
print(event.delta, end="")
await adapter.responses.delete(response.id)
Azure Notes
Azure routes by deployment, so the deployment name is used as the model id. The
Responses endpoint also requires a recent api-version; when your configured
api_version predates it, the adapter upgrades it for responses.* calls only.
Set responses_api_version to pin one explicitly:
adapter = LLMAdapter(
provider="azure_openai",
api_key="...",
endpoint="https://<resource>.openai.azure.com",
deployment_name="gpt-5",
api_version="2024-10-21", # used by chat.completions
responses_api_version="2025-03-01-preview", # used by responses.*
)
Behavior Notes
createshares the completion RPM/TPM budget withchat.completions— both consume the same deployment quota.- The management verbs (
retrieve,delete,cancel,compact,input_items.list,input_tokens.count) are retried and error-translated but are not rate limited, metered, circuit-broken, or failed over.deleteandcancelare treated as non-idempotent and are not retried. truncate=Trueonly rewrites a plain-stringinput. Item lists carry tool calls and multimodal parts, so they are counted but passed through unchanged.- All Responses models allow extra fields, so new server-side item types and attributes are preserved rather than rejected.
adapter.responses.capabilities()is available only for OpenAI and Azure OpenAI targets; unsupported providers raiseUnsupportedCapabilityErrorfrom this introspection method andResponsesError(501)fromresponses.create.
Provider extension provenance
Use normalized fields for portable logic. Provider-specific fields are available through both a legacy dictionary and a versioned extension container:
extensions = response.provider_extensions
print(extensions.provider, extensions.schema_version)
print(extensions.fields)
provider_extensions is available on normalized chat responses, stream chunks,
embeddings, Responses objects and output items, pages, counts, conversations,
and stream events. LangChain message conversion does not preserve this
container.
Layer 2 — LangChain Compatibility (ChatLLMAdapter)
ChatLLMAdapter wraps LLMAdapter as a LangChain BaseChatModel, so it can be
used with standard LangChain prompts, chains, tools, callbacks, and agents. It
requires the optional langchain-core dependency (pip install ".[langchain]").
Chat mode supports all five registered chat providers. Native async LangChain calls support OpenAI, Azure OpenAI, Azure Foundry, and Anthropic; Vertex AI is sync-only because this package has no native async Vertex chat provider.
What You Get Over Layer 1
invoke()/ainvoke()— standard LangChain call interfacestream()/astream()— streaming with LangChain message typesbatch()— parallel batch invocationsbind_tools()— attach tools and get LangChainToolCallobjects backwith_structured_output()— parse output into Pydantic models automatically- LCEL chain composition (
prompt | llm | parser) - Full compatibility with LangChain agents, memory, and callbacks
The constructor's
streamingfield is retained for LangChain configuration compatibility, but it does not make.invoke()or.ainvoke()stream. Call.stream()or.astream()explicitly. Call.close()when finished with synchronous resources, orawait .aclose()to close both sync and async resources.
Basic Invoke & Stream
from llmadapter import ChatLLMAdapter
from langchain_core.messages import HumanMessage, SystemMessage
llm = ChatLLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model="gpt-4o",
)
# invoke returns an AIMessage
response = llm.invoke("What is machine learning?")
print(response.content)
# Using LangChain message objects
response = llm.invoke([
SystemMessage(content="You are an expert data scientist."),
HumanMessage(content="Explain overfitting in one sentence."),
])
print(response.content)
# Streaming
for chunk in llm.stream("List three Python best practices."):
print(chunk.content, end="", flush=True)
Async Invoke & Stream
import asyncio
async def async_example():
response = await llm.ainvoke("Explain async/await briefly.")
print(response.content)
async for chunk in llm.astream("Count to 5."):
print(chunk.content, end="")
asyncio.run(async_example())
Tool Binding with LangChain
Define tools using the @tool decorator and bind them to the LLM:
from langchain_core.tools import tool
from langchain_core.messages import ToolMessage
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"The weather in {city} is 22 degrees C and sunny."
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
return str(eval(expression)) # noqa: S307 — for demo only
# Bind tools to the LLM
llm_with_tools = llm.bind_tools([get_weather, calculate])
# The model returns tool_calls in the AIMessage
response = llm_with_tools.invoke("What is the weather in Tokyo and what is 15 * 7?")
for tool_call in response.tool_calls:
print(f"Calling: {tool_call['name']} with {tool_call['args']}")
# Execute the tools and continue
tool_map = {"get_weather": get_weather, "calculate": calculate}
messages = [HumanMessage("What is the weather in Tokyo and what is 15 * 7?")]
messages.append(response) # add the AIMessage with tool_calls
for tool_call in response.tool_calls:
result = tool_map[tool_call["name"]].invoke(tool_call["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
# Get the final answer
final = llm_with_tools.invoke(messages)
print(final.content)
bind_tools options:
llm_with_tools = llm.bind_tools(
tools=[get_weather, calculate],
tool_choice="auto", # "auto", "none", "required" / "any", or a specific tool name
)
"any" is translated to "required". A named tool must exist in the supplied
tool list; otherwise bind_tools raises ValueError before a model request.
Structured Output with Pydantic
Automatically parse model output into a typed Pydantic model:
from pydantic import BaseModel
from typing import List
class MovieReview(BaseModel):
title: str
year: int
rating: float
pros: List[str]
cons: List[str]
structured_llm = llm.with_structured_output(MovieReview)
review = structured_llm.invoke("Review the movie Inception (2010).")
print(review.title) # "Inception"
print(review.rating) # e.g. 9.1
print(review.pros) # ["Complex narrative", "Visual effects", ...]
with_structured_output modes:
# Default: use JSON schema mode
llm.with_structured_output(MyModel)
# Use JSON schema mode (strict)
llm.with_structured_output(MyModel, method="json_schema")
# Use JSON mode (no schema enforcement)
llm.with_structured_output(MyModel, method="json_mode")
# Use a required function call instead of response-format structured output
llm.with_structured_output(MyModel, method="function_calling", include_raw=True)
With include_raw=True, the runnable returns a dictionary containing raw,
parsed, and parsing_error; parse failures are captured rather than raised by
that final parser stage.
Using the Responses API (api="responses")
Set api="responses" to retain the standard LangChain interface while routing
requests through adapter.responses. This mode is available for openai and
azure_openai providers only; the default remains legacy chat completions.
llm = ChatLLMAdapter(
provider="openai",
api="responses",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-5",
)
reply = llm.invoke("Explain retrieval-augmented generation in one sentence.")
print(reply.content)
for chunk in llm.stream("Write a haiku about Python."):
print(chunk.content, end="", flush=True)
In this mode, max_tokens is translated to max_output_tokens, chat-style
function tools are converted to the Responses tool format, and structured
output and tool binding continue to use LangChain's normal APIs. Stop sequences
are ignored because the Responses API does not support them. LangChain text,
image, and file content blocks are converted into Responses input items. Rich
Responses output such as web sources, file-search results, code-interpreter
artifacts, generated images, computer actions, MCP approvals, reasoning, and
other non-message output items is exposed through AIMessage.additional_kwargs;
use the raw adapter for Responses management methods such as retrieve,
conversations, or input_tokens.count.
LangChain callback token events are emitted only for non-empty text deltas.
Chain Composition (LCEL)
Use LangChain Expression Language (LCEL) to build processing pipelines:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# Build a chain: prompt -> LLM -> string output
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant who answers concisely."),
("human", "{question}"),
])
chain = prompt | llm | StrOutputParser()
result = chain.invoke({"question": "What is the difference between RAM and storage?"})
print(result)
# Stream the chain
for chunk in chain.stream({"question": "Explain neural networks simply."}):
print(chunk, end="")
# Batch multiple inputs in parallel
results = chain.batch([
{"question": "What is Python?"},
{"question": "What is JavaScript?"},
{"question": "What is Rust?"},
])
for r in results:
print(r[:100])
Layer 3 — Advanced Agent Runtime (LLMAdapterAgent)
The advanced runtime gives you a production-ready ReAct-style agent loop: the model thinks, calls tools, observes results, and repeats until it has an answer. Everything — retry logic, timeouts, checkpoints, observability hooks — is handled for you.
AdvancedAgentConfig — Every Parameter Explained
AdvancedAgentConfig is a dataclass that controls how the agent loop behaves. Every parameter has a sensible default.
from llmadapter import AdvancedAgentConfig
config = AdvancedAgentConfig(
max_iterations=12,
max_runtime_seconds=180.0,
parallel_tools=True,
max_concurrency=4,
tool_timeout_seconds=30.0,
reflection_enabled=True,
reflection_max_retries=2,
interrupt_on_tool_error=False,
tool_choice="auto",
max_input_tokens_per_step=None,
tool_retry_attempts=0,
tool_retry_backoff_seconds=0.0,
retry_on_tool_not_found=False,
retry_non_idempotent_tools=False,
escalate_on_retry_exhausted=False,
)
Iteration & time controls:
| Parameter | Default | Description |
|---|---|---|
max_iterations |
12 |
Maximum number of think-act-observe cycles before the agent stops. Increase for complex multi-step tasks; lower for fast responses. |
max_runtime_seconds |
180.0 |
Runtime budget in seconds. Async model and reflection calls are bounded directly; sync calls are checked before and after completion, so configure provider timeout for a transport-level limit. |
Tool execution controls:
| Parameter | Default | Description |
|---|---|---|
parallel_tools |
True |
When the model calls multiple tools in one step, run them concurrently. Significantly faster for independent tool calls. |
max_concurrency |
4 |
Maximum number of tools to run simultaneously when parallel_tools=True. |
tool_timeout_seconds |
30.0 |
Maximum seconds the agent waits for a tool result. Native async tools are cancelled; synchronous or thread-backed work cannot be forcibly stopped and may continue after the timeout. |
tool_choice |
"auto" |
Passed to the model. "auto" = model decides, "none" = no tools, tool name = force a specific tool. |
Reflection controls:
| Parameter | Default | Description |
|---|---|---|
reflection_enabled |
True |
Evaluates a non-tool candidate answer before returning it and requests a revision when needed. |
reflection_max_retries |
2 |
Maximum reflection-driven revisions per invocation, including resumed execution. |
Error behavior:
| Parameter | Default | Description |
|---|---|---|
interrupt_on_tool_error |
False |
If True, stop the agent loop immediately on any tool error and return status="interrupted". Useful when errors mean the task cannot continue. |
Token budget:
| Parameter | Default | Description |
|---|---|---|
max_input_tokens_per_step |
None |
If set, the agent returns status="stopped" with reason="max_input_tokens_per_step_exceeded" when the estimated input tokens for the next step exceed this value. Prevents runaway token usage. |
Retry & escalation:
| Parameter | Default | Description |
|---|---|---|
tool_retry_attempts |
0 |
How many times to retry a failed, explicitly idempotent tool call. 0 = no retries. |
tool_retry_backoff_seconds |
0.0 |
Seconds to wait between retry attempts. Use 0.5 or 1.0 for flaky network-dependent tools. |
retry_on_tool_not_found |
False |
If True, retry even when the tool name is not registered (instead of raising immediately). |
retry_non_idempotent_tools |
False |
Unsafe compatibility opt-in: retry ordinary tools that have not declared idempotency. Timed-out tools are never retried. |
escalate_on_retry_exhausted |
False |
If True and no fallback exists, stop after tool retries are exhausted. With a fallback, ordinary tool-error routing chooses it on the next step. |
Quick reference — common config patterns:
# Fast, cheap responses (simple Q&A with tools)
AdvancedAgentConfig(max_iterations=4, reflection_enabled=False, parallel_tools=False)
# Robust multi-step research agent
AdvancedAgentConfig(max_iterations=20, max_runtime_seconds=300, parallel_tools=True,
tool_retry_attempts=2, tool_retry_backoff_seconds=0.5)
# Strictly bounded (for user-facing features with SLA)
AdvancedAgentConfig(max_iterations=6, max_runtime_seconds=30,
max_input_tokens_per_step=4000, interrupt_on_tool_error=True)
# Fault-tolerant with fallback escalation
AdvancedAgentConfig(tool_retry_attempts=3, tool_retry_backoff_seconds=1.0,
escalate_on_retry_exhausted=True)
Creating an Agent
import os
from llmadapter import ChatLLMAdapter, AdvancedAgentConfig, create_llmadapter_agent
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> dict:
"""Get current weather for a city."""
return {"city": city, "temp_c": 23, "condition": "Sunny"}
@tool
def search_web(query: str) -> str:
"""Search the web for a query."""
return f"Results for '{query}': [placeholder search results]"
primary_llm = ChatLLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model="gpt-4o",
)
fallback_llm = ChatLLMAdapter(
provider="openai",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o-mini",
)
config = AdvancedAgentConfig(
max_iterations=8,
parallel_tools=True,
reflection_enabled=True,
tool_retry_attempts=1,
)
agent = create_llmadapter_agent(
primary_llm=primary_llm,
fallback_llm=fallback_llm, # optional: used on tool errors or escalation
tools=[get_weather, search_web],
config=config,
)
Running an Agent
result = agent.invoke("What is the weather in Paris, and find me a travel article about it.")
print(result["status"]) # "completed", "completed_with_reflection_limit", "stopped", or "interrupted"
print(result.get("output")) # final answer text when available
print(result["steps"]) # list of AgentStepRecord — every think/act/observe cycle
print(result.get("checkpoint_id")) # ID when a resumable interruption occurs
# Check how many steps were used
print(f"Completed in {len(result['steps'])} iterations")
result keys:
| Key | Description |
|---|---|
status |
Final exit condition. |
output |
Final text answer when one is available. |
reason |
Present for stopped and interrupted paths. |
messages |
Final LangChain message history. |
steps |
List of AgentStepRecord with details of each iteration. |
checkpoint_id |
ID returned only for a resumable interrupted tool-error result. |
checkpoint_lineage |
Per-invocation lineage IDs, useful for relating nested specialist runs. |
Some stop paths add diagnostics: token-budget stops include input_tokens and
limit; retry-escalation stops include failed_tools.
Status values:
| Status | Meaning |
|---|---|
"completed" |
Agent returned an answer that either skipped reflection or passed it. |
"completed_with_reflection_limit" |
Agent returned its current answer after exhausting configured reflection rewrites. |
"interrupted" |
A tool failed while interrupt_on_tool_error=True; the result contains reason="tool_error" and a resumable checkpoint_id. |
"stopped" |
A limit or configured escalation ended the run. Inspect reason: max_iterations_reached, max_runtime_exceeded, max_input_tokens_per_step_exceeded, or tool_retry_exhausted. |
Async Agent Execution
import asyncio
async def run_async_agent():
result = await agent.ainvoke(
"Research the latest trends in quantum computing."
)
print(result["status"], result["output"])
asyncio.run(run_async_agent())
For a caller-supplied tighter limit, use
agent.invoke_with_runtime_limit(input, max_runtime_seconds=...) or
await agent.ainvoke_with_runtime_limit(...). The supplied limit can only
tighten AdvancedAgentConfig.max_runtime_seconds; a negative limit is rejected.
The optional checkpoint_lineage argument lets a parent orchestration call
preserve lineage when invoking nested agents.
Checkpoints — Pause and Resume
Checkpoints let you save the agent's state mid-run and resume it later — useful when:
- A user needs to review an intermediate result before continuing
- A tool calls for human approval
- The agent hit
max_iterationsbut needs more turns - You want to test recovery from partial failures
How checkpoints work:
The runtime writes internal snapshots before tool execution, after tool results,
and around reflection transitions. The default store is
InMemoryCheckpointStore; use FileCheckpointStore when checkpoints must
survive process restarts. Internal snapshots are not all returned to callers:
the public result contains a checkpoint_id only when a tool error interrupts
the run with interrupt_on_tool_error=True.
Resuming an awaiting_tool_execution checkpoint runs its pending tool calls
again. Resuming a tool_results_ready checkpoint reuses the recorded tool
results, and resuming an awaiting_reflection checkpoint reruns reflection.
Make pending tools idempotent when an interrupted run might be resumed.
Checkpoint + resume example:
from llmadapter import create_llmadapter_agent, AdvancedAgentConfig
from llmadapter.orchestration.tools import InMemoryCheckpointStore
# Shared store — pass the same instance to keep checkpoints alive
store = InMemoryCheckpointStore()
agent = create_llmadapter_agent(
primary_llm=primary_llm,
tools=[get_weather],
config=AdvancedAgentConfig(max_iterations=3, interrupt_on_tool_error=True),
checkpoint_store=store,
)
# A failing tool call produces a resumable checkpoint only when the model calls it.
result = agent.invoke("Research the history of AI and write a summary.")
checkpoint_id = result.get("checkpoint_id")
if checkpoint_id is not None:
# Resume — agent picks up from the saved runtime state.
resumed = agent.resume(
checkpoint_id,
user_feedback="Continue from where you left off.", # optional extra instruction
)
print(resumed["status"])
print(resumed.get("output"))
else:
print("This run did not produce a resumable interruption.")
Use await agent.aresume(checkpoint_id, user_feedback=...) to resume through
the asynchronous runtime.
Manual checkpoint management:
from llmadapter.orchestration.tools import InMemoryCheckpointStore
store = InMemoryCheckpointStore()
# Save arbitrary state
store.save("my-session-id", {"messages": [], "step": 5})
# Load it back
state = store.load("my-session-id")
# Delete when done
store.delete("my-session-id")
Important:
InMemoryCheckpointStorestores data in process memory.FileCheckpointStore(directory)writes atomic JSON files with a SHA-256 checksum and restores serialized LangChain messages. It is not encrypted; protect the directory with operating-system permissions and do not treat checksum validation as access control. Dataclasses and Pydantic values serialize to JSON-compatible dictionaries; unsupported values are stringified, so use a customCheckpointStorewhen exact custom type restoration or a database/secret-backed store is required.
Event Handlers — Observability
Event handlers expose agent loop and local tool-execution boundaries without modifying agent behavior. They do not emit provider request/response telemetry, checkpoint-store I/O, or a terminal result event.
Built-in handlers:
from llmadapter import CollectingAgentEventHandler, LoggingAgentEventHandler
# 1. CollectingAgentEventHandler — stores all events in memory
collector = CollectingAgentEventHandler()
agent = create_llmadapter_agent(
primary_llm=primary_llm,
tools=[get_weather],
event_handler=collector,
)
result = agent.invoke("What is the weather in Berlin?")
# Inspect what happened
for event in collector.events:
print(f"[{event.event}] step={event.step} payload={event.payload}")
# 2. LoggingAgentEventHandler — logs to Python's logging system
import logging
logging.basicConfig(level=logging.INFO)
logger_handler = LoggingAgentEventHandler(logger_name="llmadapter.agent")
agent = create_llmadapter_agent(
primary_llm=primary_llm,
tools=[get_weather],
event_handler=logger_handler,
)
Events emitted:
| Event | Fired when | payload keys |
|---|---|---|
step_start |
An iteration begins | messages (message count) |
tool_start |
A registered tool is about to run | tool_name, args |
tool_end |
A tool returns successfully | tool_name, result, duration_s |
tool_error |
A started tool raises or times out | tool_name, error, duration_s |
step_end |
A finished answer is returned, or a tool step reaches its saved post-tool boundary | finished; tool-step events also include checkpoint_id |
CollectingAgentEventHandler stores the full tool arguments, results, and
errors in memory. Use it carefully with sensitive data. LoggingAgentEventHandler
logs redacted shape summaries rather than raw payload values.
Custom event handler:
Implement the AgentEventHandler protocol to build your own observer:
from typing import Any
class MetricsEventHandler:
def on_step_start(self, step: int, context: dict[str, Any]) -> None:
pass # e.g., start a timer
def on_tool_start(self, step: int, tool_name: str, args: dict[str, Any]) -> None:
print(f" -> calling {tool_name}")
def on_tool_end(self, step: int, tool_name: str, result: Any, duration_s: float) -> None:
print(f" <- {tool_name} returned in {duration_s:.2f}s")
def on_tool_error(self, step: int, tool_name: str, error: str, duration_s: float) -> None:
print(f" ! {tool_name} failed: {error}")
def on_step_end(self, step: int, context: dict[str, Any]) -> None:
pass
# Optional: called when a routing policy returns a decision.
# Built-in collectors/loggers do not record this optional callback.
def on_routing_decision(self, step: int, decision: dict[str, Any]) -> None:
pass
Routing Policies — Dynamic Model Selection
Routing chooses the LLM for the next agent step. A policy sees whether the previous step had a tool error, so a tool error cannot change the model that already produced that step. If no fallback LLM is configured, every built-in policy selects the primary model.
Available Policies
EscalationRoutingPolicy — switch to fallback on tool errors:
from llmadapter import EscalationRoutingPolicy
agent = create_llmadapter_agent(
primary_llm=primary_llm,
fallback_llm=fallback_llm,
tools=[get_weather],
routing_policy=EscalationRoutingPolicy(),
)
# Primary is used by default.
# If a tool error occurs, the following model step uses fallback.
When to use: Your primary model is powerful but expensive. Use a cheaper fallback that is better at correcting errors.
RoundRobinRoutingPolicy — alternate between primary and fallback:
from llmadapter import RoundRobinRoutingPolicy
agent = create_llmadapter_agent(
primary_llm=primary_llm,
fallback_llm=fallback_llm,
tools=[get_weather],
routing_policy=RoundRobinRoutingPolicy(),
)
# Step 1: primary, Step 2: fallback, Step 3: primary, ...
The round-robin index lives in the agent's _routing_state, so it survives
multiple invocations of the same agent instance and is preserved in checkpoints.
When to use: Load balancing between two equivalent deployments to avoid rate limits.
CostAwareRoutingPolicy — use fallback after a step threshold:
from llmadapter import CostAwareRoutingPolicy
agent = create_llmadapter_agent(
primary_llm=primary_llm, # powerful, expensive (first N steps)
fallback_llm=fallback_llm, # cheaper (later steps)
tools=[get_weather],
routing_policy=CostAwareRoutingPolicy(fallback_after_step=3),
)
# Steps 1-2: primary model. Steps 3+: fallback model.
When to use: Early steps need a powerful model for reasoning; later steps (simple summaries) can use a cheaper one. This is a step-count threshold, not live price, token, or usage telemetry.
Custom routing function (maximum flexibility):
def my_router(messages, step):
# Use fallback for odd-numbered steps
if step % 2 == 1:
return fallback_llm
return primary_llm
agent = create_llmadapter_agent(
primary_llm=primary_llm,
fallback_llm=fallback_llm,
tools=[get_weather],
router=my_router, # overrides routing_policy if both are set
)
router takes precedence over routing_policy and receives the current message
list plus the 1-based step number. A custom router must return a
ChatLLMAdapter; it is responsible for any error-aware or cost-aware logic.
Policies may optionally emit on_routing_decision(step, decision) to a custom
event handler. The decision payload contains policy, reason, metadata, and
the selected model name.
Tool Retry and Escalation
config = AdvancedAgentConfig(
tool_retry_attempts=3, # retry failed idempotent tools up to 3 times
tool_retry_backoff_seconds=0.5, # wait 500ms between retries
retry_on_tool_not_found=False, # don't retry if the tool name is wrong
escalate_on_retry_exhausted=True, # stop if retries fail and no fallback is configured
)
To declare that a tool is safe to repeat, set tool.llmadapter_idempotent = True
or tool.metadata["llmadapter_idempotent"] = True. Tool timeouts are never retried:
a synchronous timeout only bounds the agent's wait and the underlying work may
still be running. retry_non_idempotent_tools=True preserves legacy broad retry
behavior, but can repeat side effects and should be used only deliberately.
You can also retry tool results manually after a run:
from llmadapter.orchestration.tools import ToolExecutionResult, is_retryable_tool_error, retry_failed_tool_calls
# Check if a failed tool result is worth retrying
failed = ToolExecutionResult(
tool_call_id="call_1", tool_name="fetch", args={}, ok=False, content="", error="temporary failure"
)
if is_retryable_tool_error(failed, idempotent=True):
print("This error is retryable")
# Re-run failed calls from a completed result list
retried_results = retry_failed_tool_calls(
tool_calls=[{"id": "call_1", "name": "fetch", "args": {}}],
current_results=[failed],
tool_map=agent.tool_map,
step=1,
timeout_s=10,
max_attempts=2,
backoff_seconds=0.3,
retry_on_tool_not_found=False,
)
Layer 4 — Multi-Agent Orchestration
When a task is too large for one agent, break it into specialists — focused agents that each handle a domain. A planner agent can coordinate them.
Specialists as Tools
The easiest way to compose agents: wrap each specialist agent as a LangChain tool that the planner can call.
from llmadapter import (
ChatLLMAdapter,
AdvancedAgentConfig,
create_llmadapter_agent,
make_specialist_tools,
make_subagent_tool,
)
# Create specialist agents
finance_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o-mini")
travel_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o-mini")
finance_agent = create_llmadapter_agent(
primary_llm=finance_llm,
config=AdvancedAgentConfig(max_iterations=4),
)
travel_agent = create_llmadapter_agent(
primary_llm=travel_llm,
config=AdvancedAgentConfig(max_iterations=4),
)
# Wrap them as tools the planner can call
specialist_tools = make_specialist_tools(
{
"finance_specialist": (
"Handles budgets, costs, and price trade-offs.",
finance_agent,
),
"travel_specialist": (
"Handles itinerary planning and logistics.",
travel_agent,
),
}
)
# Create the planner agent with specialists as tools
planner_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o")
planner = create_llmadapter_agent(
primary_llm=planner_llm,
tools=specialist_tools,
config=AdvancedAgentConfig(parallel_tools=True, max_iterations=8),
)
result = planner.invoke("Plan a low-budget 3-day trip to Barcelona.")
print(result["output"])
For a single subagent tool:
from llmadapter import make_subagent_tool
research_tool = make_subagent_tool(
name="research_agent",
description="Performs deep research on any topic.",
agent=create_llmadapter_agent(
primary_llm=finance_llm,
config=AdvancedAgentConfig(max_iterations=4),
),
)
planner = create_llmadapter_agent(
primary_llm=planner_llm,
tools=[research_tool],
config=AdvancedAgentConfig(),
)
Capability Contracts (SpecialistContract)
Contracts are declarative labels for filtering and aggregation. A specialist is
selected only when its contract contains every value in
required_capabilities; a specialist without a contract is excluded when a
capability requirement is supplied. Contracts do not validate model behavior,
route work, or inject their metadata into prompts.
from llmadapter import SpecialistContract, invoke_specialists
contracts = {
"finance": SpecialistContract(
capabilities={"finance", "budget", "cost"},
weight=1.5, # consumed by WeightedScoreAggregator when selected
metadata={"model": "gpt-4o"},
),
"travel": SpecialistContract(
capabilities={"travel", "itinerary", "logistics"},
weight=1.0,
metadata={"model": "gpt-4o-mini"},
),
"legal": SpecialistContract(
capabilities={"legal", "compliance"},
weight=2.0,
),
}
specialists = {
"finance": finance_agent,
"travel": travel_agent,
# "legal": legal_agent, # not available
}
# Only call specialists that match the required capabilities
rows = invoke_specialists(
task="What is the estimated cost for a 3-day Barcelona trip?",
specialists=specialists,
contracts=contracts,
required_capabilities={"finance"}, # only finance will be called
)
for row in rows:
print(f"{row['specialist']} (weight={row['weight']}): {row['output']}")
SpecialistContract parameters:
| Parameter | Type | Description |
|---|---|---|
capabilities |
set[str] |
Tags used for subset matching against required_capabilities. |
weight |
float |
Finite, non-negative value attached to rows; WeightedScoreAggregator consumes it. Default 1.0. |
metadata |
dict |
Passive application metadata; runtime selection does not inspect it. |
Async Specialists and Shared Deadlines
Use ainvoke_specialists(...) and aaggregate_specialists(...) for
asynchronous specialist runs. They accept the same arguments as their
synchronous counterparts. max_runtime_seconds creates one shared monotonic
deadline; each specialist receives the time remaining from that deadline rather
than an independent full budget. This bounds the agent loop but cannot forcibly
stop already-running synchronous or provider work.
Shared State Across Agents (SharedState)
SharedState is a thread-safe host-side key-value store. When specialists
are launched, the runtime projects a JSON snapshot into each task prompt as
SharedStateContext(JSON). It is not a live object or shared model memory:
changes made while a specialist runs are not pushed into that specialist's
already-created prompt.
from llmadapter import SharedState
state = SharedState({
"destination": "Barcelona",
"currency": "EUR",
"budget_eur": None, # will be filled by finance specialist
})
# Pass it to invoke_specialists — each specialist gets a prompt projection.
rows = invoke_specialists(
task="Calculate total trip budget.",
specialists=specialists,
contracts=contracts,
shared_state=state,
)
# After each specialist returns, the host runtime stores its output here.
print(state.get("specialist_outputs.finance"))
print(state.get("destination")) # reads initial value
# Application code can update shared state between orchestration calls.
state.set("budget_eur", 850)
state.update({"hotel_booked": True, "flights_booked": False})
# Thread-safe snapshot for logging
snapshot = state.snapshot()
print(snapshot)
SharedState methods:
| Method | Description |
|---|---|
get(key, default=None) |
Read a value by key. |
set(key, value) |
Write a single key. |
update(dict) |
Write multiple keys at once. |
snapshot() |
Return a copy of the entire state dict. |
Use shared_state_keys to allowlist prompt-visible keys and
shared_state_max_bytes (default 16_000) to cap the JSON projection. Keep
secrets and large mutable objects out of prompt-visible state.
Aggregation Strategies
After multiple specialists respond to the same task, use an aggregator to combine their answers into one result.
from llmadapter import (
aggregate_specialists,
MajorityVoteAggregator,
WeightedScoreAggregator,
JsonMergeAggregator,
AggregatedResult,
)
rows = invoke_specialists(task="...", specialists=specialists, contracts=contracts)
All three aggregators accept include_failed=False by default. In that mode,
rows with status failed, error, interrupted, stopped, or timeout are excluded;
AggregatedResult.inputs contains the eligible rows, not necessarily the
original input list.
MajorityVoteAggregator — picks the most common answer:
aggregator = MajorityVoteAggregator(value_key="output")
result: AggregatedResult = aggregator.aggregate(rows)
print(result.consensus) # the winning answer
print(result.confidence) # fraction of specialists that agreed (0.0-1.0)
print(result.metadata) # {"votes": {"answer_A": 2, "answer_B": 1}}
When to use: Classification tasks, yes/no decisions, labeling — when you want the answer most agents agreed on. Ties are deterministic but not semantic: the implementation chooses the lexicographically greatest serialized output bucket.
WeightedScoreAggregator — picks the highest-weighted answer:
aggregator = WeightedScoreAggregator(value_key="output", weight_key="weight")
result = aggregator.aggregate(rows)
print(result.consensus) # the answer with the highest cumulative weight
print(result.confidence) # weighted fraction
When to use: When some specialists are more authoritative than others (e.g., a fine-tuned finance model should outweigh a general one).
Like majority voting, a weighted tie is resolved by the lexicographically greatest serialized output bucket. Weights must be finite and non-negative.
JsonMergeAggregator — merges all JSON responses into one dict:
aggregator = JsonMergeAggregator(conflict_policy="last_writer_wins")
result = aggregator.aggregate(rows)
# When eligible outputs are JSON objects, consensus is their merged dict.
print(result.consensus)
JsonMergeAggregator accepts last_writer_wins (default),
first_writer_wins, or error. It parses string outputs as JSON when possible,
merges only JSON-object outputs in input order, and records conflicts in
result.metadata. If no eligible output is an object, it falls back to majority
voting and reports strategy="json_merge_fallback_majority".
When to use: When each specialist provides a different piece of information (e.g., travel agent provides itinerary, finance agent provides costs) and you want one unified object.
AggregatedResult attributes:
| Attribute | Description |
|---|---|
consensus |
The final aggregated answer. |
inputs |
Eligible specialist rows after the aggregator's failure filter. |
strategy |
Name of the aggregation strategy used. |
confidence |
Score between 0-1 indicating confidence (meaning varies by strategy). |
metadata |
Strategy-specific extra data (e.g., vote counts). |
Using aggregate_specialists helper:
from llmadapter import aggregate_specialists
result = aggregate_specialists(
task="...",
specialists=specialists,
contracts=contracts,
aggregator=WeightedScoreAggregator(),
)
print(result.consensus)
Full Multi-Agent Example
import os
from llmadapter import (
ChatLLMAdapter,
AdvancedAgentConfig,
SpecialistContract,
SharedState,
create_llmadapter_agent,
invoke_specialists,
aggregate_specialists,
WeightedScoreAggregator,
CollectingAgentEventHandler,
EscalationRoutingPolicy,
)
# --- Build specialist agents ---
def make_llm(model: str) -> ChatLLMAdapter:
return ChatLLMAdapter(
provider="azure_openai",
api_key=os.environ["AZURE_OPENAI_API_KEY"],
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
api_version="2024-02-01",
model=model,
)
collector = CollectingAgentEventHandler()
finance_agent = create_llmadapter_agent(
primary_llm=make_llm("gpt-4o"),
config=AdvancedAgentConfig(max_iterations=4, reflection_enabled=False),
event_handler=collector,
)
travel_agent = create_llmadapter_agent(
primary_llm=make_llm("gpt-4o"),
config=AdvancedAgentConfig(max_iterations=4, reflection_enabled=False),
routing_policy=EscalationRoutingPolicy(),
)
# --- Contracts ---
contracts = {
"finance": SpecialistContract(capabilities={"finance", "budget"}, weight=1.5),
"travel": SpecialistContract(capabilities={"travel", "itinerary"}, weight=1.0),
}
specialists = {"finance": finance_agent, "travel": travel_agent}
# --- Shared context ---
shared_state = SharedState({"destination": "Lisbon", "days": 3, "currency": "EUR"})
# --- Run ---
rows = invoke_specialists(
task="Provide a short one-sentence recommendation for this trip.",
specialists=specialists,
contracts=contracts,
required_capabilities={"finance"}, # only finance needed
shared_state=shared_state,
)
result = WeightedScoreAggregator().aggregate(rows)
print("Final answer:", result.consensus)
print("Confidence:", result.confidence)
# --- Inspect events ---
step_events = [e for e in collector.events if e.event == "step_start"]
print(f"Finance agent used {len(step_events)} steps")
Extending with a New Provider
-
Create a provider file under
llmadapter/providers/my_provider.pyimplementing the relevantBaseChatProvider,BaseEmbeddingProvider, and/orBaseResponsesProvidercontract fromllmadapter/base.py. -
Register each implemented surface in
llmadapter/core/registry.py:
from ..providers.my_provider import (
AsyncMyChatProvider,
AsyncMyResponsesProvider,
MyChatProvider,
MyEmbeddingProvider,
MyResponsesProvider,
)
CHAT_PROVIDER_REGISTRY["my_provider"] = MyChatProvider
EMBEDDING_PROVIDER_REGISTRY["my_provider"] = MyEmbeddingProvider
ASYNC_CHAT_PROVIDER_REGISTRY["my_provider"] = AsyncMyChatProvider
RESPONSES_PROVIDER_REGISTRY["my_provider"] = MyResponsesProvider
ASYNC_RESPONSES_PROVIDER_REGISTRY["my_provider"] = AsyncMyResponsesProvider
Only register interfaces that actually exist. Provider modules are imported optionally, so a missing optional SDK should leave its registry entries absent rather than make package import fail.
-
Add a config model in
config.pyand register it inCONFIG_REGISTRY. Also register aProviderCapabilityManifestthroughregister_provider_capabilities(...); sync/async chat and embedding dispatch consult that manifest before invoking a provider. -
Add tests covering every registered surface and its normalized response shape before shipping.
Project Structure
llmadapter/
├── __init__.py # public API — re-exports everything below
├── core/ # adapter engine
│ ├── sync_adapter.py # LLMAdapter, _ChatCompletions, _Embeddings
│ ├── async_adapter.py # AsyncLLMAdapter, _AsyncChatCompletions (non-blocking I/O)
│ ├── registry.py # chat, embedding, and Responses sync/async registries
│ ├── retry.py # retry + full-jitter exponential back-off
│ ├── failover.py # backup-provider failover helpers
│ ├── execution.py # target-aware normalized request plans
│ ├── responses_capabilities.py # Responses request validation and capability gates
│ ├── streaming.py # lazy stream sessions and pre-output failover
│ ├── circuit_breaker.py # target-aware circuit breaker
│ ├── preprocess.py # message prep + provider-client cache (ProviderCache)
│ └── redaction.py # sensitive-field redaction for logs
├── ratelimit/ # rate limiting + tokenization
│ ├── limiter.py # RedisRateLimiter (pooled connections), RateLimitMetrics
│ ├── tokenizer.py # token counting / truncation (cached encodings)
│ ├── batching.py # AdapterUtility — token-aware embedding batches
│ └── logging.py # log_structured
├── structured.py # structured-output helpers (pydantic_to_response_format, …) + to_jsonable
├── integrations/
│ └── langchain.py # ChatLLMAdapter — LangChain BaseChatModel wrapper
├── orchestration/ # multi-agent runtime
│ ├── runtime.py # LLMAdapterAgent, AdvancedAgentConfig, create_llmadapter_agent
│ ├── tools.py # tool execution, budgets, checkpoints, event handlers
│ ├── routing.py # BaseRoutingPolicy and routing policy implementations
│ ├── aggregators.py # MajorityVote / WeightedScore / JsonMerge aggregators
│ └── contracts.py # SharedState, SpecialistContract
├── providers/ # one module per provider
│ ├── _shared.py # shared OpenAI-shape response mappers (OpenAI + Azure OpenAI)
│ ├── _responses_base.py # shared OpenAI/Azure Responses provider verbs
│ ├── capabilities.py # provider capability manifests
│ ├── azureopenai_provider.py
│ ├── azurefoundry_provider.py
│ ├── openai_provider.py
│ ├── vertexai_provider.py
│ └── anthropic_provider.py
├── config.py # Typed provider config models
├── models.py # Request/response models
├── responses_tools.py # Typed built-in Responses tool requests
├── errors.py # Typed exception hierarchy
├── base.py # Provider interfaces (chat, embedding, Responses)
└── constants.py # Default rate limits / retry settings
examples/
└── llmadapter_example.py # full walkthrough (chat, embed, async, LangChain, agents)
API Reference
For the complete exported API surface with all class signatures, parameters, and return types, see:
Selected top-level imports:
from llmadapter import (
# Core
LLMAdapter,
AsyncLLMAdapter,
# LangChain layer
ChatLLMAdapter,
# Agent runtime
AdvancedAgentConfig,
LLMAdapterAgent,
create_llmadapter_agent,
# Multi-agent helpers
make_subagent_tool,
make_specialist_tools,
ainvoke_specialists,
invoke_specialists,
aaggregate_specialists,
aggregate_specialists,
# Aggregators
AggregatedResult,
BaseAggregator,
JsonMergeAggregator,
MajorityVoteAggregator,
WeightedScoreAggregator,
# Orchestration
SharedState,
SpecialistContract,
# Routing policies
BaseRoutingPolicy,
RoundRobinRoutingPolicy,
EscalationRoutingPolicy,
CostAwareRoutingPolicy,
# Observability
CollectingAgentEventHandler,
LoggingAgentEventHandler,
CheckpointStore,
InMemoryCheckpointStore,
FileCheckpointStore,
# Errors
LLMAdapterError,
AuthenticationError,
ModelNotFoundError,
RateLimitExceededError,
APIConnectionError,
TimeOutError,
ChatCompletionError,
EmbeddingGenerationError,
ContentFilterError,
InvalidResponseError,
)
Metadata
Release files for llmadapterevs 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| llmadapterevs-0.1.0.tar.gz | 229.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| llmadapterevs-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 412.1 kB
Release files / llmadapterevs-0.1.0.tar.gz
| Download URL | llmadapterevs-0.1.0.tar.gz |
|---|---|
| Size | 229.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
18d0ce24d5ff2cc579d0a48821536f9b6f593492efde10a2d234886445a1bdf5
|
|
BLAKE2b-256 checksum How to use checksums |
fbc434c2ddcbb7c832f4dd1425ecd65ab0f939085ef6c260e83cd982049c5977
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / llmadapterevs-0.1.0-py3-none-any.whl
| Download URL | llmadapterevs-0.1.0-py3-none-any.whl |
|---|---|
| Size | 182.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
40782f72a06949d1bd844347ee7fa69341037df4c7d9f5cee16c38f09f81d7cd
|
|
BLAKE2b-256 checksum How to use checksums |
159e9f6a4473e1fed3215a46b9bb473f7354e6a697fa32ed1405675ff5a9fe03
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for llmadapterevs 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.