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::error::ResponseViolation;
use crate::error::ValidationViolation;

use crate::core::GenerateRequestView;
use crate::generation::{ContentPart, GenerationContinuation, Message, NativeContinuation, Role};
use crate::models::ModelId;

use super::super::wire::GeminiFunctionCall;
use super::attachments::{part_to_wire_part, parts_to_wire_parts};

pub(super) fn build_contents(
    model: &ModelId,
    request: GenerateRequestView<'_>,
) -> Result<Vec<Value>, KitError> {
    request
        .messages
        .iter()
        .filter(|message| !matches!(message.role(), Role::System | Role::Developer))
        .map(|message| message_to_content(model, message))
        .collect()
}

pub(super) fn build_system_instruction(
    request: GenerateRequestView<'_>,
) -> Result<Option<Value>, KitError> {
    let parts = request
        .messages
        .iter()
        .take_while(|message| matches!(message.role(), Role::System | Role::Developer))
        .flat_map(Message::content)
        .map(part_to_wire_part)
        .collect::<Result<Vec<_>, _>>()?;
    Ok((!parts.is_empty()).then(|| json!({"parts": parts})))
}

fn message_to_content(
    model: &ModelId,
    message: &Message<GenerationContinuation>,
) -> Result<Value, KitError> {
    if let Some(continuation) = message.continuation() {
        if continuation.model_id != *model {
            return Err(KitError::invalid_input(
                ValidationViolation::Incompatible {
                    field: "message.continuation.model",
                    other: "selected_model",
                },
                "Gemini continuation belongs to a different model",
            ));
        }
        return match &continuation.inner {
            NativeContinuation::Gemini { signed_parts } => Ok(json!({
                "role": "model",
                "parts": signed_parts,
            })),
            _ => Err(KitError::invalid_input(
                ValidationViolation::Incompatible {
                    field: "message.continuation",
                    other: "selected_provider",
                },
                "Gemini generation requires Gemini continuation state",
            )),
        };
    }
    match message.role() {
        Role::Tool => tool_result_content(model, message),
        Role::Assistant => assistant_content(message),
        Role::User => Ok(json!({
            "role": "user",
            "parts": parts_to_wire_parts(message.content())?,
        })),
        Role::System | Role::Developer => Err(KitError::invalid_state(
            "instruction messages must be rendered as systemInstruction",
        )),
    }
}

fn assistant_content(message: &Message<GenerationContinuation>) -> Result<Value, KitError> {
    let mut parts = parts_to_wire_parts(message.content())?;
    for call in message.function_calls() {
        let mut function_call = Map::new();
        function_call.insert("name".to_string(), Value::String(call.name().to_string()));
        function_call.insert("args".to_string(), call.arguments().clone());
        if let Some(id) = call.id() {
            function_call.insert("id".to_string(), Value::String(id.to_string()));
        }
        parts.push(json!({"functionCall": function_call}));
    }
    Ok(json!({"role": "model", "parts": parts}))
}

fn tool_result_content(
    model: &ModelId,
    message: &Message<GenerationContinuation>,
) -> Result<Value, KitError> {
    let supports_multimodal = matches!(
        model.as_str(),
        "models/gemini-4-argon"
            | "models/gemini-3.8-flash"
            | "models/gemini-3.1-pro-preview"
            | "models/gemini-3-flash-preview"
    );
    let mut parts = Vec::new();
    for result in message.function_results() {
        let mut content = Vec::new();
        let mut media = Vec::new();
        for part in result.content() {
            match part {
                ContentPart::Text { text } => content.push(Value::String(text.clone())),
                ContentPart::Json { value } => content.push(value.clone()),
                ContentPart::Image { media_type, .. }
                    if supports_multimodal
                        && matches!(
                            media_type.as_str(),
                            "image/png" | "image/jpeg" | "image/webp"
                        ) =>
                {
                    media.push(part_to_wire_part(part)?);
                }
                ContentPart::Document { media_type, .. }
                    if supports_multimodal
                        && matches!(media_type.as_str(), "application/pdf" | "text/plain") =>
                {
                    media.push(part_to_wire_part(part)?);
                }
                ContentPart::Image { .. }
                | ContentPart::Audio { .. }
                | ContentPart::Document { .. } => {
                    return Err(KitError::unsupported(
                        ValidationViolation::Unsupported {
                            field: "function_result.content",
                            rule: "media unavailable for selected model",
                        },
                        format!("Gemini function-result media for selected model `{model}`"),
                    ));
                }
                ContentPart::ImageUrl { .. } | ContentPart::File { .. } => {
                    return Err(KitError::unsupported(
                        ValidationViolation::Unsupported {
                            field: "function_result.content",
                            rule: "remote media references unavailable",
                        },
                        "Gemini function-result remote media references",
                    ));
                }
                ContentPart::Thinking { .. } => {
                    return Err(KitError::invalid_input(
                        ValidationViolation::InvalidValue {
                            field: "function_result.content",
                            rule: "thinking content is not permitted",
                        },
                        "Gemini function results cannot contain thinking content",
                    ));
                }
            }
        }
        let content = match content.as_slice() {
            [] => Value::Null,
            [value] => value.clone(),
            _ => Value::Array(content),
        };
        let mut response = Map::new();
        response.insert("name".to_string(), Value::String(result.name().to_string()));
        let mut output = Map::new();
        output.insert(
            if result.is_error() { "error" } else { "result" }.to_string(),
            content,
        );
        response.insert("response".to_string(), Value::Object(output));
        if !media.is_empty() {
            response.insert("parts".to_string(), Value::Array(media));
        }
        if let Some(id) = result.call_id() {
            response.insert("id".to_string(), Value::String(id.to_string()));
        }
        parts.push(json!({"functionResponse": response}));
    }
    Ok(json!({"role": "user", "parts": parts}))
}

pub(crate) fn function_call_to_tool_call(
    function_call: &GeminiFunctionCall,
) -> Result<crate::generation::FunctionCall, KitError> {
    crate::core::tool_call(
        function_call.id.clone(),
        function_call.name.clone(),
        function_call.args.clone().ok_or_else(|| {
            KitError::invalid_response(ResponseViolation::MissingField {
                field: "functionCall.args",
            })
            .with_message("Gemini functionCall did not include args".to_string())
            .with_diagnostic(None)
        })?,
    )
}