Skip to main content

phi_core/provider/
context_translation.rs

1//! G8 — Context translation strategy for cross-provider message compatibility.
2//!
3//! When messages generated by one provider (e.g., Anthropic with `Content::Thinking`)
4//! need to be consumed by a different provider, certain content types may need
5//! translation, conversion, or removal. `ContextTranslationStrategy` provides a
6//! read-only translation layer that produces temporary copies without modifying
7//! the canonical message history.
8
9use crate::provider::model::ApiProtocol;
10use crate::types::content::{Content, Message};
11
12/// Translates canonical Messages for consumption by a specific target provider.
13///
14/// This is a **read-only** operation: it produces a temporary copy of the messages
15/// with provider-incompatible content translated or removed. The original messages
16/// are never modified.
17pub trait ContextTranslationStrategy: Send + Sync {
18    /// Translate a slice of messages for the given target provider protocol.
19    fn translate_for_provider(&self, messages: &[Message], target: ApiProtocol) -> Vec<Message>;
20}
21
22/// Default translation with built-in per-provider rules.
23///
24/// Current rules:
25/// - `Content::Thinking` is kept as-is for Anthropic, converted to `Content::Text`
26///   (prefixed with `[Reasoning]`) for OpenAI variants, and dropped for other protocols.
27/// - All other content types pass through unchanged.
28pub struct DefaultContextTranslation;
29
30impl ContextTranslationStrategy for DefaultContextTranslation {
31    fn translate_for_provider(&self, messages: &[Message], target: ApiProtocol) -> Vec<Message> {
32        messages
33            .iter()
34            .map(|msg| translate_message(msg, target))
35            .collect()
36    }
37}
38
39fn translate_message(msg: &Message, target: ApiProtocol) -> Message {
40    match msg {
41        Message::Assistant {
42            content,
43            stop_reason,
44            model,
45            provider,
46            usage,
47            timestamp,
48            error_message,
49        } => {
50            let translated_content = content
51                .iter()
52                .filter_map(|c| translate_content(c, target))
53                .collect();
54            Message::Assistant {
55                content: translated_content,
56                stop_reason: stop_reason.clone(),
57                model: model.clone(),
58                provider: provider.clone(),
59                usage: usage.clone(),
60                timestamp: *timestamp,
61                error_message: error_message.clone(),
62            }
63        }
64        // User and ToolResult pass through unchanged
65        other => other.clone(),
66    }
67}
68
69fn translate_content(content: &Content, target: ApiProtocol) -> Option<Content> {
70    match content {
71        Content::Thinking { thinking, .. } => match target {
72            // Anthropic keeps thinking blocks as-is
73            ApiProtocol::AnthropicMessages => Some(content.clone()),
74            // OpenAI variants: convert thinking to text
75            ApiProtocol::OpenAiCompletions
76            | ApiProtocol::OpenAiResponses
77            | ApiProtocol::AzureOpenAiResponses => Some(Content::Text {
78                text: format!("[Reasoning] {}", thinking),
79            }),
80            // Google/Bedrock: drop thinking (unsupported)
81            _ => {
82                tracing::warn!(
83                    "Dropping Content::Thinking for provider {:?} (unsupported)",
84                    target
85                );
86                None
87            }
88        },
89        // All other content types pass through
90        _ => Some(content.clone()),
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::types::content::StopReason;
98    use crate::types::usage::Usage;
99
100    fn make_assistant_with_thinking() -> Message {
101        Message::Assistant {
102            content: vec![
103                Content::Thinking {
104                    thinking: "Let me think...".to_string(),
105                    signature: None,
106                },
107                Content::Text {
108                    text: "Here is my answer.".to_string(),
109                },
110            ],
111            stop_reason: StopReason::Stop,
112            model: "test".to_string(),
113            provider: "test".to_string(),
114            usage: Usage::default(),
115            timestamp: 0,
116            error_message: None,
117        }
118    }
119
120    #[test]
121    fn test_anthropic_keeps_thinking() {
122        let strategy = DefaultContextTranslation;
123        let msgs = vec![make_assistant_with_thinking()];
124        let result = strategy.translate_for_provider(&msgs, ApiProtocol::AnthropicMessages);
125        assert_eq!(result.len(), 1);
126        if let Message::Assistant { content, .. } = &result[0] {
127            assert_eq!(content.len(), 2);
128            assert!(matches!(&content[0], Content::Thinking { .. }));
129        } else {
130            panic!("Expected assistant message");
131        }
132    }
133
134    #[test]
135    fn test_openai_converts_thinking_to_text() {
136        let strategy = DefaultContextTranslation;
137        let msgs = vec![make_assistant_with_thinking()];
138        let result = strategy.translate_for_provider(&msgs, ApiProtocol::OpenAiCompletions);
139        if let Message::Assistant { content, .. } = &result[0] {
140            assert_eq!(content.len(), 2);
141            match &content[0] {
142                Content::Text { text } => assert!(text.starts_with("[Reasoning]")),
143                other => panic!("Expected Text, got {:?}", other),
144            }
145        }
146    }
147
148    #[test]
149    fn test_google_drops_thinking() {
150        let strategy = DefaultContextTranslation;
151        let msgs = vec![make_assistant_with_thinking()];
152        let result = strategy.translate_for_provider(&msgs, ApiProtocol::GoogleGenerativeAi);
153        if let Message::Assistant { content, .. } = &result[0] {
154            assert_eq!(content.len(), 1); // Thinking dropped, only Text remains
155            assert!(matches!(&content[0], Content::Text { .. }));
156        }
157    }
158
159    #[test]
160    fn test_user_messages_pass_through() {
161        let strategy = DefaultContextTranslation;
162        let msgs = vec![Message::user("Hello")];
163        let result = strategy.translate_for_provider(&msgs, ApiProtocol::OpenAiCompletions);
164        assert_eq!(result.len(), 1);
165        assert!(matches!(&result[0], Message::User { .. }));
166    }
167}