Adapters translate between the canonical ModelInput / LlmEvent algebra and provider-specific wire formats. Lutum ships three adapters: ClaudeAdapter (Anthropic Messages API), OpenAiAdapter (OpenAI Responses API and OpenAI-compatible backends), and OpenRouterGenerationClient (OpenRouter usage recovery).

On This Page

ClaudeAdapter

ClaudeAdapter targets the Anthropic Claude Messages API. Enable it with the claude feature:

[dependencies]
lutum = { version = "0.1.0", features = ["claude"] }
lutum-claude = "0.1.0"
use std::sync::Arc;
use lutum::{Lutum, ModelName, SharedPoolBudgetManager, SharedPoolBudgetOptions};
use lutum_claude::ClaudeAdapter;

let adapter = ClaudeAdapter::new(std::env::var("ANTHROPIC_API_KEY")?)
    .with_default_model(ModelName::new("claude-opus-4-5")?);

let llm = Lutum::new(
    Arc::new(adapter),
    SharedPoolBudgetManager::new(SharedPoolBudgetOptions::default()),
);

Extended Thinking

Claude supports extended thinking. The simplest way is to set a default budget on the adapter:

use lutum_claude::ClaudeAdapter;

let adapter = ClaudeAdapter::new(api_key)
    .with_default_model(ModelName::new("claude-opus-4-5")?)
    .with_default_thinking_budget(8000);   // budget_tokens applied to every request

For per-request control, implement ResolveBudgetTokens and register it with with_resolve_budget_tokens. The hook receives the request's RequestExtensions and the adapter's default budget, and returns the budget to use (or None to disable thinking):

use lutum_claude::{ClaudeAdapter, ResolveBudgetTokens};

// User-defined extension type — not provided by lutum_claude
#[derive(Clone)]
struct ThinkingBudget(u32);

#[lutum::impl_hook(ResolveBudgetTokens)]
async fn my_budget_resolver(
    extensions: &RequestExtensions,
    default: Option<u32>,
) -> Option<u32> {
    extensions.get::<ThinkingBudget>().map(|b| b.0).or(default)
}

let adapter = ClaudeAdapter::new(api_key)
    .with_resolve_budget_tokens(MyBudgetResolver);

// Attach a per-request budget via RequestExtensions
let result = session
    .text_turn(&llm)
    .ext(ThinkingBudget(8000))
    .collect()
    .await?;

OpenAiAdapter

OpenAiAdapter targets the OpenAI Responses API. It also works with any OpenAI-compatible endpoint (Ollama, Azure OpenAI, Together AI, etc.):

[dependencies]
lutum = { version = "0.1.0", features = ["openai"] }
lutum-openai = "0.1.0"
use std::sync::Arc;
use lutum::{Lutum, ModelName, SharedPoolBudgetManager, SharedPoolBudgetOptions};
use lutum_openai::OpenAiAdapter;

// Standard OpenAI
let adapter = OpenAiAdapter::new(std::env::var("OPENAI_API_KEY")?)
    .with_default_model(ModelName::new("gpt-4o")?);

// Ollama (OpenAI-compatible)
let adapter = OpenAiAdapter::new("ollama")
    .with_base_url("http://localhost:11434/v1/")
    .with_default_model(ModelName::new("llama3.2")?);

let llm = Lutum::new(
    Arc::new(adapter),
    SharedPoolBudgetManager::new(SharedPoolBudgetOptions::default()),
);

Reasoning Effort

OpenAI o-series models support a reasoning_effort parameter. Configure it via ReasoningEffortResolver in OpenAiHooksSet:

use lutum_openai::{OpenAiAdapter, OpenAiHooksSet, ReasoningEffort};

#[lutum::impl_hook(ResolveReasoningEffort)]
async fn my_resolver(
    extensions: &RequestExtensions,
    last: Option<Option<ReasoningEffort>>,
) -> Option<ReasoningEffort> {
    Some(ReasoningEffort::High)
}

let hooks = OpenAiHooksSet::new().with_reasoning_effort(MyResolver);
let adapter = OpenAiAdapter::new(api_key).with_hooks(hooks);

OpenRouter

OpenRouter returns usage data out-of-band via GET /api/v1/generation. Use OpenRouterGenerationClient as the UsageRecoveryAdapter and wire it in via Lutum::from_parts:

[dependencies]
lutum-openrouter = "0.1.0"
use std::sync::Arc;
use lutum::{Lutum, ModelName, SharedPoolBudgetManager, SharedPoolBudgetOptions};
use lutum_openai::OpenAiAdapter;
use lutum_openrouter::OpenRouterGenerationClient;

let turn_adapter = Arc::new(
    OpenAiAdapter::new(std::env::var("OPENROUTER_API_KEY")?)
        .with_base_url("https://openrouter.ai/api/v1/")
        .with_default_model(ModelName::new("anthropic/claude-opus-4-5")?)
);

let recovery = Arc::new(
    OpenRouterGenerationClient::new(std::env::var("OPENROUTER_API_KEY")?)
);

let llm = Lutum::from_parts(
    turn_adapter.clone(),
    turn_adapter,         // completion adapter (same for OpenRouter)
    recovery,            // usage recovery adapter
    SharedPoolBudgetManager::new(SharedPoolBudgetOptions::default()),
);

Dynamic Model Routing

Both ClaudeAdapter and OpenAiAdapter accept a ModelSelector for request-level model routing. Implement ModelSelector::select_model(&RequestExtensions) -> ModelSelection and attach it to the adapter:

use lutum_openai::{OpenAiAdapter, ModelSelection, SelectOpenaiModel};

struct TenantModelRouter;

impl SelectOpenaiModel for TenantModelRouter {
    fn select_model(&self, extensions: &RequestExtensions) -> ModelSelection {
        let model = if extensions.get::<PremiumTenant>().is_some() {
            "gpt-4o"
        } else {
            "gpt-4o-mini"
        };
        ModelSelection { primary: Some(model.into()), fallbacks: None }
    }
}

let adapter = OpenAiAdapter::new(api_key)
    .with_model_selector(TenantModelRouter);

Mixing Adapters with from_parts

Lutum::from_parts accepts the three adapter traits separately, allowing any combination:

// Claude turns, OpenAI completion, OpenRouter usage recovery
let llm = Lutum::from_parts(
    Arc::new(claude_adapter),   // dyn TurnAdapter
    Arc::new(openai_adapter),   // dyn CompletionAdapter
    Arc::new(openrouter),       // dyn UsageRecoveryAdapter
    budget,
);

This is also required when you want to use the structured_completion or completion APIs, since Lutum::new does not wire a CompletionAdapter.