phi-core 0.10.0

Simple, effective agent loop with tool execution and event streaming
Documentation
//! G8 — Context translation strategy for cross-provider message compatibility.
//!
//! When messages generated by one provider (e.g., Anthropic with `Content::Thinking`)
//! need to be consumed by a different provider, certain content types may need
//! translation, conversion, or removal. `ContextTranslationStrategy` provides a
//! read-only translation layer that produces temporary copies without modifying
//! the canonical message history.

use crate::provider::model::ApiProtocol;
use crate::types::content::{Content, Message};

/// Translates canonical Messages for consumption by a specific target provider.
///
/// This is a **read-only** operation: it produces a temporary copy of the messages
/// with provider-incompatible content translated or removed. The original messages
/// are never modified.
pub trait ContextTranslationStrategy: Send + Sync {
    /// Translate a slice of messages for the given target provider protocol.
    fn translate_for_provider(&self, messages: &[Message], target: ApiProtocol) -> Vec<Message>;
}

/// Default translation with built-in per-provider rules.
///
/// Current rules:
/// - `Content::Thinking` is kept as-is for Anthropic, converted to `Content::Text`
///   (prefixed with `[Reasoning]`) for OpenAI variants, and dropped for other protocols.
/// - All other content types pass through unchanged.
pub struct DefaultContextTranslation;

impl ContextTranslationStrategy for DefaultContextTranslation {
    fn translate_for_provider(&self, messages: &[Message], target: ApiProtocol) -> Vec<Message> {
        messages
            .iter()
            .map(|msg| translate_message(msg, target))
            .collect()
    }
}

fn translate_message(msg: &Message, target: ApiProtocol) -> Message {
    match msg {
        Message::Assistant {
            content,
            stop_reason,
            model,
            provider,
            usage,
            timestamp,
            error_message,
        } => {
            let translated_content = content
                .iter()
                .filter_map(|c| translate_content(c, target))
                .collect();
            Message::Assistant {
                content: translated_content,
                stop_reason: stop_reason.clone(),
                model: model.clone(),
                provider: provider.clone(),
                usage: usage.clone(),
                timestamp: *timestamp,
                error_message: error_message.clone(),
            }
        }
        // User and ToolResult pass through unchanged
        other => other.clone(),
    }
}

fn translate_content(content: &Content, target: ApiProtocol) -> Option<Content> {
    match content {
        Content::Thinking { thinking, .. } => match target {
            // Anthropic keeps thinking blocks as-is
            ApiProtocol::AnthropicMessages => Some(content.clone()),
            // OpenAI variants: convert thinking to text
            ApiProtocol::OpenAiCompletions
            | ApiProtocol::OpenAiResponses
            | ApiProtocol::AzureOpenAiResponses => Some(Content::Text {
                text: format!("[Reasoning] {}", thinking),
            }),
            // Google/Bedrock: drop thinking (unsupported)
            _ => {
                tracing::warn!(
                    "Dropping Content::Thinking for provider {:?} (unsupported)",
                    target
                );
                None
            }
        },
        // All other content types pass through
        _ => Some(content.clone()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::content::StopReason;
    use crate::types::usage::Usage;

    fn make_assistant_with_thinking() -> Message {
        Message::Assistant {
            content: vec![
                Content::Thinking {
                    thinking: "Let me think...".to_string(),
                    signature: None,
                },
                Content::Text {
                    text: "Here is my answer.".to_string(),
                },
            ],
            stop_reason: StopReason::Stop,
            model: "test".to_string(),
            provider: "test".to_string(),
            usage: Usage::default(),
            timestamp: 0,
            error_message: None,
        }
    }

    #[test]
    fn test_anthropic_keeps_thinking() {
        let strategy = DefaultContextTranslation;
        let msgs = vec![make_assistant_with_thinking()];
        let result = strategy.translate_for_provider(&msgs, ApiProtocol::AnthropicMessages);
        assert_eq!(result.len(), 1);
        if let Message::Assistant { content, .. } = &result[0] {
            assert_eq!(content.len(), 2);
            assert!(matches!(&content[0], Content::Thinking { .. }));
        } else {
            panic!("Expected assistant message");
        }
    }

    #[test]
    fn test_openai_converts_thinking_to_text() {
        let strategy = DefaultContextTranslation;
        let msgs = vec![make_assistant_with_thinking()];
        let result = strategy.translate_for_provider(&msgs, ApiProtocol::OpenAiCompletions);
        if let Message::Assistant { content, .. } = &result[0] {
            assert_eq!(content.len(), 2);
            match &content[0] {
                Content::Text { text } => assert!(text.starts_with("[Reasoning]")),
                other => panic!("Expected Text, got {:?}", other),
            }
        }
    }

    #[test]
    fn test_google_drops_thinking() {
        let strategy = DefaultContextTranslation;
        let msgs = vec![make_assistant_with_thinking()];
        let result = strategy.translate_for_provider(&msgs, ApiProtocol::GoogleGenerativeAi);
        if let Message::Assistant { content, .. } = &result[0] {
            assert_eq!(content.len(), 1); // Thinking dropped, only Text remains
            assert!(matches!(&content[0], Content::Text { .. }));
        }
    }

    #[test]
    fn test_user_messages_pass_through() {
        let strategy = DefaultContextTranslation;
        let msgs = vec![Message::user("Hello")];
        let result = strategy.translate_for_provider(&msgs, ApiProtocol::OpenAiCompletions);
        assert_eq!(result.len(), 1);
        assert!(matches!(&result[0], Message::User { .. }));
    }
}