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.
- llmadapterevs-0.1.0/PKG-INFO +2983 -0
- llmadapterevs-0.1.0/README.md +2936 -0
- llmadapterevs-0.1.0/llmadapter/__init__.py +205 -0
- llmadapterevs-0.1.0/llmadapter/base.py +120 -0
- llmadapterevs-0.1.0/llmadapter/config.py +191 -0
- llmadapterevs-0.1.0/llmadapter/constants.py +13 -0
- llmadapterevs-0.1.0/llmadapter/core/__init__.py +32 -0
- llmadapterevs-0.1.0/llmadapter/core/async_adapter.py +876 -0
- llmadapterevs-0.1.0/llmadapter/core/circuit_breaker.py +140 -0
- llmadapterevs-0.1.0/llmadapter/core/execution.py +279 -0
- llmadapterevs-0.1.0/llmadapter/core/failover.py +110 -0
- llmadapterevs-0.1.0/llmadapter/core/preprocess.py +630 -0
- llmadapterevs-0.1.0/llmadapter/core/redaction.py +47 -0
- llmadapterevs-0.1.0/llmadapter/core/registry.py +80 -0
- llmadapterevs-0.1.0/llmadapter/core/responses_capabilities.py +313 -0
- llmadapterevs-0.1.0/llmadapter/core/responses_management.py +81 -0
- llmadapterevs-0.1.0/llmadapter/core/retry.py +83 -0
- llmadapterevs-0.1.0/llmadapter/core/streaming.py +549 -0
- llmadapterevs-0.1.0/llmadapter/core/sync_adapter.py +1075 -0
- llmadapterevs-0.1.0/llmadapter/errors.py +439 -0
- llmadapterevs-0.1.0/llmadapter/integrations/__init__.py +20 -0
- llmadapterevs-0.1.0/llmadapter/integrations/langchain.py +1174 -0
- llmadapterevs-0.1.0/llmadapter/models.py +1020 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/__init__.py +62 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/aggregators.py +215 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/contracts.py +164 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/routing.py +196 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/runtime.py +1754 -0
- llmadapterevs-0.1.0/llmadapter/orchestration/tools.py +773 -0
- llmadapterevs-0.1.0/llmadapter/providers/__init__.py +58 -0
- llmadapterevs-0.1.0/llmadapter/providers/_responses_base.py +384 -0
- llmadapterevs-0.1.0/llmadapter/providers/_shared.py +411 -0
- llmadapterevs-0.1.0/llmadapter/providers/anthropic_provider.py +999 -0
- llmadapterevs-0.1.0/llmadapter/providers/azurefoundry_provider.py +749 -0
- llmadapterevs-0.1.0/llmadapter/providers/azureopenai_provider.py +681 -0
- llmadapterevs-0.1.0/llmadapter/providers/capabilities.py +137 -0
- llmadapterevs-0.1.0/llmadapter/providers/openai_provider.py +615 -0
- llmadapterevs-0.1.0/llmadapter/providers/vertexai_provider.py +817 -0
- llmadapterevs-0.1.0/llmadapter/ratelimit/__init__.py +24 -0
- llmadapterevs-0.1.0/llmadapter/ratelimit/batching.py +76 -0
- llmadapterevs-0.1.0/llmadapter/ratelimit/limiter.py +435 -0
- llmadapterevs-0.1.0/llmadapter/ratelimit/logging.py +29 -0
- llmadapterevs-0.1.0/llmadapter/ratelimit/tokenizer.py +217 -0
- llmadapterevs-0.1.0/llmadapter/responses_tools.py +95 -0
- llmadapterevs-0.1.0/llmadapter/structured.py +291 -0
- llmadapterevs-0.1.0/llmadapterevs.egg-info/PKG-INFO +2983 -0
- llmadapterevs-0.1.0/llmadapterevs.egg-info/SOURCES.txt +50 -0
- llmadapterevs-0.1.0/llmadapterevs.egg-info/dependency_links.txt +1 -0
- llmadapterevs-0.1.0/llmadapterevs.egg-info/requires.txt +43 -0
- llmadapterevs-0.1.0/llmadapterevs.egg-info/top_level.txt +1 -0
- llmadapterevs-0.1.0/pyproject.toml +78 -0
- 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
|
+
```
|