1use crate::error::SubXError;
2use crate::services::ai::hosted_hint::{append_local_hint, maybe_attach_local_hint};
3use crate::services::ai::prompts::{PromptBuilder, ResponseParser};
4use crate::services::ai::retry::HttpRetryClient;
5use crate::services::ai::{
6 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
7};
8use async_trait::async_trait;
9use reqwest::Client;
10use serde_json::{Value, json};
11use std::time::Duration;
12use tokio::time;
13use url::{ParseError, Url};
14
15pub struct AzureOpenAIClient {
17 client: Client,
18 api_key: String,
19 model: String,
20 base_url: String,
21 api_version: String,
22 temperature: f32,
23 max_tokens: u32,
24 retry_attempts: u32,
25 retry_delay_ms: u64,
26 request_timeout_seconds: u64,
27 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
28}
29
30impl std::fmt::Debug for AzureOpenAIClient {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("AzureOpenAIClient")
33 .field("client", &self.client)
34 .field("api_key", &"[REDACTED]")
35 .field("model", &self.model)
36 .field("base_url", &self.base_url)
37 .field("api_version", &self.api_version)
38 .field("temperature", &self.temperature)
39 .field("max_tokens", &self.max_tokens)
40 .field("retry_attempts", &self.retry_attempts)
41 .field("retry_delay_ms", &self.retry_delay_ms)
42 .field("request_timeout_seconds", &self.request_timeout_seconds)
43 .finish()
44 }
45}
46
47const DEFAULT_AZURE_API_VERSION: &str = "2025-04-01-preview";
48
49impl AzureOpenAIClient {
50 #[allow(clippy::too_many_arguments)]
52 pub fn new_with_all(
53 api_key: String,
54 model: String,
55 base_url: String,
56 api_version: String,
57 temperature: f32,
58 max_tokens: u32,
59 retry_attempts: u32,
60 retry_delay_ms: u64,
61 request_timeout_seconds: u64,
62 ) -> Self {
63 let client = Client::builder()
64 .timeout(Duration::from_secs(request_timeout_seconds))
65 .build()
66 .expect("Failed to create HTTP client");
67 AzureOpenAIClient {
68 client,
69 api_key,
70 model,
71 base_url: base_url.trim_end_matches('/').to_string(),
72 api_version,
73 temperature,
74 max_tokens,
75 retry_attempts,
76 retry_delay_ms,
77 request_timeout_seconds,
78 reporter: crate::core::report::noop(),
79 }
80 }
81
82 pub fn with_reporter(
89 mut self,
90 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
91 ) -> Self {
92 self.reporter = reporter;
93 self
94 }
95
96 pub fn from_config(config: &crate::config::AIConfig) -> crate::Result<Self> {
98 let api_key = config
99 .api_key
100 .as_ref()
101 .filter(|key| !key.trim().is_empty())
102 .ok_or_else(|| SubXError::config("Missing Azure OpenAI API Key".to_string()))?
103 .clone();
104 let deployment_name = config.model.clone();
106 if deployment_name.trim().is_empty() {
107 return Err(SubXError::config(
108 "Missing Azure OpenAI deployment name in model field".to_string(),
109 ));
110 }
111 let api_version = config
112 .api_version
113 .clone()
114 .unwrap_or_else(|| DEFAULT_AZURE_API_VERSION.to_string());
115
116 let parsed = match Url::parse(&config.base_url) {
118 Ok(u) => u,
119 Err(ParseError::EmptyHost) => {
120 return Err(SubXError::config(
121 "Azure OpenAI endpoint missing host".to_string(),
122 ));
123 }
124 Err(e) => {
125 return Err(SubXError::config(format!(
126 "Invalid Azure OpenAI endpoint: {}",
127 e
128 )));
129 }
130 };
131 if !matches!(parsed.scheme(), "http" | "https") {
132 return Err(SubXError::config(
133 "Azure OpenAI endpoint must use http or https".to_string(),
134 ));
135 }
136 crate::services::ai::security::warn_on_insecure_http(&parsed, &api_key);
137
138 Ok(Self::new_with_all(
139 api_key,
140 config.model.clone(),
141 config.base_url.clone(),
142 api_version,
143 config.temperature,
144 config.max_tokens,
145 config.retry_attempts,
146 config.retry_delay_ms,
147 config.request_timeout_seconds,
148 ))
149 }
150
151 async fn make_request_with_retry(
152 &self,
153 request: reqwest::RequestBuilder,
154 ) -> crate::Result<reqwest::Response> {
155 let mut attempts = 0;
156 loop {
157 let cloned = request.try_clone().ok_or_else(|| {
158 crate::error::SubXError::AiService(
159 "Request body cannot be cloned for retry".to_string(),
160 )
161 })?;
162 match cloned.send().await {
163 Ok(resp) => {
164 if attempts > 0 {
165 log::info!("Request succeeded after {} retry attempts", attempts);
166 }
167 return Ok(resp);
168 }
169 Err(e) if (attempts as u32) < self.retry_attempts => {
170 attempts += 1;
171 log::warn!(
172 "Request attempt {} failed: {}. Retrying in {}ms...",
173 attempts,
174 e,
175 self.retry_delay_ms
176 );
177 if e.is_timeout() {
178 log::warn!(
179 "This appears to be a timeout error. Consider increasing 'ai.request_timeout_seconds' in config."
180 );
181 }
182 time::sleep(Duration::from_millis(self.retry_delay_ms)).await;
183 }
184 Err(e) => {
185 log::error!(
186 "Request failed after {} attempts. Final error: {}",
187 attempts + 1,
188 e
189 );
190 if e.is_timeout() {
191 log::error!(
192 "AI service error: Request timed out after multiple attempts. Try increasing 'ai.request_timeout_seconds' configuration."
193 );
194 } else if e.is_connect() {
195 log::error!(
196 "AI service error: Connection failed. Check network connection and Azure OpenAI endpoint settings."
197 );
198 }
199 return Err(e.into());
200 }
201 }
202 }
203 }
204
205 pub async fn chat_completion(&self, messages: Vec<Value>) -> crate::Result<String> {
207 let url = format!(
208 "{}/openai/deployments/{}/chat/completions?api-version={}",
209 self.base_url, self.model, self.api_version
210 );
211 let mut req = self
212 .client
213 .post(url)
214 .header("Content-Type", "application/json");
215 if self.api_key.to_lowercase().starts_with("bearer ") {
216 req = req.header("Authorization", self.api_key.clone());
217 } else {
218 req = req.header("api-key", self.api_key.clone());
219 }
220 let body = json!({
221 "messages": messages,
222 "temperature": self.temperature,
223 "max_tokens": self.max_tokens,
224 "stream": false
225 });
226 let request = req.json(&body);
227 let mut response = match self.make_request_with_retry(request).await {
228 Ok(r) => r,
229 Err(e) => return Err(maybe_attach_local_hint(e, &self.base_url)),
230 };
231
232 const MAX_AI_RESPONSE_BYTES: u64 = 10 * 1024 * 1024; if let Some(len) = response.content_length() {
234 if len > MAX_AI_RESPONSE_BYTES {
235 return Err(SubXError::AiService(format!(
236 "AI response too large: {} bytes (limit: {} bytes)",
237 len, MAX_AI_RESPONSE_BYTES
238 )));
239 }
240 }
241
242 if !response.status().is_success() {
243 let status = response.status();
244 let text = response.text().await?;
245 let safe_body = crate::services::ai::error_sanitizer::sanitize_url_in_error(
246 &crate::services::ai::error_sanitizer::truncate_error_body(
247 &text,
248 crate::services::ai::error_sanitizer::DEFAULT_ERROR_BODY_MAX_LEN,
249 ),
250 );
251 return Err(SubXError::AiService(format!(
252 "Azure OpenAI API error {}: {}",
253 status, safe_body
254 )));
255 }
256 let mut body = Vec::new();
259 while let Some(chunk) = response.chunk().await? {
260 body.extend_from_slice(&chunk);
261 if body.len() as u64 > MAX_AI_RESPONSE_BYTES {
262 return Err(SubXError::AiService(format!(
263 "AI response too large: {} bytes read (limit: {} bytes)",
264 body.len(),
265 MAX_AI_RESPONSE_BYTES
266 )));
267 }
268 }
269 let resp_json: Value = serde_json::from_slice(&body)
270 .map_err(|e| SubXError::AiService(format!("Failed to parse AI response: {}", e)))?;
271 if let Some(usage) = resp_json.get("usage") {
272 if let (Some(p), Some(c), Some(t)) = (
273 usage.get("prompt_tokens").and_then(Value::as_u64),
274 usage.get("completion_tokens").and_then(Value::as_u64),
275 usage.get("total_tokens").and_then(Value::as_u64),
276 ) {
277 let model = resp_json
279 .get("model")
280 .and_then(Value::as_str)
281 .unwrap_or(self.model.as_str())
282 .to_string();
283 let stats = crate::services::ai::AiUsageStats {
284 model,
285 prompt_tokens: p as u32,
286 completion_tokens: c as u32,
287 total_tokens: t as u32,
288 };
289 self.reporter.ai_usage(&stats);
290 }
291 }
292 let content = resp_json["choices"][0]["message"]["content"]
293 .as_str()
294 .ok_or_else(|| {
295 SubXError::AiService(append_local_hint("Invalid API response format"))
296 })?;
297 Ok(content.to_string())
298 }
299}
300
301impl PromptBuilder for AzureOpenAIClient {}
302impl ResponseParser for AzureOpenAIClient {}
303impl HttpRetryClient for AzureOpenAIClient {
304 fn retry_attempts(&self) -> u32 {
305 self.retry_attempts
306 }
307
308 fn retry_delay_ms(&self) -> u64 {
309 self.retry_delay_ms
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::config::Config;
317
318 #[test]
319 fn test_azure_openai_from_config_and_url_construction() {
320 let mut config = Config::default();
321 config.ai.provider = "azure-openai".to_string();
322 config.ai.api_key = Some("test-api-key".to_string());
323 config.ai.model = "deployment-name".to_string();
324 config.ai.base_url = "https://example.openai.azure.com".to_string();
325 config.ai.api_version = Some("2025-04-01-preview".to_string());
326
327 let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
328 let url = format!(
329 "{}/openai/deployments/{}/chat/completions?api-version={}",
330 client.base_url, client.model, client.api_version
331 );
332 assert!(url.contains("deployment-name"));
333 }
334
335 #[test]
336 fn test_missing_model_error() {
337 let mut config = Config::default();
338 config.ai.provider = "azure-openai".to_string();
339 config.ai.api_key = Some("test-api-key".to_string());
340 config.ai.model = "".to_string();
341 config.ai.base_url = "https://example.openai.azure.com".to_string();
342
343 let err = AzureOpenAIClient::from_config(&config.ai)
344 .unwrap_err()
345 .to_string();
346 assert!(err.contains("Missing Azure OpenAI deployment name in model field"));
347 }
348
349 #[test]
350 fn test_azure_openai_client_creation_with_defaults() {
351 let mut config = Config::default();
352 config.ai.provider = "azure-openai".to_string();
353 config.ai.api_key = Some("test-api-key".to_string());
354 config.ai.model = "deployment-name".to_string();
355 config.ai.base_url = "https://example.openai.azure.com".to_string();
356 let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
359 assert_eq!(
360 client.api_version,
361 super::DEFAULT_AZURE_API_VERSION.to_string()
362 );
363 }
364
365 #[test]
366 fn test_azure_openai_client_missing_api_key() {
367 let mut config = Config::default();
368 config.ai.provider = "azure-openai".to_string();
369 config.ai.api_key = None;
370 config.ai.model = "deployment-name".to_string();
371 config.ai.base_url = "https://example.openai.azure.com".to_string();
372
373 let result = AzureOpenAIClient::from_config(&config.ai);
374 let err = result.unwrap_err().to_string();
375 assert!(err.contains("Missing Azure OpenAI API Key"));
376 }
377
378 #[test]
379 fn test_azure_openai_client_invalid_base_url() {
380 let mut config = Config::default();
381 config.ai.provider = "azure-openai".to_string();
382 config.ai.api_key = Some("test-api-key".to_string());
383 config.ai.model = "deployment-name".to_string();
384 config.ai.base_url = "invalid-url".to_string();
385
386 let result = AzureOpenAIClient::from_config(&config.ai);
387 let err = result.unwrap_err().to_string();
388 assert!(err.contains("Invalid Azure OpenAI endpoint"));
389 }
390
391 #[test]
392 fn test_azure_openai_client_invalid_url_scheme() {
393 let mut config = Config::default();
394 config.ai.provider = "azure-openai".to_string();
395 config.ai.api_key = Some("test-api-key".to_string());
396 config.ai.model = "deployment-name".to_string();
397 config.ai.base_url = "ftp://example.openai.azure.com".to_string();
398
399 let result = AzureOpenAIClient::from_config(&config.ai);
400 let err = result.unwrap_err().to_string();
401 assert!(err.contains("must use http or https"));
402 }
403
404 #[test]
405 fn test_azure_openai_client_url_without_host() {
406 let mut config = Config::default();
407 config.ai.provider = "azure-openai".to_string();
408 config.ai.api_key = Some("test-api-key".to_string());
409 config.ai.model = "deployment-name".to_string();
410 config.ai.base_url = "https://".to_string();
411
412 let result = AzureOpenAIClient::from_config(&config.ai);
413 let err = result.unwrap_err().to_string();
414 assert!(err.contains("missing host"));
415 }
416
417 #[test]
418 fn test_azure_openai_with_custom_model_and_version() {
419 let mock_model = "custom-model-123";
420 let mock_version = "2023-12-01-preview";
421
422 let mut config = Config::default();
423 config.ai.provider = "azure-openai".to_string();
424 config.ai.api_key = Some("test-api-key".to_string());
425 config.ai.model = mock_model.to_string();
426 config.ai.base_url = "https://custom.openai.azure.com".to_string();
427 config.ai.api_version = Some(mock_version.to_string());
428
429 let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
430 assert_eq!(client.model, mock_model);
431 assert_eq!(client.api_version, mock_version);
432 }
433
434 #[test]
435 fn test_azure_openai_with_trailing_slash_in_url() {
436 let mut config = Config::default();
437 config.ai.provider = "azure-openai".to_string();
438 config.ai.api_key = Some("test-api-key".to_string());
439 config.ai.model = "deployment-name".to_string();
440 config.ai.base_url = "https://example.openai.azure.com/".to_string(); let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
443 assert_eq!(
444 client.base_url,
445 "https://example.openai.azure.com".to_string()
446 );
447 }
448
449 #[test]
450 fn test_azure_openai_with_custom_temperature_and_tokens() {
451 let mut config = Config::default();
452 config.ai.provider = "azure-openai".to_string();
453 config.ai.api_key = Some("test-api-key".to_string());
454 config.ai.model = "deployment-name".to_string();
455 config.ai.base_url = "https://example.openai.azure.com".to_string();
456 config.ai.temperature = 0.8;
457 config.ai.max_tokens = 2000;
458
459 let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
460 assert!((client.temperature - 0.8).abs() < f32::EPSILON);
461 assert_eq!(client.max_tokens, 2000);
462 }
463
464 #[test]
465 fn test_azure_openai_with_custom_retry_and_timeout() {
466 let mut config = Config::default();
467 config.ai.provider = "azure-openai".to_string();
468 config.ai.api_key = Some("test-api-key".to_string());
469 config.ai.model = "deployment-name".to_string();
470 config.ai.base_url = "https://example.openai.azure.com".to_string();
471 config.ai.retry_attempts = 5;
472 config.ai.retry_delay_ms = 2000;
473 config.ai.request_timeout_seconds = 180;
474
475 let client = AzureOpenAIClient::from_config(&config.ai).unwrap();
476 assert_eq!(client.retry_attempts, 5);
477 assert_eq!(client.retry_delay_ms, 2000);
478 assert_eq!(client.request_timeout_seconds, 180);
479 }
480
481 #[test]
482 fn test_azure_openai_new_with_all_parameters() {
483 let client = AzureOpenAIClient::new_with_all(
484 "test-api-key".to_string(),
485 "gpt-test".to_string(),
486 "https://example.openai.azure.com".to_string(),
487 "2025-04-01-preview".to_string(),
488 0.7,
489 4000,
490 3,
491 1000,
492 120,
493 );
494 assert!(format!("{:?}", client).contains("AzureOpenAIClient"));
495 }
496
497 #[test]
498 fn test_azure_openai_error_handling_empty_api_key() {
499 let mut config = Config::default();
500 config.ai.provider = "azure-openai".to_string();
501 config.ai.api_key = Some("".to_string()); config.ai.model = "deployment-name".to_string();
503 config.ai.base_url = "https://example.openai.azure.com".to_string();
504
505 let err = AzureOpenAIClient::from_config(&config.ai)
506 .unwrap_err()
507 .to_string();
508 assert!(err.contains("Missing Azure OpenAI API Key"));
509 }
510
511 #[tokio::test]
513 async fn test_hosted_hint_connection_refused_loopback() {
514 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
515 let port = listener.local_addr().unwrap().port();
516 drop(listener);
517 let client = AzureOpenAIClient::new_with_all(
518 "k".into(),
519 "dep".into(),
520 format!("http://127.0.0.1:{}", port),
521 "2025-04-01-preview".into(),
522 0.0,
523 16,
524 0,
525 0,
526 1,
527 );
528 let err = client
529 .chat_completion(vec![json!({"role":"user","content":"x"})])
530 .await
531 .unwrap_err();
532 let msg = err.to_string();
533 assert!(
534 msg.contains("ollama") && msg.contains("ai.provider"),
535 "expected local-provider hint: {msg}"
536 );
537 }
538
539 #[tokio::test]
542 async fn test_hosted_hint_http_200_non_openai_body() {
543 use wiremock::matchers::{method, path};
544 use wiremock::{Mock, MockServer, ResponseTemplate};
545 let server = MockServer::start().await;
546 Mock::given(method("POST"))
547 .and(path("/openai/deployments/dep/chat/completions"))
550 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hello": "world" })))
551 .mount(&server)
552 .await;
553 let client = AzureOpenAIClient::new_with_all(
554 "k".into(),
555 "dep".into(),
556 server.uri(),
557 "2025-04-01-preview".into(),
558 0.0,
559 16,
560 0,
561 0,
562 5,
563 );
564 let err = client
565 .chat_completion(vec![json!({"role":"user","content":"x"})])
566 .await
567 .unwrap_err();
568 let msg = err.to_string();
569 assert!(
570 msg.contains("Invalid API response format")
571 && msg.contains("ollama")
572 && msg.contains("ai.provider"),
573 "expected hint-bearing parse-shape error: {msg}"
574 );
575 }
576
577 #[tokio::test]
580 async fn test_hosted_hint_not_emitted_for_public_host() {
581 let client = AzureOpenAIClient::new_with_all(
582 "k".into(),
583 "dep".into(),
584 "https://192.0.2.1".into(),
585 "2025-04-01-preview".into(),
586 0.0,
587 16,
588 0,
589 0,
590 1,
591 );
592 let err = client
593 .chat_completion(vec![json!({"role":"user","content":"x"})])
594 .await
595 .unwrap_err();
596 let msg = err.to_string();
597 assert!(
598 !msg.contains("ollama"),
599 "public-host failure must NOT carry the hint: {msg}"
600 );
601 }
602}
603
604#[async_trait]
605impl AIProvider for AzureOpenAIClient {
606 async fn analyze_content(&self, request: AnalysisRequest) -> crate::Result<MatchResult> {
607 let prompt = self.build_analysis_prompt(&request);
608 let messages = vec![
609 json!({"role": "system", "content": "You are a professional subtitle matching assistant that can analyze the correspondence between video and subtitle files."}),
610 json!({"role": "user", "content": prompt}),
611 ];
612 let resp = self.chat_completion(messages).await?;
613 self.parse_match_result(&resp)
614 }
615
616 async fn verify_match(
617 &self,
618 verification: VerificationRequest,
619 ) -> crate::Result<ConfidenceScore> {
620 let prompt = self.build_verification_prompt(&verification);
621 let messages = vec![
622 json!({"role": "system", "content": "Please evaluate the confidence level of subtitle matching and provide a score between 0-1."}),
623 json!({"role": "user", "content": prompt}),
624 ];
625 let resp = self.chat_completion(messages).await?;
626 self.parse_confidence_score(&resp)
627 }
628
629 async fn chat_completion(&self, messages: Vec<Value>) -> crate::Result<String> {
630 AzureOpenAIClient::chat_completion(self, messages).await
631 }
632}