Normandy.LLM.ClaudioAdapter
(normandy v2.2.0)
View Source
Adapter for integrating Claudio (Anthropic's Claude API client) with Normandy agents.
This adapter implements the Normandy.Agents.Model protocol to enable
Normandy agents to use Claude models via the Claudio library.
Features
- Full Messages API support
- Tool/function calling integration
- Streaming responses (
stream/3returns the events) - Prompt caching support
- Vision/multimodal inputs
- Thinking mode configuration
Example
# Initialize Claudio client
client = %Normandy.LLM.ClaudioAdapter{
api_key: System.get_env("ANTHROPIC_API_KEY"),
options: %{
timeout: 60_000,
enable_caching: true
}
}
# Use with Normandy agent
agent = Normandy.Agents.BaseAgent.init(%{
client: client,
model: "claude-opus-5-5"
})
{:ok, agent, response} = Normandy.Agents.BaseAgent.run(agent, user_input)Configuration
The adapter reads these keys from options (a map). Nothing below is sent unless set,
except prompt caching, which is on unless :enable_caching is false:
:timeout- Connection timeout in milliseconds (Claudio's default: 60_000):recv_timeout- How long to wait for data once connected, in milliseconds (Claudio's default: 120_000); raise it for long thinking or streaming calls:enable_caching- Prompt caching (default:true;falseturns it off): an explicit breakpoint on the system prompt and the last tool, plus the API's automatic breakpoint for the conversation. Keep context-provider output stable across calls, or the cache stops at the system prompt. Normandy uses 3 of the API's 4 cache breakpoints (system, last tool, automatic); callers who add their owncache_controlmarkers can add at most one more, or the API rejects the request. A cache write costs 1.25 times the input price and a cache read 0.1 times, so an agent that makes one call per conversation pays more with it on; a tool loop, which re-sends its prefix on every call, pays less:thinking-:adaptive(Claude 4.6 and later) or:disabled:thinking_display-:summarized | :omitted | :updates; requiresthinking: :adaptive:thinking_budget- Fixed thinking budget in tokens, for models that still acceptbudget_tokens(4.6 and earlier, such as Haiku 4.5; Opus 4.7+, 5.x, Sonnet 5 and Fable return 400); mutually exclusive with:thinking.thinking: :disabledalso returns 400 on Opus 5.5, which always thinks:thinking_block_binding-:error | :drop_block: what the API does with a replayed thinking block whose history changed since it was produced (sends thethinking-binding-controls-2026-08-01beta).:drop_blockdrops that block and every later one instead of rejecting the request. Normandy's own history edits already drop the thinking they invalidate (seeNormandy.Components.AgentMemory.drop_thinking/1); this covers edits it can't see, such as a context provider whose output changes the system prompt. Requiresthinking: :adaptiveor:thinking_budget:effort-:low | :medium | :high | :xhigh | :max:speed-:fast | :standard(fast mode research preview):inference_geo-:global | :us:fallbacks-:defaultor a list of model ids/maps (server-side refusal fallback); Normandy rejects it on streaming calls (partial output would reach the callback):task_budget- total tokens for the agentic turn (advisory; API minimum 20,000):on_parse_failure-:fallback | :error, what to do when the output still does not match the schema after the JSON retries; overrides the:normandyapp env (seeNormandy.LLM.JsonDeserializer):base_url- Custom API base URL (for testing); the struct'sbase_urlfield wins:req_options- Req options merged into the HTTP client:structured_outputs-falseto use the legacy parse-and-retry path
Any other key, a non-boolean :enable_caching/:structured_outputs, or a missing
api_key raises ArgumentError when the agent is built (BaseAgent.init/1).
Errors
converse/3 and stream/3 return {:error, %Normandy.LLMError{}} on failure,
classified from the Claudio error type (or the HTTP status, for Claudio's generic
:api_error, which it uses for every streaming HTTP error):
:rate_limit(429),:overloaded(529),:server(5xx, 408, no status) and:transport(connection-level failures) are retryable:authentication(401),:permission(403),:not_found(404) and:invalid_request(other 4xx) are not retryable:unknowncovers anything else and is not retryableon_parse_failure: :errorreturnstype: :invalid_output, and so does:fallbackwhen the schema has no:chat_messagefield to put the raw text in
Claudio's client-level retry: (in req_options or the client config) retries HTTP
failures before Normandy sees them; enabling it together with the agent's retry
multiplies the attempts.
validate_options!/1 checks the option combinations and values above once, up front;
BaseAgent.init/1 and ConfigTemplate.rebuild/3 call it for you.
Which models accept which values is left to the API: an unsupported pairing returns the API's 400 through the normal error path.
Structured Outputs
By default, this adapter uses Anthropic native constrained decoding (structured outputs) to obtain schema-valid JSON from the model without relying on the legacy parse-retry loop. The structured output path is skipped — and the legacy parse-retry path is used instead — when any of the following conditions are true:
- The kill-switch is off:
config :normandy, :structured_outputs, false(global) orclient.options[:structured_outputs] = false(per-client, overrides the global setting). - The response schema is incompatible with constrained decoding: contains an open
:mapfield, a non-JSON-Schema scalar type (e.g.:date,:binary,:any), or exceeds the nesting depth guard. - A tool-loop call (
response_model: %ToolCallResponse{}) or a stream withoutrequest.output_format. With it, the format (the agent's non-chat output schema) is sent asoutput_config.formatbeside the tools: it constrains the final answer's text, and atool_useresponse is still a tool call.stream/3re-sends without the format on a 400 naming it, asconverse/3does. - Any other call that carries tools.
- The API answers 400 with a message naming the output format or schema. Any other 400 (such as "prompt is too long") is returned as the error, without a second request.
A response that stops at max_tokens, model_context_window_exceeded or refusal is
never parsed, re-asked or passed to :on_parse_failure, on either path: converse/3
returns the empty response model with that stop_reason, and Normandy turns it into
{:error, %Normandy.LLMError{type: :max_tokens | :refusal}}.
Summary
Functions
Builds the Claudio HTTP client (a Req.Request) for this adapter: the API key,
base_url (when set), finch pool and options[:req_options] are respected.
req_options is merged with Req.merge/2, as in OpenAICompatibleAdapter
(for example [plug: {Req.Test, MyStub}] in tests).
Raises ArgumentError when client.options can never produce a valid request, and
returns client unchanged otherwise.
Types
Functions
@spec to_claudio_client(t()) :: Req.Request.t()
Builds the Claudio HTTP client (a Req.Request) for this adapter: the API key,
base_url (when set), finch pool and options[:req_options] are respected.
req_options is merged with Req.merge/2, as in OpenAICompatibleAdapter
(for example [plug: {Req.Test, MyStub}] in tests).
Raises ArgumentError when client.options can never produce a valid request, and
returns client unchanged otherwise.
Checks the option combinations (:thinking with :thinking_budget, :thinking_display
without thinking: :adaptive), the values Claudio validates in its own request setters,
and the shape of options[:req_options]. It runs the same option pass real requests
run, against a placeholder request, so nothing is duplicated and no network is touched.
BaseAgent.init/1 and ConfigTemplate.rebuild/3 call it, so a misconfigured agent
fails when it is configured rather than on every call, where the retry policy would
wait through its backoff before surfacing the error.
The :fallbacks check on streaming calls is not made here: it depends on the call,
so it stays per call.