Agent() uses Anthropic by default. Select a provider and an explicit model ID through AgentConfig; the tool and result APIs remain the same.
Provider configuration
| Provider | Dependency | Environment variable |
|---|---|---|
| anthropic | Included | ANTHROPIC_API_KEY |
| openai | harnessx[openai] | OPENAI_API_KEY |
| gemini | harnessx[gemini] | GEMINI_API_KEY |
| openrouter | harnessx[openrouter] | OPENROUTER_API_KEY |
| azure | harnessx[azure] | AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, OPENAI_API_VERSION |
Choose a model available to your account. The SDK does not maintain a live provider model catalog.
import os
from harnessx import Agent, AgentConfig
agent = Agent(config=AgentConfig(
provider="openrouter",
model=os.environ["AGENT_MODEL"],
system_prompt="You are a concise data-analysis assistant.",
))
The default config uses anthropic and claude-sonnet-4-6. Changing the provider does not automatically choose a compatible model; set both.
On Azure the model is the deployment name, and an endpoint plus API version are required. AzureOpenAIProvider also accepts azure_endpoint, api_key, api_version, and azure_deployment for explicit construction and injection. "azure" is the only Azure provider name.
Images, documents and audio
A user turn can carry a file as well as text. Pass agent.run() or agent.run_stream() a list of content blocks instead of a string: a text block for the question, and an image, document or audio block for the file. A media block holds base64 data and its media type. agent.stream_text() takes a string only.
import base64
from pathlib import Path
from harnessx import Agent, AgentConfig
async def describe(path: str) -> str:
data = base64.b64encode(Path(path).read_bytes()).decode()
async with Agent(config=AgentConfig(provider="gemini", model="gemini-3.6-flash")) as agent:
result = await agent.run([
{"type": "text", "text": "What is on this receipt?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": data}},
])
return result.raise_for_status().output
| Block | Media types |
|---|---|
image |
image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif |
document |
application/pdf |
audio |
audio/wav, audio/mp3, audio/mpeg, audio/aiff, audio/aac, audio/ogg, audio/flac, audio/mp4, audio/x-m4a, audio/webm |
Media blocks belong to user messages. The media type is checked when the message is built: Message(role="user", content=[...]) raises ValueError for a type outside this table, such as image/jpg, and the same block passed to agent.run() fails the run before any model call.
Each provider translates the blocks into its vendor's format:
| Provider | Images | PDFs | Audio |
|---|---|---|---|
| gemini | Yes | Yes | Yes |
| openai, azure, openrouter | Yes | Yes | WAV and MP3 only. Another audio type fails the run with ValueError |
| anthropic | Yes | Yes | No. The run fails with ValueError instead of sending the prompt without the clip |
Whether a particular model accepts a given file is up to the vendor. examples/01-basics/media_input.py sends an image, PDF or audio clip to Gemini:
uv run python examples/01-basics/media_input.py path/to/photo.jpg
With no path it sends a 1x1 PNG held in the script. It needs the gemini extra and GEMINI_API_KEY, and HARNESSX_MODEL selects the model.
Retries, fallback, and caching
The engine retries transient model failures under AgentConfig.retry, and
AgentConfig.fallbacks names the providers to try after the primary. See
Retries & fallback models.
Every provider also receives a prompt-cache hint for the stable prefix and translates it into its vendor's mechanism. See Prompt caching.
Reply budgets
Set AgentConfig.max_tokens to choose your own reply budget.
Model limits
With max_tokens unset, the reply budget comes from the model's own limits: its
output limit, capped at 32K and fitted into what's left of the context window.
Anthropic's provider gets those limits from the Models API and remembers them
for the rest of the process. If the built-in table already knows the model, the
first call goes ahead with the table's numbers while the API is asked; a model
the table doesn't know waits for the API.
AnthropicProvider.clear_model_limits_cache() clears what it remembered. The
other providers use the built-in table, matched by longest prefix. If the table
doesn't know your model, or has it wrong, register its limits:
from harnessx import register_model_limits
register_model_limits("my-finetune", context_window=200_000, max_output=16_000)
register_model_limits(prefix, *, context_window, max_output) applies to every
model whose normalized id starts with prefix, and beats every other source.
ModelLimits(context_window=..., max_output=...) is the record it stores. A
custom provider that can look limits up overrides model_limits(model) to
return a ModelLimits, or None to fall back to the table; it may be a
coroutine. A default_max_tokens() override sets a custom provider's default
budget. See Long-run
reliability for the lookup
order and what happens when a reply is cut off.
Stop reasons and usage
Every provider maps its vendor's stop or finish reason onto StopReason:
StopReason |
Anthropic | OpenAI-compatible | Gemini |
|---|---|---|---|
END_TURN |
end_turn |
stop |
STOP |
TOOL_USE |
tool_use |
tool_calls |
a reply with function calls |
MAX_TOKENS |
max_tokens |
length |
MAX_TOKENS |
STOP_SEQUENCE |
stop_sequence |
||
SAFETY |
content_filter |
SAFETY, BLOCKLIST, PROHIBITED_CONTENT |
|
OTHER |
anything else | anything else | MALFORMED_FUNCTION_CALL and anything else |
TokenUsage.thinking_tokens reports reasoning tokens where the provider counts
them separately.
Refusals, paused turns and thinking tokens
StopReason.REFUSAL: Anthropic'srefusal, or an OpenAI-compatible reply carryingmessage.refusal.StopReason.PAUSE_TURN: Anthropic'spause_turn, from long server-tool turns. The loop sends the turn back to resume it.- Anthropic's
model_context_window_exceededmaps toMAX_TOKENS. - Gemini's
SPII,RECITATIONandIMAGE_SAFETYmap toSAFETY, andMALFORMED_FUNCTION_CALLtoOTHER. AMAX_TOKENSreply that carries function calls staysMAX_TOKENS.
A run that ends in REFUSAL or SAFETY completes but is not ok:
result.refused is true and raise_for_status() raises RunRefused. Tool calls
in such a reply are not run.
TokenUsage.output_tokens includes thinking tokens on every built-in provider,
so cost estimates and max_cost_dollars count them; thinking_tokens is the
breakdown.
Streams without a finish reason
A streamed reply must end with the provider's stop or finish reason. One that
stalls or ends early is retried. For an OpenAI-compatible server that omits the
finish reason on plain text, pass allow_missing_finish_reason_for_text=True to
OpenAIProvider, OpenRouterProvider, or AzureOpenAIProvider:
from harnessx.providers import OpenAIProvider
provider = OpenAIProvider(allow_missing_finish_reason_for_text=True)
It never lets an incomplete tool call through. See Streams that stall or end early.
Requiring a tool call
By default the model chooses between calling a tool and answering with text. An
agent whose every turn should be a tool call can say so with tool_choice, so the
model cannot answer with prose that describes the call it meant to make:
from harnessx import AnthropicProvider, ToolChoice
provider = AnthropicProvider(tool_choice=ToolChoice.ANY)
| Value | Meaning |
|---|---|
"auto" |
The model may call a tool or answer with text |
"any" |
The model must call one of the tools |
"none" |
The model may not call tools |
Every built-in provider accepts it, and sends it in its own vendor's form: OpenAI
spells "any" as "required", and Gemini sends it as its function-calling mode,
alongside the cached prompt when prompt caching is on. Each provider also reads an
environment variable: ANTHROPIC_TOOL_CHOICE, OPENAI_TOOL_CHOICE,
GEMINI_TOOL_CHOICE, OPENROUTER_TOOL_CHOICE, AZURE_OPENAI_TOOL_CHOICE. Left
unset, nothing is added to the request.
Long tool arguments on Anthropic
Anthropic's provider asks for tool arguments to stream as the model writes them
(eager_input_streaming on each tool). Without it, the API holds a tool call back
until its arguments are complete, so a model writing a whole report with
write_file sends nothing for minutes and looks stalled. Server tools such as web
search, and tools that set the field themselves, are left alone. An
Anthropic-compatible endpoint that rejects the field gets the request again
without it. To turn it off:
provider = AnthropicProvider(eager_tool_streaming=False)
The Anthropic SDK is forgiving about streamed arguments, so JSON that was garbled or cut off could still reach a tool as a partial input. To stop that, the provider keeps each call's argument text as it streams and checks it properly once the reply is done. If it doesn't parse, the tool doesn't run; the model gets an error and sends the call again. See Long-run reliability.
Tool calls as they are written
Every built-in provider reports a tool call while the model is still writing it,
as RunEventType.TOOL_CALL_DELTA on the run stream. The first
delta for a call has the tool's name and no arguments yet.
| Provider | What the deltas carry |
|---|---|
| Anthropic | The arguments in fragments, as the model writes them |
| OpenAI, Azure OpenAI, OpenRouter | The arguments in fragments. A call that arrives without an id gets one at its first fragment and keeps it when it runs |
| Gemini | The whole arguments in one delta, since Gemini sends each call complete |
Run the provider example
uv run python examples/01-basics/switching_providers.py \
--provider openrouter \
--model YOUR_MODEL_ID \
--prompt "What is 40 * 10?"
Add --no-stream to use ordinary runs. Omit --prompt for a multi-turn conversation. The example includes a real calculator tool.
Inject a provider
Agent accepts a provider implementing LLMProvider for custom adapters or scripted tests. Only two different built-in names contradict each other: Agent(provider=OpenAIProvider(), config=AgentConfig(provider="anthropic")) is rejected. A custom provider with any name is accepted with any config label. Non-Anthropic providers require explicit configuration and a model.
To use your own SDK client, wrap it in a provider: Agent(provider=AnthropicProvider(client=client)).
A custom provider implements create(), optionally stream(), and count_tokens(*, model, messages, system: str | None, tools); accept system=None, matching create(). Override default_max_tokens(model) to choose its reply budget when max_tokens is unset. The prompt-cache hint is only offered to a provider whose create() declares a cache parameter; a **kwargs signature does not count.
Register a provider name
register_provider(name, factory, *, replace=False) adds a name that AgentConfig(provider=name) accepts and make_provider(name) builds by calling factory(**kwargs). Built-in names cannot be replaced; registering an existing custom name again requires replace=True. registered_providers() lists the custom names and unregister_provider(name) removes one.
from harnessx import Agent, AgentConfig, register_provider
from myapp.providers import ScriptedProvider # your LLMProvider implementation
register_provider("scripted", ScriptedProvider) # factory(**kwargs) -> LLMProvider
agent = Agent(config=AgentConfig(provider="scripted", model="fixture"))
Registration is process-local. A process that resumes a persisted session must register the same names before loading it, exactly as it must bind the same tools.
Resource ownership
The agent closes a provider it constructs, including a fallback chain built from AgentConfig.fallbacks. An injected provider stays caller-owned. Every LLMProvider is an async context manager, so async with closes the clients it built:
from harnessx import Agent, AgentConfig, AnthropicProvider
async def answer(prompt):
async with AnthropicProvider() as provider:
async with Agent(config=AgentConfig(), provider=provider) as agent:
result = await agent.run(prompt)
return result.output
await provider.aclose() does the same outside a with block. Use async with agent to release the agent's own resources reliably.
Diagnose failures
A key being present does not establish that a service is reachable or a model is enabled for that account. Inspect RunResult.error and provider responses for authentication, rate, model availability, or unsupported parameter failures. Keep temperature unset unless the selected model accepts it.
Decisions are not a provider
Decisions with Jev has its own DecisionProvider contract.
jev is not an AgentConfig.provider value, and its usage is accounted
separately.