providers-kit 0.0.2

Portable enum-dispatch Kit over multiple model provider APIs.
Documentation
use serde_json::{Map, Value, json};

use crate::KitError;
use crate::core::{GenerateRequestView, filter_tools_for_choice};
use crate::error::ValidationViolation;
use crate::generation::{
    ContentPart, GenerationContinuation, Message, NativeContinuation, ResponseFormat, Role,
};

use super::{
    attachments::{
        assistant_content_part_to_block, tool_result_content_part_to_block,
        user_content_part_to_block,
    },
    reasoning::ClaudeThinkingProfile,
    tools::{tool_choice_to_value, tool_spec_to_value},
};

const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1_024;

enum AnthropicMessageRole {
    User,
    Assistant,
}

impl AnthropicMessageRole {
    fn into_value(
        self,
        model: &str,
        message: &Message<GenerationContinuation>,
    ) -> Result<Value, KitError> {
        if let Some(continuation) = message.continuation() {
            if !matches!(self, Self::Assistant) {
                return Err(KitError::invalid_input(
                    ValidationViolation::Incompatible {
                        field: "message.continuation",
                        other: "message.role",
                    },
                    "Anthropic continuation requires an assistant message",
                ));
            }
            if continuation.model_id.as_str() != model {
                return Err(KitError::invalid_input(
                    ValidationViolation::Incompatible {
                        field: "message.continuation.model",
                        other: "selected_model",
                    },
                    "Anthropic continuation belongs to a different model",
                ));
            }
            return match &continuation.inner {
                NativeContinuation::Anthropic { content_blocks } => Ok(json!({
                    "role": "assistant",
                    "content": content_blocks,
                })),
                _ => Err(KitError::invalid_input(
                    ValidationViolation::Incompatible {
                        field: "message.continuation",
                        other: "selected_provider",
                    },
                    "Anthropic generation requires Anthropic continuation state",
                )),
            };
        }

        let render = match self {
            Self::User => user_content_part_to_block,
            Self::Assistant => assistant_content_part_to_block,
        };
        let mut content = message
            .content()
            .iter()
            .map(render)
            .collect::<Result<Vec<_>, _>>()?;
        let role = match self {
            Self::User => "user",
            Self::Assistant => {
                for tool_call in message.function_calls() {
                    let id = tool_call.id().ok_or_else(|| {
                        KitError::invalid_input(
                            ValidationViolation::MissingField {
                                field: "function_call.id",
                            },
                            "Anthropic assistant tool_use history requires a provider call id",
                        )
                    })?;
                    content.push(json!({
                        "type": "tool_use",
                        "id": id,
                        "name": tool_call.name(),
                        "input": tool_call.arguments(),
                    }));
                }
                "assistant"
            }
        };
        Ok(json!({"role": role, "content": content}))
    }
}

pub(crate) fn build_message_body(
    model: &str,
    thinking_profile: ClaudeThinkingProfile,
    request: GenerateRequestView<'_>,
    stream: bool,
) -> Result<Value, KitError> {
    let generation = request.generation;
    if generation.temperature().is_some() && generation.top_p().is_some() {
        return Err(KitError::invalid_input(
            ValidationViolation::Incompatible {
                field: "generation.temperature",
                other: "generation.top_p",
            },
            "Anthropic generation cannot combine temperature and top_p",
        ));
    }
    if generation.presence_penalty().is_some()
        || generation.frequency_penalty().is_some()
        || generation.seed().is_some()
    {
        return Err(KitError::unsupported(
            ValidationViolation::Unsupported {
                field: "generation",
                rule: "penalties and seed unavailable",
            },
            "Anthropic generation penalties or seed",
        ));
    }
    thinking_profile.validate_sampling(generation)?;

    let mut body = Map::new();
    body.insert("model".to_string(), Value::String(model.to_string()));
    let filtered_tools = filter_tools_for_choice(request.tools, generation.tool_choice())?;
    crate::providers::anthropic::tools::AnthropicHostedPlanner::new().validate(
        model,
        filtered_tools
            .iter()
            .filter_map(crate::generation::ProviderToolSpec::hosted_tool),
    )?;
    let tools = filtered_tools
        .iter()
        .map(tool_spec_to_value)
        .collect::<Result<Vec<_>, _>>()?;
    let max_tokens = generation
        .max_output_tokens()
        .map_or(DEFAULT_MAX_OUTPUT_TOKENS, std::num::NonZeroU32::get);
    body.insert("max_tokens".to_string(), json!(max_tokens));

    if let Some(system) = build_system_blocks(request)? {
        body.insert("system".to_string(), system);
    }
    body.insert(
        "messages".to_string(),
        Value::Array(build_messages_array(model, request)?),
    );
    if !tools.is_empty() {
        body.insert("tools".to_string(), Value::Array(tools));
    }
    if let Some(output_config) = response_format_to_output_config(request.response_format)? {
        body.insert("output_config".to_string(), output_config);
    }

    let thinking = match request.reasoning {
        Some(reasoning) => {
            if let Some(tokens) = reasoning.max_reasoning_tokens
                && tokens >= max_tokens
            {
                return Err(KitError::invalid_input(
                    ValidationViolation::OutOfRange {
                        field: "reasoning.max_reasoning_tokens",
                        rule: "less than generation.max_output_tokens",
                    },
                    "Anthropic reasoning max_reasoning_tokens must be less than generation.max_output_tokens",
                ));
            }
            thinking_profile.thinking(reasoning)?
        }
        None => None,
    };
    thinking_profile.validate_tool_choice(
        generation.tool_choice(),
        request
            .reasoning
            .is_some_and(|reasoning| reasoning.max_reasoning_tokens.is_some()),
    )?;
    if let Some(tool_choice) = generation.tool_choice() {
        body.insert("tool_choice".to_string(), tool_choice_to_value(tool_choice));
    }
    if let Some(reasoning) = request.reasoning {
        if let Some(thinking) = thinking {
            body.insert("thinking".to_string(), thinking);
        }
        if let Some(effort) = thinking_profile.effort(reasoning.effort)? {
            let output_config = body
                .entry("output_config".to_string())
                .or_insert_with(|| Value::Object(Map::new()))
                .as_object_mut()
                .ok_or_else(|| {
                    KitError::invalid_state("Anthropic output_config was not an object")
                })?;
            output_config.insert("effort".to_string(), Value::String(effort.to_string()));
        }
    }

    if let Some(temperature) = generation.temperature() {
        body.insert("temperature".to_string(), json!(temperature));
    }
    if let Some(top_p) = generation.top_p() {
        body.insert("top_p".to_string(), json!(top_p));
    }
    if let Some(top_k) = generation.top_k() {
        body.insert("top_k".to_string(), json!(top_k.get()));
    }
    if !generation.stop_sequences().is_empty() {
        body.insert(
            "stop_sequences".to_string(),
            json!(generation.stop_sequences()),
        );
    }
    if stream {
        body.insert("stream".to_string(), Value::Bool(true));
    }
    Ok(Value::Object(body))
}

fn tool_result_message_to_value(
    message: &Message<GenerationContinuation>,
) -> Result<Value, KitError> {
    if !message.content().is_empty() {
        return Err(KitError::invalid_input(
            ValidationViolation::InvalidValue {
                field: "message.content",
                rule: "tool content belongs in typed function results",
            },
            "Anthropic tool messages carry content through typed function results",
        ));
    }
    Ok(json!({
        "role": "user",
        "content": message.function_results()
            .iter()
            .map(|result| {
                let call_id = result.call_id().ok_or_else(|| {
                    KitError::invalid_input(
                        ValidationViolation::MissingField { field: "function_result.call_id" }, "Anthropic function results require a provider call id",
                    )
                })?;
                Ok(json!({
                    "type": "tool_result",
                    "tool_use_id": call_id,
                    "content": tool_result_content_value(result.content())?,
                    "is_error": result.is_error(),
                }))
            })
            .collect::<Result<Vec<_>, KitError>>()?,
    }))
}

fn tool_result_content_value(content: &[ContentPart]) -> Result<Value, KitError> {
    if let [ContentPart::Text { text }] = content {
        return Ok(Value::String(text.clone()));
    }
    if let [ContentPart::Json { value }] = content {
        return Ok(Value::String(value.to_string()));
    }
    content
        .iter()
        .map(tool_result_content_part_to_block)
        .collect::<Result<Vec<_>, _>>()
        .map(Value::Array)
}

fn build_system_blocks(request: GenerateRequestView<'_>) -> Result<Option<Value>, KitError> {
    let mut blocks = Vec::new();
    for message in request.messages {
        if !matches!(message.role(), Role::System | Role::Developer) {
            break;
        }
        for part in message.content() {
            let ContentPart::Text { text } = part else {
                return Err(KitError::invalid_input(
                    ValidationViolation::InvalidValue {
                        field: "message.content",
                        rule: "instructions require text-only content",
                    },
                    "Anthropic instructions require text-only content",
                ));
            };
            blocks.push(json!({"type": "text", "text": text}));
        }
    }
    Ok((!blocks.is_empty()).then_some(Value::Array(blocks)))
}

fn build_messages_array(
    model: &str,
    request: GenerateRequestView<'_>,
) -> Result<Vec<Value>, KitError> {
    let mut messages = Vec::new();
    for message in request.messages {
        match message.role() {
            Role::System | Role::Developer => {}
            Role::User => messages.push(AnthropicMessageRole::User.into_value(model, message)?),
            Role::Assistant => {
                messages.push(AnthropicMessageRole::Assistant.into_value(model, message)?)
            }
            Role::Tool => messages.push(tool_result_message_to_value(message)?),
        }
    }
    Ok(messages)
}

fn response_format_to_output_config(
    format: Option<&ResponseFormat>,
) -> Result<Option<Value>, KitError> {
    match format {
        None | Some(ResponseFormat::Text) => Ok(None),
        Some(ResponseFormat::JsonSchema(schema)) => Ok(Some(json!({
            "format": {
                "type": "json_schema",
                "schema": schema.schema(),
            },
        }))),
        Some(ResponseFormat::JsonObject) => Err(KitError::unsupported(
            ValidationViolation::Unsupported {
                field: "response_format",
                rule: "JSON object output requires a schema",
            },
            "Anthropic schema-free JSON output",
        )),
    }
}