starweaver-model 0.8.1

Provider-neutral model protocol and wire adapters for Starweaver
Documentation
//! Anthropic settings and cache-control mapping.

use serde_json::{Value, json};

use crate::{
    ModelError, ModelSettings,
    adapter::ToolDefinition,
    providers::{insert_nonempty_description, provider_tool_schema_without_meta},
};

pub(super) fn apply_anthropic_settings(
    request: &mut serde_json::Map<String, Value>,
    settings: Option<&ModelSettings>,
) -> Result<(), ModelError> {
    let Some(settings) = settings else {
        return Ok(());
    };
    let automatic_cache = anthropic_cache_ttl(Some(settings), "anthropic_cache");
    let message_cache = anthropic_cache_ttl(Some(settings), "anthropic_cache_messages");
    if automatic_cache.is_some() && message_cache.is_some() {
        return Err(ModelError::MessageMapping(
            "anthropic_cache and anthropic_cache_messages cannot both be enabled".to_string(),
        ));
    }
    if let Some(ttl) = automatic_cache {
        request.insert("cache_control".to_string(), anthropic_cache_control(ttl));
    }
    if let Some(thinking) = &settings.thinking {
        let thinking_mode = thinking.mode.as_deref().unwrap_or("enabled");
        let mut payload = serde_json::Map::new();
        payload.insert("type".to_string(), json!(thinking_mode));
        if thinking_mode == "enabled" {
            payload.insert(
                "budget_tokens".to_string(),
                json!(thinking.budget_tokens.unwrap_or(1024)),
            );
        }
        if thinking_mode == "adaptive" {
            payload.insert("display".to_string(), json!("summarized"));
            if !thinking.effort.is_empty() {
                let output_config = request
                    .entry("output_config".to_string())
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                if let Some(output_config) = output_config.as_object_mut() {
                    output_config.insert("effort".to_string(), json!(thinking.effort));
                }
            }
        }
        request.insert("thinking".to_string(), Value::Object(payload));
    }
    if let Some(temperature) = settings.temperature {
        request.insert("temperature".to_string(), json!(temperature));
    }
    if let Some(top_p) = settings.top_p {
        request.insert("top_p".to_string(), json!(top_p));
    }
    if let Some(top_k) = settings.top_k {
        request.insert("top_k".to_string(), json!(top_k));
    }
    if !settings.stop_sequences.is_empty() {
        request.insert("stop_sequences".to_string(), json!(settings.stop_sequences));
    }
    if let Some(anthropic) = &settings.provider_settings.anthropic {
        if let Some(metadata) = &anthropic.metadata {
            request.insert("metadata".to_string(), metadata.clone());
        }
        if let Some(context_management) = &anthropic.context_management {
            request.insert("context_management".to_string(), context_management.clone());
        }
        if let Some(container) = &anthropic.container {
            request.insert("container".to_string(), json!(container));
        }
        if let Some(service_tier) = &anthropic.service_tier {
            request.insert("service_tier".to_string(), json!(service_tier));
        }
    }
    if let Some(tool_choice) = settings.tool_choice.as_ref() {
        let mut choice = anthropic_tool_choice(tool_choice);
        if settings.parallel_tool_calls == Some(false)
            && !matches!(tool_choice, crate::settings::ToolChoice::None)
            && let Some(choice) = choice.as_object_mut()
        {
            choice.insert("disable_parallel_tool_use".to_string(), json!(true));
        }
        request.insert("tool_choice".to_string(), choice);
    } else if settings.parallel_tool_calls == Some(false) {
        request.insert(
            "tool_choice".to_string(),
            json!({"type": "auto", "disable_parallel_tool_use": true}),
        );
    }
    if let Some(options) = settings
        .provider_options
        .as_ref()
        .and_then(Value::as_object)
    {
        request.extend(
            options
                .iter()
                .filter(|(key, _)| !is_internal_anthropic_option(key))
                .map(|(key, value)| (key.clone(), value.clone())),
        );
    }
    Ok(())
}

pub(super) fn append_anthropic_tools(
    request: &mut serde_json::Map<String, Value>,
    tools: &[ToolDefinition],
    settings: Option<&ModelSettings>,
) {
    if tools.is_empty() {
        return;
    }
    let mut definitions = tools
        .iter()
        .map(|tool| {
            let mut definition = serde_json::Map::new();
            definition.insert("name".to_string(), json!(tool.name));
            insert_nonempty_description(&mut definition, tool.description.as_ref());
            definition.insert(
                "input_schema".to_string(),
                provider_tool_schema_without_meta(&tool.parameters),
            );
            Value::Object(definition)
        })
        .collect::<Vec<_>>();
    if let Some(ttl) = anthropic_cache_ttl(settings, "anthropic_cache_tool_definitions")
        && let Some(last) = definitions.last_mut()
    {
        last["cache_control"] = anthropic_cache_control(ttl);
    }
    request.insert("tools".to_string(), Value::Array(definitions));
}

pub(super) fn apply_anthropic_message_cache(
    request: &mut serde_json::Map<String, Value>,
    settings: Option<&ModelSettings>,
) -> Result<(), ModelError> {
    let Some(ttl) = anthropic_cache_ttl(settings, "anthropic_cache_messages") else {
        return Ok(());
    };
    let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) else {
        return Ok(());
    };
    let Some(target) = messages.iter_mut().rev().find_map(|message| {
        message
            .get_mut("content")
            .and_then(Value::as_array_mut)
            .and_then(|content| {
                content
                    .iter_mut()
                    .rev()
                    .find(|block| anthropic_block_is_cacheable(block))
            })
    }) else {
        return Ok(());
    };
    if anthropic_cache_control_ttl(target)?.is_none() {
        target["cache_control"] = anthropic_cache_control(ttl);
    }
    Ok(())
}

pub(super) fn validate_anthropic_cache_points(
    request: &serde_json::Map<String, Value>,
) -> Result<(), ModelError> {
    let automatic_ttl = request
        .get("cache_control")
        .map(anthropic_cache_control_value_ttl)
        .transpose()?;

    if let Some(automatic_ttl) = automatic_ttl
        && let Some(target) = last_anthropic_cacheable_block(request)
        && let Some(explicit_ttl) = anthropic_cache_control_ttl(target)?
        && explicit_ttl != automatic_ttl
    {
        return Err(ModelError::MessageMapping(format!(
            "anthropic_cache TTL {automatic_ttl} conflicts with the explicit {explicit_ttl} cache point on the last cacheable prompt block"
        )));
    }

    let mut saw_five_minute_point = false;
    for blocks in [
        request.get("tools").and_then(Value::as_array),
        request.get("system").and_then(Value::as_array),
    ]
    .into_iter()
    .flatten()
    {
        validate_anthropic_cache_ttl_order(blocks, &mut saw_five_minute_point)?;
    }
    if let Some(messages) = request.get("messages").and_then(Value::as_array) {
        for message in messages {
            if let Some(blocks) = message.get("content").and_then(Value::as_array) {
                validate_anthropic_cache_ttl_order(blocks, &mut saw_five_minute_point)?;
            }
        }
    }
    if let Some(ttl) = automatic_ttl {
        validate_anthropic_ttl_position(ttl, &mut saw_five_minute_point)?;
    }
    Ok(())
}

fn validate_anthropic_cache_ttl_order(
    blocks: &[Value],
    saw_five_minute_point: &mut bool,
) -> Result<(), ModelError> {
    for block in blocks {
        if let Some(ttl) = anthropic_cache_control_ttl(block)? {
            validate_anthropic_ttl_position(ttl, saw_five_minute_point)?;
        }
    }
    Ok(())
}

fn validate_anthropic_ttl_position(
    ttl: &str,
    saw_five_minute_point: &mut bool,
) -> Result<(), ModelError> {
    match ttl {
        "5m" => *saw_five_minute_point = true,
        "1h" if *saw_five_minute_point => {
            return Err(ModelError::MessageMapping(
                "Anthropic 1h cache points must precede all 5m cache points".to_string(),
            ));
        }
        "1h" => {}
        _ => unreachable!("cache TTL was validated before ordering"),
    }
    Ok(())
}

fn last_anthropic_cacheable_block(request: &serde_json::Map<String, Value>) -> Option<&Value> {
    request
        .get("messages")
        .and_then(Value::as_array)
        .and_then(|messages| {
            messages.iter().rev().find_map(|message| {
                message
                    .get("content")
                    .and_then(Value::as_array)
                    .and_then(|blocks| {
                        blocks
                            .iter()
                            .rev()
                            .find(|block| anthropic_block_is_cacheable(block))
                    })
            })
        })
        .or_else(|| {
            request
                .get("system")
                .and_then(Value::as_array)
                .and_then(|blocks| {
                    blocks
                        .iter()
                        .rev()
                        .find(|block| anthropic_block_is_cacheable(block))
                })
        })
        .or_else(|| {
            request
                .get("tools")
                .and_then(Value::as_array)
                .and_then(|blocks| blocks.last())
        })
}

pub(super) fn anthropic_block_is_cacheable(block: &Value) -> bool {
    matches!(
        block.get("type").and_then(Value::as_str),
        Some("text" | "image" | "document" | "tool_use" | "tool_result")
    )
}

fn anthropic_cache_control_ttl(block: &Value) -> Result<Option<&str>, ModelError> {
    let Some(cache_control) = block.get("cache_control") else {
        return Ok(None);
    };
    anthropic_cache_control_value_ttl(cache_control).map(Some)
}

fn anthropic_cache_control_value_ttl(cache_control: &Value) -> Result<&str, ModelError> {
    let Some(cache_control) = cache_control.as_object() else {
        return Err(ModelError::MessageMapping(
            "Anthropic cache_control must be an object".to_string(),
        ));
    };
    if cache_control.get("type").and_then(Value::as_str) != Some("ephemeral") {
        return Err(ModelError::MessageMapping(
            "Anthropic cache_control.type must be ephemeral".to_string(),
        ));
    }
    match cache_control.get("ttl").and_then(Value::as_str) {
        None | Some("5m") => Ok("5m"),
        Some("1h") => Ok("1h"),
        Some(ttl) => Err(ModelError::MessageMapping(format!(
            "unsupported Anthropic cache_control TTL {ttl}; expected 5m or 1h"
        ))),
    }
}

pub(super) fn limit_anthropic_cache_points(
    request: &mut serde_json::Map<String, Value>,
) -> Result<(), ModelError> {
    let max_explicit = if request.contains_key("cache_control") {
        3
    } else {
        4
    };
    let system_points = request
        .get("system")
        .and_then(Value::as_array)
        .map_or(0, |blocks| {
            blocks
                .iter()
                .filter(|block| block.get("cache_control").is_some())
                .count()
        });
    let tool_points = request
        .get("tools")
        .and_then(Value::as_array)
        .map_or(0, |tools| {
            tools
                .iter()
                .filter(|tool| tool.get("cache_control").is_some())
                .count()
        });
    let reserved = system_points + tool_points;
    if reserved > max_explicit {
        return Err(ModelError::MessageMapping(format!(
            "Anthropic system and tool definitions use {reserved} cache points, exceeding the available {max_explicit}"
        )));
    }

    let mut remaining = max_explicit - reserved;
    if let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) {
        for message in messages.iter_mut().rev() {
            let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
                continue;
            };
            for block in content.iter_mut().rev() {
                if block.get("cache_control").is_none() {
                    continue;
                }
                if remaining > 0 {
                    remaining -= 1;
                } else if let Some(block) = block.as_object_mut() {
                    block.remove("cache_control");
                }
            }
        }
    }
    Ok(())
}

fn anthropic_tool_choice(choice: &crate::settings::ToolChoice) -> Value {
    match choice {
        crate::settings::ToolChoice::Auto | crate::settings::ToolChoice::ToolOrOutput { .. } => {
            json!({"type": "auto"})
        }
        crate::settings::ToolChoice::None => json!({"type": "none"}),
        crate::settings::ToolChoice::Required | crate::settings::ToolChoice::Tools { .. } => {
            json!({"type": "any"})
        }
        crate::settings::ToolChoice::Tool { name } => json!({"type": "tool", "name": name}),
    }
}

pub(super) fn anthropic_cache_ttl(
    settings: Option<&ModelSettings>,
    key: &str,
) -> Option<&'static str> {
    let value = settings
        .and_then(|settings| settings.provider_options.as_ref())
        .and_then(|options| options.get(key));
    match value {
        Some(Value::Bool(true)) => Some("5m"),
        Some(Value::String(value)) if value == "5m" => Some("5m"),
        Some(Value::String(value)) if value == "1h" => Some("1h"),
        _ => None,
    }
}

pub(super) fn anthropic_cache_control(ttl: &str) -> Value {
    json!({"type": "ephemeral", "ttl": ttl})
}

fn is_internal_anthropic_option(key: &str) -> bool {
    matches!(
        key,
        "anthropic_cache"
            | "anthropic_cache_instructions"
            | "anthropic_cache_tool_definitions"
            | "anthropic_cache_response"
            | "anthropic_cache_messages"
            | "anthropic_effort"
    )
}