llmadapterevs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. llmadapterevs-0.1.0/PKG-INFO +2983 -0
  2. llmadapterevs-0.1.0/README.md +2936 -0
  3. llmadapterevs-0.1.0/llmadapter/__init__.py +205 -0
  4. llmadapterevs-0.1.0/llmadapter/base.py +120 -0
  5. llmadapterevs-0.1.0/llmadapter/config.py +191 -0
  6. llmadapterevs-0.1.0/llmadapter/constants.py +13 -0
  7. llmadapterevs-0.1.0/llmadapter/core/__init__.py +32 -0
  8. llmadapterevs-0.1.0/llmadapter/core/async_adapter.py +876 -0
  9. llmadapterevs-0.1.0/llmadapter/core/circuit_breaker.py +140 -0
  10. llmadapterevs-0.1.0/llmadapter/core/execution.py +279 -0
  11. llmadapterevs-0.1.0/llmadapter/core/failover.py +110 -0
  12. llmadapterevs-0.1.0/llmadapter/core/preprocess.py +630 -0
  13. llmadapterevs-0.1.0/llmadapter/core/redaction.py +47 -0
  14. llmadapterevs-0.1.0/llmadapter/core/registry.py +80 -0
  15. llmadapterevs-0.1.0/llmadapter/core/responses_capabilities.py +313 -0
  16. llmadapterevs-0.1.0/llmadapter/core/responses_management.py +81 -0
  17. llmadapterevs-0.1.0/llmadapter/core/retry.py +83 -0
  18. llmadapterevs-0.1.0/llmadapter/core/streaming.py +549 -0
  19. llmadapterevs-0.1.0/llmadapter/core/sync_adapter.py +1075 -0
  20. llmadapterevs-0.1.0/llmadapter/errors.py +439 -0
  21. llmadapterevs-0.1.0/llmadapter/integrations/__init__.py +20 -0
  22. llmadapterevs-0.1.0/llmadapter/integrations/langchain.py +1174 -0
  23. llmadapterevs-0.1.0/llmadapter/models.py +1020 -0
  24. llmadapterevs-0.1.0/llmadapter/orchestration/__init__.py +62 -0
  25. llmadapterevs-0.1.0/llmadapter/orchestration/aggregators.py +215 -0
  26. llmadapterevs-0.1.0/llmadapter/orchestration/contracts.py +164 -0
  27. llmadapterevs-0.1.0/llmadapter/orchestration/routing.py +196 -0
  28. llmadapterevs-0.1.0/llmadapter/orchestration/runtime.py +1754 -0
  29. llmadapterevs-0.1.0/llmadapter/orchestration/tools.py +773 -0
  30. llmadapterevs-0.1.0/llmadapter/providers/__init__.py +58 -0
  31. llmadapterevs-0.1.0/llmadapter/providers/_responses_base.py +384 -0
  32. llmadapterevs-0.1.0/llmadapter/providers/_shared.py +411 -0
  33. llmadapterevs-0.1.0/llmadapter/providers/anthropic_provider.py +999 -0
  34. llmadapterevs-0.1.0/llmadapter/providers/azurefoundry_provider.py +749 -0
  35. llmadapterevs-0.1.0/llmadapter/providers/azureopenai_provider.py +681 -0
  36. llmadapterevs-0.1.0/llmadapter/providers/capabilities.py +137 -0
  37. llmadapterevs-0.1.0/llmadapter/providers/openai_provider.py +615 -0
  38. llmadapterevs-0.1.0/llmadapter/providers/vertexai_provider.py +817 -0
  39. llmadapterevs-0.1.0/llmadapter/ratelimit/__init__.py +24 -0
  40. llmadapterevs-0.1.0/llmadapter/ratelimit/batching.py +76 -0
  41. llmadapterevs-0.1.0/llmadapter/ratelimit/limiter.py +435 -0
  42. llmadapterevs-0.1.0/llmadapter/ratelimit/logging.py +29 -0
  43. llmadapterevs-0.1.0/llmadapter/ratelimit/tokenizer.py +217 -0
  44. llmadapterevs-0.1.0/llmadapter/responses_tools.py +95 -0
  45. llmadapterevs-0.1.0/llmadapter/structured.py +291 -0
  46. llmadapterevs-0.1.0/llmadapterevs.egg-info/PKG-INFO +2983 -0
  47. llmadapterevs-0.1.0/llmadapterevs.egg-info/SOURCES.txt +50 -0
  48. llmadapterevs-0.1.0/llmadapterevs.egg-info/dependency_links.txt +1 -0
  49. llmadapterevs-0.1.0/llmadapterevs.egg-info/requires.txt +43 -0
  50. llmadapterevs-0.1.0/llmadapterevs.egg-info/top_level.txt +1 -0
  51. llmadapterevs-0.1.0/pyproject.toml +78 -0
  52. llmadapterevs-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,2983 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmadapterevs
3
+ Version: 0.1.0
4
+ Summary: A unified Python client for OpenAI-compatible and multi-provider LLM APIs.
5
+ Author: Rituraj.Raman
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pydantic>=2.11.7
13
+ Requires-Dist: tiktoken>=0.12.0
14
+ Requires-Dist: openai<4.0.0,>=3.1.0
15
+ Requires-Dist: anthropic>=0.40.0
16
+ Requires-Dist: azure-ai-inference>=1.0.0b9
17
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0
18
+ Requires-Dist: google-genai>=1.49.0
19
+ Provides-Extra: base
20
+ Requires-Dist: openai<4.0.0,>=3.1.0; extra == "base"
21
+ Provides-Extra: anthropic
22
+ Requires-Dist: anthropic>=0.40.0; extra == "anthropic"
23
+ Provides-Extra: azure-foundry
24
+ Requires-Dist: azure-ai-inference>=1.0.0b9; extra == "azure-foundry"
25
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0; extra == "azure-foundry"
26
+ Provides-Extra: vertexai
27
+ Requires-Dist: google-genai>=1.49.0; extra == "vertexai"
28
+ Provides-Extra: langchain
29
+ Requires-Dist: langchain-core>=0.3.0; extra == "langchain"
30
+ Requires-Dist: langchain>=1.0.0; extra == "langchain"
31
+ Provides-Extra: redis
32
+ Requires-Dist: redis>=6.4.0; extra == "redis"
33
+ Provides-Extra: all
34
+ Requires-Dist: openai<4.0.0,>=3.1.0; extra == "all"
35
+ Requires-Dist: redis>=6.4.0; extra == "all"
36
+ Requires-Dist: anthropic>=0.40.0; extra == "all"
37
+ Requires-Dist: azure-ai-inference>=1.0.0b9; extra == "all"
38
+ Requires-Dist: aiohttp<4.0.0,>=3.9.0; extra == "all"
39
+ Requires-Dist: google-genai>=1.49.0; extra == "all"
40
+ Requires-Dist: langchain-core>=0.3.0; extra == "all"
41
+ Requires-Dist: langchain>=1.0.0; extra == "all"
42
+ Provides-Extra: dev
43
+ Requires-Dist: build>=1.2.2; extra == "dev"
44
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
45
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
46
+ Requires-Dist: python-dotenv>=1.0.1; extra == "dev"
47
+
48
+ # LLMAdapter
49
+
50
+ LLMAdapter is a unified Python client for Large Language Model (LLM) providers. It gives you one consistent interface
51
+ for chat completions, embeddings, tool calling, streaming, context caching, and full agentic workflows — across
52
+ **Azure OpenAI**, **Azure Foundry**, **OpenAI**, **Vertex AI**, and **Anthropic** — without changing your code when
53
+ you switch providers.
54
+
55
+ > **New to LLMs or AI APIs?** Start with [Layer 1 — Core Adapter](#layer-1--core-adapter-llmadapter--asyncllmadapter).
56
+ > Once you're comfortable, work your way through the layers below.
57
+
58
+ ## Start Here
59
+
60
+ The [runnable example](examples/llmadapter_example.py) covers chat,
61
+ embeddings, Responses, async requests, LangChain, agents, and orchestration.
62
+ Install its optional dependencies, then try the credential-free resilience
63
+ section first:
64
+
65
+ ```bash
66
+ pip install -e ".[all,dev]"
67
+ python examples/llmadapter_example.py --section resilience
68
+ ```
69
+
70
+ To exercise provider-backed sections, configure the
71
+ [Azure OpenAI credentials](#environment-variables--credentials) used by the
72
+ example. Run everything with `--section all`, or choose one section:
73
+
74
+ ```bash
75
+ python examples/llmadapter_example.py --section core
76
+ python examples/llmadapter_example.py --section responses
77
+ python examples/llmadapter_example.py --section async
78
+ python examples/llmadapter_example.py --section langchain
79
+ python examples/llmadapter_example.py --section agent
80
+ python examples/llmadapter_example.py --section orchestration
81
+ python examples/llmadapter_example.py --section all
82
+ ```
83
+
84
+ The Responses section requires an Azure OpenAI deployment that supports that
85
+ API; sections requiring credentials are skipped when the chat profile is
86
+ missing. For exact signatures, capability boundaries, and return types, use the
87
+ [API reference](docs/API_REFERENCE.md).
88
+
89
+ ---
90
+
91
+ ## Table of Contents
92
+
93
+ 1. [Feature Guide](#feature-guide--what-to-use-when-and-why)
94
+ 2. [Installation](#installation)
95
+ 3. [Environment Variables & Credentials](#environment-variables--credentials)
96
+ 4. [Layer 1 — Core Adapter (`LLMAdapter` / `AsyncLLMAdapter`)](#layer-1--core-adapter-llmadapter--asyncllmadapter)
97
+ - [Supported Providers & Configuration](#supported-providers--configuration)
98
+ - [Chat Completions — Sync](#chat-completions--sync)
99
+ - [Chat Completions — Streaming](#chat-completions--streaming)
100
+ - [Chat Completions — Async](#chat-completions--async)
101
+ - [Async Streaming](#async-streaming)
102
+ - [All Chat Parameters Reference](#all-chat-parameters-reference)
103
+ - [Structured Outputs](#structured-outputs) — JSON Object / JSON Schema / Typed Parse (`.parse()`)
104
+ - [Tool Calling (Raw API)](#tool-calling-raw-api)
105
+ - [Multimodal Input (Images)](#multimodal-input-images)
106
+ - [Embeddings](#embeddings)
107
+ - [Context Caching](#context-caching)
108
+ - [Failover / Backup Config](#failover--backup-config)
109
+ - [Rate Limiting](#rate-limiting)
110
+ - [Error Handling](#error-handling)
111
+ - [Response Shapes](#response-shapes)
112
+ 5. [Responses API (`adapter.responses`)](#responses-api-adapterresponses)
113
+ - [Methods](#methods)
114
+ - [Basic Usage](#basic-usage)
115
+ - [Streaming](#streaming)
116
+ - [Structured Outputs](#structured-outputs-1)
117
+ - [Remote MCP and connectors](#remote-mcp-and-connectors)
118
+ - [Multi-Turn State](#multi-turn-state)
119
+ - [Managing Stored Responses](#managing-stored-responses)
120
+ - [Pre-Flight Token Counting](#pre-flight-token-counting)
121
+ - [Async](#async)
122
+ - [Azure Notes](#azure-notes)
123
+ - [Provider extension provenance](#provider-extension-provenance)
124
+ 6. [Layer 2 — LangChain Compatibility (`ChatLLMAdapter`)](#layer-2--langchain-compatibility-chatllmadapter)
125
+ - [What You Get Over Layer 1](#what-you-get-over-layer-1)
126
+ - [Basic Invoke & Stream](#basic-invoke--stream)
127
+ - [Async Invoke & Stream](#async-invoke--stream)
128
+ - [Tool Binding with LangChain](#tool-binding-with-langchain)
129
+ - [Structured Output with Pydantic](#structured-output-with-pydantic)
130
+ - [Using the Responses API (`api="responses"`)](#using-the-responses-api-apiresponses)
131
+ - [Chain Composition (LCEL)](#chain-composition-lcel)
132
+ 7. [Layer 3 — Advanced Agent Runtime (`LLMAdapterAgent`)](#layer-3--advanced-agent-runtime-llmadapteragent)
133
+ - [`AdvancedAgentConfig` — Every Parameter Explained](#advancedagentconfig--every-parameter-explained)
134
+ - [Creating an Agent](#creating-an-agent)
135
+ - [Running an Agent](#running-an-agent)
136
+ - [Async Agent Execution](#async-agent-execution)
137
+ - [Checkpoints — Pause and Resume](#checkpoints--pause-and-resume)
138
+ - [Event Handlers — Observability](#event-handlers--observability)
139
+ - [Routing Policies — Dynamic Model Selection](#routing-policies--dynamic-model-selection)
140
+ - [Tool Retry and Escalation](#tool-retry-and-escalation)
141
+ 8. [Layer 4 — Multi-Agent Orchestration](#layer-4--multi-agent-orchestration)
142
+ - [Specialists as Tools](#specialists-as-tools)
143
+ - [Capability Contracts (`SpecialistContract`)](#capability-contracts-specialistcontract)
144
+ - [Async Specialists and Shared Deadlines](#async-specialists-and-shared-deadlines)
145
+ - [Shared State Across Agents (`SharedState`)](#shared-state-across-agents-sharedstate)
146
+ - [Aggregation Strategies](#aggregation-strategies)
147
+ - [Full Multi-Agent Example](#full-multi-agent-example)
148
+ 9. [Extending with a New Provider](#extending-with-a-new-provider)
149
+ 10. [Project Structure](#project-structure)
150
+ 11. [API Reference](#api-reference)
151
+
152
+ ---
153
+
154
+ ## Feature Guide — What to Use, When, and Why
155
+
156
+ Use the simplest layer that meets your needs. Each layer builds on the one
157
+ below it; provider and model support still depends on the operation.
158
+
159
+ | Need | Use | Why / important notes |
160
+ |---|---|---|
161
+ | Portable text, tools, and embeddings | `LLMAdapter` | Lowest-level, provider-neutral API. Use for application services and batch jobs. |
162
+ | Non-blocking requests or streams | `AsyncLLMAdapter` | Uses provider-native async chat/Responses clients where available. |
163
+ | New OpenAI/Azure response workflows | `adapter.responses` | Supports typed output, background jobs, state, built-in tools, and structured parse. OpenAI/Azure OpenAI only. |
164
+ | Existing OpenAI-shaped integration | `adapter.chat.completions` | Retains legacy Chat Completions behavior across all supported chat providers. |
165
+ | LangChain chains or agents | `ChatLLMAdapter` | Choose `api="chat"` for broad provider support or `api="responses"` for OpenAI/Azure Responses features. |
166
+ | Tool-running autonomous workflows | `create_llmadapter_agent` | Adds bounded tool concurrency, deadlines, retry policy, routing, reflection, and checkpoints. |
167
+ | Several specialist agents | `invoke_specialists` / `aggregate_specialists` | Filters specialists by capability, attaches weights, and returns an explicit aggregation result. |
168
+
169
+ `ChatLLMAdapter` and all agent/orchestration exports require `langchain-core`, provided by the `langchain` extra.
170
+
171
+ ### One configuration shape for every provider
172
+
173
+ Pass the union of provider-specific fields without changing caller code when you
174
+ switch providers. Unrelated fields are accepted and ignored by the selected
175
+ provider. Default routing always uses:
176
+
177
+ $$
178
+ \text{deployment\_name} \rightarrow \text{per-call model} \rightarrow \text{configured model}
179
+ $$
180
+
181
+ Use `deployment_name` whenever your endpoint routes by deployment, including
182
+ Azure OpenAI native endpoints and Azure OpenAI-compatible `/openai/v1/`
183
+ endpoints. A configured deployment intentionally cannot be overridden by a
184
+ per-call model alias. Without a deployment, most providers accept a per-call
185
+ `model`; Anthropic fixes its model when the adapter is created and ignores
186
+ per-call `model` overrides.
187
+
188
+ ### Provider and capability boundaries
189
+
190
+ | Feature | OpenAI | Azure OpenAI | Azure Foundry | Anthropic | Vertex AI |
191
+ |--------------------------|----------------:|----------------:|---------------:|-----------------:|----------------------------------:|
192
+ | Legacy chat | Yes | Yes | Yes | Yes | Yes, sync only |
193
+ | Embeddings | Yes | Yes | Yes | No | No |
194
+ | Native async chat | Yes | Yes | Yes | Yes | No |
195
+ | Responses API | Yes | Yes | No | No | No |
196
+ | Built-in Responses tools | Yes | Yes | No | No | No |
197
+ | Prompt caching | Usage telemetry | Usage telemetry | No adapter API | Explicit markers | Provider-level CachedContent only |
198
+
199
+ Call `adapter.responses.capabilities()` before relying on model/deployment
200
+ specific Responses features. A result of `unknown` means the provider may
201
+ support the feature, but it should be validated on the target deployment.
202
+
203
+ ### Production defaults
204
+
205
+ - Use `timeout` to set provider transport timeouts.
206
+ - Use `share_clients=True` only when adapters are frequently created; shared
207
+ clients are lease-safe during eviction.
208
+ - Use `circuit_breaker=True` for fail-fast behavior during provider outages.
209
+ - Use `backup={...}` for an explicit compatible fallback target.
210
+ - Configure `rpm`, `tpm`, `context_output_reserve_tokens`, and an explicit
211
+ Redis outage policy for predictable cost and latency control.
212
+ - Keep provider credentials in environment variables or a secret manager, never
213
+ in notebooks, examples, source files, or committed `.env` files.
214
+
215
+ ---
216
+
217
+ ## Installation
218
+
219
+ ```bash
220
+ # Create and activate a virtual environment (recommended)
221
+ python -m venv .venv
222
+ source .venv/bin/activate # Linux / macOS
223
+ # .venv\Scripts\activate # Windows
224
+
225
+ # Python 3.11 or later is required.
226
+
227
+ # Install the base package: OpenAI and Azure OpenAI support.
228
+ pip install .
229
+
230
+ # Install one optional integration when needed.
231
+ pip install ".[redis]" # Redis-backed distributed rate limiting
232
+ pip install ".[anthropic]" # Anthropic chat
233
+ pip install ".[azure-foundry]" # Azure Foundry chat and embeddings
234
+ pip install ".[vertexai]" # Vertex AI sync chat
235
+ pip install ".[langchain]" # ChatLLMAdapter and agent/orchestration APIs
236
+
237
+ # Install every provider integration plus LangChain.
238
+ pip install ".[all]"
239
+
240
+ # Install the runnable example, tests, and development tooling.
241
+ pip install -e ".[all,dev]"
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Environment Variables & Credentials
247
+
248
+ LLMAdapter does not load environment variables or `.env` files automatically. Load credentials in application code or
249
+ from a secret manager, then pass the resulting values to the adapter constructor. The runnable example loads a local
250
+ `.env` file for convenience. The live-test hook loads `.env` only when
251
+ `LLMADAPTER_RUN_LIVE_TESTS=1` is already set in the shell.
252
+
253
+ **Create a `.env` file:**
254
+
255
+ ```dotenv
256
+ # Azure OpenAI
257
+ AZURE_OPENAI_API_KEY=your-azure-openai-key
258
+ AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
259
+ AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o
260
+ AZURE_OPENAI_API_VERSION=2024-02-01
261
+ AZURE_OPENAI_MODEL=gpt-4o
262
+
263
+ # Azure OpenAI embeddings (optional for the runnable example)
264
+ AZURE_OPENAI_EMBEDDING_API_KEY=your-azure-openai-embedding-key
265
+ AZURE_OPENAI_EMBEDDING_ENDPOINT=https://your-embedding-resource.openai.azure.com/
266
+ AZURE_OPENAI_EMBEDDING_API_VERSION=2024-02-01
267
+ AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-small
268
+
269
+ # Azure Foundry (optional)
270
+ AZURE_FOUNDRY_API_KEY=your-foundry-key
271
+ AZURE_FOUNDRY_ENDPOINT=https://your-foundry-endpoint.inference.ai.azure.com/
272
+ AZURE_FOUNDRY_DEPLOYMENT_NAME=Llama-4-Maverick
273
+ AZURE_FOUNDRY_API_VERSION=2024-05-01-preview
274
+
275
+ # OpenAI (optional)
276
+ OPENAI_API_KEY=sk-...
277
+ OPENAI_ORG=org-...
278
+ OPENAI_PROJECT=proj_...
279
+
280
+ # Vertex AI (optional)
281
+ VERTEXAI_CREDENTIALS_PATH=/path/to/service_account.json
282
+ VERTEXAI_LOCATION=us-central1
283
+ VERTEXAI_MODEL=gemini-2.5-pro
284
+
285
+ # Anthropic (optional)
286
+ ANTHROPIC_API_KEY=sk-ant-api03-...
287
+ ```
288
+
289
+ **Load in Python:**
290
+
291
+ The following local-development convenience uses `python-dotenv`, which is
292
+ included in the `dev` extra. Install `.[dev]` (or install `python-dotenv`
293
+ separately) before using it; production applications can instead read
294
+ `os.environ` through their normal secret-management flow.
295
+
296
+ ```python
297
+ import os
298
+ from dotenv import load_dotenv
299
+
300
+ load_dotenv() # reads .env from the current directory
301
+
302
+ api_key = os.environ["AZURE_OPENAI_API_KEY"]
303
+ ```
304
+
305
+ Run live tests explicitly:
306
+
307
+ ```bash
308
+ LLMADAPTER_RUN_LIVE_TESTS=1 python -m pytest test/ -m live
309
+ ```
310
+
311
+ ---
312
+
313
+ ## Layer 1 — Core Adapter (`LLMAdapter` / `AsyncLLMAdapter`)
314
+
315
+ The core adapter is a thin, consistent wrapper around provider HTTP APIs. Think of it as a smarter version of the
316
+ official `openai` Python client that also supports Azure and other providers without any code changes on your side.
317
+
318
+ ### Supported Providers & Configuration
319
+
320
+ `LLMAdapter` and `AsyncLLMAdapter` accept the union of provider-specific
321
+ constructor fields. This means one shared configuration object may include
322
+ fields such as `deployment_name`, `api_version`, `endpoint`, `location`, and
323
+ `credentials_path` without changing when the provider changes. Each provider
324
+ uses the fields it understands and ignores unrelated fields. A configured
325
+ `deployment_name` takes precedence over a per-call `model`, followed by the configured `model`.
326
+
327
+ | Provider | Required constructor fields | Optional provider-specific fields |
328
+ |---|---|---|
329
+ | `openai` | `api_key` | `endpoint`, `organization`, `project` |
330
+ | `azure_openai` | `api_key`, `endpoint`, `api_version`, `deployment_name` | `responses_api_version`, `region` |
331
+ | `azure_foundry` | `api_key`, `endpoint`, `api_version`, `deployment_name` | — |
332
+ | `anthropic` | `api_key` | `endpoint`, `deployment_name`, `enable_prompt_caching` |
333
+ | `vertexai` | `credentials_path`, `location` | `scopes` |
334
+
335
+ Shared operational fields include `timeout`, `max_retries`, `retry_base_delay`, `rpm`, `tpm`, embedding limits,
336
+ `completion_context_window`, `context_output_reserve_tokens`, `context_overhead_tokens`, `backup`, and `user_id`.
337
+ `mcp_policy` is required before remote MCP or connector tools can be sent. A backup must contain a `provider` and is
338
+ validated during adapter construction.
339
+
340
+ #### Azure OpenAI
341
+
342
+ ```python
343
+ import os
344
+ from llmadapter import LLMAdapter
345
+
346
+ adapter = LLMAdapter(
347
+ provider="azure_openai",
348
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
349
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
350
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
351
+ api_version="2024-02-01",
352
+ model="gpt-4o",
353
+ redis_url="redis://server_disabled", # disables all adapter rate-limit enforcement
354
+ user_id="my-app-user",
355
+ )
356
+ ```
357
+
358
+ #### Azure Foundry
359
+
360
+ Used for open-weight models (Llama, Mistral, Phi, etc.) hosted on Azure AI Foundry.
361
+
362
+ ```python
363
+ adapter = LLMAdapter(
364
+ provider="azure_foundry",
365
+ api_key=os.environ["AZURE_FOUNDRY_API_KEY"],
366
+ endpoint=os.environ["AZURE_FOUNDRY_ENDPOINT"],
367
+ deployment_name=os.environ["AZURE_FOUNDRY_DEPLOYMENT_NAME"],
368
+ api_version="2024-05-01-preview",
369
+ model="Llama-4-Maverick",
370
+ redis_url="redis://server_disabled",
371
+ user_id="my-app-user",
372
+ )
373
+ ```
374
+
375
+ #### OpenAI
376
+
377
+ ```python
378
+ adapter = LLMAdapter(
379
+ provider="openai",
380
+ api_key=os.environ["OPENAI_API_KEY"],
381
+ model="gpt-4o",
382
+ organization=os.environ.get("OPENAI_ORG"),
383
+ project=os.environ.get("OPENAI_PROJECT"),
384
+ timeout=60,
385
+ )
386
+ ```
387
+
388
+ For OpenAI-compatible gateways or local proxies, set `endpoint` to the base URL.
389
+
390
+ #### Vertex AI
391
+
392
+ The Vertex AI provider requires an extra dependency:
393
+
394
+ ```bash
395
+ pip install google-genai==1.49.0
396
+ ```
397
+
398
+ ```python
399
+ adapter = LLMAdapter(
400
+ provider="vertexai",
401
+ credentials_path=os.environ["VERTEXAI_CREDENTIALS_PATH"],
402
+ location=os.environ.get("VERTEXAI_LOCATION", "us-central1"),
403
+ model=os.environ.get("VERTEXAI_MODEL", "gemini-2.5-pro"),
404
+ scopes=["https://www.googleapis.com/auth/cloud-platform"],
405
+ redis_url="redis://server_disabled",
406
+ user_id="my-app-user",
407
+ )
408
+ ```
409
+
410
+ > **Vertex AI notes:** Only synchronous chat is implemented. Async chat and embeddings are not available for the Vertex
411
+ > AI provider. Messages are converted to Gemini content parts, system instructions, tool calls, and tool responses.
412
+ > Common provider-specific
413
+ > kwargs: `system_instruction`, `temperature`, `top_p`, `top_k`, `candidate_count`, `seed`, `max_output_tokens`,
414
+ `stop_sequences`, `presence_penalty`, `frequency_penalty`.
415
+
416
+ #### Anthropic
417
+
418
+ ```python
419
+ adapter = LLMAdapter(
420
+ provider="anthropic",
421
+ api_key=os.environ["ANTHROPIC_API_KEY"],
422
+ model="claude-opus-4-5", # or claude-3-7-sonnet, claude-haiku-3-5, etc.
423
+ enable_prompt_caching=True, # auto-inject cache_control on system + tools (default True)
424
+ redis_url="redis://server_disabled",
425
+ user_id="my-app-user",
426
+ )
427
+ ```
428
+
429
+ > **Anthropic notes:** Two caching modes are available:
430
+ > - **Explicit block-level** (`enable_prompt_caching=True`, default): injects `cache_control` on the last system block
431
+ > and last tool definition — caches the static prefix independently from the conversation.
432
+ > - **Automatic caching**: pass `cache_control={"type": "ephemeral"}` as a kwarg to `create()` and Anthropic
433
+ > automatically caches up to the last cacheable block. Best for multi-turn conversations.
434
+ >
435
+ > Cache eligibility is decided by Anthropic and varies by model and cache mode. The adapter applies markers but does
436
+ > not enforce a local token threshold.
437
+
438
+ ---
439
+
440
+ ### Chat Completions — Sync
441
+
442
+ The basic request/response pattern. You send a list of messages and get a response back.
443
+
444
+ ```python
445
+ response = adapter.chat.completions.create(
446
+ messages=[
447
+ {"role": "system", "content": "You are a helpful assistant."},
448
+ {"role": "user", "content": "What is the capital of France?"},
449
+ ],
450
+ temperature=0.0, # 0 = deterministic, 1 = creative
451
+ max_tokens=64,
452
+ )
453
+
454
+ # Access the reply
455
+ print(response.choices[0].message.content)
456
+
457
+ # Access token usage
458
+ print(f"Prompt tokens: {response.usage.prompt_tokens}")
459
+ print(f"Completion tokens: {response.usage.completion_tokens}")
460
+ print(f"Total tokens: {response.usage.total_tokens}")
461
+ ```
462
+
463
+ **Understanding `messages`:**
464
+
465
+ | Role | Purpose |
466
+ |-------------|---------------------------------------------------------------------|
467
+ | `system` | Sets the AI's personality and instructions. Used once at the start. |
468
+ | `user` | Your question or prompt. |
469
+ | `assistant` | Previous AI responses (for multi-turn conversations). |
470
+ | `tool` | The result of a tool/function call (see Tool Calling). |
471
+
472
+ **Multi-turn conversation:**
473
+
474
+ ```python
475
+ messages = [
476
+ {"role": "system", "content": "You are a helpful assistant."},
477
+ {"role": "user", "content": "My name is Alice."},
478
+ ]
479
+
480
+ response = adapter.chat.completions.create(messages=messages)
481
+ reply = response.choices[0].message.content
482
+
483
+ # Add assistant's reply to history, then continue
484
+ messages.append({"role": "assistant", "content": reply})
485
+ messages.append({"role": "user", "content": "What's my name?"})
486
+
487
+ response2 = adapter.chat.completions.create(messages=messages)
488
+ print(response2.choices[0].message.content) # "Your name is Alice."
489
+ ```
490
+
491
+ ---
492
+
493
+ ### Chat Completions — Streaming
494
+
495
+ Streaming returns text token-by-token as it is generated, which feels faster to the end user.
496
+
497
+ ```python
498
+ stream = adapter.chat.completions.create(
499
+ messages=[{"role": "user", "content": "Tell me a short story about a robot."}],
500
+ stream=True,
501
+ )
502
+
503
+ for chunk in stream:
504
+ # The last chunk may carry usage statistics
505
+ if chunk.usage:
506
+ print(f"\n[Total tokens: {chunk.usage.total_tokens}]")
507
+ continue
508
+ if chunk.choices:
509
+ print(chunk.choices[0].delta.content or "", end="", flush=True)
510
+ ```
511
+
512
+ > The adapter automatically injects `stream_options={"include_usage": True}` when the provider supports it, so the final
513
+ > chunk contains token counts.
514
+
515
+ ---
516
+
517
+ ### Chat Completions — Async
518
+
519
+ Use `AsyncLLMAdapter` when you are in an `async` context (FastAPI, asyncio event loop, etc.).
520
+
521
+ ```python
522
+ import asyncio
523
+ import os
524
+ from llmadapter import AsyncLLMAdapter
525
+
526
+
527
+ async def main():
528
+ adapter = AsyncLLMAdapter(
529
+ provider="azure_openai",
530
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
531
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
532
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
533
+ api_version="2024-02-01",
534
+ model="gpt-4o",
535
+ )
536
+
537
+ response = await adapter.chat.completions.create(
538
+ messages=[{"role": "user", "content": "Say hello in French."}],
539
+ max_tokens=16,
540
+ )
541
+ print(response.choices[0].message.content)
542
+
543
+
544
+ asyncio.run(main())
545
+ ```
546
+
547
+ ---
548
+
549
+ ### Async Streaming
550
+
551
+ ```python
552
+ async def stream_example():
553
+ stream = await adapter.chat.completions.create(
554
+ messages=[{"role": "user", "content": "Explain Python generators briefly."}],
555
+ stream=True,
556
+ )
557
+
558
+ async for chunk in stream:
559
+ if chunk.usage:
560
+ print(f"\n[Tokens: {chunk.usage.total_tokens}]")
561
+ continue
562
+ if chunk.choices:
563
+ print(chunk.choices[0].delta.content or "", end="", flush=True)
564
+ ```
565
+
566
+ ---
567
+
568
+ ### All Chat Parameters Reference
569
+
570
+ Pass provider/model-dependent keyword arguments to `adapter.chat.completions.create(...)`. Unsupported fields may be
571
+ ignored, translated, or rejected by the selected provider.
572
+
573
+ | Parameter | Type | Description |
574
+ |-------------------------|--------------------|-------------------------------------------------------------------------------------|
575
+ | `messages` | `list[dict]` | **Required.** The conversation history. |
576
+ | `model` | `str` | Per-call model override when the provider supports it; ignored when a deployment is configured and by Anthropic. |
577
+ | `stream` | `bool` | Return a streaming iterator instead of a full response. |
578
+ | `temperature` | `float` | Randomness. `0` = deterministic, `1` = creative. |
579
+ | `top_p` | `float` | Nucleus sampling probability. Alternative to temperature. |
580
+ | `max_tokens` | `int` | Maximum tokens to generate in the response. |
581
+ | `max_completion_tokens` | `int` | Use this instead of `max_tokens` for reasoning models (o1, o3). |
582
+ | `stop` | `str \| list[str]` | Stop sequences — generation halts when any of these appear. |
583
+ | `n` | `int` | Number of completions to generate. Default `1`. |
584
+ | `seed` | `int` | Seed for reproducibility (provider support varies). |
585
+ | `presence_penalty` | `float` | Penalizes new tokens already present in the response. |
586
+ | `frequency_penalty` | `float` | Penalizes repeated tokens. |
587
+ | `user` | `str` | A user identifier for abuse detection. |
588
+ | `response_format` | `dict \| type` | Raw JSON-object/schema dictionaries are provider-dependent; a **Pydantic `BaseModel` subclass** is the portable typed-parse route. |
589
+ | `tools` | `list[dict]` | List of function/tool definitions. |
590
+ | `tool_choice` | `str \| dict` | `"auto"`, `"none"`, or `{"type": "function", "function": {"name": "..."}}`. |
591
+ | `parallel_tool_calls` | `bool` | OpenAI-compatible control; support is provider/model dependent. |
592
+ | `logit_bias` | `dict` | Token probability adjustments. |
593
+ | `logprobs` | `bool` | Return log probabilities for output tokens. |
594
+ | `top_logprobs` | `int` | Number of top log probability alternatives to return. |
595
+ | `modalities` | `list[str]` | Output modalities, e.g. `["text", "audio"]`. |
596
+ | `audio` | `dict` | Audio output configuration. |
597
+ | `prediction` | `dict` | Predicted output hint for latency optimization. |
598
+ | `store` | `bool` | Store the conversation in the provider's storage. |
599
+ | `metadata` | `dict` | Arbitrary metadata attached to the request. |
600
+ | `reasoning_effort` | `str` | For reasoning models: `"low"`, `"medium"`, `"high"`. |
601
+ | `extra_body` | `dict` | Pass provider-specific extras (e.g., Azure RAG `data_sources`). |
602
+ | `live_logs` | `bool` | Sync adapter only: enables redacted INFO request/response logging. |
603
+ | `truncate` | `bool` | Automatically truncate messages to fit the context window. |
604
+
605
+ ---
606
+
607
+ ### Structured Outputs
608
+
609
+ Force the model to return a specific JSON structure. There are three modes, in order of increasing ergonomics.
610
+
611
+ #### Mode 1 — JSON Object mode
612
+
613
+ The model returns valid JSON, but the schema is not enforced. Fastest, but you must parse and validate yourself.
614
+
615
+ ```python
616
+ import json
617
+
618
+ response = adapter.chat.completions.create(
619
+ messages=[
620
+ {"role": "system", "content": "Always respond with valid JSON."},
621
+ {"role": "user", "content": "Return a user profile with name and age."},
622
+ ],
623
+ response_format={"type": "json_object"},
624
+ temperature=0,
625
+ )
626
+
627
+ data = json.loads(response.choices[0].message.content)
628
+ print(data)
629
+ ```
630
+
631
+ #### Mode 2 — JSON Schema mode
632
+
633
+ The model is constrained to your exact schema. You still parse the raw JSON string yourself.
634
+
635
+ ```python
636
+ response = adapter.chat.completions.create(
637
+ messages=[
638
+ {"role": "system", "content": "Follow the schema exactly."},
639
+ {"role": "user", "content": "Classify the sentiment: 'This product is amazing!'"},
640
+ ],
641
+ response_format={
642
+ "type": "json_schema",
643
+ "json_schema": {
644
+ "name": "sentiment_result",
645
+ "strict": True,
646
+ "schema": {
647
+ "type": "object",
648
+ "properties": {
649
+ "label": {"type": "string", "enum": ["positive", "negative", "neutral"]},
650
+ "confidence": {"type": "number"},
651
+ },
652
+ "required": ["label", "confidence"],
653
+ "additionalProperties": False,
654
+ },
655
+ },
656
+ },
657
+ temperature=0,
658
+ )
659
+ ```
660
+
661
+ #### Mode 3 — Typed Parse (recommended)
662
+
663
+ Pass a **Pydantic `BaseModel` subclass** as `response_format`, or use the dedicated `.parse()` method. This is the
664
+ portable structured-output route: each provider uses its supported native schema or forced-tool mechanism and the
665
+ adapter deserializes the result into `message.parsed` when available.
666
+
667
+ ```python
668
+ from pydantic import BaseModel
669
+ from typing import List
670
+
671
+ class MovieReview(BaseModel):
672
+ title: str
673
+ year: int
674
+ rating: float
675
+ pros: List[str]
676
+ cons: List[str]
677
+
678
+ # Option A — via .parse() (clearest intent)
679
+ response = adapter.chat.completions.parse(
680
+ messages=[
681
+ {"role": "system", "content": "You are a film critic."},
682
+ {"role": "user", "content": "Review Inception (2010)."},
683
+ ],
684
+ response_format=MovieReview,
685
+ temperature=0,
686
+ )
687
+
688
+ review: MovieReview = response.choices[0].message.parsed
689
+ print(review.title) # "Inception"
690
+ print(review.rating) # e.g. 9.1
691
+ print(review.pros) # ["Complex narrative", ...]
692
+
693
+ # Option B — via .create() with stream=False (identical result)
694
+ response = adapter.chat.completions.create(
695
+ messages=[{"role": "user", "content": "Review Inception."}],
696
+ response_format=MovieReview,
697
+ temperature=0,
698
+ )
699
+ review = response.choices[0].message.parsed
700
+ ```
701
+
702
+ **How each provider implements typed parse:**
703
+
704
+ | Provider | Mechanism | Notes |
705
+ |---|---|---|
706
+ | OpenAI / Azure OpenAI | `beta.chat.completions.parse()` — native SDK | Full strict mode; `message.parsed` set by the SDK |
707
+ | Anthropic | Forced tool use — schema injected as a tool's `input_schema` | Works across all Claude models/versions |
708
+ | Vertex AI | Native `response_schema=<class>` in `GenerateContentConfig` | SDK sanitizes schema for Gemini; `response.parsed` read natively |
709
+ | Azure Foundry | `JsonSchemaFormat` with strict-compliant schema | `additionalProperties:false` + all-required enforced |
710
+
711
+ > **Streaming + Pydantic class:** Passing a Pydantic class with `stream=True` automatically converts it to a strict
712
+ > `json_schema` dict. You get the structured text stream but no automatic `parsed` deserialization — iterate chunks
713
+ > and parse the final assembled content manually.
714
+
715
+ **`message.parsed` is serialization-safe:**
716
+
717
+ ```python
718
+ import json
719
+
720
+ dumped = json.loads(response.model_dump_json())
721
+ print(dumped["choices"][0]["message"]["parsed"]) # dict, not a Pydantic instance
722
+ ```
723
+
724
+ > **When to use which?** Raw `json_object` and `json_schema` dictionaries are provider/model-specific. Use the
725
+ > Pydantic class / `.parse()` mode when you need the adapter's portable typed-parse behavior. Anthropic does not honor
726
+ > raw Chat Completions `response_format` dictionaries; its typed path uses forced tool calling.
727
+
728
+ ---
729
+
730
+ ### Tool Calling (Raw API)
731
+
732
+ Tool calling lets you define Python functions as "tools" the model can invoke. The model decides when to call a tool,
733
+ and your code actually executes it.
734
+
735
+ **Step 1 — Define a tool:**
736
+
737
+ ```python
738
+ tools = [
739
+ {
740
+ "type": "function",
741
+ "function": {
742
+ "name": "get_weather",
743
+ "description": "Get the current weather for a city.",
744
+ "parameters": {
745
+ "type": "object",
746
+ "properties": {
747
+ "city": {"type": "string", "description": "City name, e.g. 'Tokyo'"},
748
+ },
749
+ "required": ["city"],
750
+ },
751
+ },
752
+ }
753
+ ]
754
+ ```
755
+
756
+ **Step 2 — Send the request with tools:**
757
+
758
+ ```python
759
+ response = adapter.chat.completions.create(
760
+ messages=[{"role": "user", "content": "What is the weather in Tokyo?"}],
761
+ tools=tools,
762
+ tool_choice="auto", # let the model decide when to call a tool
763
+ )
764
+
765
+ message = response.choices[0].message
766
+ ```
767
+
768
+ **Step 3 — Execute the tool and send the result back:**
769
+
770
+ ```python
771
+ import json
772
+
773
+ messages = [{"role": "user", "content": "What is the weather in Tokyo?"}]
774
+
775
+ # Append the assistant's tool_call message
776
+ messages.append({
777
+ "role": "assistant",
778
+ "content": message.content,
779
+ "tool_calls": [tc.model_dump() for tc in (message.tool_calls or [])],
780
+ })
781
+
782
+ # Run your actual function
783
+ tool_call = message.tool_calls[0]
784
+ args = json.loads(tool_call.function.arguments)
785
+ weather_result = {"city": args["city"], "temperature": 22, "condition": "Sunny"}
786
+
787
+ # Append the tool result
788
+ messages.append({
789
+ "role": "tool",
790
+ "tool_call_id": tool_call.id,
791
+ "content": json.dumps(weather_result),
792
+ })
793
+
794
+ # Get the final answer
795
+ final = adapter.chat.completions.create(messages=messages, tools=tools)
796
+ print(final.choices[0].message.content)
797
+ ```
798
+
799
+ > **Tip:** For a managed agentic loop (instead of writing this pattern yourself),
800
+ > see [Layer 3](#layer-3--advanced-agent-runtime-llmadapteragent).
801
+
802
+ ---
803
+
804
+ ### Multimodal Input (Images)
805
+
806
+ Send images alongside text by using a list of content parts in the user message:
807
+
808
+ ```python
809
+ response = adapter.chat.completions.create(
810
+ messages=[
811
+ {
812
+ "role": "user",
813
+ "content": [
814
+ {"type": "text", "text": "What objects do you see in this image?"},
815
+ {
816
+ "type": "image_url",
817
+ "image_url": {
818
+ "url": "https://example.com/photo.jpg",
819
+ "detail": "low", # "low", "high", or "auto"
820
+ },
821
+ },
822
+ ],
823
+ }
824
+ ],
825
+ max_tokens=256,
826
+ )
827
+
828
+ print(response.choices[0].message.content)
829
+ ```
830
+
831
+ > The provider endpoint must be able to fetch the image URL. `detail="low"` / `"high"` is an OpenAI-compatible
832
+ > control; other providers may ignore it. Provider-specific image processing and pricing remain provider-managed.
833
+
834
+ ---
835
+
836
+ ### Embeddings
837
+
838
+ Embeddings turn text into numerical vectors, useful for semantic search, clustering, and similarity comparisons.
839
+
840
+ **Basic embeddings:**
841
+
842
+ ```python
843
+ response = adapter.embeddings.create(
844
+ input=["Hello world", "Embeddings represent meaning"],
845
+ model="text-embedding-3-large",
846
+ )
847
+
848
+ for item in response.data:
849
+ print(f"Index {item.index}: vector length = {len(item.embedding)}")
850
+
851
+ print(f"Total tokens: {response.usage.total_tokens}")
852
+ ```
853
+
854
+ **Batched embeddings** (for large document sets):
855
+
856
+ ```python
857
+ # Process in batches of 2 to stay within provider limits
858
+ response = adapter.embeddings.create(
859
+ input=["Doc 1", "Doc 2", "Doc 3", "Doc 4", "Doc 5"],
860
+ model="text-embedding-3-large",
861
+ batching=True,
862
+ batchsize=2, # send 2 texts per API call
863
+ restore_order=True, # reassemble in original order when done
864
+ )
865
+ ```
866
+
867
+ **Reduced dimensions** (saves storage and speeds up similarity search):
868
+
869
+ ```python
870
+ response = adapter.embeddings.create(
871
+ input=["Compact vector test"],
872
+ model="text-embedding-3-large",
873
+ dimensions=256, # reduce from 3072 to 256 dimensions
874
+ )
875
+ ```
876
+
877
+ **All embedding parameters:**
878
+
879
+ | Parameter | Type | Description |
880
+ |-------------------|--------------------|----------------------------------------------------|
881
+ | `input` | `str \| list[str]` | Text(s) to embed. |
882
+ | `model` | `str` | Embedding model name. |
883
+ | `batching` | `bool` | Process input in batches. |
884
+ | `batchsize` | `int` | Texts per API call when batching. |
885
+ | `restore_order` | `bool` | Reassemble batched results in input order. |
886
+ | `dimensions` | `int` | Reduce output vector size (model must support it). |
887
+ | `encoding_format` | `str` | `"float"` or `"base64"`. |
888
+ | `live_logs` | `bool` | Sync adapter only: enable redacted INFO request/response logs. |
889
+
890
+ ---
891
+
892
+ ### Context Caching
893
+
894
+ Context caching lets you store a static prompt prefix on the provider's servers so repeated calls reuse it instead
895
+ of re-billing the same tokens on every request.
896
+
897
+ #### Mechanism per provider
898
+
899
+ | Provider | Mechanism | Control |
900
+ |-----------------------|--------------------------------------------------------------------------|-----------------------------------------------------|
901
+ | Azure OpenAI / OpenAI | **Automatic** — any static prefix ≥ 1 024 tokens is cached transparently | None needed |
902
+ | Anthropic | **Explicit** — `cache_control: {type: ephemeral}` markers | `enable_prompt_caching=True` (default) |
903
+ | Vertex AI | **Explicit** — create a `CachedContent` resource, then attach it by name | Direct Vertex provider integration; no stable adapter-level lifecycle API |
904
+ | Azure Foundry | None — backend does not currently expose cache signals | N/A |
905
+
906
+ #### Reading cache metrics
907
+
908
+ Normalized usage exposes cache-related fields, but non-zero telemetry depends on what the provider and model return:
909
+
910
+ ```python
911
+ response = adapter.chat.completions.create(
912
+ messages=[{"role": "user", "content": "Summarize the current cache telemetry."}],
913
+ )
914
+
915
+ usage = response.usage
916
+ print(usage.prompt_tokens) # total input tokens billed
917
+ print(usage.cached_tokens) # tokens served from cache (any provider)
918
+ print(usage.cache_creation_input_tokens) # cache creation telemetry when returned
919
+ print(usage.cache_read_input_tokens) # cache-read telemetry when returned
920
+ print(usage.cache_savings_pct) # percentage of prompt served from cache
921
+ ```
922
+
923
+ `cached_tokens` is a unified read-only property that works for all providers:
924
+
925
+ - **OpenAI / Azure OpenAI** — reads `prompt_tokens_details.cached_tokens`
926
+ - **Anthropic / Vertex AI** — reads `cache_read_input_tokens`
927
+
928
+ #### Azure OpenAI / OpenAI — automatic caching
929
+
930
+ No code changes required. Just send the same static system prompt (≥ 1 024 tokens) on repeated calls:
931
+
932
+ ```python
933
+ SYSTEM = "You are a detailed technical assistant..." * 20 # must exceed ~1024 tokens
934
+
935
+ messages = [
936
+ {"role": "system", "content": SYSTEM},
937
+ {"role": "user", "content": "What is a Merkle tree?"},
938
+ ]
939
+
940
+ # First call — cold (cache miss)
941
+ r1 = adapter.chat.completions.create(messages=messages, model="gpt-4o", temperature=0)
942
+ print(r1.usage.cached_tokens) # 0 on first call
943
+
944
+ # Second call — hot (cache hit)
945
+ r2 = adapter.chat.completions.create(messages=messages, model="gpt-4o", temperature=0)
946
+ print(r2.usage.cached_tokens) # e.g. 1280 — 68% savings
947
+ print(r2.usage.cache_savings_pct) # e.g. 68.9
948
+ ```
949
+
950
+ Streaming also reports cache tokens in the final usage chunk:
951
+
952
+ ```python
953
+ last_usage = None
954
+ for chunk in adapter.chat.completions.create(
955
+ messages=messages, model="gpt-4o", stream=True,
956
+ stream_options={"include_usage": True},
957
+ ):
958
+ if chunk.usage:
959
+ last_usage = chunk.usage
960
+
961
+ print(last_usage.cached_tokens) # same field, same value
962
+ ```
963
+
964
+ #### Anthropic — explicit `cache_control`
965
+
966
+ The provider supports two caching modes (both work via the same `create()` call):
967
+
968
+ **Mode 1 — Explicit block-level (default, `enable_prompt_caching=True`):**
969
+ `cache_control` is injected on the last system block and last tool definition.
970
+ Caches the static prefix independently from the growing conversation history.
971
+
972
+ ```python
973
+ adapter = LLMAdapter(
974
+ provider="anthropic",
975
+ api_key=os.environ["ANTHROPIC_API_KEY"],
976
+ model="claude-opus-4-5",
977
+ enable_prompt_caching=True, # default — no further action needed
978
+ )
979
+
980
+ messages = [
981
+ {"role": "system", "content": SYSTEM}, # cache_control injected automatically
982
+ {"role": "user", "content": "Explain bloom filters."},
983
+ ]
984
+
985
+ r1 = adapter.chat.completions.create(messages=messages, model="claude-opus-4-5")
986
+ print(r1.usage.cache_creation_input_tokens) # tokens written to cache on first call
987
+
988
+ r2 = adapter.chat.completions.create(messages=messages, model="claude-opus-4-5")
989
+ print(r2.usage.cache_read_input_tokens) # tokens read from cache (~10% cost)
990
+ print(r2.usage.cached_tokens) # convenience alias
991
+ ```
992
+
993
+ **Mode 2 — Automatic caching (top-level `cache_control` kwarg):**
994
+ Anthropic automatically caches up to the last cacheable block and advances the
995
+ breakpoint as the conversation grows. Best for multi-turn conversations.
996
+
997
+ ```python
998
+ r = adapter.chat.completions.create(
999
+ messages=messages,
1000
+ model="claude-opus-4-5",
1001
+ cache_control={"type": "ephemeral"}, # top-level automatic caching
1002
+ )
1003
+ ```
1004
+
1005
+ > **Minimum cacheable tokens:** ≥ 4 096 for Opus 4.x and Haiku 4.5; ≥ 2 048 for Sonnet 4.6 and Haiku 3.5;
1006
+ > ≥ 1 024 for Sonnet 4.5/4/3.7 and Opus 4.1/4. Requests below the threshold silently skip caching.
1007
+
1008
+ To opt out of block-level injection for a specific adapter:
1009
+
1010
+ ```python
1011
+ adapter = LLMAdapter(
1012
+ provider="anthropic",
1013
+ api_key=os.environ["ANTHROPIC_API_KEY"],
1014
+ model="claude-opus-4-5",
1015
+ enable_prompt_caching=False,
1016
+ )
1017
+ ```
1018
+
1019
+ #### Vertex AI — `CachedContent`
1020
+
1021
+ `VertexAIChatProvider` implements provider-level CachedContent creation, and chat calls can pass `cached_content` when
1022
+ the selected Gemini target supports it. `LLMAdapter` does not expose a stable adapter-level CachedContent lifecycle API;
1023
+ do not access private provider caches to obtain an underlying client. Use the direct provider integration only when that
1024
+ provider-specific lifecycle is required, and treat cache-read telemetry as provider-returned usage metadata.
1025
+
1026
+ ---
1027
+
1028
+ ### Failover / Backup Config
1029
+
1030
+ Configure a backup provider that automatically takes over if the primary fails:
1031
+
1032
+ ```python
1033
+ adapter = LLMAdapter(
1034
+ provider="azure_openai",
1035
+ api_key=os.environ["PRIMARY_KEY"],
1036
+ endpoint=os.environ["PRIMARY_ENDPOINT"],
1037
+ deployment_name="primary-gpt4o",
1038
+ api_version="2024-02-01",
1039
+ model="gpt-4o",
1040
+ backup={
1041
+ "provider": "azure_openai",
1042
+ "api_key": os.environ["BACKUP_KEY"],
1043
+ "endpoint": os.environ["BACKUP_ENDPOINT"],
1044
+ "deployment_name": "backup-gpt4o",
1045
+ "api_version": "2024-02-01",
1046
+ "model": "gpt-4o",
1047
+ },
1048
+ )
1049
+
1050
+ # If the primary fails, the backup is tried automatically
1051
+ response = adapter.chat.completions.create(
1052
+ messages=[{"role": "user", "content": "Hello"}]
1053
+ )
1054
+ ```
1055
+
1056
+ > **When to use:** Production systems where uptime matters — region failover, deployment quota exhaustion, or temporary
1057
+ > provider outages. Chat and Responses creation fail over only for retryable
1058
+ > errors (such as rate limits, connection failures, and server errors), not
1059
+ > invalid requests. Each target's circuit breaker also guards backup calls.
1060
+
1061
+ ---
1062
+
1063
+ ### Rate Limiting
1064
+
1065
+ LLMAdapter uses shared in-process counters by default. For distributed deployments,
1066
+ configure a Redis instance. Each quota is scoped to the provider target
1067
+ (provider, endpoint, deployment/model), user, request type, and time window:
1068
+
1069
+ ```python
1070
+ adapter = LLMAdapter(
1071
+ provider="azure_openai",
1072
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
1073
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
1074
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
1075
+ api_version="2024-02-01",
1076
+ model="gpt-4o",
1077
+ # Rate limits
1078
+ rpm=100, # max requests per minute (chat)
1079
+ tpm=100000, # max tokens per minute (chat)
1080
+ embedding_rpm=60, # max requests per minute (embeddings)
1081
+ embedding_tpm=60000, # max tokens per minute (embeddings)
1082
+ completion_context_window=128000, # context window size for truncation
1083
+ context_output_reserve_tokens=4096, # preserve room for generation
1084
+ # Redis for distributed tracking
1085
+ redis_url="redis://localhost:6379/0",
1086
+ user_id="user-42", # per-user tracking key
1087
+ )
1088
+ ```
1089
+
1090
+ When a configured Redis backend is unavailable, choose an explicit policy:
1091
+
1092
+ ```python
1093
+ adapter = LLMAdapter(
1094
+ provider="openai",
1095
+ api_key=os.environ["OPENAI_API_KEY"],
1096
+ model="gpt-4o",
1097
+ redis_url="redis://localhost:6379/0",
1098
+ rate_limit_failure_policy="memory_fallback", # default: keep local limits
1099
+ redis_connect_timeout=1.0,
1100
+ )
1101
+ ```
1102
+
1103
+ Supported policies are `"memory_fallback"`, `"fail_open"`, and `"fail_closed"`.
1104
+ `"fail_closed"` raises `RateLimitBackendUnavailableError` rather than sending an
1105
+ unmetered request.
1106
+
1107
+ **Disable rate limiting (development):**
1108
+
1109
+ ```python
1110
+ adapter = LLMAdapter(
1111
+ provider="openai",
1112
+ api_key=os.environ["OPENAI_API_KEY"],
1113
+ model="gpt-4o",
1114
+ redis_url="redis://server_disabled",
1115
+ )
1116
+ ```
1117
+
1118
+ `redis_url=None` uses shared in-process limits. The `redis://server_disabled` sentinel disables all adapter rate-limit
1119
+ enforcement and should only be used deliberately in local development or tests. The limiter uses fixed windows; denied
1120
+ requests still reserve the current window. A single request larger than the configured TPM limit raises `LLMAdapterError`.
1121
+
1122
+ ### Context budgets
1123
+
1124
+ `completion_context_window` is enforced as a request-wide budget, not a
1125
+ per-message limit. LLMAdapter reserves the greater of
1126
+ `context_output_reserve_tokens` and any per-call generation limit
1127
+ (`max_tokens`, `max_completion_tokens`, or `max_output_tokens`), then estimates
1128
+ message framing and tool/schema overhead. If a request cannot fit, it raises
1129
+ `ContextLengthExceededError`; pass `truncate=True` to retain system/developer
1130
+ messages and the newest eligible text history within that budget.
1131
+
1132
+ Multimodal chat blocks and Responses item lists are never rewritten merely to
1133
+ fit a budget. If they exceed capacity, the adapter raises instead of changing
1134
+ their structure. Server-side state referenced by `previous_response_id` or a
1135
+ Conversation is not locally measurable and remains provider-enforced.
1136
+
1137
+ ---
1138
+
1139
+ ### Resilience & Performance Options
1140
+
1141
+ These optional `LLMAdapter` / `AsyncLLMAdapter` constructor kwargs tune resilience and
1142
+ throughput. Each is off/default unless set.
1143
+
1144
+ | Option | Default | Effect |
1145
+ |------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1146
+ | `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. |
1147
+ | `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. |
1148
+ | `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. |
1149
+
1150
+ ```python
1151
+ from llmadapter import LLMAdapter
1152
+
1153
+ adapter = LLMAdapter(
1154
+ provider="anthropic",
1155
+ api_key=os.environ["ANTHROPIC_API_KEY"],
1156
+ model="claude-opus-4-5",
1157
+ share_clients=True, # reuse one client across adapters with this config
1158
+ circuit_breaker=True, # fail fast when the provider is persistently unhealthy
1159
+ )
1160
+ ```
1161
+
1162
+
1163
+ > **Retries:** the adapter is the single source of retry truth — provider SDK built-in
1164
+ > retries are disabled, and `max_retries` / `retry_base_delay` (or a per-call `max_retries=`)
1165
+ > control full-jitter exponential back-off. `max_retries=3` means up to four attempts. Provider `retry_after` values
1166
+ > are honored when available. Agent tool retries are separate: they use fixed backoff and retry only eligible failed
1167
+ > tool results. Set `max_retries=0` to disable core-request retries.
1168
+
1169
+ > **Streaming lifecycle:** a streaming request opens its provider stream only when you begin
1170
+ > iterating. The circuit breaker records success only after the stream is fully consumed. Pre-output events are buffered,
1171
+ > so establishment and pre-visible failures can retry up to `max_retries` times per target before backup failover. Once output is
1172
+ > visible, the adapter never retries, fails over, or replays the stream. Provider-emitted terminal failure events are not
1173
+ > replayed to a backup.
1174
+ > Errors from lazy stream creation surface from the iterator rather than from `create(..., stream=True)`.
1175
+ > Close a stream (`stream.close()` or `await stream.aclose()`) if you stop consuming it early;
1176
+ > the LangChain integration closes its underlying adapter stream when its stream iterator is closed.
1177
+
1178
+ > **Async embeddings:** `AsyncLLMAdapter` exposes `await adapter.embeddings.create(...)` with
1179
+ > the same signature as the sync adapter; the (synchronous) embedding providers run in a worker
1180
+ > thread so the event loop stays free.
1181
+
1182
+ ---
1183
+
1184
+ ### Error Handling
1185
+
1186
+ LLMAdapter raises typed exceptions so you can handle failures precisely:
1187
+
1188
+ ```python
1189
+ from llmadapter.errors import (
1190
+ LLMAdapterError, # Base class for all adapter errors
1191
+ ConfigurationError, # Invalid adapter or backup configuration
1192
+ ContextLengthExceededError, # Request cannot fit configured context budget
1193
+ AuthenticationError, # Invalid API key
1194
+ ModelNotFoundError, # Deployment/model name wrong
1195
+ RateLimitExceededError, # Provider-side 429 response
1196
+ RateLimitBackendUnavailableError, # Fail-closed Redis backend unavailable
1197
+ APIConnectionError, # Network issues, timeouts
1198
+ TimeOutError, # Request exceeded timeout
1199
+ ChatCompletionError, # General completion failure
1200
+ EmbeddingGenerationError, # Embedding request failed
1201
+ ContentFilterError, # Response blocked by content policy
1202
+ InvalidResponseError, # Malformed or unexpected response
1203
+ InvalidResponseRequestError, # Invalid Responses create contract
1204
+ UnsupportedCapabilityError, # Explicitly unsupported Responses feature
1205
+ ResponsesError, # Responses API operation failed
1206
+ ResponseNotFoundError, # Stored response was not found
1207
+ )
1208
+
1209
+ try:
1210
+ response = adapter.chat.completions.create(
1211
+ messages=[{"role": "user", "content": "Hello"}]
1212
+ )
1213
+ except AuthenticationError:
1214
+ print("Check your API key.")
1215
+ except RateLimitExceededError:
1216
+ print("Slow down — you have hit your rate limit.")
1217
+ except ContentFilterError as exc:
1218
+ print(f"Content blocked: {exc.message}")
1219
+ print(f"Filter details: {exc.context.get('filter_results')}")
1220
+ except APIConnectionError as exc:
1221
+ print(f"Network error: {exc.message}")
1222
+ except LLMAdapterError as exc:
1223
+ # Catch-all for any adapter error
1224
+ print(f"Error [{exc.status_code}] {exc.error_code}: {exc.message}")
1225
+ ```
1226
+
1227
+ **Error attributes:**
1228
+
1229
+ | Attribute | Description |
1230
+ |---------------|-------------------------------------------------|
1231
+ | `message` | Human-readable description. |
1232
+ | `status_code` | HTTP status code from the provider. |
1233
+ | `error_code` | Adapter-defined error code string. |
1234
+ | `context` | Dict with extra details (e.g., filter results). |
1235
+
1236
+ ---
1237
+
1238
+ ### Response Shapes
1239
+
1240
+ **Chat completion response:**
1241
+
1242
+ ```python
1243
+ response.id # request ID
1244
+ response.model # model that was used
1245
+ response.choices[0].message.content # text reply
1246
+ response.choices[0].message.parsed # Pydantic model instance (set by .parse() — None otherwise)
1247
+ response.choices[0].message.tool_calls # list of tool calls (if any)
1248
+ response.choices[0].finish_reason # "stop", "tool_calls", "length", etc.
1249
+ response.usage.prompt_tokens
1250
+ response.usage.completion_tokens
1251
+ response.usage.total_tokens
1252
+ response.usage.cached_tokens # tokens served from cache (all providers)
1253
+ response.usage.cache_creation_input_tokens # tokens written to cache (Anthropic mapping)
1254
+ response.usage.cache_read_input_tokens # tokens read from cache (Anthropic/Vertex mappings)
1255
+ response.usage.cache_savings_pct # % of prompt served from cache (0–100)
1256
+ response.prompt_filter_results # Azure content filter results
1257
+ response.metadata # extra metadata dict
1258
+ response.provider_extensions # versioned provider provenance container
1259
+ ```
1260
+
1261
+ **Streaming chunk:**
1262
+
1263
+ ```python
1264
+ chunk.choices[0].delta.content # text fragment (may be None)
1265
+ chunk.choices[0].delta.tool_calls # tool call fragments
1266
+ chunk.choices[0].finish_reason # set on the last content chunk
1267
+ chunk.usage # set on the final usage chunk
1268
+ ```
1269
+
1270
+ **Embedding response:**
1271
+
1272
+ ```python
1273
+ response.data[0].embedding # list[float] — the vector
1274
+ response.data[0].index # position in input list
1275
+ response.model # model used
1276
+ response.usage.total_tokens
1277
+ ```
1278
+
1279
+ ---
1280
+
1281
+ ## Responses API (`adapter.responses`)
1282
+
1283
+ Alongside the legacy `chat.completions` surface, the adapter exposes the OpenAI
1284
+ Responses API (`/v1/responses`) at `adapter.responses`. Create operations use
1285
+ token counting, rate limiting, retries with full-jitter backoff, optional circuit
1286
+ breaking, and optional backup failover. Management operations are retried and
1287
+ error-translated but are not metered, circuit-broken, or failed over.
1288
+
1289
+ For streams, provider creation is lazy and breaker success is recorded only after
1290
+ full consumption. Retry and failover can occur only before visible output is
1291
+ released; a partially visible stream is never replayed to a backup provider.
1292
+
1293
+ > **Provider support:** `openai` and `azure_openai` only. Other providers raise
1294
+ > `ResponsesError` with `status_code=501`. `chat.completions` remains available
1295
+ > for all five providers, so nothing about your existing code changes.
1296
+
1297
+ ### Methods
1298
+
1299
+ ```python
1300
+ adapter.responses.create(input, stream=False, truncate=False, **kwargs) # -> Response | Iterator[ResponseStreamEvent]
1301
+ adapter.responses.stream(input, **kwargs) # -> Iterator[ResponseStreamEvent]
1302
+ adapter.responses.parse(input, text_format=Model, **kwargs) # -> Response (with .output_parsed)
1303
+ adapter.responses.retrieve(response_id, **kwargs) # -> Response
1304
+ adapter.responses.delete(response_id) # -> ResponseDeleted
1305
+ adapter.responses.cancel(response_id) # -> Response
1306
+ adapter.responses.compact(input=[...], **kwargs) # -> CompactedResponse
1307
+ adapter.responses.input_items.list(response_id, **kwargs) # -> ResponseItemList
1308
+ adapter.responses.input_tokens.count(**kwargs) # -> InputTokenCountResponse
1309
+ adapter.responses.capabilities() # -> ResponseCapabilities
1310
+ adapter.responses.create_background(input, **kwargs) # -> Response
1311
+ adapter.responses.wait(response_id, timeout=..., **kwargs) # -> Response
1312
+ adapter.responses.submit_mcp_approval(previous_response_id, approval) # -> Response
1313
+ adapter.responses.conversations.create(items=None, **kwargs) # -> Conversation
1314
+ adapter.responses.conversations.retrieve(conversation_id, **kwargs) # -> Conversation
1315
+ adapter.responses.conversations.update(conversation_id, **kwargs) # -> Conversation
1316
+ adapter.responses.conversations.delete(conversation_id, **kwargs) # -> ConversationDeleted
1317
+ adapter.responses.conversations.list_items(conversation_id, **kwargs) # -> ResponseItemList
1318
+ adapter.responses.conversations.retrieve_item(conversation_id, item_id) # -> ResponseItem
1319
+ adapter.responses.conversations.delete_item(conversation_id, item_id) # -> Conversation
1320
+ ```
1321
+
1322
+ `AsyncLLMAdapter` exposes the same operations as awaitables or async streams;
1323
+ `responses.capabilities()` remains synchronous because it resolves local
1324
+ configuration only.
1325
+ `responses.compact` accepts input items or `previous_response_id` to select
1326
+ the history to compact.
1327
+
1328
+ ### Capability contract and strict requests
1329
+
1330
+ Responses requests are validated before token reservation, retry, failover, or
1331
+ provider-client creation. Typos and incompatible state references raise
1332
+ `InvalidResponseRequestError`; explicitly disabled target features raise
1333
+ `UnsupportedCapabilityError`.
1334
+
1335
+ ```python
1336
+ caps = adapter.responses.capabilities()
1337
+ print(caps.status("tool.web_search")) # supported / unknown / unsupported
1338
+ print(caps.supports("tool.web_search")) # True / None / False
1339
+
1340
+ adapter = LLMAdapter(
1341
+ provider="azure_openai",
1342
+ # ... normal Azure configuration ...
1343
+ responses_capabilities={
1344
+ "input_tokens.count": False, # deployment-specific known limitation
1345
+ "tool.web_search": False,
1346
+ },
1347
+ )
1348
+ ```
1349
+
1350
+ With a custom `openai` endpoint or compatible gateway, Responses features
1351
+ start as `unknown` rather than claiming native OpenAI support. Unknown features
1352
+ can still be called; use `responses_capabilities` to record verified support or
1353
+ known limitations for the configured target.
1354
+
1355
+ After adapter-only `max_retries` and `live_logs` controls are removed, the
1356
+ strict provider request contract includes `instructions`, `tools`, `tool_choice`,
1357
+ `parallel_tool_calls`, `max_tool_calls`, `include`, `background`, `conversation`,
1358
+ `previous_response_id`, `reasoning`, `text`, `store`, `metadata`, `prompt`,
1359
+ `prompt_cache_key`, `prompt_cache_options`, `prompt_cache_retention`,
1360
+ `truncation`, `safety_identifier`, `service_tier`, `user`, `context`,
1361
+ `extra_headers`, `extra_query`, and per-call `timeout`, plus generation fields
1362
+ `model`, `temperature`, `top_p`, `top_logprobs`, and `max_output_tokens`, and
1363
+ `stream_options` for streaming; structured-output
1364
+ aliases `text_format` and `response_format` are also accepted as Pydantic model
1365
+ classes. Use `extra_body` for a new provider field that has not yet been added
1366
+ to the adapter contract.
1367
+ `previous_response_id` and `conversation` cannot be combined. Unknown tool types
1368
+ and unsupported `include` values are rejected before dispatch.
1369
+
1370
+ `ResponseCapabilities.supports(feature)` returns `True` for known support,
1371
+ `False` for an explicit restriction, and `None` when support is target- or
1372
+ deployment-dependent.
1373
+
1374
+ `max_retries` overrides the configured retry count for a call (subject to the
1375
+ side-effecting request safeguards). In the synchronous adapter, `live_logs`
1376
+ controls redacted INFO logging; the async adapter accepts and ignores it.
1377
+
1378
+ ### Basic Usage
1379
+
1380
+ ```python
1381
+ from llmadapter import LLMAdapter
1382
+
1383
+ adapter = LLMAdapter(provider="openai", api_key="sk-...", model="gpt-5")
1384
+
1385
+ response = adapter.responses.create(
1386
+ "Summarize the Apollo program in two sentences.",
1387
+ instructions="You are concise and factual.",
1388
+ max_output_tokens=200,
1389
+ )
1390
+ print(response.output_text)
1391
+ print(response.usage.total_tokens, response.usage.cached_tokens, response.usage.reasoning_tokens)
1392
+ ```
1393
+
1394
+ `input` accepts either a plain string or a list of Responses input items:
1395
+
1396
+ ```python
1397
+ response = adapter.responses.create([
1398
+ {"role": "user", "content": [{"type": "input_text", "text": "Describe this chart."}]},
1399
+ ])
1400
+ ```
1401
+
1402
+ ### Streaming
1403
+
1404
+ Streaming yields `ResponseStreamEvent` objects rather than chat chunks. Branch on
1405
+ `event.type`:
1406
+
1407
+ ```python
1408
+ for event in adapter.responses.stream("Tell me a story."):
1409
+ if event.type == "response.output_text.delta":
1410
+ print(event.delta, end="", flush=True)
1411
+ elif event.type == "response.completed":
1412
+ print("\nTokens:", event.usage.total_tokens)
1413
+ ```
1414
+
1415
+ Known event and output-item types are mapped to typed subclasses such as
1416
+ `ResponseOutputTextDeltaEvent`, `ResponseCompletedEvent`,
1417
+ `ResponseFunctionCallItem`, `ResponseWebSearchCallItem`, and
1418
+ `ResponseCodeInterpreterCallItem`. New provider event/item types remain
1419
+ available through the generic `ResponseStreamEvent` and `ResponseOutputItem`
1420
+ fallback models, including their preserved extra fields.
1421
+
1422
+ ### Typed web and file search tools
1423
+
1424
+ Use `WebSearchTool` and `FileSearchTool` instead of manually building tool
1425
+ dictionaries. Included results are exposed through `response.web_sources` and
1426
+ `response.file_search_results`.
1427
+
1428
+ ```python
1429
+ from llmadapter import FileSearchTool, WebSearchTool
1430
+
1431
+ response = adapter.responses.create(
1432
+ "Compare internal notes with current public information.",
1433
+ tools=[
1434
+ WebSearchTool(search_context_size="medium"),
1435
+ FileSearchTool(vector_store_ids=["vs_123"], max_num_results=5),
1436
+ ],
1437
+ include=["web_search_call.action.sources", "file_search_call.results"],
1438
+ )
1439
+
1440
+ for source in response.web_sources:
1441
+ print(source.title, source.url)
1442
+ for result in response.file_search_results:
1443
+ print(result.filename, result.score)
1444
+ ```
1445
+
1446
+ Tool availability remains capability-gated by provider, API version, deployment,
1447
+ and model. Azure deployment support is frequently model-specific.
1448
+
1449
+ ### Typed execution tools
1450
+
1451
+ `CodeInterpreterTool`, `ImageGenerationTool`, and `ComputerTool` provide typed
1452
+ configuration for Responses tools. Their results are available as
1453
+ `response.code_interpreter_outputs`, `response.generated_images`, and
1454
+ `response.computer_actions`.
1455
+
1456
+ ```python
1457
+ from llmadapter import CodeInterpreterTool, ImageGenerationTool
1458
+
1459
+ response = adapter.responses.create(
1460
+ "Analyze the spreadsheet and create a chart image.",
1461
+ tools=[
1462
+ CodeInterpreterTool(container={"type": "auto", "file_ids": ["file_123"]}),
1463
+ ImageGenerationTool(size="1024x1024"),
1464
+ ],
1465
+ )
1466
+ ```
1467
+
1468
+ Computer use is a separate, application-controlled workflow:
1469
+
1470
+ ```python
1471
+ from llmadapter import ComputerTool, ResponseComputerCallItem
1472
+
1473
+ response = adapter.responses.create(
1474
+ "Inspect the isolated test browser.",
1475
+ tools=[ComputerTool()],
1476
+ )
1477
+ for call in response.output:
1478
+ if isinstance(call, ResponseComputerCallItem):
1479
+ print(call.call_id, call.computer_actions, call.pending_safety_checks)
1480
+ ```
1481
+
1482
+ `ComputerTool()` sends `{"type": "computer"}`; it does not operate a browser.
1483
+ Each `computer_call` holds **ordered** actions and a `call_id` needed to return
1484
+ a screenshot. `response.computer_actions` is flattened for inspection only.
1485
+ An application must review safety checks, authorize and execute permitted
1486
+ actions in order in a persistent isolated UI, capture its actual screenshot,
1487
+ and send it back using `previous_response_id=response.id` and an input item
1488
+ with `{"type": "computer_call_output", "call_id": call.call_id, "output":
1489
+ {"type": "computer_screenshot", "image_url": screenshot_data_url,
1490
+ "detail": "original"}}`. Do not execute untrusted page instructions or
1491
+ consequential actions without user confirmation; bound and verify each run.
1492
+ Computer tool availability depends on the configured model and endpoint.
1493
+
1494
+ ### Remote MCP and connectors
1495
+
1496
+ `McpTool` and `ConnectorTool` use the Responses MCP transport. They are rejected
1497
+ unless the adapter has an explicit allowlist policy. Match `allowed_origins` to a
1498
+ server URL's `netloc` (host and optional port), not its full URL.
1499
+
1500
+ ```python
1501
+ import os
1502
+
1503
+ from llmadapter import McpApprovalDecision, McpTool
1504
+
1505
+ adapter = LLMAdapter(
1506
+ provider="openai",
1507
+ api_key=os.environ["OPENAI_API_KEY"],
1508
+ model="gpt-5",
1509
+ mcp_policy={
1510
+ "enabled": True,
1511
+ "allowed_server_labels": ["trusted"],
1512
+ "allowed_origins": ["mcp.example.com"],
1513
+ "allowed_connector_ids": ["connector_123"],
1514
+ },
1515
+ )
1516
+
1517
+ response = adapter.responses.create(
1518
+ "Use the approved remote tool.",
1519
+ tools=[McpTool(server_label="trusted", server_url="https://mcp.example.com")],
1520
+ )
1521
+
1522
+ for approval in response.pending_mcp_approvals:
1523
+ adapter.responses.submit_mcp_approval(
1524
+ previous_response_id=response.id,
1525
+ approval=McpApprovalDecision(approval_request_id=approval.approval_request_id, approve=True),
1526
+ )
1527
+ ```
1528
+
1529
+ `require_approval` defaults to `"always"`. `computer`, `code_interpreter`,
1530
+ `image_generation`, remote MCP/connector tools, and background Responses creates
1531
+ suppress automatic adapter retries because they can produce side effects.
1532
+
1533
+ ### Structured Outputs
1534
+
1535
+ Pass a Pydantic model class as `text_format`; the deserialized instance lands on
1536
+ `response.output_parsed`.
1537
+
1538
+ ```python
1539
+ from pydantic import BaseModel
1540
+
1541
+ class TripPlan(BaseModel):
1542
+ city: str
1543
+ daily_budget_eur: int
1544
+
1545
+ plan = adapter.responses.parse("Return city=Paris and a daily budget.", text_format=TripPlan)
1546
+ print(plan.output_parsed.city, plan.output_parsed.daily_budget_eur)
1547
+ ```
1548
+
1549
+ ### Multi-Turn State
1550
+
1551
+ The Responses API can hold conversation state server-side, so you don't have to
1552
+ resend the whole history:
1553
+
1554
+ ```python
1555
+ first = adapter.responses.create("My favourite colour is teal.", store=True)
1556
+ second = adapter.responses.create("What is my favourite colour?", previous_response_id=first.id)
1557
+ print(second.output_text)
1558
+ ```
1559
+
1560
+ For provider targets that expose the Conversations resource, use the capability-
1561
+ gated namespace:
1562
+
1563
+ ```python
1564
+ conversation = adapter.responses.conversations.create(
1565
+ items=[{"role": "user", "content": "Remember that I prefer teal."}],
1566
+ metadata={"topic": "preferences"},
1567
+ )
1568
+ adapter.responses.conversations.update(conversation.id, metadata={"topic": "profile"})
1569
+ items = adapter.responses.conversations.list_items(conversation.id)
1570
+ adapter.responses.conversations.delete(conversation.id)
1571
+ ```
1572
+
1573
+ ### Background workflows and reasoning continuity
1574
+
1575
+ Use `create_background` and `wait` for stored long-running Responses. Polling
1576
+ uses bounded full-jitter backoff and can optionally cancel a response when its
1577
+ local deadline expires.
1578
+
1579
+ ```python
1580
+ job = adapter.responses.create_background("Run the long analysis.")
1581
+ final = adapter.responses.wait(job.id, timeout=300, cancel_on_timeout=True)
1582
+ ```
1583
+
1584
+ `create_background` sets `background=True` and defaults `store=True` without
1585
+ overriding an explicit `store=False`. `wait(..., on_poll=callback)` invokes
1586
+ `callback(response, attempts)` on each poll; the async variant also awaits an
1587
+ async callback. Cancellation on timeout is best effort.
1588
+
1589
+ Async callers can use `await adapter.responses.create_background(...)` and
1590
+ `await adapter.responses.wait(...)`. If
1591
+ `include=["reasoning.encrypted_content"]` is requested, encrypted continuity
1592
+ payloads are available through `response.encrypted_reasoning_content` for
1593
+ provider-supported stateless follow-up workflows.
1594
+
1595
+ ### Managing Stored Responses
1596
+
1597
+ ```python
1598
+ fetched = adapter.responses.retrieve(first.id)
1599
+
1600
+ page = adapter.responses.input_items.list(first.id, limit=20)
1601
+ for item in page:
1602
+ print(item.type, item.id)
1603
+ if page.has_more:
1604
+ page = adapter.responses.input_items.list(first.id, after=page.last_id)
1605
+
1606
+ background = adapter.responses.create("Long job...", background=True, store=True)
1607
+ adapter.responses.cancel(background.id)
1608
+
1609
+ compacted = adapter.responses.compact(previous_response_id=first.id)
1610
+ adapter.responses.delete(first.id)
1611
+ ```
1612
+
1613
+ `ResponseItemList` is a cursor page with `data`, `first_id`, `last_id`, and
1614
+ `has_more`; it also supports direct iteration and `len(page)`.
1615
+
1616
+ ### Pre-Flight Token Counting
1617
+
1618
+ `input_tokens.count` asks the server how many input tokens a request would
1619
+ consume, before you pay for it:
1620
+
1621
+ ```python
1622
+ count = adapter.responses.input_tokens.count(
1623
+ input="How many tokens is this prompt?",
1624
+ instructions="You are a helpful assistant.",
1625
+ )
1626
+ print(count.input_tokens)
1627
+ ```
1628
+
1629
+ ### Async
1630
+
1631
+ ```python
1632
+ from llmadapter import AsyncLLMAdapter
1633
+
1634
+ adapter = AsyncLLMAdapter(provider="openai", api_key="sk-...", model="gpt-5")
1635
+
1636
+ response = await adapter.responses.create("Hello!")
1637
+
1638
+ async for event in await adapter.responses.create("Stream this.", stream=True):
1639
+ if event.type == "response.output_text.delta":
1640
+ print(event.delta, end="")
1641
+
1642
+ await adapter.responses.delete(response.id)
1643
+ ```
1644
+
1645
+ ### Azure Notes
1646
+
1647
+ Azure routes by deployment, so the deployment name is used as the model id. The
1648
+ Responses endpoint also requires a recent api-version; when your configured
1649
+ `api_version` predates it, the adapter upgrades it for `responses.*` calls only.
1650
+ Set `responses_api_version` to pin one explicitly:
1651
+
1652
+ ```python
1653
+ adapter = LLMAdapter(
1654
+ provider="azure_openai",
1655
+ api_key="...",
1656
+ endpoint="https://<resource>.openai.azure.com",
1657
+ deployment_name="gpt-5",
1658
+ api_version="2024-10-21", # used by chat.completions
1659
+ responses_api_version="2025-03-01-preview", # used by responses.*
1660
+ )
1661
+ ```
1662
+
1663
+ ### Behavior Notes
1664
+
1665
+ - `create` shares the completion RPM/TPM budget with `chat.completions` — both
1666
+ consume the same deployment quota.
1667
+ - The management verbs (`retrieve`, `delete`, `cancel`, `compact`,
1668
+ `input_items.list`, `input_tokens.count`) are retried and error-translated but
1669
+ are not rate limited, metered, circuit-broken, or failed over. `delete` and
1670
+ `cancel` are treated as non-idempotent and are not retried.
1671
+ - `truncate=True` only rewrites a plain-string `input`. Item lists carry tool
1672
+ calls and multimodal parts, so they are counted but passed through unchanged.
1673
+ - All Responses models allow extra fields, so new server-side item types and
1674
+ attributes are preserved rather than rejected.
1675
+ - `adapter.responses.capabilities()` is available only for OpenAI and Azure OpenAI
1676
+ targets; unsupported providers raise `UnsupportedCapabilityError` from this
1677
+ introspection method and `ResponsesError(501)` from `responses.create`.
1678
+
1679
+ ### Provider extension provenance
1680
+
1681
+ Use normalized fields for portable logic. Provider-specific fields are available
1682
+ through both a legacy dictionary and a versioned extension container:
1683
+
1684
+ ```python
1685
+ extensions = response.provider_extensions
1686
+ print(extensions.provider, extensions.schema_version)
1687
+ print(extensions.fields)
1688
+ ```
1689
+
1690
+ `provider_extensions` is available on normalized chat responses, stream chunks,
1691
+ embeddings, Responses objects and output items, pages, counts, conversations,
1692
+ and stream events. LangChain message conversion does not preserve this
1693
+ container.
1694
+
1695
+ ## Layer 2 — LangChain Compatibility (`ChatLLMAdapter`)
1696
+
1697
+ `ChatLLMAdapter` wraps `LLMAdapter` as a LangChain `BaseChatModel`, so it can be
1698
+ used with standard LangChain prompts, chains, tools, callbacks, and agents. It
1699
+ requires the optional `langchain-core` dependency (`pip install ".[langchain]"`).
1700
+
1701
+ Chat mode supports all five registered chat providers. Native async LangChain
1702
+ calls support OpenAI, Azure OpenAI, Azure Foundry, and Anthropic; Vertex AI is
1703
+ sync-only because this package has no native async Vertex chat provider.
1704
+
1705
+ ### What You Get Over Layer 1
1706
+
1707
+ - `invoke()` / `ainvoke()` — standard LangChain call interface
1708
+ - `stream()` / `astream()` — streaming with LangChain message types
1709
+ - `batch()` — parallel batch invocations
1710
+ - `bind_tools()` — attach tools and get LangChain `ToolCall` objects back
1711
+ - `with_structured_output()` — parse output into Pydantic models automatically
1712
+ - LCEL chain composition (`prompt | llm | parser`)
1713
+ - Full compatibility with LangChain agents, memory, and callbacks
1714
+
1715
+ > The constructor's `streaming` field is retained for LangChain configuration
1716
+ > compatibility, but it does **not** make `.invoke()` or `.ainvoke()` stream.
1717
+ > Call `.stream()` or `.astream()` explicitly. Call `.close()` when finished
1718
+ > with synchronous resources, or `await .aclose()` to close both sync and async
1719
+ > resources.
1720
+
1721
+ ### Basic Invoke & Stream
1722
+
1723
+ ```python
1724
+ from llmadapter import ChatLLMAdapter
1725
+ from langchain_core.messages import HumanMessage, SystemMessage
1726
+
1727
+ llm = ChatLLMAdapter(
1728
+ provider="azure_openai",
1729
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
1730
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
1731
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
1732
+ api_version="2024-02-01",
1733
+ model="gpt-4o",
1734
+ )
1735
+
1736
+ # invoke returns an AIMessage
1737
+ response = llm.invoke("What is machine learning?")
1738
+ print(response.content)
1739
+
1740
+ # Using LangChain message objects
1741
+ response = llm.invoke([
1742
+ SystemMessage(content="You are an expert data scientist."),
1743
+ HumanMessage(content="Explain overfitting in one sentence."),
1744
+ ])
1745
+ print(response.content)
1746
+
1747
+ # Streaming
1748
+ for chunk in llm.stream("List three Python best practices."):
1749
+ print(chunk.content, end="", flush=True)
1750
+ ```
1751
+
1752
+ ### Async Invoke & Stream
1753
+
1754
+ ```python
1755
+ import asyncio
1756
+
1757
+
1758
+ async def async_example():
1759
+ response = await llm.ainvoke("Explain async/await briefly.")
1760
+ print(response.content)
1761
+
1762
+ async for chunk in llm.astream("Count to 5."):
1763
+ print(chunk.content, end="")
1764
+
1765
+
1766
+ asyncio.run(async_example())
1767
+ ```
1768
+
1769
+ ### Tool Binding with LangChain
1770
+
1771
+ Define tools using the `@tool` decorator and bind them to the LLM:
1772
+
1773
+ ```python
1774
+ from langchain_core.tools import tool
1775
+ from langchain_core.messages import ToolMessage
1776
+
1777
+
1778
+ @tool
1779
+ def get_weather(city: str) -> str:
1780
+ """Get the current weather for a city."""
1781
+ return f"The weather in {city} is 22 degrees C and sunny."
1782
+
1783
+
1784
+ @tool
1785
+ def calculate(expression: str) -> str:
1786
+ """Evaluate a mathematical expression."""
1787
+ return str(eval(expression)) # noqa: S307 — for demo only
1788
+
1789
+
1790
+ # Bind tools to the LLM
1791
+ llm_with_tools = llm.bind_tools([get_weather, calculate])
1792
+
1793
+ # The model returns tool_calls in the AIMessage
1794
+ response = llm_with_tools.invoke("What is the weather in Tokyo and what is 15 * 7?")
1795
+
1796
+ for tool_call in response.tool_calls:
1797
+ print(f"Calling: {tool_call['name']} with {tool_call['args']}")
1798
+
1799
+ # Execute the tools and continue
1800
+ tool_map = {"get_weather": get_weather, "calculate": calculate}
1801
+ messages = [HumanMessage("What is the weather in Tokyo and what is 15 * 7?")]
1802
+ messages.append(response) # add the AIMessage with tool_calls
1803
+
1804
+ for tool_call in response.tool_calls:
1805
+ result = tool_map[tool_call["name"]].invoke(tool_call["args"])
1806
+ messages.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
1807
+
1808
+ # Get the final answer
1809
+ final = llm_with_tools.invoke(messages)
1810
+ print(final.content)
1811
+ ```
1812
+
1813
+ **`bind_tools` options:**
1814
+
1815
+ ```python
1816
+ llm_with_tools = llm.bind_tools(
1817
+ tools=[get_weather, calculate],
1818
+ tool_choice="auto", # "auto", "none", "required" / "any", or a specific tool name
1819
+ )
1820
+ ```
1821
+
1822
+ `"any"` is translated to `"required"`. A named tool must exist in the supplied
1823
+ tool list; otherwise `bind_tools` raises `ValueError` before a model request.
1824
+
1825
+ ### Structured Output with Pydantic
1826
+
1827
+ Automatically parse model output into a typed Pydantic model:
1828
+
1829
+ ```python
1830
+ from pydantic import BaseModel
1831
+ from typing import List
1832
+
1833
+
1834
+ class MovieReview(BaseModel):
1835
+ title: str
1836
+ year: int
1837
+ rating: float
1838
+ pros: List[str]
1839
+ cons: List[str]
1840
+
1841
+
1842
+ structured_llm = llm.with_structured_output(MovieReview)
1843
+
1844
+ review = structured_llm.invoke("Review the movie Inception (2010).")
1845
+ print(review.title) # "Inception"
1846
+ print(review.rating) # e.g. 9.1
1847
+ print(review.pros) # ["Complex narrative", "Visual effects", ...]
1848
+ ```
1849
+
1850
+ **`with_structured_output` modes:**
1851
+
1852
+ ```python
1853
+ # Default: use JSON schema mode
1854
+ llm.with_structured_output(MyModel)
1855
+
1856
+ # Use JSON schema mode (strict)
1857
+ llm.with_structured_output(MyModel, method="json_schema")
1858
+
1859
+ # Use JSON mode (no schema enforcement)
1860
+ llm.with_structured_output(MyModel, method="json_mode")
1861
+
1862
+ # Use a required function call instead of response-format structured output
1863
+ llm.with_structured_output(MyModel, method="function_calling", include_raw=True)
1864
+ ```
1865
+
1866
+ With `include_raw=True`, the runnable returns a dictionary containing `raw`,
1867
+ `parsed`, and `parsing_error`; parse failures are captured rather than raised by
1868
+ that final parser stage.
1869
+
1870
+ ### Using the Responses API (`api="responses"`)
1871
+
1872
+ Set `api="responses"` to retain the standard LangChain interface while routing
1873
+ requests through `adapter.responses`. This mode is available for `openai` and
1874
+ `azure_openai` providers only; the default remains legacy chat completions.
1875
+
1876
+ ```python
1877
+ llm = ChatLLMAdapter(
1878
+ provider="openai",
1879
+ api="responses",
1880
+ api_key=os.environ["OPENAI_API_KEY"],
1881
+ model="gpt-5",
1882
+ )
1883
+
1884
+ reply = llm.invoke("Explain retrieval-augmented generation in one sentence.")
1885
+ print(reply.content)
1886
+
1887
+ for chunk in llm.stream("Write a haiku about Python."):
1888
+ print(chunk.content, end="", flush=True)
1889
+ ```
1890
+
1891
+ In this mode, `max_tokens` is translated to `max_output_tokens`, chat-style
1892
+ function tools are converted to the Responses tool format, and structured
1893
+ output and tool binding continue to use LangChain's normal APIs. Stop sequences
1894
+ are ignored because the Responses API does not support them. LangChain text,
1895
+ image, and file content blocks are converted into Responses input items. Rich
1896
+ Responses output such as web sources, file-search results, code-interpreter
1897
+ artifacts, generated images, computer actions, MCP approvals, reasoning, and
1898
+ other non-message output items is exposed through `AIMessage.additional_kwargs`;
1899
+ use the raw adapter for Responses management methods such as `retrieve`,
1900
+ `conversations`, or `input_tokens.count`.
1901
+
1902
+ LangChain callback token events are emitted only for non-empty text deltas.
1903
+
1904
+ ### Chain Composition (LCEL)
1905
+
1906
+ Use LangChain Expression Language (LCEL) to build processing pipelines:
1907
+
1908
+ ```python
1909
+ from langchain_core.prompts import ChatPromptTemplate
1910
+ from langchain_core.output_parsers import StrOutputParser
1911
+
1912
+ # Build a chain: prompt -> LLM -> string output
1913
+ prompt = ChatPromptTemplate.from_messages([
1914
+ ("system", "You are a helpful assistant who answers concisely."),
1915
+ ("human", "{question}"),
1916
+ ])
1917
+
1918
+ chain = prompt | llm | StrOutputParser()
1919
+
1920
+ result = chain.invoke({"question": "What is the difference between RAM and storage?"})
1921
+ print(result)
1922
+
1923
+ # Stream the chain
1924
+ for chunk in chain.stream({"question": "Explain neural networks simply."}):
1925
+ print(chunk, end="")
1926
+
1927
+ # Batch multiple inputs in parallel
1928
+ results = chain.batch([
1929
+ {"question": "What is Python?"},
1930
+ {"question": "What is JavaScript?"},
1931
+ {"question": "What is Rust?"},
1932
+ ])
1933
+ for r in results:
1934
+ print(r[:100])
1935
+ ```
1936
+
1937
+ ---
1938
+
1939
+ ## Layer 3 — Advanced Agent Runtime (`LLMAdapterAgent`)
1940
+
1941
+ The advanced runtime gives you a production-ready **ReAct-style agent loop**: the model thinks, calls tools, observes
1942
+ results, and repeats until it has an answer. Everything — retry logic, timeouts, checkpoints, observability hooks — is
1943
+ handled for you.
1944
+
1945
+ ### `AdvancedAgentConfig` — Every Parameter Explained
1946
+
1947
+ `AdvancedAgentConfig` is a dataclass that controls how the agent loop behaves. Every parameter has a sensible default.
1948
+
1949
+ ```python
1950
+ from llmadapter import AdvancedAgentConfig
1951
+
1952
+ config = AdvancedAgentConfig(
1953
+ max_iterations=12,
1954
+ max_runtime_seconds=180.0,
1955
+ parallel_tools=True,
1956
+ max_concurrency=4,
1957
+ tool_timeout_seconds=30.0,
1958
+ reflection_enabled=True,
1959
+ reflection_max_retries=2,
1960
+ interrupt_on_tool_error=False,
1961
+ tool_choice="auto",
1962
+ max_input_tokens_per_step=None,
1963
+ tool_retry_attempts=0,
1964
+ tool_retry_backoff_seconds=0.0,
1965
+ retry_on_tool_not_found=False,
1966
+ retry_non_idempotent_tools=False,
1967
+ escalate_on_retry_exhausted=False,
1968
+ )
1969
+ ```
1970
+
1971
+ **Iteration & time controls:**
1972
+
1973
+ | Parameter | Default | Description |
1974
+ |-----------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------|
1975
+ | `max_iterations` | `12` | Maximum number of think-act-observe cycles before the agent stops. Increase for complex multi-step tasks; lower for fast responses. |
1976
+ | `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. |
1977
+
1978
+ **Tool execution controls:**
1979
+
1980
+ | Parameter | Default | Description |
1981
+ |------------------------|----------|--------------------------------------------------------------------------------------------------------------------------|
1982
+ | `parallel_tools` | `True` | When the model calls multiple tools in one step, run them concurrently. Significantly faster for independent tool calls. |
1983
+ | `max_concurrency` | `4` | Maximum number of tools to run simultaneously when `parallel_tools=True`. |
1984
+ | `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. |
1985
+ | `tool_choice` | `"auto"` | Passed to the model. `"auto"` = model decides, `"none"` = no tools, tool name = force a specific tool. |
1986
+
1987
+ **Reflection controls:**
1988
+
1989
+ | Parameter | Default | Description |
1990
+ |--------------------------|---------|-----------------------------------------------------------------------------------------------------|
1991
+ | `reflection_enabled` | `True` | Evaluates a non-tool candidate answer before returning it and requests a revision when needed. |
1992
+ | `reflection_max_retries` | `2` | Maximum reflection-driven revisions per invocation, including resumed execution. |
1993
+
1994
+ **Error behavior:**
1995
+
1996
+ | Parameter | Default | Description |
1997
+ |---------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------|
1998
+ | `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. |
1999
+
2000
+ **Token budget:**
2001
+
2002
+ | Parameter | Default | Description |
2003
+ |-----------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
2004
+ | `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. |
2005
+
2006
+ **Retry & escalation:**
2007
+
2008
+ | Parameter | Default | Description |
2009
+ |-------------------------------|---------|-----------------------------------------------------------------------------------------------|
2010
+ | `tool_retry_attempts` | `0` | How many times to retry a failed, explicitly idempotent tool call. `0` = no retries. |
2011
+ | `tool_retry_backoff_seconds` | `0.0` | Seconds to wait between retry attempts. Use `0.5` or `1.0` for flaky network-dependent tools. |
2012
+ | `retry_on_tool_not_found` | `False` | If `True`, retry even when the tool name is not registered (instead of raising immediately). |
2013
+ | `retry_non_idempotent_tools` | `False` | Unsafe compatibility opt-in: retry ordinary tools that have not declared idempotency. Timed-out tools are never retried. |
2014
+ | `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. |
2015
+
2016
+ **Quick reference — common config patterns:**
2017
+
2018
+ ```python
2019
+ # Fast, cheap responses (simple Q&A with tools)
2020
+ AdvancedAgentConfig(max_iterations=4, reflection_enabled=False, parallel_tools=False)
2021
+
2022
+ # Robust multi-step research agent
2023
+ AdvancedAgentConfig(max_iterations=20, max_runtime_seconds=300, parallel_tools=True,
2024
+ tool_retry_attempts=2, tool_retry_backoff_seconds=0.5)
2025
+
2026
+ # Strictly bounded (for user-facing features with SLA)
2027
+ AdvancedAgentConfig(max_iterations=6, max_runtime_seconds=30,
2028
+ max_input_tokens_per_step=4000, interrupt_on_tool_error=True)
2029
+
2030
+ # Fault-tolerant with fallback escalation
2031
+ AdvancedAgentConfig(tool_retry_attempts=3, tool_retry_backoff_seconds=1.0,
2032
+ escalate_on_retry_exhausted=True)
2033
+ ```
2034
+
2035
+ ---
2036
+
2037
+ ### Creating an Agent
2038
+
2039
+ ```python
2040
+ import os
2041
+ from llmadapter import ChatLLMAdapter, AdvancedAgentConfig, create_llmadapter_agent
2042
+ from langchain_core.tools import tool
2043
+
2044
+
2045
+ @tool
2046
+ def get_weather(city: str) -> dict:
2047
+ """Get current weather for a city."""
2048
+ return {"city": city, "temp_c": 23, "condition": "Sunny"}
2049
+
2050
+
2051
+ @tool
2052
+ def search_web(query: str) -> str:
2053
+ """Search the web for a query."""
2054
+ return f"Results for '{query}': [placeholder search results]"
2055
+
2056
+
2057
+ primary_llm = ChatLLMAdapter(
2058
+ provider="azure_openai",
2059
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
2060
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
2061
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
2062
+ api_version="2024-02-01",
2063
+ model="gpt-4o",
2064
+ )
2065
+
2066
+ fallback_llm = ChatLLMAdapter(
2067
+ provider="openai",
2068
+ api_key=os.environ["OPENAI_API_KEY"],
2069
+ model="gpt-4o-mini",
2070
+ )
2071
+
2072
+ config = AdvancedAgentConfig(
2073
+ max_iterations=8,
2074
+ parallel_tools=True,
2075
+ reflection_enabled=True,
2076
+ tool_retry_attempts=1,
2077
+ )
2078
+
2079
+ agent = create_llmadapter_agent(
2080
+ primary_llm=primary_llm,
2081
+ fallback_llm=fallback_llm, # optional: used on tool errors or escalation
2082
+ tools=[get_weather, search_web],
2083
+ config=config,
2084
+ )
2085
+ ```
2086
+
2087
+ ---
2088
+
2089
+ ### Running an Agent
2090
+
2091
+ ```python
2092
+ result = agent.invoke("What is the weather in Paris, and find me a travel article about it.")
2093
+
2094
+ print(result["status"]) # "completed", "completed_with_reflection_limit", "stopped", or "interrupted"
2095
+ print(result.get("output")) # final answer text when available
2096
+ print(result["steps"]) # list of AgentStepRecord — every think/act/observe cycle
2097
+ print(result.get("checkpoint_id")) # ID when a resumable interruption occurs
2098
+
2099
+ # Check how many steps were used
2100
+ print(f"Completed in {len(result['steps'])} iterations")
2101
+ ```
2102
+
2103
+ **`result` keys:**
2104
+
2105
+ | Key | Description |
2106
+ |----------------------|-------------|
2107
+ | `status` | Final exit condition. |
2108
+ | `output` | Final text answer when one is available. |
2109
+ | `reason` | Present for `stopped` and `interrupted` paths. |
2110
+ | `messages` | Final LangChain message history. |
2111
+ | `steps` | List of `AgentStepRecord` with details of each iteration. |
2112
+ | `checkpoint_id` | ID returned only for a resumable `interrupted` tool-error result. |
2113
+ | `checkpoint_lineage` | Per-invocation lineage IDs, useful for relating nested specialist runs. |
2114
+
2115
+ Some stop paths add diagnostics: token-budget stops include `input_tokens` and
2116
+ `limit`; retry-escalation stops include `failed_tools`.
2117
+
2118
+ **Status values:**
2119
+
2120
+ | Status | Meaning |
2121
+ |------------------------------------|---------|
2122
+ | `"completed"` | Agent returned an answer that either skipped reflection or passed it. |
2123
+ | `"completed_with_reflection_limit"` | Agent returned its current answer after exhausting configured reflection rewrites. |
2124
+ | `"interrupted"` | A tool failed while `interrupt_on_tool_error=True`; the result contains `reason="tool_error"` and a resumable `checkpoint_id`. |
2125
+ | `"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`. |
2126
+
2127
+ ---
2128
+
2129
+ ### Async Agent Execution
2130
+
2131
+ ```python
2132
+ import asyncio
2133
+
2134
+
2135
+ async def run_async_agent():
2136
+ result = await agent.ainvoke(
2137
+ "Research the latest trends in quantum computing."
2138
+ )
2139
+ print(result["status"], result["output"])
2140
+
2141
+
2142
+ asyncio.run(run_async_agent())
2143
+ ```
2144
+
2145
+ For a caller-supplied tighter limit, use
2146
+ `agent.invoke_with_runtime_limit(input, max_runtime_seconds=...)` or
2147
+ `await agent.ainvoke_with_runtime_limit(...)`. The supplied limit can only
2148
+ tighten `AdvancedAgentConfig.max_runtime_seconds`; a negative limit is rejected.
2149
+ The optional `checkpoint_lineage` argument lets a parent orchestration call
2150
+ preserve lineage when invoking nested agents.
2151
+
2152
+ ---
2153
+
2154
+ ### Checkpoints — Pause and Resume
2155
+
2156
+ Checkpoints let you **save the agent's state mid-run** and resume it later — useful when:
2157
+
2158
+ - A user needs to review an intermediate result before continuing
2159
+ - A tool calls for human approval
2160
+ - The agent hit `max_iterations` but needs more turns
2161
+ - You want to test recovery from partial failures
2162
+
2163
+ **How checkpoints work:**
2164
+
2165
+ The runtime writes internal snapshots before tool execution, after tool results,
2166
+ and around reflection transitions. The default store is
2167
+ `InMemoryCheckpointStore`; use `FileCheckpointStore` when checkpoints must
2168
+ survive process restarts. Internal snapshots are not all returned to callers:
2169
+ the public result contains a `checkpoint_id` only when a tool error interrupts
2170
+ the run with `interrupt_on_tool_error=True`.
2171
+
2172
+ Resuming an `awaiting_tool_execution` checkpoint runs its pending tool calls
2173
+ again. Resuming a `tool_results_ready` checkpoint reuses the recorded tool
2174
+ results, and resuming an `awaiting_reflection` checkpoint reruns reflection.
2175
+ Make pending tools idempotent when an interrupted run might be resumed.
2176
+
2177
+ **Checkpoint + resume example:**
2178
+
2179
+ ```python
2180
+ from llmadapter import create_llmadapter_agent, AdvancedAgentConfig
2181
+ from llmadapter.orchestration.tools import InMemoryCheckpointStore
2182
+
2183
+ # Shared store — pass the same instance to keep checkpoints alive
2184
+ store = InMemoryCheckpointStore()
2185
+
2186
+ agent = create_llmadapter_agent(
2187
+ primary_llm=primary_llm,
2188
+ tools=[get_weather],
2189
+ config=AdvancedAgentConfig(max_iterations=3, interrupt_on_tool_error=True),
2190
+ checkpoint_store=store,
2191
+ )
2192
+
2193
+ # A failing tool call produces a resumable checkpoint only when the model calls it.
2194
+ result = agent.invoke("Research the history of AI and write a summary.")
2195
+ checkpoint_id = result.get("checkpoint_id")
2196
+ if checkpoint_id is not None:
2197
+ # Resume — agent picks up from the saved runtime state.
2198
+ resumed = agent.resume(
2199
+ checkpoint_id,
2200
+ user_feedback="Continue from where you left off.", # optional extra instruction
2201
+ )
2202
+ print(resumed["status"])
2203
+ print(resumed.get("output"))
2204
+ else:
2205
+ print("This run did not produce a resumable interruption.")
2206
+ ```
2207
+
2208
+ Use `await agent.aresume(checkpoint_id, user_feedback=...)` to resume through
2209
+ the asynchronous runtime.
2210
+
2211
+ **Manual checkpoint management:**
2212
+
2213
+ ```python
2214
+ from llmadapter.orchestration.tools import InMemoryCheckpointStore
2215
+
2216
+ store = InMemoryCheckpointStore()
2217
+
2218
+ # Save arbitrary state
2219
+ store.save("my-session-id", {"messages": [], "step": 5})
2220
+
2221
+ # Load it back
2222
+ state = store.load("my-session-id")
2223
+
2224
+ # Delete when done
2225
+ store.delete("my-session-id")
2226
+ ```
2227
+
2228
+ > **Important:** `InMemoryCheckpointStore` stores data in process memory. `FileCheckpointStore(directory)` writes
2229
+ > atomic JSON files with a SHA-256 checksum and restores serialized LangChain
2230
+ > messages. It is not encrypted; protect the directory with operating-system
2231
+ > permissions and do not treat checksum validation as access control. Dataclasses
2232
+ > and Pydantic values serialize to JSON-compatible dictionaries; unsupported
2233
+ > values are stringified, so use a custom `CheckpointStore` when exact custom
2234
+ > type restoration or a database/secret-backed store is required.
2235
+
2236
+ ---
2237
+
2238
+ ### Event Handlers — Observability
2239
+
2240
+ Event handlers expose agent loop and local tool-execution boundaries without
2241
+ modifying agent behavior. They do not emit provider request/response telemetry,
2242
+ checkpoint-store I/O, or a terminal result event.
2243
+
2244
+ **Built-in handlers:**
2245
+
2246
+ ```python
2247
+ from llmadapter import CollectingAgentEventHandler, LoggingAgentEventHandler
2248
+
2249
+ # 1. CollectingAgentEventHandler — stores all events in memory
2250
+ collector = CollectingAgentEventHandler()
2251
+
2252
+ agent = create_llmadapter_agent(
2253
+ primary_llm=primary_llm,
2254
+ tools=[get_weather],
2255
+ event_handler=collector,
2256
+ )
2257
+
2258
+ result = agent.invoke("What is the weather in Berlin?")
2259
+
2260
+ # Inspect what happened
2261
+ for event in collector.events:
2262
+ print(f"[{event.event}] step={event.step} payload={event.payload}")
2263
+
2264
+ # 2. LoggingAgentEventHandler — logs to Python's logging system
2265
+ import logging
2266
+
2267
+ logging.basicConfig(level=logging.INFO)
2268
+
2269
+ logger_handler = LoggingAgentEventHandler(logger_name="llmadapter.agent")
2270
+
2271
+ agent = create_llmadapter_agent(
2272
+ primary_llm=primary_llm,
2273
+ tools=[get_weather],
2274
+ event_handler=logger_handler,
2275
+ )
2276
+ ```
2277
+
2278
+ **Events emitted:**
2279
+
2280
+ | Event | Fired when | `payload` keys |
2281
+ |--------------|------------|----------------|
2282
+ | `step_start` | An iteration begins | `messages` (message count) |
2283
+ | `tool_start` | A registered tool is about to run | `tool_name`, `args` |
2284
+ | `tool_end` | A tool returns successfully | `tool_name`, `result`, `duration_s` |
2285
+ | `tool_error` | A started tool raises or times out | `tool_name`, `error`, `duration_s` |
2286
+ | `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` |
2287
+
2288
+ `CollectingAgentEventHandler` stores the full tool arguments, results, and
2289
+ errors in memory. Use it carefully with sensitive data. `LoggingAgentEventHandler`
2290
+ logs redacted shape summaries rather than raw payload values.
2291
+
2292
+ **Custom event handler:**
2293
+
2294
+ Implement the `AgentEventHandler` protocol to build your own observer:
2295
+
2296
+ ```python
2297
+ from typing import Any
2298
+
2299
+
2300
+ class MetricsEventHandler:
2301
+ def on_step_start(self, step: int, context: dict[str, Any]) -> None:
2302
+ pass # e.g., start a timer
2303
+
2304
+ def on_tool_start(self, step: int, tool_name: str, args: dict[str, Any]) -> None:
2305
+ print(f" -> calling {tool_name}")
2306
+
2307
+ def on_tool_end(self, step: int, tool_name: str, result: Any, duration_s: float) -> None:
2308
+ print(f" <- {tool_name} returned in {duration_s:.2f}s")
2309
+
2310
+ def on_tool_error(self, step: int, tool_name: str, error: str, duration_s: float) -> None:
2311
+ print(f" ! {tool_name} failed: {error}")
2312
+
2313
+ def on_step_end(self, step: int, context: dict[str, Any]) -> None:
2314
+ pass
2315
+
2316
+ # Optional: called when a routing policy returns a decision.
2317
+ # Built-in collectors/loggers do not record this optional callback.
2318
+ def on_routing_decision(self, step: int, decision: dict[str, Any]) -> None:
2319
+ pass
2320
+ ```
2321
+
2322
+ ---
2323
+
2324
+ ### Routing Policies — Dynamic Model Selection
2325
+
2326
+ Routing chooses the LLM for the next agent step. A policy sees whether the
2327
+ **previous** step had a tool error, so a tool error cannot change the model that
2328
+ already produced that step. If no fallback LLM is configured, every built-in
2329
+ policy selects the primary model.
2330
+
2331
+ #### Available Policies
2332
+
2333
+ **`EscalationRoutingPolicy`** — switch to fallback on tool errors:
2334
+
2335
+ ```python
2336
+ from llmadapter import EscalationRoutingPolicy
2337
+
2338
+ agent = create_llmadapter_agent(
2339
+ primary_llm=primary_llm,
2340
+ fallback_llm=fallback_llm,
2341
+ tools=[get_weather],
2342
+ routing_policy=EscalationRoutingPolicy(),
2343
+ )
2344
+ # Primary is used by default.
2345
+ # If a tool error occurs, the following model step uses fallback.
2346
+ ```
2347
+
2348
+ > **When to use:** Your primary model is powerful but expensive. Use a cheaper fallback that is better at correcting
2349
+ > errors.
2350
+
2351
+ **`RoundRobinRoutingPolicy`** — alternate between primary and fallback:
2352
+
2353
+ ```python
2354
+ from llmadapter import RoundRobinRoutingPolicy
2355
+
2356
+ agent = create_llmadapter_agent(
2357
+ primary_llm=primary_llm,
2358
+ fallback_llm=fallback_llm,
2359
+ tools=[get_weather],
2360
+ routing_policy=RoundRobinRoutingPolicy(),
2361
+ )
2362
+ # Step 1: primary, Step 2: fallback, Step 3: primary, ...
2363
+ ```
2364
+
2365
+ The round-robin index lives in the agent's `_routing_state`, so it survives
2366
+ multiple invocations of the same agent instance and is preserved in checkpoints.
2367
+
2368
+ > **When to use:** Load balancing between two equivalent deployments to avoid rate limits.
2369
+
2370
+ **`CostAwareRoutingPolicy`** — use fallback after a step threshold:
2371
+
2372
+ ```python
2373
+ from llmadapter import CostAwareRoutingPolicy
2374
+
2375
+ agent = create_llmadapter_agent(
2376
+ primary_llm=primary_llm, # powerful, expensive (first N steps)
2377
+ fallback_llm=fallback_llm, # cheaper (later steps)
2378
+ tools=[get_weather],
2379
+ routing_policy=CostAwareRoutingPolicy(fallback_after_step=3),
2380
+ )
2381
+ # Steps 1-2: primary model. Steps 3+: fallback model.
2382
+ ```
2383
+
2384
+ > **When to use:** Early steps need a powerful model for reasoning; later steps (simple summaries) can use a cheaper
2385
+ > one. This is a step-count threshold, not live price, token, or usage telemetry.
2386
+
2387
+ **Custom routing function** (maximum flexibility):
2388
+
2389
+ ```python
2390
+ def my_router(messages, step):
2391
+ # Use fallback for odd-numbered steps
2392
+ if step % 2 == 1:
2393
+ return fallback_llm
2394
+ return primary_llm
2395
+
2396
+
2397
+ agent = create_llmadapter_agent(
2398
+ primary_llm=primary_llm,
2399
+ fallback_llm=fallback_llm,
2400
+ tools=[get_weather],
2401
+ router=my_router, # overrides routing_policy if both are set
2402
+ )
2403
+ ```
2404
+
2405
+ `router` takes precedence over `routing_policy` and receives the current message
2406
+ list plus the 1-based step number. A custom router must return a
2407
+ `ChatLLMAdapter`; it is responsible for any error-aware or cost-aware logic.
2408
+ Policies may optionally emit `on_routing_decision(step, decision)` to a custom
2409
+ event handler. The decision payload contains `policy`, `reason`, `metadata`, and
2410
+ the selected model name.
2411
+
2412
+ ---
2413
+
2414
+ ### Tool Retry and Escalation
2415
+
2416
+ ```python
2417
+ config = AdvancedAgentConfig(
2418
+ tool_retry_attempts=3, # retry failed idempotent tools up to 3 times
2419
+ tool_retry_backoff_seconds=0.5, # wait 500ms between retries
2420
+ retry_on_tool_not_found=False, # don't retry if the tool name is wrong
2421
+ escalate_on_retry_exhausted=True, # stop if retries fail and no fallback is configured
2422
+ )
2423
+ ```
2424
+
2425
+ To declare that a tool is safe to repeat, set `tool.llmadapter_idempotent = True`
2426
+ or `tool.metadata["llmadapter_idempotent"] = True`. Tool timeouts are never retried:
2427
+ a synchronous timeout only bounds the agent's wait and the underlying work may
2428
+ still be running. `retry_non_idempotent_tools=True` preserves legacy broad retry
2429
+ behavior, but can repeat side effects and should be used only deliberately.
2430
+
2431
+ You can also retry tool results manually after a run:
2432
+
2433
+ ```python
2434
+ from llmadapter.orchestration.tools import ToolExecutionResult, is_retryable_tool_error, retry_failed_tool_calls
2435
+
2436
+ # Check if a failed tool result is worth retrying
2437
+ failed = ToolExecutionResult(
2438
+ tool_call_id="call_1", tool_name="fetch", args={}, ok=False, content="", error="temporary failure"
2439
+ )
2440
+ if is_retryable_tool_error(failed, idempotent=True):
2441
+ print("This error is retryable")
2442
+
2443
+ # Re-run failed calls from a completed result list
2444
+ retried_results = retry_failed_tool_calls(
2445
+ tool_calls=[{"id": "call_1", "name": "fetch", "args": {}}],
2446
+ current_results=[failed],
2447
+ tool_map=agent.tool_map,
2448
+ step=1,
2449
+ timeout_s=10,
2450
+ max_attempts=2,
2451
+ backoff_seconds=0.3,
2452
+ retry_on_tool_not_found=False,
2453
+ )
2454
+ ```
2455
+
2456
+ ---
2457
+
2458
+ ## Layer 4 — Multi-Agent Orchestration
2459
+
2460
+ When a task is too large for one agent, break it into **specialists** — focused
2461
+ agents that each handle a domain. A **planner agent** can coordinate them.
2462
+
2463
+ ### Specialists as Tools
2464
+
2465
+ The easiest way to compose agents: wrap each specialist agent as a LangChain tool that the planner can call.
2466
+
2467
+ ```python
2468
+ from llmadapter import (
2469
+ ChatLLMAdapter,
2470
+ AdvancedAgentConfig,
2471
+ create_llmadapter_agent,
2472
+ make_specialist_tools,
2473
+ make_subagent_tool,
2474
+ )
2475
+
2476
+ # Create specialist agents
2477
+ finance_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o-mini")
2478
+ travel_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o-mini")
2479
+
2480
+ finance_agent = create_llmadapter_agent(
2481
+ primary_llm=finance_llm,
2482
+ config=AdvancedAgentConfig(max_iterations=4),
2483
+ )
2484
+ travel_agent = create_llmadapter_agent(
2485
+ primary_llm=travel_llm,
2486
+ config=AdvancedAgentConfig(max_iterations=4),
2487
+ )
2488
+
2489
+ # Wrap them as tools the planner can call
2490
+ specialist_tools = make_specialist_tools(
2491
+ {
2492
+ "finance_specialist": (
2493
+ "Handles budgets, costs, and price trade-offs.",
2494
+ finance_agent,
2495
+ ),
2496
+ "travel_specialist": (
2497
+ "Handles itinerary planning and logistics.",
2498
+ travel_agent,
2499
+ ),
2500
+ }
2501
+ )
2502
+
2503
+ # Create the planner agent with specialists as tools
2504
+ planner_llm = ChatLLMAdapter(provider="openai", api_key="sk-...", model="gpt-4o")
2505
+ planner = create_llmadapter_agent(
2506
+ primary_llm=planner_llm,
2507
+ tools=specialist_tools,
2508
+ config=AdvancedAgentConfig(parallel_tools=True, max_iterations=8),
2509
+ )
2510
+
2511
+ result = planner.invoke("Plan a low-budget 3-day trip to Barcelona.")
2512
+ print(result["output"])
2513
+ ```
2514
+
2515
+ **For a single subagent tool:**
2516
+
2517
+ ```python
2518
+ from llmadapter import make_subagent_tool
2519
+
2520
+ research_tool = make_subagent_tool(
2521
+ name="research_agent",
2522
+ description="Performs deep research on any topic.",
2523
+ agent=create_llmadapter_agent(
2524
+ primary_llm=finance_llm,
2525
+ config=AdvancedAgentConfig(max_iterations=4),
2526
+ ),
2527
+ )
2528
+
2529
+ planner = create_llmadapter_agent(
2530
+ primary_llm=planner_llm,
2531
+ tools=[research_tool],
2532
+ config=AdvancedAgentConfig(),
2533
+ )
2534
+ ```
2535
+
2536
+ ---
2537
+
2538
+ ### Capability Contracts (`SpecialistContract`)
2539
+
2540
+ Contracts are declarative labels for filtering and aggregation. A specialist is
2541
+ selected only when its contract contains **every** value in
2542
+ `required_capabilities`; a specialist without a contract is excluded when a
2543
+ capability requirement is supplied. Contracts do not validate model behavior,
2544
+ route work, or inject their `metadata` into prompts.
2545
+
2546
+ ```python
2547
+ from llmadapter import SpecialistContract, invoke_specialists
2548
+
2549
+ contracts = {
2550
+ "finance": SpecialistContract(
2551
+ capabilities={"finance", "budget", "cost"},
2552
+ weight=1.5, # consumed by WeightedScoreAggregator when selected
2553
+ metadata={"model": "gpt-4o"},
2554
+ ),
2555
+ "travel": SpecialistContract(
2556
+ capabilities={"travel", "itinerary", "logistics"},
2557
+ weight=1.0,
2558
+ metadata={"model": "gpt-4o-mini"},
2559
+ ),
2560
+ "legal": SpecialistContract(
2561
+ capabilities={"legal", "compliance"},
2562
+ weight=2.0,
2563
+ ),
2564
+ }
2565
+
2566
+ specialists = {
2567
+ "finance": finance_agent,
2568
+ "travel": travel_agent,
2569
+ # "legal": legal_agent, # not available
2570
+ }
2571
+
2572
+ # Only call specialists that match the required capabilities
2573
+ rows = invoke_specialists(
2574
+ task="What is the estimated cost for a 3-day Barcelona trip?",
2575
+ specialists=specialists,
2576
+ contracts=contracts,
2577
+ required_capabilities={"finance"}, # only finance will be called
2578
+ )
2579
+
2580
+ for row in rows:
2581
+ print(f"{row['specialist']} (weight={row['weight']}): {row['output']}")
2582
+ ```
2583
+
2584
+ **`SpecialistContract` parameters:**
2585
+
2586
+ | Parameter | Type | Description |
2587
+ |----------------|------------|-------------------------------------------------------|
2588
+ | `capabilities` | `set[str]` | Tags used for subset matching against `required_capabilities`. |
2589
+ | `weight` | `float` | Finite, non-negative value attached to rows; `WeightedScoreAggregator` consumes it. Default `1.0`. |
2590
+ | `metadata` | `dict` | Passive application metadata; runtime selection does not inspect it. |
2591
+
2592
+ ---
2593
+
2594
+ ### Async Specialists and Shared Deadlines
2595
+
2596
+ Use `ainvoke_specialists(...)` and `aaggregate_specialists(...)` for
2597
+ asynchronous specialist runs. They accept the same arguments as their
2598
+ synchronous counterparts. `max_runtime_seconds` creates one shared monotonic
2599
+ deadline; each specialist receives the time remaining from that deadline rather
2600
+ than an independent full budget. This bounds the agent loop but cannot forcibly
2601
+ stop already-running synchronous or provider work.
2602
+
2603
+ ---
2604
+
2605
+ ### Shared State Across Agents (`SharedState`)
2606
+
2607
+ `SharedState` is a **thread-safe host-side key-value store**. When specialists
2608
+ are launched, the runtime projects a JSON snapshot into each task prompt as
2609
+ `SharedStateContext(JSON)`. It is not a live object or shared model memory:
2610
+ changes made while a specialist runs are not pushed into that specialist's
2611
+ already-created prompt.
2612
+
2613
+ ```python
2614
+ from llmadapter import SharedState
2615
+
2616
+ state = SharedState({
2617
+ "destination": "Barcelona",
2618
+ "currency": "EUR",
2619
+ "budget_eur": None, # will be filled by finance specialist
2620
+ })
2621
+
2622
+ # Pass it to invoke_specialists — each specialist gets a prompt projection.
2623
+ rows = invoke_specialists(
2624
+ task="Calculate total trip budget.",
2625
+ specialists=specialists,
2626
+ contracts=contracts,
2627
+ shared_state=state,
2628
+ )
2629
+
2630
+ # After each specialist returns, the host runtime stores its output here.
2631
+ print(state.get("specialist_outputs.finance"))
2632
+ print(state.get("destination")) # reads initial value
2633
+
2634
+ # Application code can update shared state between orchestration calls.
2635
+ state.set("budget_eur", 850)
2636
+ state.update({"hotel_booked": True, "flights_booked": False})
2637
+
2638
+ # Thread-safe snapshot for logging
2639
+ snapshot = state.snapshot()
2640
+ print(snapshot)
2641
+ ```
2642
+
2643
+ **`SharedState` methods:**
2644
+
2645
+ | Method | Description |
2646
+ |--------------------------|-----------------------------------------|
2647
+ | `get(key, default=None)` | Read a value by key. |
2648
+ | `set(key, value)` | Write a single key. |
2649
+ | `update(dict)` | Write multiple keys at once. |
2650
+ | `snapshot()` | Return a copy of the entire state dict. |
2651
+
2652
+ Use `shared_state_keys` to allowlist prompt-visible keys and
2653
+ `shared_state_max_bytes` (default `16_000`) to cap the JSON projection. Keep
2654
+ secrets and large mutable objects out of prompt-visible state.
2655
+
2656
+ ---
2657
+
2658
+ ### Aggregation Strategies
2659
+
2660
+ After multiple specialists respond to the same task, use an aggregator to combine their answers into one result.
2661
+
2662
+ ```python
2663
+ from llmadapter import (
2664
+ aggregate_specialists,
2665
+ MajorityVoteAggregator,
2666
+ WeightedScoreAggregator,
2667
+ JsonMergeAggregator,
2668
+ AggregatedResult,
2669
+ )
2670
+
2671
+ rows = invoke_specialists(task="...", specialists=specialists, contracts=contracts)
2672
+ ```
2673
+
2674
+ All three aggregators accept `include_failed=False` by default. In that mode,
2675
+ rows with status `failed`, `error`, `interrupted`, `stopped`, or `timeout` are excluded;
2676
+ `AggregatedResult.inputs` contains the eligible rows, not necessarily the
2677
+ original input list.
2678
+
2679
+ **`MajorityVoteAggregator`** — picks the most common answer:
2680
+
2681
+ ```python
2682
+ aggregator = MajorityVoteAggregator(value_key="output")
2683
+ result: AggregatedResult = aggregator.aggregate(rows)
2684
+
2685
+ print(result.consensus) # the winning answer
2686
+ print(result.confidence) # fraction of specialists that agreed (0.0-1.0)
2687
+ print(result.metadata) # {"votes": {"answer_A": 2, "answer_B": 1}}
2688
+ ```
2689
+
2690
+ > **When to use:** Classification tasks, yes/no decisions, labeling — when you want the answer most agents agreed on.
2691
+ > Ties are deterministic but not semantic: the implementation chooses the
2692
+ > lexicographically greatest serialized output bucket.
2693
+
2694
+ **`WeightedScoreAggregator`** — picks the highest-weighted answer:
2695
+
2696
+ ```python
2697
+ aggregator = WeightedScoreAggregator(value_key="output", weight_key="weight")
2698
+ result = aggregator.aggregate(rows)
2699
+
2700
+ print(result.consensus) # the answer with the highest cumulative weight
2701
+ print(result.confidence) # weighted fraction
2702
+ ```
2703
+
2704
+ > **When to use:** When some specialists are more authoritative than others (e.g., a fine-tuned finance model should
2705
+ > outweigh a general one).
2706
+
2707
+ Like majority voting, a weighted tie is resolved by the lexicographically
2708
+ greatest serialized output bucket. Weights must be finite and non-negative.
2709
+
2710
+ **`JsonMergeAggregator`** — merges all JSON responses into one dict:
2711
+
2712
+ ```python
2713
+ aggregator = JsonMergeAggregator(conflict_policy="last_writer_wins")
2714
+ result = aggregator.aggregate(rows)
2715
+
2716
+ # When eligible outputs are JSON objects, consensus is their merged dict.
2717
+ print(result.consensus)
2718
+ ```
2719
+
2720
+ `JsonMergeAggregator` accepts `last_writer_wins` (default),
2721
+ `first_writer_wins`, or `error`. It parses string outputs as JSON when possible,
2722
+ merges only JSON-object outputs in input order, and records conflicts in
2723
+ `result.metadata`. If no eligible output is an object, it falls back to majority
2724
+ voting and reports `strategy="json_merge_fallback_majority"`.
2725
+
2726
+ > **When to use:** When each specialist provides a different piece of information (e.g., travel agent provides
2727
+ > itinerary, finance agent provides costs) and you want one unified object.
2728
+
2729
+ **`AggregatedResult` attributes:**
2730
+
2731
+ | Attribute | Description |
2732
+ |--------------|-----------------------------------------------------------------------|
2733
+ | `consensus` | The final aggregated answer. |
2734
+ | `inputs` | Eligible specialist rows after the aggregator's failure filter. |
2735
+ | `strategy` | Name of the aggregation strategy used. |
2736
+ | `confidence` | Score between 0-1 indicating confidence (meaning varies by strategy). |
2737
+ | `metadata` | Strategy-specific extra data (e.g., vote counts). |
2738
+
2739
+ **Using `aggregate_specialists` helper:**
2740
+
2741
+ ```python
2742
+ from llmadapter import aggregate_specialists
2743
+
2744
+ result = aggregate_specialists(
2745
+ task="...",
2746
+ specialists=specialists,
2747
+ contracts=contracts,
2748
+ aggregator=WeightedScoreAggregator(),
2749
+ )
2750
+ print(result.consensus)
2751
+ ```
2752
+
2753
+ ---
2754
+
2755
+ ### Full Multi-Agent Example
2756
+
2757
+ ```python
2758
+ import os
2759
+ from llmadapter import (
2760
+ ChatLLMAdapter,
2761
+ AdvancedAgentConfig,
2762
+ SpecialistContract,
2763
+ SharedState,
2764
+ create_llmadapter_agent,
2765
+ invoke_specialists,
2766
+ aggregate_specialists,
2767
+ WeightedScoreAggregator,
2768
+ CollectingAgentEventHandler,
2769
+ EscalationRoutingPolicy,
2770
+ )
2771
+
2772
+
2773
+ # --- Build specialist agents ---
2774
+ def make_llm(model: str) -> ChatLLMAdapter:
2775
+ return ChatLLMAdapter(
2776
+ provider="azure_openai",
2777
+ api_key=os.environ["AZURE_OPENAI_API_KEY"],
2778
+ endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
2779
+ deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"],
2780
+ api_version="2024-02-01",
2781
+ model=model,
2782
+ )
2783
+
2784
+
2785
+ collector = CollectingAgentEventHandler()
2786
+
2787
+ finance_agent = create_llmadapter_agent(
2788
+ primary_llm=make_llm("gpt-4o"),
2789
+ config=AdvancedAgentConfig(max_iterations=4, reflection_enabled=False),
2790
+ event_handler=collector,
2791
+ )
2792
+ travel_agent = create_llmadapter_agent(
2793
+ primary_llm=make_llm("gpt-4o"),
2794
+ config=AdvancedAgentConfig(max_iterations=4, reflection_enabled=False),
2795
+ routing_policy=EscalationRoutingPolicy(),
2796
+ )
2797
+
2798
+ # --- Contracts ---
2799
+ contracts = {
2800
+ "finance": SpecialistContract(capabilities={"finance", "budget"}, weight=1.5),
2801
+ "travel": SpecialistContract(capabilities={"travel", "itinerary"}, weight=1.0),
2802
+ }
2803
+ specialists = {"finance": finance_agent, "travel": travel_agent}
2804
+
2805
+ # --- Shared context ---
2806
+ shared_state = SharedState({"destination": "Lisbon", "days": 3, "currency": "EUR"})
2807
+
2808
+ # --- Run ---
2809
+ rows = invoke_specialists(
2810
+ task="Provide a short one-sentence recommendation for this trip.",
2811
+ specialists=specialists,
2812
+ contracts=contracts,
2813
+ required_capabilities={"finance"}, # only finance needed
2814
+ shared_state=shared_state,
2815
+ )
2816
+
2817
+ result = WeightedScoreAggregator().aggregate(rows)
2818
+ print("Final answer:", result.consensus)
2819
+ print("Confidence:", result.confidence)
2820
+
2821
+ # --- Inspect events ---
2822
+ step_events = [e for e in collector.events if e.event == "step_start"]
2823
+ print(f"Finance agent used {len(step_events)} steps")
2824
+ ```
2825
+
2826
+ ---
2827
+
2828
+ ## Extending with a New Provider
2829
+
2830
+ 1. **Create a provider file** under `llmadapter/providers/my_provider.py` implementing the relevant
2831
+ `BaseChatProvider`, `BaseEmbeddingProvider`, and/or `BaseResponsesProvider` contract from `llmadapter/base.py`.
2832
+
2833
+ 2. **Register each implemented surface in `llmadapter/core/registry.py`:**
2834
+
2835
+ ```python
2836
+ from ..providers.my_provider import (
2837
+ AsyncMyChatProvider,
2838
+ AsyncMyResponsesProvider,
2839
+ MyChatProvider,
2840
+ MyEmbeddingProvider,
2841
+ MyResponsesProvider,
2842
+ )
2843
+
2844
+ CHAT_PROVIDER_REGISTRY["my_provider"] = MyChatProvider
2845
+ EMBEDDING_PROVIDER_REGISTRY["my_provider"] = MyEmbeddingProvider
2846
+ ASYNC_CHAT_PROVIDER_REGISTRY["my_provider"] = AsyncMyChatProvider
2847
+ RESPONSES_PROVIDER_REGISTRY["my_provider"] = MyResponsesProvider
2848
+ ASYNC_RESPONSES_PROVIDER_REGISTRY["my_provider"] = AsyncMyResponsesProvider
2849
+ ```
2850
+
2851
+ Only register interfaces that actually exist. Provider modules are imported
2852
+ optionally, so a missing optional SDK should leave its registry entries absent
2853
+ rather than make package import fail.
2854
+
2855
+ 3. **Add a config model** in `config.py` and register it in `CONFIG_REGISTRY`.
2856
+ Also register a `ProviderCapabilityManifest` through
2857
+ `register_provider_capabilities(...)`; sync/async chat and embedding dispatch
2858
+ consult that manifest before invoking a provider.
2859
+
2860
+ 4. **Add tests** covering every registered surface and its normalized response
2861
+ shape before shipping.
2862
+
2863
+ ---
2864
+
2865
+ ## Project Structure
2866
+
2867
+ ```
2868
+ llmadapter/
2869
+ ├── __init__.py # public API — re-exports everything below
2870
+ ├── core/ # adapter engine
2871
+ │ ├── sync_adapter.py # LLMAdapter, _ChatCompletions, _Embeddings
2872
+ │ ├── async_adapter.py # AsyncLLMAdapter, _AsyncChatCompletions (non-blocking I/O)
2873
+ │ ├── registry.py # chat, embedding, and Responses sync/async registries
2874
+ │ ├── retry.py # retry + full-jitter exponential back-off
2875
+ │ ├── failover.py # backup-provider failover helpers
2876
+ │ ├── execution.py # target-aware normalized request plans
2877
+ │ ├── responses_capabilities.py # Responses request validation and capability gates
2878
+ │ ├── streaming.py # lazy stream sessions and pre-output failover
2879
+ │ ├── circuit_breaker.py # target-aware circuit breaker
2880
+ │ ├── preprocess.py # message prep + provider-client cache (ProviderCache)
2881
+ │ └── redaction.py # sensitive-field redaction for logs
2882
+ ├── ratelimit/ # rate limiting + tokenization
2883
+ │ ├── limiter.py # RedisRateLimiter (pooled connections), RateLimitMetrics
2884
+ │ ├── tokenizer.py # token counting / truncation (cached encodings)
2885
+ │ ├── batching.py # AdapterUtility — token-aware embedding batches
2886
+ │ └── logging.py # log_structured
2887
+ ├── structured.py # structured-output helpers (pydantic_to_response_format, …) + to_jsonable
2888
+ ├── integrations/
2889
+ │ └── langchain.py # ChatLLMAdapter — LangChain BaseChatModel wrapper
2890
+ ├── orchestration/ # multi-agent runtime
2891
+ │ ├── runtime.py # LLMAdapterAgent, AdvancedAgentConfig, create_llmadapter_agent
2892
+ │ ├── tools.py # tool execution, budgets, checkpoints, event handlers
2893
+ │ ├── routing.py # BaseRoutingPolicy and routing policy implementations
2894
+ │ ├── aggregators.py # MajorityVote / WeightedScore / JsonMerge aggregators
2895
+ │ └── contracts.py # SharedState, SpecialistContract
2896
+ ├── providers/ # one module per provider
2897
+ │ ├── _shared.py # shared OpenAI-shape response mappers (OpenAI + Azure OpenAI)
2898
+ │ ├── _responses_base.py # shared OpenAI/Azure Responses provider verbs
2899
+ │ ├── capabilities.py # provider capability manifests
2900
+ │ ├── azureopenai_provider.py
2901
+ │ ├── azurefoundry_provider.py
2902
+ │ ├── openai_provider.py
2903
+ │ ├── vertexai_provider.py
2904
+ │ └── anthropic_provider.py
2905
+ ├── config.py # Typed provider config models
2906
+ ├── models.py # Request/response models
2907
+ ├── responses_tools.py # Typed built-in Responses tool requests
2908
+ ├── errors.py # Typed exception hierarchy
2909
+ ├── base.py # Provider interfaces (chat, embedding, Responses)
2910
+ └── constants.py # Default rate limits / retry settings
2911
+ examples/
2912
+ └── llmadapter_example.py # full walkthrough (chat, embed, async, LangChain, agents)
2913
+ ```
2914
+
2915
+ ---
2916
+
2917
+ ## API Reference
2918
+
2919
+ For the complete exported API surface with all class signatures, parameters, and return types, see:
2920
+
2921
+ **[docs/API_REFERENCE.md](docs/API_REFERENCE.md)**
2922
+
2923
+ **Selected top-level imports:**
2924
+
2925
+ ```python
2926
+ from llmadapter import (
2927
+ # Core
2928
+ LLMAdapter,
2929
+ AsyncLLMAdapter,
2930
+
2931
+ # LangChain layer
2932
+ ChatLLMAdapter,
2933
+
2934
+ # Agent runtime
2935
+ AdvancedAgentConfig,
2936
+ LLMAdapterAgent,
2937
+ create_llmadapter_agent,
2938
+
2939
+ # Multi-agent helpers
2940
+ make_subagent_tool,
2941
+ make_specialist_tools,
2942
+ ainvoke_specialists,
2943
+ invoke_specialists,
2944
+ aaggregate_specialists,
2945
+ aggregate_specialists,
2946
+
2947
+ # Aggregators
2948
+ AggregatedResult,
2949
+ BaseAggregator,
2950
+ JsonMergeAggregator,
2951
+ MajorityVoteAggregator,
2952
+ WeightedScoreAggregator,
2953
+
2954
+ # Orchestration
2955
+ SharedState,
2956
+ SpecialistContract,
2957
+
2958
+ # Routing policies
2959
+ BaseRoutingPolicy,
2960
+ RoundRobinRoutingPolicy,
2961
+ EscalationRoutingPolicy,
2962
+ CostAwareRoutingPolicy,
2963
+
2964
+ # Observability
2965
+ CollectingAgentEventHandler,
2966
+ LoggingAgentEventHandler,
2967
+ CheckpointStore,
2968
+ InMemoryCheckpointStore,
2969
+ FileCheckpointStore,
2970
+
2971
+ # Errors
2972
+ LLMAdapterError,
2973
+ AuthenticationError,
2974
+ ModelNotFoundError,
2975
+ RateLimitExceededError,
2976
+ APIConnectionError,
2977
+ TimeOutError,
2978
+ ChatCompletionError,
2979
+ EmbeddingGenerationError,
2980
+ ContentFilterError,
2981
+ InvalidResponseError,
2982
+ )
2983
+ ```