tl_cli/translation/
client.rs

1use anyhow::{Context, Result};
2use futures_util::Stream;
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::borrow::Cow;
7use std::pin::Pin;
8
9use super::prompt::{SYSTEM_PROMPT_TEMPLATE, build_system_prompt};
10
11/// A request to translate text.
12///
13/// Contains all parameters needed to perform a translation and compute
14/// a unique cache key.
15#[derive(Debug, Clone)]
16pub struct TranslationRequest {
17    /// The text to translate.
18    pub source_text: String,
19    /// The target language (ISO 639-1 code, e.g., "ja", "en").
20    pub target_language: String,
21    /// The model to use for translation.
22    pub model: String,
23    /// The API endpoint URL.
24    pub endpoint: String,
25}
26
27impl TranslationRequest {
28    /// Computes a unique cache key for this request.
29    ///
30    /// The key is a SHA-256 hash of the source text, target language,
31    /// model, endpoint, and prompt template hash.
32    pub fn cache_key(&self) -> String {
33        let prompt_hash = Self::prompt_hash();
34
35        let cache_input = serde_json::json!({
36            "source_text": self.source_text,
37            "target_language": self.target_language,
38            "model": self.model,
39            "endpoint": self.endpoint,
40            "prompt_hash": prompt_hash
41        });
42
43        let mut hasher = Sha256::new();
44        hasher.update(cache_input.to_string().as_bytes());
45        hex::encode(hasher.finalize())
46    }
47
48    /// Computes a hash of the system prompt template.
49    ///
50    /// Used to invalidate cache when the prompt changes.
51    pub fn prompt_hash() -> String {
52        let mut hasher = Sha256::new();
53        hasher.update(SYSTEM_PROMPT_TEMPLATE.as_bytes());
54        hex::encode(hasher.finalize())
55    }
56}
57
58// Use Cow to avoid cloning strings that are only borrowed for serialization
59#[derive(Debug, Serialize)]
60struct ChatCompletionRequest<'a> {
61    model: &'a str,
62    messages: Vec<Message<'a>>,
63    stream: bool,
64}
65
66#[derive(Debug, Serialize)]
67struct Message<'a> {
68    role: &'static str,
69    content: Cow<'a, str>,
70}
71
72#[derive(Debug, Deserialize)]
73struct StreamResponse {
74    choices: Vec<StreamChoice>,
75}
76
77#[derive(Debug, Deserialize)]
78struct StreamChoice {
79    delta: Delta,
80}
81
82#[derive(Debug, Deserialize)]
83struct Delta {
84    content: Option<String>,
85}
86
87/// Client for translating text using OpenAI-compatible APIs.
88///
89/// Supports streaming responses for real-time output.
90///
91/// # Example
92///
93/// ```no_run
94/// use tl_cli::translation::{TranslationClient, TranslationRequest};
95/// use futures_util::StreamExt;
96///
97/// # async fn example() -> anyhow::Result<()> {
98/// let client = TranslationClient::new(
99///     "http://localhost:11434".to_string(),
100///     None,
101/// );
102///
103/// let request = TranslationRequest {
104///     source_text: "Hello, world!".to_string(),
105///     target_language: "ja".to_string(),
106///     model: "gemma3:12b".to_string(),
107///     endpoint: "http://localhost:11434".to_string(),
108/// };
109///
110/// let mut stream = client.translate_stream(&request).await?;
111/// while let Some(chunk) = stream.next().await {
112///     print!("{}", chunk?);
113/// }
114/// # Ok(())
115/// # }
116/// ```
117pub struct TranslationClient {
118    client: Client,
119    endpoint: String,
120    api_key: Option<String>,
121}
122
123impl TranslationClient {
124    /// Creates a new translation client.
125    pub fn new(endpoint: String, api_key: Option<String>) -> Self {
126        Self {
127            client: Client::new(),
128            endpoint,
129            api_key,
130        }
131    }
132
133    /// Translates text and returns a stream of response chunks.
134    ///
135    /// The stream yields chunks of the translated text as they arrive,
136    /// enabling real-time display of the translation.
137    pub async fn translate_stream(
138        &self,
139        request: &TranslationRequest,
140    ) -> Result<Pin<Box<dyn Stream<Item = Result<String>> + Send>>> {
141        let url = format!(
142            "{}/v1/chat/completions",
143            self.endpoint.trim_end_matches('/')
144        );
145
146        // Build system prompt once (returns owned String)
147        let system_prompt = build_system_prompt(&request.target_language);
148
149        let chat_request = ChatCompletionRequest {
150            model: &request.model,
151            messages: vec![
152                Message {
153                    role: "system",
154                    content: Cow::Owned(system_prompt),
155                },
156                Message {
157                    role: "user",
158                    content: Cow::Borrowed(&request.source_text),
159                },
160            ],
161            stream: true,
162        };
163
164        let mut http_request = self.client.post(&url).json(&chat_request);
165
166        // Add Authorization header if API key is present
167        if let Some(api_key) = &self.api_key {
168            http_request = http_request.header("Authorization", format!("Bearer {api_key}"));
169        }
170
171        let response = http_request
172            .send()
173            .await
174            .with_context(|| format!("Failed to connect to API endpoint: {url}"))?;
175
176        if !response.status().is_success() {
177            let status = response.status();
178            let body = response.text().await.unwrap_or_default();
179            anyhow::bail!("API request failed with status {status}: {body}");
180        }
181
182        let mut stream = response.bytes_stream();
183
184        let mapped_stream = async_stream::stream! {
185            use futures_util::StreamExt;
186            let mut buffer = String::new();
187
188            while let Some(chunk_result) = stream.next().await {
189                let chunk = match chunk_result {
190                    Ok(c) => c,
191                    Err(e) => {
192                        yield Err(anyhow::anyhow!("Stream error: {e}"));
193                        continue;
194                    }
195                };
196
197                buffer.push_str(&String::from_utf8_lossy(&chunk));
198
199                while let Some(line_end) = buffer.find('\n') {
200                    let line: String = buffer.drain(..=line_end).collect();
201                    let line = line.trim();
202
203                    if line.is_empty() {
204                        continue;
205                    }
206                    if line == "data: [DONE]" {
207                        return;
208                    }
209
210                    if let Some(content) = parse_sse_line(line) {
211                        yield Ok(content);
212                    }
213                }
214            }
215        };
216
217        Ok(Box::pin(mapped_stream))
218    }
219}
220
221fn parse_sse_line(line: &str) -> Option<String> {
222    let json_str = line.strip_prefix("data: ")?;
223
224    let response = serde_json::from_str::<StreamResponse>(json_str).ok()?;
225
226    let content: String = response
227        .choices
228        .into_iter()
229        .filter_map(|c| c.delta.content)
230        .filter(|c| !c.is_empty())
231        .collect();
232
233    if content.is_empty() {
234        None
235    } else {
236        Some(content)
237    }
238}