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/3 returns 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; false turns 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 own cache_control markers 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; requires thinking: :adaptive

  • :thinking_budget - Fixed thinking budget in tokens, for models that still accept budget_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: :disabled also 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 the thinking-binding-controls-2026-08-01 beta). :drop_block drops that block and every later one instead of rejecting the request. Normandy's own history edits already drop the thinking they invalidate (see Normandy.Components.AgentMemory.drop_thinking/1); this covers edits it can't see, such as a context provider whose output changes the system prompt. Requires thinking: :adaptive or :thinking_budget

  • :effort - :low | :medium | :high | :xhigh | :max

  • :speed - :fast | :standard (fast mode research preview)

  • :inference_geo - :global | :us

  • :fallbacks - :default or 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 :normandy app env (see Normandy.LLM.JsonDeserializer)

  • :base_url - Custom API base URL (for testing); the struct's base_url field wins
  • :req_options - Req options merged into the HTTP client
  • :structured_outputs - false to 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
  • :unknown covers anything else and is not retryable
  • on_parse_failure: :error returns type: :invalid_output, and so does :fallback when the schema has no :chat_message field 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) or client.options[:structured_outputs] = false (per-client, overrides the global setting).
  • The response schema is incompatible with constrained decoding: contains an open :map field, 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 without request.output_format. With it, the format (the agent's non-chat output schema) is sent as output_config.format beside the tools: it constrains the final answer's text, and a tool_use response is still a tool call. stream/3 re-sends without the format on a 400 naming it, as converse/3 does.
  • 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

t()

@type t() :: %Normandy.LLM.ClaudioAdapter{
  api_key: String.t(),
  base_url: String.t() | nil,
  finch: atom() | nil,
  options: map()
}

Functions

to_claudio_client(client)

@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).

validate_options!(client)

@spec validate_options!(t()) :: t()

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.