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 vec![
105 ChatMessage {
106 role: "system",
107 content: "You are a subtitle translator. You translate subtitle dialogue while \
108 preserving numbered tags exactly. Return only the translated lines. \
109 Do not add explanations, markdown, notes, or code fences. \
110 Do not add curly-brace commands or backslash formatting."
111 .to_string(),
112 },
113 ChatMessage {
114 role: "user",
115 content: format!(
116 "Translate the following subtitle dialogue to {target_language}.\n\n\
117 Preserve every numeric tag exactly, like <1>, <2>, <3>.\n\
118 Keep line breaks inside each subtitle when needed.\n\n\
119 Subtitle dialogue:\n\
120 {source_text}",
121 target_language = request.target_language,
122 source_text = request.source_text,
123 ),
124 },
125 ]
126}
127
128#[derive(Debug, Serialize)]
129struct ChatMessage {
130 role: &'static str,
131 content: String,
132}
133
134#[derive(Debug, Serialize)]
135struct ChatCompletionRequest<'a> {
136 model: &'a str,
137 messages: Vec<ChatMessage>,
138 stream: bool,
139}
140
141#[derive(Debug, Deserialize)]
142struct ChatCompletionResponse {
143 choices: Vec<ChatChoice>,
144}
145
146#[derive(Debug, Deserialize)]
147struct ChatChoice {
148 message: ChatChoiceMessage,
149}
150
151#[derive(Debug, Deserialize)]
152struct ChatChoiceMessage {
153 content: String,
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use wiremock::matchers::{header, method, path};
160 use wiremock::{Mock, MockServer, ResponseTemplate};
161
162 #[tokio::test]
163 async fn translates_numbered_text() {
164 let server = MockServer::start().await;
165
166 Mock::given(method("POST"))
167 .and(path("/v1/chat/completions"))
168 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
169 "choices": [{
170 "message": { "content": "<1> Olá\n<2> mundo" }
171 }]
172 })))
173 .mount(&server)
174 .await;
175
176 let translator = OpenRouterTranslator::with_base_url(
177 server.uri(),
178 "sk-test",
179 "meta-llama/llama-3.3-70b-instruct:free",
180 )
181 .unwrap();
182 let result = translator
183 .translate(TranslationRequest {
184 source_text: "<1> hello\n<2> world",
185 target_language: "pt-BR",
186 })
187 .await
188 .unwrap();
189
190 assert_eq!(result, "<1> Olá\n<2> mundo");
191 }
192
193 #[tokio::test]
194 async fn sends_bearer_auth_and_attribution_headers() {
195 let server = MockServer::start().await;
196
197 Mock::given(method("POST"))
198 .and(header("Authorization", "Bearer sk-my-key"))
199 .and(header("HTTP-Referer", "https://github.com/Gitlawb/psyche-subtitle-toolkit"))
200 .and(header("X-Title", "psyche-subtitle-toolkit"))
201 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
202 "choices": [{ "message": { "content": "<1> ok" } }]
203 })))
204 .mount(&server)
205 .await;
206
207 let translator =
208 OpenRouterTranslator::with_base_url(server.uri(), "sk-my-key", "some/model").unwrap();
209 translator
210 .translate(TranslationRequest {
211 source_text: "<1> test",
212 target_language: "en",
213 })
214 .await
215 .unwrap();
216 }
217
218 #[tokio::test]
219 async fn error_on_non_200() {
220 let server = MockServer::start().await;
221
222 Mock::given(method("POST"))
223 .respond_with(
224 ResponseTemplate::new(401).set_body_string(r#"{"error": "invalid api key"}"#),
225 )
226 .mount(&server)
227 .await;
228
229 let translator =
230 OpenRouterTranslator::with_base_url(server.uri(), "sk-bad", "some/model").unwrap();
231 let err = translator
232 .translate(TranslationRequest {
233 source_text: "<1> hello",
234 target_language: "en",
235 })
236 .await
237 .unwrap_err();
238
239 assert!(err.to_string().contains("openrouter"));
240 }
241
242 #[tokio::test]
243 async fn error_on_empty_choices() {
244 let server = MockServer::start().await;
245
246 Mock::given(method("POST"))
247 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
248 "choices": []
249 })))
250 .mount(&server)
251 .await;
252
253 let translator =
254 OpenRouterTranslator::with_base_url(server.uri(), "sk-test", "some/model").unwrap();
255 let err = translator
256 .translate(TranslationRequest {
257 source_text: "<1> hello",
258 target_language: "en",
259 })
260 .await
261 .unwrap_err();
262
263 assert!(err.to_string().contains("no choices"));
264 }
265}