tl_cli/translation/
client.rs1use 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#[derive(Debug, Clone)]
16pub struct TranslationRequest {
17 pub source_text: String,
19 pub target_language: String,
21 pub model: String,
23 pub endpoint: String,
25}
26
27impl TranslationRequest {
28 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 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#[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
87pub struct TranslationClient {
118 client: Client,
119 endpoint: String,
120 api_key: Option<String>,
121}
122
123impl TranslationClient {
124 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 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 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 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}