Skip to main content

subx_core/services/ai/
openrouter.rs

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
17/// OpenRouter client implementation
18pub 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    /// Create new OpenRouterClient with default configuration
58    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    /// Create new OpenRouterClient with custom base URL and timeout
79    #[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    /// Attach a reporting sink, consuming and returning the client.
109    ///
110    /// # Arguments
111    ///
112    /// * `reporter` - Sink that receives [`crate::core::report::AiUsage`]
113    ///   after every successful API response.
114    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    /// Create client from unified configuration
123    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        // Validate base URL format
130        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    /// Validate base URL format
146    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    /// Send a raw chat completion request to the OpenRouter Chat Completions API.
167    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; // 10 MiB
190        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        // Bounded chunked read to guard against oversized responses when
215        // content_length() is not reported by the server.
216        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        // Parse usage statistics and display
236        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                    // Retry on server error statuses (5xx) if attempts remain
269                    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        // First request fails, second succeeds
481        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,  // Allow 2 retries
502            50, // Short delay for testing
503        );
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    /// §3.6 — connection refused against `127.0.0.1` on a port with no
618    /// listener results in a hint-bearing error.
619    #[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    /// §3.6 — HTTP 200 with a non-OpenAI body MUST surface the hint.
646    #[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    /// §3.6 negative — a failure against a public host MUST NOT surface
677    /// the hint. Uses TEST-NET-1 (RFC 5737) so the test is hermetic.
678    #[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}