llmkit-anthropic 0.1.0

Anthropic (Claude) Messages API provider adapter for llmkit-rs
Documentation
//! Mapping between llmkit types and the Anthropic `/v1/messages` wire format.

use llmkit_core::{
    ChatRequest, ChatResponse, FinishReason, LlmError, LlmResult, Message, MessageContent, Role,
    ToolCall, ToolChoice,
};
use serde_json::json;

use crate::types::*;

const DEFAULT_MAX_TOKENS: u32 = 1024;

pub(crate) fn build_request(
    req: &ChatRequest,
    model: String,
    stream: bool,
) -> MessagesRequest {
    let messages = req.messages.iter().map(map_message).collect();

    let tools = req.tools.as_ref().map(|ts| {
        ts.iter()
            .map(|t| WireTool {
                name: t.name.clone(),
                description: t.description.clone(),
                input_schema: t.input_schema.clone(),
            })
            .collect()
    });

    let tool_choice = req.tool_choice.as_ref().map(map_tool_choice);

    MessagesRequest {
        model,
        messages,
        // Anthropic requires max_tokens; default when the caller omits it.
        max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
        system: req.system.clone(),
        temperature: req.temperature,
        stop_sequences: req.stop.clone(),
        tools,
        tool_choice,
        stream,
    }
}

fn map_message(m: &Message) -> WireMessage {
    // Anthropic has only `user` and `assistant` roles; tool results are user
    // turns carrying a tool_result block. System prompts go in the top-level
    // `system` field, not the message list.
    match &m.content {
        MessageContent::ToolResult { tool_use_id, content } => WireMessage {
            role: "user".into(),
            content: vec![WireBlock::ToolResult {
                tool_use_id: tool_use_id.clone(),
                content: content.clone(),
            }],
        },
        MessageContent::ToolUse { id, name, input } => WireMessage {
            role: "assistant".into(),
            content: vec![WireBlock::ToolUse {
                id: id.clone(),
                name: name.clone(),
                input: input.clone(),
            }],
        },
        other => WireMessage {
            role: role_str(m.role).into(),
            content: vec![WireBlock::Text { text: other.as_text().unwrap_or_default() }],
        },
    }
}

fn role_str(role: Role) -> &'static str {
    match role {
        Role::Assistant => "assistant",
        // user / system / tool all collapse to user at the message level.
        _ => "user",
    }
}

fn map_tool_choice(choice: &ToolChoice) -> serde_json::Value {
    match choice {
        ToolChoice::Auto => json!({ "type": "auto" }),
        ToolChoice::Any => json!({ "type": "any" }),
        ToolChoice::None => json!({ "type": "none" }),
        ToolChoice::Tool(name) => json!({ "type": "tool", "name": name }),
    }
}

pub(crate) fn map_stop_reason(reason: Option<&str>) -> FinishReason {
    match reason {
        Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
        Some("max_tokens") => FinishReason::MaxTokens,
        Some("tool_use") => FinishReason::ToolUse,
        Some("refusal") => FinishReason::ContentFilter,
        Some(other) => FinishReason::Other(other.to_string()),
        None => FinishReason::Stop,
    }
}

pub(crate) fn map_response(resp: MessagesResponse, latency_ms: u64) -> LlmResult<ChatResponse> {
    let mut text = String::new();
    let mut tool_calls = Vec::new();

    for block in resp.content {
        match block {
            RespBlock::Text { text: t } => text.push_str(&t),
            RespBlock::ToolUse { id, name, input } => {
                tool_calls.push(ToolCall::new(id, name, input));
            }
            RespBlock::Unknown => {}
        }
    }

    let usage = resp
        .usage
        .map(|u| llmkit_core::TokenUsage::new(u.input_tokens, u.output_tokens))
        .unwrap_or_default();

    if text.is_empty() && tool_calls.is_empty() && usage.total() == 0 {
        return Err(LlmError::Provider { status: 200, message: "empty response".into() });
    }

    Ok(ChatResponse {
        id: resp.id,
        provider: "anthropic".into(),
        model: resp.model,
        message: Message::assistant(text),
        finish_reason: map_stop_reason(resp.stop_reason.as_deref()),
        tool_calls,
        usage,
        cost: None,
        latency_ms,
    })
}