Model recipes: Bedrock, Azure OpenAI, Mistral, Groq, OpenRouter¶
Each recipe wraps the vendor's own SDK in a CallableModel. The adapter adds no
dependency: you install the SDK, and the recipe imports it only when you build the model.
Every recipe turns the vendor's usage into Usage, so the ledger prices it from your
Prices table, and turns SDK errors into typed model errors.
flowchart LR
L["run_tool_loop<br/>or executor.model(port)"] -->|LLMRequest| C["CallableModel(call)"]
C --> T["to vendor kwargs"] --> S["vendor SDK<br/>converse · chat.complete · chat.completions.create"]
S -->|"reply"| F["from vendor reply<br/>text · tool calls · Usage"] --> C
S -.->|"SDK error"| E["typed_error<br/>ModelRateLimited · ModelTimeout …"] -.-> C
C -->|LLMResponse| L
| Vendor | SDK | Recipe | Shape |
|---|---|---|---|
| Amazon Bedrock | boto3 |
examples/models_bedrock.py |
Converse API, own converter |
| Azure OpenAI | openai |
examples/models_openai_compatible.py |
Chat Completions |
| Groq | groq |
examples/models_openai_compatible.py |
Chat Completions |
| OpenRouter | openai |
examples/models_openai_compatible.py |
Chat Completions |
| Mistral | mistralai |
examples/models_mistral.py |
Chat Completions with Mistral's names |
Amazon Bedrock (Converse)¶
def converse_model(client: Any, model_id: str) -> CallableModel:
"""A model port over a `bedrock-runtime` client's `converse` call."""
def call(request: LLMRequest) -> dict[str, Any]:
model = request.model or model_id
try:
raw = client.converse(**to_converse(request, model)) # sync: runs in a worker thread
except Exception as exc:
error = typed_error(exc, provider="bedrock", model=model)
if error is None:
raise
raise error from exc
return from_converse(raw)
return CallableModel(call, provider="bedrock", model=model_id)
def bedrock_model(model_id: str, region: str = "us-east-1") -> CallableModel:
"""Bedrock with the default AWS credential chain; boto3 is imported on first use."""
import boto3
return converse_model(boto3.client("bedrock-runtime", region_name=region), model_id)
The converter keeps the system prompt apart, sends tool results as one user turn, and
maps stopReason to a finish reason:
STOP = {"end_turn": "stop", "stop_sequence": "stop", "tool_use": "tool_calls",
"max_tokens": "length", "guardrail_intervened": "content_filter",
"content_filtered": "content_filter"} # fmt: skip
def _turn(message: Message) -> dict[str, Any]:
content: list[dict[str, Any]] = [{"text": message.content}] if message.content else []
content += [
{"toolUse": {"toolUseId": call.id, "name": call.name, "input": call.arguments}}
for call in message.tool_calls
]
return {"role": message.role, "content": content}
def to_converse(request: LLMRequest, model_id: str) -> dict[str, Any]:
"""`converse(**kwargs)` for a request: system apart, tool results as user turns."""
turns: list[dict[str, Any]] = []
for message in request.messages:
if message.role == "system":
continue
if message.role != "tool":
turns.append(_turn(message))
continue
result = {
"toolResult": {
"toolUseId": message.tool_call_id,
"content": [{"text": message.content}],
}
}
if turns and turns[-1]["role"] == "user" and "toolResult" in turns[-1]["content"][0]:
turns[-1]["content"].append(result) # results of one turn share one user message
else:
turns.append({"role": "user", "content": [result]})
kwargs: dict[str, Any] = {"modelId": model_id, "messages": turns}
system = [{"text": m.content} for m in request.messages if m.role == "system"]
if system:
kwargs["system"] = system
config = {"maxTokens": request.max_output_tokens, "temperature": request.temperature,
"topP": request.top_p, "stopSequences": list(request.stop) or None} # fmt: skip
if any(value is not None for value in config.values()):
kwargs["inferenceConfig"] = {k: v for k, v in config.items() if v is not None}
if request.tools and request.tool_choice != "none": # Converse has no "none": send no tools
specs = [
{
"toolSpec": {
"name": t.name,
"description": t.description,
"inputSchema": {"json": t.parameters},
}
}
for t in request.tools
]
choice: dict[str, Any] = {"auto": {"auto": {}}, "required": {"any": {}}}.get(
request.tool_choice, {"tool": {"name": request.tool_choice}}
)
kwargs["toolConfig"] = {"tools": specs, "toolChoice": choice}
return kwargs
def from_converse(raw: dict[str, Any]) -> dict[str, Any]:
"""A `CallableModel` reply mapping from a Converse response."""
blocks = raw.get("output", {}).get("message", {}).get("content", [])
calls = [
ToolCall(
id=b["toolUse"]["toolUseId"],
name=b["toolUse"]["name"],
arguments=b["toolUse"].get("input") or {},
)
for b in blocks
if "toolUse" in b
]
usage = raw.get("usage", {})
reason = raw.get("stopReason", "")
return {
"text": "".join(b["text"] for b in blocks if "text" in b),
"tool_calls": calls,
"finish_reason": STOP.get(reason, "other"),
"raw_finish_reason": reason,
"usage": Usage( # Converse counts cache reads and writes apart from inputTokens
input_tokens=usage.get("inputTokens", 0),
cached_input_tokens=usage.get("cacheReadInputTokens", 0),
cache_write_input_tokens=usage.get("cacheWriteInputTokens", 0),
output_tokens=usage.get("outputTokens", 0),
),
}
Converse has no tool_choice="none", so the recipe sends no tools then. Credentials come
from the default AWS chain (environment, profile or role).
Azure OpenAI, Groq and OpenRouter¶
All three speak Chat Completions, so one function covers them. It reuses
build_chat_request and parse_chat_completion from agent_adapter.models.openai:
def chat_model(
create: Callable[..., Any], *, provider: str, model: str, plain_tools: bool = False
) -> CallableModel:
"""A model port over any OpenAI-shaped `chat.completions.create` of a vendor SDK."""
def call(request: LLMRequest) -> dict[str, Any]:
kwargs = build_chat_request(request, model=request.model or model, strict_tools=False)
if plain_tools: # send tool schemas without the OpenAI-only "strict" flag
for tool in kwargs.get("tools", []):
tool["function"].pop("strict", None)
try:
raw = create(**kwargs) # a sync SDK call: CallableModel runs it in a worker thread
except Exception as exc:
error = typed_error(exc, provider=provider, model=model)
if error is None:
raise
raise error from exc
reply = parse_chat_completion(raw) # SDK object or dict -> text, tool calls, usage
return {
"id": reply.id,
"text": reply.text,
"tool_calls": reply.tool_calls,
"finish_reason": reply.finish_reason,
"raw_finish_reason": reply.raw_finish_reason,
"usage": reply.usage, # prompt tokens split into uncached, cached and written
}
return CallableModel(call, provider=provider, model=model)
def azure_model(endpoint: str, api_key: str, deployment: str) -> CallableModel:
"""Azure OpenAI: `model` is your deployment name; the SDK is imported on first use."""
from openai import AzureOpenAI
client = AzureOpenAI(azure_endpoint=endpoint, api_key=api_key, api_version="2024-10-21")
return chat_model(client.chat.completions.create, provider="azure", model=deployment)
def groq_model(api_key: str, model: str = "llama-3.3-70b-versatile") -> CallableModel:
"""Groq's own SDK (`pip install groq`)."""
from groq import Groq
client = Groq(api_key=api_key)
return chat_model(
client.chat.completions.create, provider="groq", model=model, plain_tools=True
)
OPENROUTER = "https://openrouter.ai/api/v1"
def openrouter_model(api_key: str, model: str = "openai/gpt-5.4") -> CallableModel:
"""OpenRouter through the openai SDK; `model` is OpenRouter's "vendor/model" id."""
from openai import OpenAI
client = OpenAI(base_url=OPENROUTER, api_key=api_key)
return chat_model(client.chat.completions.create, provider="openrouter", model=model)
| Vendor | Notes |
|---|---|
| Azure OpenAI | model is your deployment name; use the API version your resource supports |
| Groq | tool schemas go without the OpenAI-only strict flag |
| OpenRouter | ids are vendor/model; one pattern row ("openai/*") prices a whole vendor |
Azure also works with the built-in port: OpenAIModel("shop-gpt", client=AsyncAzureOpenAI(...)).
Mistral¶
def complete_model(client: Any, model: str) -> CallableModel:
"""A model port over a `Mistral` client's `chat.complete`."""
def call(request: LLMRequest) -> dict[str, Any]:
name = request.model or model
try:
raw = client.chat.complete(**to_mistral(request, name)) # sync: a worker thread
except Exception as exc:
error = typed_error(exc, provider="mistral", model=name)
if error is None:
raise
raise error from exc
return from_mistral(raw)
return CallableModel(call, provider="mistral", model=model)
def mistral_model(api_key: str, model: str = "mistral-large-latest") -> CallableModel:
"""Mistral's API; the SDK is imported on first use."""
from mistralai import Mistral
return complete_model(Mistral(api_key=api_key), model)
Mistral calls the output limit max_tokens and the seed random_seed, and "any"
forces a tool. It may send content as a list of chunks and arguments as an object:
def to_mistral(request: LLMRequest, model: str) -> dict[str, Any]:
"""`chat.complete(**kwargs)`: Chat Completions kwargs with Mistral's own names."""
kwargs = build_chat_request(request, model=model, strict_tools=False)
if "max_completion_tokens" in kwargs:
kwargs["max_tokens"] = kwargs.pop("max_completion_tokens")
if "seed" in kwargs:
kwargs["random_seed"] = kwargs.pop("seed")
kwargs.pop("reasoning_effort", None) # not a Mistral chat parameter
if kwargs.get("tool_choice") == "required":
kwargs["tool_choice"] = "any"
return kwargs
def _text(content: Any) -> str | None:
"""Mistral may send content as a list of chunks; keep the text ones."""
if isinstance(content, list):
return "".join(str(c.get("text", "")) for c in content if c.get("type") == "text")
return None if content is None else str(content)
def from_mistral(raw: Any) -> dict[str, Any]:
"""A `CallableModel` reply mapping from a `ChatCompletionResponse` (or its dict)."""
data = raw.model_dump() if hasattr(raw, "model_dump") else dict(raw)
for choice in data.get("choices") or []:
message = choice.get("message") or {}
message["content"] = _text(message.get("content"))
for call in message.get("tool_calls") or []:
arguments = call["function"].get("arguments")
if isinstance(arguments, dict): # Mistral may send arguments as an object
call["function"]["arguments"] = json.dumps(arguments)
reply = parse_chat_completion(data)
return {
"id": reply.id,
"text": reply.text,
"tool_calls": reply.tool_calls,
"finish_reason": reply.finish_reason,
"raw_finish_reason": reply.raw_finish_reason,
"usage": reply.usage,
}
Prices¶
Each recipe loads its own rows. The rates in the examples are placeholders: copy yours from the vendor's price page.
PRICES = Prices.load( # example rates: copy yours from each vendor's price page
{
"version": "mine-2026-11",
"models": {
"shop-gpt": {"provider": "azure", "input": 2.50, "cached_input": 0.25, "output": 15.0},
"llama-3.3-70b-versatile": {"provider": "groq", "input": 0.60, "output": 0.80},
"openai/*": {"provider": "openrouter", "input": 2.50, "output": 15.00},
},
}
)
Errors¶
def typed_error(exc: Exception, *, provider: str, model: str) -> ModelError | None:
"""The SDK error as a `ModelError`, or None for one that is not a provider error."""
# Typed errors let retries, budgets and FallbackModel tell a rate limit from a bug.
status = getattr(exc, "status_code", None)
response = getattr(exc, "response", None)
if status is None and isinstance(response, dict): # botocore's ClientError
status = response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if isinstance(status, int):
cls = with_overflow(class_for_status(status), exc, getattr(exc, "body", None))
return model_error(cls, exc, provider=provider, model=model, status_code=status)
name = type(exc).__name__ # SDKs name their transport errors alike
if "Timeout" in name:
return model_error(ModelTimeout, exc, provider=provider, model=model)
if "Connection" in name:
return model_error(ModelUnavailable, exc, provider=provider, model=model)
return None
| SDK error | Becomes |
|---|---|
a status code (status_code, or botocore's ResponseMetadata) |
ModelRateLimited (429), ModelAuthError (401, 403), ModelTimeout (408), ModelUnavailable (5xx), ModelBadRequest (4xx) or ModelContextOverflow |
| a timeout or connection error | ModelTimeout, ModelUnavailable |
| anything else | propagates unchanged |
FallbackModel falls over on rate limits, outages and timeouts, so the typed errors make
a second vendor a one-line change (Models).
Output¶
Each example runs offline on a fake SDK client:
The line under each answer is the price of each model call from the ledger.
Files¶
| File | Does |
|---|---|
examples/models_bedrock.py |
Bedrock Converse: converter, model port, prices |
examples/models_openai_compatible.py |
Azure OpenAI, Groq, OpenRouter over one Chat Completions port |
examples/models_mistral.py |
Mistral: converter, model port, prices |
examples/models_common.py |
typed_error and the shared demo run |
tests/docs/test_model_recipes.py |
runs each recipe on fake SDK modules |
More on wrapping a model: Bring your own model.