1use crate::Result;
2use crate::error::SubXError;
3use crate::services::ai::AiUsageStats;
4use crate::services::ai::{
5 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
6};
7use async_trait::async_trait;
8use reqwest::Client;
9use serde_json::{Value, json};
10use std::time::Duration;
11use tokio::time;
12
13use crate::services::ai::hosted_hint::{append_local_hint, maybe_attach_local_hint};
14use crate::services::ai::prompts::{PromptBuilder, ResponseParser};
15use crate::services::ai::retry::HttpRetryClient;
16
17pub struct OpenRouterClient {
19 client: Client,
20 api_key: String,
21 model: String,
22 temperature: f32,
23 max_tokens: u32,
24 retry_attempts: u32,
25 retry_delay_ms: u64,
26 base_url: String,
27 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
28}
29
30impl std::fmt::Debug for OpenRouterClient {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("OpenRouterClient")
33 .field("client", &self.client)
34 .field("api_key", &"[REDACTED]")
35 .field("model", &self.model)
36 .field("temperature", &self.temperature)
37 .field("max_tokens", &self.max_tokens)
38 .field("retry_attempts", &self.retry_attempts)
39 .field("retry_delay_ms", &self.retry_delay_ms)
40 .field("base_url", &self.base_url)
41 .finish()
42 }
43}
44
45impl PromptBuilder for OpenRouterClient {}
46impl ResponseParser for OpenRouterClient {}
47impl HttpRetryClient for OpenRouterClient {
48 fn retry_attempts(&self) -> u32 {
49 self.retry_attempts
50 }
51 fn retry_delay_ms(&self) -> u64 {
52 self.retry_delay_ms
53 }
54}
55
56impl OpenRouterClient {
57 pub fn new(
59 api_key: String,
60 model: String,
61 temperature: f32,
62 max_tokens: u32,
63 retry_attempts: u32,
64 retry_delay_ms: u64,
65 ) -> Self {
66 Self::new_with_base_url_and_timeout(
67 api_key,
68 model,
69 temperature,
70 max_tokens,
71 retry_attempts,
72 retry_delay_ms,
73 "https://openrouter.ai/api/v1".to_string(),
74 120,
75 )
76 }
77
78 #[allow(clippy::too_many_arguments)]
80 pub fn new_with_base_url_and_timeout(
81 api_key: String,
82 model: String,
83 temperature: f32,
84 max_tokens: u32,
85 retry_attempts: u32,
86 retry_delay_ms: u64,
87 base_url: String,
88 request_timeout_seconds: u64,
89 ) -> Self {
90 let client = Client::builder()
91 .timeout(Duration::from_secs(request_timeout_seconds))
92 .build()
93 .expect("Failed to create HTTP client");
94
95 Self {
96 client,
97 api_key,
98 model,
99 temperature,
100 max_tokens,
101 retry_attempts,
102 retry_delay_ms,
103 base_url: base_url.trim_end_matches('/').to_string(),
104 reporter: crate::core::report::noop(),
105 }
106 }
107
108 pub fn with_reporter(
115 mut self,
116 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
117 ) -> Self {
118 self.reporter = reporter;
119 self
120 }
121
122 pub fn from_config(config: &crate::config::AIConfig) -> crate::Result<Self> {
124 let api_key = config
125 .api_key
126 .as_ref()
127 .ok_or_else(|| SubXError::config("Missing OpenRouter API Key"))?;
128
129 Self::validate_base_url(&config.base_url)?;
131 crate::services::ai::security::warn_on_insecure_http_str(&config.base_url, api_key);
132
133 Ok(Self::new_with_base_url_and_timeout(
134 api_key.clone(),
135 config.model.clone(),
136 config.temperature,
137 config.max_tokens,
138 config.retry_attempts,
139 config.retry_delay_ms,
140 config.base_url.clone(),
141 config.request_timeout_seconds,
142 ))
143 }
144
145 fn validate_base_url(url: &str) -> crate::Result<()> {
147 use url::Url;
148 let parsed =
149 Url::parse(url).map_err(|e| SubXError::config(format!("Invalid base URL: {}", e)))?;
150
151 if !matches!(parsed.scheme(), "http" | "https") {
152 return Err(SubXError::config(
153 "Base URL must use http or https protocol".to_string(),
154 ));
155 }
156
157 if parsed.host().is_none() {
158 return Err(SubXError::config(
159 "Base URL must contain a valid hostname".to_string(),
160 ));
161 }
162
163 Ok(())
164 }
165
166 pub async fn chat_completion(&self, messages: Vec<Value>) -> Result<String> {
168 let request_body = json!({
169 "model": self.model,
170 "messages": messages,
171 "temperature": self.temperature,
172 "max_tokens": self.max_tokens,
173 });
174
175 let request = self
176 .client
177 .post(format!("{}/chat/completions", self.base_url))
178 .header("Authorization", format!("Bearer {}", self.api_key))
179 .header("Content-Type", "application/json")
180 .header("HTTP-Referer", "https://github.com/jim60105/subx-cli")
181 .header("X-Title", "Subx")
182 .json(&request_body);
183
184 let mut response = match self.make_request_with_retry(request).await {
185 Ok(r) => r,
186 Err(e) => return Err(maybe_attach_local_hint(e, &self.base_url)),
187 };
188
189 const MAX_AI_RESPONSE_BYTES: u64 = 10 * 1024 * 1024; if let Some(len) = response.content_length() {
191 if len > MAX_AI_RESPONSE_BYTES {
192 return Err(SubXError::AiService(format!(
193 "AI response too large: {} bytes (limit: {} bytes)",
194 len, MAX_AI_RESPONSE_BYTES
195 )));
196 }
197 }
198
199 if !response.status().is_success() {
200 let status = response.status();
201 let error_text = response.text().await?;
202 let safe_body = crate::services::ai::error_sanitizer::sanitize_url_in_error(
203 &crate::services::ai::error_sanitizer::truncate_error_body(
204 &error_text,
205 crate::services::ai::error_sanitizer::DEFAULT_ERROR_BODY_MAX_LEN,
206 ),
207 );
208 return Err(SubXError::AiService(format!(
209 "OpenRouter API error {}: {}",
210 status, safe_body
211 )));
212 }
213
214 let mut body = Vec::new();
217 while let Some(chunk) = response.chunk().await? {
218 body.extend_from_slice(&chunk);
219 if body.len() as u64 > MAX_AI_RESPONSE_BYTES {
220 return Err(SubXError::AiService(format!(
221 "AI response too large: {} bytes read (limit: {} bytes)",
222 body.len(),
223 MAX_AI_RESPONSE_BYTES
224 )));
225 }
226 }
227 let response_json: Value = serde_json::from_slice(&body)
228 .map_err(|e| SubXError::AiService(format!("Failed to parse AI response: {}", e)))?;
229 let content = response_json["choices"][0]["message"]["content"]
230 .as_str()
231 .ok_or_else(|| {
232 SubXError::AiService(append_local_hint("Invalid API response format"))
233 })?;
234
235 if let Some(usage_obj) = response_json.get("usage") {
237 if let (Some(p), Some(c), Some(t)) = (
238 usage_obj.get("prompt_tokens").and_then(Value::as_u64),
239 usage_obj.get("completion_tokens").and_then(Value::as_u64),
240 usage_obj.get("total_tokens").and_then(Value::as_u64),
241 ) {
242 let stats = AiUsageStats {
243 model: self.model.clone(),
244 prompt_tokens: p as u32,
245 completion_tokens: c as u32,
246 total_tokens: t as u32,
247 };
248 self.reporter.ai_usage(&stats);
249 }
250 }
251
252 Ok(content.to_string())
253 }
254
255 async fn make_request_with_retry(
256 &self,
257 request: reqwest::RequestBuilder,
258 ) -> crate::Result<reqwest::Response> {
259 let mut attempts = 0;
260 loop {
261 let cloned = request.try_clone().ok_or_else(|| {
262 crate::error::SubXError::AiService(
263 "Request body cannot be cloned for retry".to_string(),
264 )
265 })?;
266 match cloned.send().await {
267 Ok(resp) => {
268 if resp.status().is_server_error() && (attempts as u32) < self.retry_attempts {
270 attempts += 1;
271 log::warn!(
272 "Request attempt {} failed with status {}. Retrying in {}ms...",
273 attempts,
274 resp.status(),
275 self.retry_delay_ms
276 );
277 time::sleep(Duration::from_millis(self.retry_delay_ms)).await;
278 continue;
279 }
280 if attempts > 0 {
281 log::info!("Request succeeded after {} retry attempts", attempts);
282 }
283 return Ok(resp);
284 }
285 Err(e) if (attempts as u32) < self.retry_attempts => {
286 attempts += 1;
287 log::warn!(
288 "Request attempt {} failed: {}. Retrying in {}ms...",
289 attempts,
290 e,
291 self.retry_delay_ms
292 );
293
294 if e.is_timeout() {
295 log::warn!(
296 "This appears to be a timeout error. If this persists, consider increasing 'ai.request_timeout_seconds' in your configuration."
297 );
298 }
299
300 time::sleep(Duration::from_millis(self.retry_delay_ms)).await;
301 continue;
302 }
303 Err(e) => {
304 log::error!(
305 "Request failed after {} attempts. Final error: {}",
306 attempts + 1,
307 e
308 );
309
310 if e.is_timeout() {
311 log::error!(
312 "AI service error: Request timed out after multiple attempts. \
313 This usually indicates network connectivity issues or server overload. \
314 Try increasing 'ai.request_timeout_seconds' configuration. \
315 Hint: check network connection and API service status"
316 );
317 } else if e.is_connect() {
318 log::error!(
319 "AI service error: Connection failed. \
320 Hint: check network connection and API base URL settings"
321 );
322 }
323
324 return Err(e.into());
325 }
326 }
327 }
328 }
329}
330
331#[async_trait]
332impl AIProvider for OpenRouterClient {
333 async fn analyze_content(&self, request: AnalysisRequest) -> Result<MatchResult> {
334 let prompt = self.build_analysis_prompt(&request);
335 let messages = vec![
336 json!({"role": "system", "content": "You are a professional subtitle matching assistant that can analyze the correspondence between video and subtitle files."}),
337 json!({"role": "user", "content": prompt}),
338 ];
339 let response = self.chat_completion(messages).await?;
340 self.parse_match_result(&response)
341 }
342
343 async fn verify_match(&self, verification: VerificationRequest) -> Result<ConfidenceScore> {
344 let prompt = self.build_verification_prompt(&verification);
345 let messages = vec![
346 json!({"role": "system", "content": "Please evaluate the confidence level of subtitle matching and provide a score between 0-1."}),
347 json!({"role": "user", "content": prompt}),
348 ];
349 let response = self.chat_completion(messages).await?;
350 self.parse_confidence_score(&response)
351 }
352
353 async fn chat_completion(&self, messages: Vec<Value>) -> Result<String> {
354 OpenRouterClient::chat_completion(self, messages).await
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use mockall::mock;
362 use serde_json::json;
363 use wiremock::matchers::{header, method, path};
364 use wiremock::{Mock, MockServer, ResponseTemplate};
365
366 mock! {
367 AIClient {}
368
369 #[async_trait]
370 impl AIProvider for AIClient {
371 async fn analyze_content(&self, request: AnalysisRequest) -> crate::Result<MatchResult>;
372 async fn verify_match(&self, verification: VerificationRequest) -> crate::Result<ConfidenceScore>;
373 }
374 }
375
376 #[tokio::test]
377 async fn test_openrouter_client_creation() {
378 let client = OpenRouterClient::new(
379 "test-key".into(),
380 "deepseek/deepseek-r1-0528:free".into(),
381 0.5,
382 1000,
383 2,
384 100,
385 );
386 assert_eq!(client.api_key, "test-key");
387 assert_eq!(client.model, "deepseek/deepseek-r1-0528:free");
388 assert_eq!(client.temperature, 0.5);
389 assert_eq!(client.max_tokens, 1000);
390 assert_eq!(client.retry_attempts, 2);
391 assert_eq!(client.retry_delay_ms, 100);
392 assert_eq!(client.base_url, "https://openrouter.ai/api/v1");
393 }
394
395 #[tokio::test]
396 async fn test_openrouter_client_creation_with_custom_base_url() {
397 let client = OpenRouterClient::new_with_base_url_and_timeout(
398 "test-key".into(),
399 "deepseek/deepseek-r1-0528:free".into(),
400 0.3,
401 2000,
402 3,
403 200,
404 "https://custom-openrouter.ai/api/v1".into(),
405 60,
406 );
407 assert_eq!(client.base_url, "https://custom-openrouter.ai/api/v1");
408 }
409
410 #[tokio::test]
411 async fn test_chat_completion_success() {
412 let server = MockServer::start().await;
413 Mock::given(method("POST"))
414 .and(path("/chat/completions"))
415 .and(header("authorization", "Bearer test-key"))
416 .and(header(
417 "HTTP-Referer",
418 "https://github.com/jim60105/subx-cli",
419 ))
420 .and(header("X-Title", "Subx"))
421 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
422 "choices": [{"message": {"content": "test response content"}}],
423 "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
424 })))
425 .mount(&server)
426 .await;
427
428 let mut client = OpenRouterClient::new(
429 "test-key".into(),
430 "deepseek/deepseek-r1-0528:free".into(),
431 0.3,
432 1000,
433 1,
434 0,
435 );
436 client.base_url = server.uri();
437
438 let messages = vec![json!({"role":"user","content":"test"})];
439 let resp = client.chat_completion(messages).await.unwrap();
440 assert_eq!(resp, "test response content");
441 }
442
443 #[tokio::test]
444 async fn test_chat_completion_error_handling() {
445 let server = MockServer::start().await;
446 Mock::given(method("POST"))
447 .and(path("/chat/completions"))
448 .respond_with(ResponseTemplate::new(401).set_body_json(json!({
449 "error": {"message":"Invalid API key"}
450 })))
451 .mount(&server)
452 .await;
453
454 let mut client = OpenRouterClient::new(
455 "bad-key".into(),
456 "deepseek/deepseek-r1-0528:free".into(),
457 0.3,
458 1000,
459 1,
460 0,
461 );
462 client.base_url = server.uri();
463
464 let messages = vec![json!({"role":"user","content":"test"})];
465 let result = client.chat_completion(messages).await;
466 assert!(result.is_err());
467 assert!(
468 result
469 .err()
470 .unwrap()
471 .to_string()
472 .contains("OpenRouter API error 401")
473 );
474 }
475
476 #[tokio::test]
477 async fn test_retry_mechanism() {
478 let server = MockServer::start().await;
479
480 Mock::given(method("POST"))
482 .and(path("/chat/completions"))
483 .respond_with(ResponseTemplate::new(500))
484 .up_to_n_times(1)
485 .mount(&server)
486 .await;
487
488 Mock::given(method("POST"))
489 .and(path("/chat/completions"))
490 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
491 "choices": [{"message": {"content": "success after retry"}}]
492 })))
493 .mount(&server)
494 .await;
495
496 let mut client = OpenRouterClient::new(
497 "test-key".into(),
498 "deepseek/deepseek-r1-0528:free".into(),
499 0.3,
500 1000,
501 2, 50, );
504 client.base_url = server.uri();
505
506 let messages = vec![json!({"role":"user","content":"test"})];
507 let result = client.chat_completion(messages).await.unwrap();
508 assert_eq!(result, "success after retry");
509 }
510
511 #[test]
512 fn test_openrouter_client_from_config() {
513 let config = crate::config::AIConfig {
514 provider: "openrouter".to_string(),
515 api_key: Some("test-key".to_string()),
516 model: "deepseek/deepseek-r1-0528:free".to_string(),
517 base_url: "https://openrouter.ai/api/v1".to_string(),
518 max_sample_length: 500,
519 temperature: 0.7,
520 max_tokens: 2000,
521 retry_attempts: 3,
522 retry_delay_ms: 150,
523 request_timeout_seconds: 120,
524 api_version: None,
525 };
526
527 let client = OpenRouterClient::from_config(&config).unwrap();
528 assert_eq!(client.api_key, "test-key");
529 assert_eq!(client.model, "deepseek/deepseek-r1-0528:free");
530 assert_eq!(client.temperature, 0.7);
531 assert_eq!(client.max_tokens, 2000);
532 assert_eq!(client.retry_attempts, 3);
533 assert_eq!(client.retry_delay_ms, 150);
534 }
535
536 #[test]
537 fn test_openrouter_client_from_config_missing_api_key() {
538 let config = crate::config::AIConfig {
539 provider: "openrouter".to_string(),
540 api_key: None,
541 model: "deepseek/deepseek-r1-0528:free".to_string(),
542 base_url: "https://openrouter.ai/api/v1".to_string(),
543 max_sample_length: 500,
544 temperature: 0.3,
545 max_tokens: 1000,
546 retry_attempts: 2,
547 retry_delay_ms: 100,
548 request_timeout_seconds: 30,
549 api_version: None,
550 };
551
552 let result = OpenRouterClient::from_config(&config);
553 assert!(result.is_err());
554 assert!(
555 result
556 .err()
557 .unwrap()
558 .to_string()
559 .contains("Missing OpenRouter API Key")
560 );
561 }
562
563 #[test]
564 fn test_openrouter_client_from_config_invalid_base_url() {
565 let config = crate::config::AIConfig {
566 provider: "openrouter".to_string(),
567 api_key: Some("test-key".to_string()),
568 model: "deepseek/deepseek-r1-0528:free".to_string(),
569 base_url: "ftp://invalid.url".to_string(),
570 max_sample_length: 500,
571 temperature: 0.3,
572 max_tokens: 1000,
573 retry_attempts: 2,
574 retry_delay_ms: 100,
575 request_timeout_seconds: 30,
576 api_version: None,
577 };
578
579 let result = OpenRouterClient::from_config(&config);
580 assert!(result.is_err());
581 assert!(
582 result
583 .err()
584 .unwrap()
585 .to_string()
586 .contains("must use http or https protocol")
587 );
588 }
589
590 #[test]
591 fn test_prompt_building_and_parsing() {
592 let client = OpenRouterClient::new(
593 "test-key".into(),
594 "deepseek/deepseek-r1-0528:free".into(),
595 0.1,
596 1000,
597 0,
598 0,
599 );
600 let request = AnalysisRequest {
601 video_files: vec!["video1.mp4".into()],
602 subtitle_files: vec!["subtitle1.srt".into()],
603 content_samples: vec![],
604 };
605
606 let prompt = client.build_analysis_prompt(&request);
607 assert!(prompt.contains("video1.mp4"));
608 assert!(prompt.contains("subtitle1.srt"));
609 assert!(prompt.contains("JSON"));
610
611 let json_response = r#"{ "matches": [], "confidence":0.9, "reasoning":"test reason" }"#;
612 let match_result = client.parse_match_result(json_response).unwrap();
613 assert_eq!(match_result.confidence, 0.9);
614 assert_eq!(match_result.reasoning, "test reason");
615 }
616
617 #[tokio::test]
620 async fn test_hosted_hint_connection_refused_loopback() {
621 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
622 let port = listener.local_addr().unwrap().port();
623 drop(listener);
624 let client = OpenRouterClient::new_with_base_url_and_timeout(
625 "k".into(),
626 "deepseek/deepseek-r1-0528:free".into(),
627 0.0,
628 16,
629 0,
630 0,
631 format!("http://127.0.0.1:{}", port),
632 1,
633 );
634 let err = client
635 .chat_completion(vec![json!({"role":"user","content":"x"})])
636 .await
637 .unwrap_err();
638 let msg = err.to_string();
639 assert!(
640 msg.contains("ollama") && msg.contains("ai.provider"),
641 "expected local-provider hint: {msg}"
642 );
643 }
644
645 #[tokio::test]
647 async fn test_hosted_hint_http_200_non_openai_body() {
648 let server = MockServer::start().await;
649 Mock::given(method("POST"))
650 .and(path("/chat/completions"))
651 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hello": "world" })))
652 .mount(&server)
653 .await;
654 let mut client = OpenRouterClient::new(
655 "k".into(),
656 "deepseek/deepseek-r1-0528:free".into(),
657 0.0,
658 16,
659 0,
660 0,
661 );
662 client.base_url = server.uri();
663 let err = client
664 .chat_completion(vec![json!({"role":"user","content":"x"})])
665 .await
666 .unwrap_err();
667 let msg = err.to_string();
668 assert!(
669 msg.contains("Invalid API response format")
670 && msg.contains("ollama")
671 && msg.contains("ai.provider"),
672 "expected hint-bearing parse-shape error: {msg}"
673 );
674 }
675
676 #[tokio::test]
679 async fn test_hosted_hint_not_emitted_for_public_host() {
680 let client = OpenRouterClient::new_with_base_url_and_timeout(
681 "k".into(),
682 "deepseek/deepseek-r1-0528:free".into(),
683 0.0,
684 16,
685 0,
686 0,
687 "https://192.0.2.1/api/v1".to_string(),
688 1,
689 );
690 let err = client
691 .chat_completion(vec![json!({"role":"user","content":"x"})])
692 .await
693 .unwrap_err();
694 let msg = err.to_string();
695 assert!(
696 !msg.contains("ollama"),
697 "public-host failure must NOT carry the hint: {msg}"
698 );
699 }
700}