psyche_subtitle_toolkit/translation/
openrouter.rs1use serde::{Deserialize, Serialize};
2
3use crate::error::{Result, SubtitleToolkitError};
4
5use super::{TranslationRequest, Translator};
6
7#[derive(Debug, Clone)]
25pub struct OpenRouterTranslator {
26 client: reqwest::Client,
27 base_url: String,
28 api_key: String,
29 model: String,
30}
31
32impl OpenRouterTranslator {
33 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Result<Self> {
35 Self::with_base_url("https://openrouter.ai/api", api_key, model)
36 }
37
38 pub fn with_base_url(
40 base_url: impl Into<String>,
41 api_key: impl Into<String>,
42 model: impl Into<String>,
43 ) -> Result<Self> {
44 let client = reqwest::Client::builder()
45 .timeout(std::time::Duration::from_secs(120))
46 .build()
47 .map_err(SubtitleToolkitError::Http)?;
48 Ok(Self {
49 client,
50 base_url: base_url.into().trim_end_matches('/').to_string(),
51 api_key: api_key.into(),
52 model: model.into(),
53 })
54 }
55}
56
57#[async_trait::async_trait]
58impl Translator for OpenRouterTranslator {
59 async fn translate(&self, request: TranslationRequest<'_>) -> Result<String> {
60 let messages = build_messages(&request);
61
62 let response = self
63 .client
64 .post(format!("{}/v1/chat/completions", self.base_url))
65 .header("Authorization", format!("Bearer {}", self.api_key))
66 .header("HTTP-Referer", "https://github.com/Gitlawb/psyche-subtitle-toolkit")
67 .header("X-Title", "psyche-subtitle-toolkit")
68 .json(&ChatCompletionRequest {
69 model: &self.model,
70 messages,
71 stream: false,
72 })
73 .send()
74 .await?;
75
76 if !response.status().is_success() {
77 return Err(SubtitleToolkitError::Translation {
78 provider: "openrouter",
79 message: response
80 .text()
81 .await
82 .unwrap_or_else(|_| "request failed".into()),
83 });
84 }
85
86 let body = response.json::<ChatCompletionResponse>().await?;
87 let content = body
88 .choices
89 .first()
90 .ok_or_else(|| SubtitleToolkitError::Translation {
91 provider: "openrouter",
92 message: "response contained no choices".into(),
93 })?
94 .message
95 .content
96 .trim()
97 .to_string();
98
99 Ok(content)
100 }
101}
102
103fn build_messages(request: &TranslationRequest<'_>) -> Vec<ChatMessage> {
104 let source_hint = match request.source_language {
105 Some(lang) => format!("The source language is {lang}.\n"),
106 None => String::new(),
107 };
108 vec![
109 ChatMessage {
110 role: "system",
111 content: "You are a subtitle translator. You translate subtitle dialogue while \
112 preserving numbered tags exactly. Return only the translated lines. \
113 Do not add explanations, markdown, notes, or code fences. \
114 Do not add curly-brace commands or backslash formatting."
115 .to_string(),
116 },
117 ChatMessage {
118 role: "user",
119 content: format!(
120 "Translate the following subtitle dialogue to {target_language}.\n\
121 {source_hint}\n\
122 Preserve every numeric tag exactly, like <1>, <2>, <3>.\n\
123 Keep line breaks inside each subtitle when needed.\n\n\
124 Subtitle dialogue:\n\
125 {source_text}",
126 target_language = request.target_language,
127 source_text = request.source_text,
128 ),
129 },
130 ]
131}
132
133#[derive(Debug, Serialize)]
134struct ChatMessage {
135 role: &'static str,
136 content: String,
137}
138
139#[derive(Debug, Serialize)]
140struct ChatCompletionRequest<'a> {
141 model: &'a str,
142 messages: Vec<ChatMessage>,
143 stream: bool,
144}
145
146#[derive(Debug, Deserialize)]
147struct ChatCompletionResponse {
148 choices: Vec<ChatChoice>,
149}
150
151#[derive(Debug, Deserialize)]
152struct ChatChoice {
153 message: ChatChoiceMessage,
154}
155
156#[derive(Debug, Deserialize)]
157struct ChatChoiceMessage {
158 content: String,
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use wiremock::matchers::{header, method, path};
165 use wiremock::{Mock, MockServer, ResponseTemplate};
166
167 #[tokio::test]
168 async fn translates_numbered_text() {
169 let server = MockServer::start().await;
170
171 Mock::given(method("POST"))
172 .and(path("/v1/chat/completions"))
173 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
174 "choices": [{
175 "message": { "content": "<1> Olá\n<2> mundo" }
176 }]
177 })))
178 .mount(&server)
179 .await;
180
181 let translator = OpenRouterTranslator::with_base_url(
182 server.uri(),
183 "sk-test",
184 "meta-llama/llama-3.3-70b-instruct:free",
185 )
186 .unwrap();
187 let result = translator
188 .translate(TranslationRequest {
189 source_text: "<1> hello\n<2> world",
190 target_language: "pt-BR",
191 source_language: None,
192 })
193 .await
194 .unwrap();
195
196 assert_eq!(result, "<1> Olá\n<2> mundo");
197 }
198
199 #[tokio::test]
200 async fn sends_bearer_auth_and_attribution_headers() {
201 let server = MockServer::start().await;
202
203 Mock::given(method("POST"))
204 .and(header("Authorization", "Bearer sk-my-key"))
205 .and(header("HTTP-Referer", "https://github.com/Gitlawb/psyche-subtitle-toolkit"))
206 .and(header("X-Title", "psyche-subtitle-toolkit"))
207 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
208 "choices": [{ "message": { "content": "<1> ok" } }]
209 })))
210 .mount(&server)
211 .await;
212
213 let translator =
214 OpenRouterTranslator::with_base_url(server.uri(), "sk-my-key", "some/model").unwrap();
215 translator
216 .translate(TranslationRequest {
217 source_text: "<1> test",
218 target_language: "en",
219 source_language: None,
220 })
221 .await
222 .unwrap();
223 }
224
225 #[tokio::test]
226 async fn error_on_non_200() {
227 let server = MockServer::start().await;
228
229 Mock::given(method("POST"))
230 .respond_with(
231 ResponseTemplate::new(401).set_body_string(r#"{"error": "invalid api key"}"#),
232 )
233 .mount(&server)
234 .await;
235
236 let translator =
237 OpenRouterTranslator::with_base_url(server.uri(), "sk-bad", "some/model").unwrap();
238 let err = translator
239 .translate(TranslationRequest {
240 source_text: "<1> hello",
241 target_language: "en",
242 source_language: None,
243 })
244 .await
245 .unwrap_err();
246
247 assert!(err.to_string().contains("openrouter"));
248 }
249
250 #[tokio::test]
251 async fn error_on_empty_choices() {
252 let server = MockServer::start().await;
253
254 Mock::given(method("POST"))
255 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
256 "choices": []
257 })))
258 .mount(&server)
259 .await;
260
261 let translator =
262 OpenRouterTranslator::with_base_url(server.uri(), "sk-test", "some/model").unwrap();
263 let err = translator
264 .translate(TranslationRequest {
265 source_text: "<1> hello",
266 target_language: "en",
267 source_language: None,
268 })
269 .await
270 .unwrap_err();
271
272 assert!(err.to_string().contains("no choices"));
273 }
274}