starweaver-model 0.11.0

Provider-neutral model protocol and wire adapters for Starweaver
Documentation
#![allow(clippy::unwrap_used)]

use serde_json::json;

use super::AnthropicMessagesAdapter;
use crate::message::{ModelMessage, ModelResponse, ModelResponsePart, ProviderPartInfo};

fn response_with_provider_thinking(provider_name: &str) -> ModelMessage {
    ModelMessage::Response(ModelResponse {
        parts: vec![ModelResponsePart::ProviderThinking {
            text: "inspect context".to_string(),
            signature: Some("provider-signature".to_string()),
            provider: ProviderPartInfo::new(provider_name).with_id("thinking_1"),
        }],
        usage: starweaver_usage::Usage::default(),
        model_name: None,
        provider: None,
        finish_reason: None,
        timestamp: None,
        run_id: None,
        conversation_id: None,
        metadata: serde_json::Map::new(),
    })
}

#[test]
fn build_request_replays_anthropic_provider_thinking_natively() {
    let request = AnthropicMessagesAdapter::build_request(
        "claude-test",
        &[response_with_provider_thinking("anthropic")],
        None,
        &[],
    )
    .unwrap();

    let content = request["messages"][0]["content"].as_array().unwrap();
    assert_eq!(content[0]["type"], "thinking");
    assert_eq!(content[0]["thinking"], "inspect context");
    assert_eq!(content[0]["signature"], "provider-signature");
}

#[test]
fn build_request_does_not_replay_foreign_thinking_signature() {
    let request = AnthropicMessagesAdapter::build_request(
        "claude-test",
        &[response_with_provider_thinking("openai")],
        None,
        &[],
    )
    .unwrap();

    let content = request["messages"][0]["content"].as_array().unwrap();
    assert_eq!(content[0]["type"], "text");
    assert_eq!(content[0]["text"], "<think>\ninspect context\n</think>");
    assert!(content[0].get("signature").is_none());
}

#[test]
fn build_request_does_not_replay_ambiguous_thinking_signature() {
    let response = ModelMessage::Response(ModelResponse {
        parts: vec![ModelResponsePart::Thinking {
            text: "inspect context".to_string(),
            signature: Some("ambiguous-signature".to_string()),
        }],
        usage: starweaver_usage::Usage::default(),
        model_name: None,
        provider: None,
        finish_reason: None,
        timestamp: None,
        run_id: None,
        conversation_id: None,
        metadata: serde_json::Map::new(),
    });

    let request =
        AnthropicMessagesAdapter::build_request("claude-test", &[response], None, &[]).unwrap();

    let content = request["messages"][0]["content"].as_array().unwrap();
    assert_eq!(content[0]["type"], "text");
    assert_eq!(content[0]["text"], "<think>\ninspect context\n</think>");
    assert!(content[0].get("signature").is_none());
    assert!(
        !serde_json::to_string(&request)
            .unwrap()
            .contains("ambiguous-signature")
    );
}

#[test]
fn build_request_replays_anthropic_redacted_thinking_natively() {
    let response = ModelMessage::Response(ModelResponse {
        parts: vec![ModelResponsePart::ProviderOpaque {
            item_type: "redacted_thinking".to_string(),
            payload: json!({
                "type": "redacted_thinking",
                "data": "encrypted-redacted-thinking"
            }),
            provider: ProviderPartInfo::new("anthropic"),
        }],
        usage: starweaver_usage::Usage::default(),
        model_name: None,
        provider: None,
        finish_reason: None,
        timestamp: None,
        run_id: None,
        conversation_id: None,
        metadata: serde_json::Map::new(),
    });

    let request =
        AnthropicMessagesAdapter::build_request("claude-test", &[response], None, &[]).unwrap();

    let content = request["messages"][0]["content"].as_array().unwrap();
    assert_eq!(content[0]["type"], "redacted_thinking");
    assert_eq!(content[0]["data"], "encrypted-redacted-thinking");
}

#[test]
fn parse_response_preserves_anthropic_provider_thinking() {
    let response = AnthropicMessagesAdapter::parse_response(&json!({
        "id": "msg_1",
        "model": "claude-test",
        "stop_reason": "end_turn",
        "content": [{
            "type": "thinking",
            "id": "thinking_1",
            "thinking": "inspect",
            "signature": "anthropic-signature"
        }],
        "usage": {"input_tokens": 1, "output_tokens": 2}
    }))
    .unwrap();

    assert!(matches!(
        &response.parts[0],
        ModelResponsePart::ProviderThinking { text, signature, provider }
            if text == "inspect"
                && signature.as_deref() == Some("anthropic-signature")
                && provider.provider_name.as_deref() == Some("anthropic")
                && provider.id.as_deref() == Some("thinking_1")
    ));
}

#[test]
fn parse_response_preserves_anthropic_redacted_thinking() {
    let response = AnthropicMessagesAdapter::parse_response(&json!({
        "id": "msg_1",
        "model": "claude-test",
        "stop_reason": "end_turn",
        "content": [{
            "type": "redacted_thinking",
            "data": "encrypted-redacted-thinking"
        }],
        "usage": {"input_tokens": 1, "output_tokens": 2}
    }))
    .unwrap();

    assert!(matches!(
        &response.parts[0],
        ModelResponsePart::ProviderOpaque { item_type, payload, provider }
            if item_type == "redacted_thinking"
                && payload["data"] == "encrypted-redacted-thinking"
                && provider.provider_name.as_deref() == Some("anthropic")
    ));
}

#[test]
fn parse_response_preserves_reported_anthropic_cache_write_aggregate() {
    let response = AnthropicMessagesAdapter::parse_response(&json!({
        "id": "msg_cache",
        "model": "claude-test",
        "stop_reason": "end_turn",
        "content": [{"type": "text", "text": "done"}],
        "usage": {
            "input_tokens": 7,
            "output_tokens": 8,
            "cache_creation_input_tokens": 8,
            "cache_creation": {
                "ephemeral_5m_input_tokens": 4,
                "ephemeral_1h_input_tokens": 5
            },
            "cache_read_input_tokens": 10
        }
    }))
    .unwrap();

    assert_eq!(response.usage.input_tokens, 25);
    assert_eq!(response.usage.cache_write_tokens, 8);
    assert_eq!(response.usage.cache_write_1h_tokens, 5);
    assert_eq!(response.usage.cache_read_tokens, 10);
    assert_eq!(response.usage.total_tokens, 33);
    assert_eq!(response.usage.effective_cache_write_tokens(), 8);
    assert_eq!(response.usage.effective_cache_write_1h_tokens(), 5);
}