Skip to main content

aptu_core/ai/
provider.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! AI provider trait and shared implementations.
4//!
5//! Defines the `AiProvider` trait that all AI providers must implement,
6//! along with default implementations for shared logic like prompt building,
7//! request sending, and response parsing.
8
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use reqwest::Client;
12use secrecy::SecretString;
13use tracing::{debug, instrument};
14
15use super::AiResponse;
16use super::types::{
17    ChatCompletionRequest, ChatCompletionResponse, ChatMessage, IssueDetails, ResponseFormat,
18    TriageResponse,
19};
20use crate::history::AiStats;
21
22/// Parses JSON response from AI provider, detecting truncated responses.
23///
24/// If the JSON parsing fails with an EOF error (indicating the response was cut off),
25/// returns a `TruncatedResponse` error that can be retried. Other JSON errors are
26/// wrapped as `InvalidAIResponse`.
27///
28/// # Arguments
29///
30/// * `text` - The JSON text to parse
31/// * `provider` - The name of the AI provider (for error context)
32///
33/// # Returns
34///
35/// Parsed value of type T, or an error if parsing fails
36fn parse_ai_json<T: serde::de::DeserializeOwned>(text: &str, provider: &str) -> Result<T> {
37    match serde_json::from_str::<T>(text) {
38        Ok(value) => Ok(value),
39        Err(e) => {
40            // Check if this is an EOF error (truncated response)
41            if e.is_eof() {
42                Err(anyhow::anyhow!(
43                    crate::error::AptuError::TruncatedResponse {
44                        provider: provider.to_string(),
45                    }
46                ))
47            } else {
48                Err(anyhow::anyhow!(crate::error::AptuError::InvalidAIResponse(
49                    e
50                )))
51            }
52        }
53    }
54}
55
56/// Maximum length for issue body to stay within token limits.
57pub const MAX_BODY_LENGTH: usize = 4000;
58
59/// Maximum number of comments to include in the prompt.
60pub const MAX_COMMENTS: usize = 5;
61
62/// Maximum number of files to include in PR review prompt.
63pub const MAX_FILES: usize = 20;
64
65/// Maximum total diff size (in characters) for PR review prompt.
66pub const MAX_TOTAL_DIFF_SIZE: usize = 50_000;
67
68/// Maximum number of labels to include in the prompt.
69pub const MAX_LABELS: usize = 30;
70
71/// Maximum number of milestones to include in the prompt.
72pub const MAX_MILESTONES: usize = 10;
73
74/// AI provider trait for issue triage and creation.
75///
76/// Defines the interface that all AI providers must implement.
77/// Default implementations are provided for shared logic.
78#[async_trait]
79pub trait AiProvider: Send + Sync {
80    /// Returns the name of the provider (e.g., "gemini", "openrouter").
81    fn name(&self) -> &str;
82
83    /// Returns the API URL for this provider.
84    fn api_url(&self) -> &str;
85
86    /// Returns the environment variable name for the API key.
87    fn api_key_env(&self) -> &str;
88
89    /// Returns the HTTP client for making requests.
90    fn http_client(&self) -> &Client;
91
92    /// Returns the API key for authentication.
93    fn api_key(&self) -> &SecretString;
94
95    /// Returns the model name.
96    fn model(&self) -> &str;
97
98    /// Returns the maximum tokens for API responses.
99    fn max_tokens(&self) -> u32;
100
101    /// Returns the temperature for API requests.
102    fn temperature(&self) -> f32;
103
104    /// Returns the maximum retry attempts for rate-limited requests.
105    ///
106    /// Default implementation returns 3. Providers can override
107    /// to use a different retry limit.
108    fn max_attempts(&self) -> u32 {
109        3
110    }
111
112    /// Returns the circuit breaker for this provider (optional).
113    ///
114    /// Default implementation returns None. Providers can override
115    /// to provide circuit breaker functionality.
116    fn circuit_breaker(&self) -> Option<&super::CircuitBreaker> {
117        None
118    }
119
120    /// Builds HTTP headers for API requests.
121    ///
122    /// Default implementation includes Authorization and Content-Type headers.
123    /// Providers can override to add custom headers.
124    fn build_headers(&self) -> reqwest::header::HeaderMap {
125        let mut headers = reqwest::header::HeaderMap::new();
126        if let Ok(val) = "application/json".parse() {
127            headers.insert("Content-Type", val);
128        }
129        headers
130    }
131
132    /// Validates the model configuration.
133    ///
134    /// Default implementation does nothing. Providers can override
135    /// to enforce constraints (e.g., free tier validation).
136    fn validate_model(&self) -> Result<()> {
137        Ok(())
138    }
139
140    /// Sends a chat completion request to the provider's API (HTTP-only, no retry).
141    ///
142    /// Default implementation handles HTTP headers, error responses (401, 429).
143    /// Does not include retry logic - use `send_and_parse()` for retry behavior.
144    #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
145    async fn send_request_inner(
146        &self,
147        request: &ChatCompletionRequest,
148    ) -> Result<ChatCompletionResponse> {
149        use secrecy::ExposeSecret;
150        use tracing::warn;
151
152        use crate::error::AptuError;
153
154        let mut req = self.http_client().post(self.api_url());
155
156        // Add Authorization header
157        req = req.header(
158            "Authorization",
159            format!("Bearer {}", self.api_key().expose_secret()),
160        );
161
162        // Add custom headers from provider
163        for (key, value) in &self.build_headers() {
164            req = req.header(key.clone(), value.clone());
165        }
166
167        let response = req
168            .json(request)
169            .send()
170            .await
171            .context(format!("Failed to send request to {} API", self.name()))?;
172
173        // Check for HTTP errors
174        let status = response.status();
175        if !status.is_success() {
176            if status.as_u16() == 401 {
177                anyhow::bail!(
178                    "Invalid {} API key. Check your {} environment variable.",
179                    self.name(),
180                    self.api_key_env()
181                );
182            } else if status.as_u16() == 429 {
183                warn!("Rate limited by {} API", self.name());
184                // Parse Retry-After header (seconds), default to 0 if not present
185                let retry_after = response
186                    .headers()
187                    .get("Retry-After")
188                    .and_then(|h| h.to_str().ok())
189                    .and_then(|s| s.parse::<u64>().ok())
190                    .unwrap_or(0);
191                debug!(retry_after, "Parsed Retry-After header");
192                return Err(AptuError::RateLimited {
193                    provider: self.name().to_string(),
194                    retry_after,
195                }
196                .into());
197            }
198            let error_body = response.text().await.unwrap_or_default();
199            anyhow::bail!(
200                "{} API error (HTTP {}): {}",
201                self.name(),
202                status.as_u16(),
203                error_body
204            );
205        }
206
207        // Parse response
208        let completion: ChatCompletionResponse = response
209            .json()
210            .await
211            .context(format!("Failed to parse {} API response", self.name()))?;
212
213        Ok(completion)
214    }
215
216    /// Sends a chat completion request and parses the response with retry logic.
217    ///
218    /// This method wraps both HTTP request and JSON parsing in a single retry loop,
219    /// allowing truncated responses to be retried. Includes circuit breaker handling.
220    ///
221    /// # Arguments
222    ///
223    /// * `request` - The chat completion request to send
224    ///
225    /// # Returns
226    ///
227    /// A tuple of (parsed response, stats) extracted from the API response
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - API request fails (network, timeout, rate limit)
233    /// - Response cannot be parsed as valid JSON (including truncated responses)
234    #[instrument(skip(self, request), fields(provider = self.name(), model = self.model()))]
235    async fn send_and_parse<T: serde::de::DeserializeOwned + Send>(
236        &self,
237        request: &ChatCompletionRequest,
238    ) -> Result<(T, AiStats)> {
239        use tracing::{info, warn};
240
241        use crate::error::AptuError;
242        use crate::retry::{extract_retry_after, is_retryable_anyhow};
243
244        // Check circuit breaker before attempting request
245        if let Some(cb) = self.circuit_breaker()
246            && cb.is_open()
247        {
248            return Err(AptuError::CircuitOpen.into());
249        }
250
251        // Start timing (outside retry loop to measure total time including retries)
252        let start = std::time::Instant::now();
253
254        // Custom retry loop that respects retry_after from RateLimited errors
255        let mut attempt: u32 = 0;
256        let max_attempts: u32 = self.max_attempts();
257
258        // Helper function to avoid closure-in-expression clippy warning
259        #[allow(clippy::items_after_statements)]
260        async fn try_request<T: serde::de::DeserializeOwned>(
261            provider: &(impl AiProvider + ?Sized),
262            request: &ChatCompletionRequest,
263        ) -> Result<(T, ChatCompletionResponse)> {
264            // Send HTTP request
265            let completion = provider.send_request_inner(request).await?;
266
267            // Extract message content
268            let content = completion
269                .choices
270                .first()
271                .map(|c| c.message.content.clone())
272                .context("No response from AI model")?;
273
274            debug!(response_length = content.len(), "Received AI response");
275
276            // Parse JSON response (inside retry loop, so truncated responses are retried)
277            let parsed: T = parse_ai_json(&content, provider.name())?;
278
279            Ok((parsed, completion))
280        }
281
282        let (parsed, completion): (T, ChatCompletionResponse) = loop {
283            attempt += 1;
284
285            let result = try_request(self, request).await;
286
287            match result {
288                Ok(success) => break success,
289                Err(err) => {
290                    // Check if error is retryable
291                    if !is_retryable_anyhow(&err) || attempt >= max_attempts {
292                        return Err(err);
293                    }
294
295                    // Extract retry_after if present, otherwise use exponential backoff
296                    let delay = if let Some(retry_after_duration) = extract_retry_after(&err) {
297                        debug!(
298                            retry_after_secs = retry_after_duration.as_secs(),
299                            "Using Retry-After value from rate limit error"
300                        );
301                        retry_after_duration
302                    } else {
303                        // Use exponential backoff with jitter: 1s, 2s, 4s + 0-500ms
304                        let backoff_secs = 2_u64.pow(attempt.saturating_sub(1));
305                        let jitter_ms = fastrand::u64(0..500);
306                        std::time::Duration::from_millis(backoff_secs * 1000 + jitter_ms)
307                    };
308
309                    let error_msg = err.to_string();
310                    warn!(
311                        error = %error_msg,
312                        delay_secs = delay.as_secs(),
313                        attempt,
314                        max_attempts,
315                        "Retrying after error"
316                    );
317
318                    // Drop err before await to avoid holding non-Send value across await
319                    drop(err);
320                    tokio::time::sleep(delay).await;
321                }
322            }
323        };
324
325        // Record success in circuit breaker
326        if let Some(cb) = self.circuit_breaker() {
327            cb.record_success();
328        }
329
330        // Calculate duration (total time including any retries)
331        #[allow(clippy::cast_possible_truncation)]
332        let duration_ms = start.elapsed().as_millis() as u64;
333
334        // Build AI stats from usage info (trust API's cost field)
335        let (input_tokens, output_tokens, cost_usd) = if let Some(usage) = completion.usage {
336            (usage.prompt_tokens, usage.completion_tokens, usage.cost)
337        } else {
338            // If no usage info, default to 0
339            debug!("No usage information in API response");
340            (0, 0, None)
341        };
342
343        let ai_stats = AiStats {
344            provider: self.name().to_string(),
345            model: self.model().to_string(),
346            input_tokens,
347            output_tokens,
348            duration_ms,
349            cost_usd,
350            fallback_provider: None,
351        };
352
353        // Emit structured metrics
354        info!(
355            duration_ms,
356            input_tokens,
357            output_tokens,
358            cost_usd = ?cost_usd,
359            model = %self.model(),
360            "AI request completed"
361        );
362
363        Ok((parsed, ai_stats))
364    }
365
366    /// Analyzes a GitHub issue using the provider's API.
367    ///
368    /// Returns a structured triage response with summary, labels, questions, duplicates, and usage stats.
369    ///
370    /// # Arguments
371    ///
372    /// * `issue` - Issue details to analyze
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if:
377    /// - API request fails (network, timeout, rate limit)
378    /// - Response cannot be parsed as valid JSON
379    #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
380    async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
381        debug!(model = %self.model(), "Calling {} API", self.name());
382
383        // Build request
384        let request = ChatCompletionRequest {
385            model: self.model().to_string(),
386            messages: vec![
387                ChatMessage {
388                    role: "system".to_string(),
389                    content: Self::build_system_prompt(None),
390                },
391                ChatMessage {
392                    role: "user".to_string(),
393                    content: Self::build_user_prompt(issue),
394                },
395            ],
396            response_format: Some(ResponseFormat {
397                format_type: "json_object".to_string(),
398                json_schema: None,
399            }),
400            max_tokens: Some(self.max_tokens()),
401            temperature: Some(self.temperature()),
402        };
403
404        // Send request and parse JSON with retry logic
405        let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
406
407        debug!(
408            input_tokens = ai_stats.input_tokens,
409            output_tokens = ai_stats.output_tokens,
410            duration_ms = ai_stats.duration_ms,
411            cost_usd = ?ai_stats.cost_usd,
412            "AI analysis complete"
413        );
414
415        Ok(AiResponse {
416            triage,
417            stats: ai_stats,
418        })
419    }
420
421    /// Creates a formatted GitHub issue using the provider's API.
422    ///
423    /// Takes raw issue title and body, formats them using AI (conventional commit style,
424    /// structured body), and returns the formatted content with suggested labels.
425    ///
426    /// # Arguments
427    ///
428    /// * `title` - Raw issue title from user
429    /// * `body` - Raw issue body/description from user
430    /// * `repo` - Repository name for context (owner/repo format)
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if:
435    /// - API request fails (network, timeout, rate limit)
436    /// - Response cannot be parsed as valid JSON
437    #[instrument(skip(self), fields(repo = %repo))]
438    async fn create_issue(
439        &self,
440        title: &str,
441        body: &str,
442        repo: &str,
443    ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
444        debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
445
446        // Build request
447        let request = ChatCompletionRequest {
448            model: self.model().to_string(),
449            messages: vec![
450                ChatMessage {
451                    role: "system".to_string(),
452                    content: Self::build_create_system_prompt(None),
453                },
454                ChatMessage {
455                    role: "user".to_string(),
456                    content: Self::build_create_user_prompt(title, body, repo),
457                },
458            ],
459            response_format: Some(ResponseFormat {
460                format_type: "json_object".to_string(),
461                json_schema: None,
462            }),
463            max_tokens: Some(self.max_tokens()),
464            temperature: Some(self.temperature()),
465        };
466
467        // Send request and parse JSON with retry logic
468        let (create_response, ai_stats) = self
469            .send_and_parse::<super::types::CreateIssueResponse>(&request)
470            .await?;
471
472        debug!(
473            title_len = create_response.formatted_title.len(),
474            body_len = create_response.formatted_body.len(),
475            labels = create_response.suggested_labels.len(),
476            input_tokens = ai_stats.input_tokens,
477            output_tokens = ai_stats.output_tokens,
478            duration_ms = ai_stats.duration_ms,
479            "Issue formatting complete with stats"
480        );
481
482        Ok((create_response, ai_stats))
483    }
484
485    /// Builds the system prompt for issue triage.
486    #[must_use]
487    fn build_system_prompt(custom_guidance: Option<&str>) -> String {
488        let context = super::context::load_custom_guidance(custom_guidance);
489        let schema = "{\n  \"summary\": \"A 2-3 sentence summary of what the issue is about and its impact\",\n  \"suggested_labels\": [\"label1\", \"label2\"],\n  \"clarifying_questions\": [\"question1\", \"question2\"],\n  \"potential_duplicates\": [\"#123\", \"#456\"],\n  \"related_issues\": [\n    {\n      \"number\": 789,\n      \"title\": \"Related issue title\",\n      \"reason\": \"Brief explanation of why this is related\"\n    }\n  ],\n  \"status_note\": \"Optional note about issue status (e.g., claimed, in-progress)\",\n  \"contributor_guidance\": {\n    \"beginner_friendly\": true,\n    \"reasoning\": \"1-2 sentence explanation of beginner-friendliness assessment\"\n  },\n  \"implementation_approach\": \"Optional suggestions for implementation based on repository structure\",\n  \"suggested_milestone\": \"Optional milestone title for the issue\"\n}";
490        let guidelines = "Guidelines:\n\
491- summary: Concise explanation of the problem/request and why it matters\n\
492- suggested_labels: Prefer labels from the Available Labels list provided. Choose from: bug, enhancement, documentation, question, duplicate, invalid, wontfix. If a more specific label exists in the repository, use it instead of generic ones.\n\
493- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
494- potential_duplicates: Only include if you detect likely duplicates from the context. Leave empty array if none. A duplicate is an issue that describes the exact same problem.\n\
495- related_issues: Include issues from the search results that are contextually related but NOT duplicates. Provide brief reasoning for each. Leave empty array if none are relevant.\n\
496- status_note: Detect if someone has claimed the issue or is working on it. Look for patterns like \"I'd like to work on this\", \"I'll submit a PR\", \"working on this\", or \"@user I've assigned you\". If claimed, set status_note to a brief description (e.g., \"Issue claimed by @username\"). If not claimed, leave as null or empty string.\n\
497- contributor_guidance: Assess whether the issue is suitable for beginners. Consider: scope (small, well-defined), file count (few files to modify), required knowledge (no deep expertise needed), clarity (clear problem statement). Set beginner_friendly to true if all factors are favorable. Provide 1-2 sentence reasoning explaining the assessment.\n\
498- implementation_approach: Based on the repository structure provided, suggest specific files or modules to modify. Reference the file paths from the repository structure. Be concrete and actionable. Leave as null or empty string if no specific guidance can be provided.\n\
499- suggested_milestone: If applicable, suggest a milestone title from the Available Milestones list. Only include if a milestone is clearly relevant to the issue. Leave as null or empty string if no milestone is appropriate.\n\
500\n\
501Be helpful, concise, and actionable. Focus on what a maintainer needs to know.";
502        format!(
503            "You are an OSS issue triage assistant. Analyze the provided GitHub issue and \
504             provide structured triage information.\n\n{context}\n\nYour response MUST be valid \
505             JSON with this exact schema:\n{schema}\n\n{guidelines}"
506        )
507    }
508
509    /// Builds the user prompt containing the issue details.
510    #[must_use]
511    fn build_user_prompt(issue: &IssueDetails) -> String {
512        use std::fmt::Write;
513
514        let mut prompt = String::new();
515
516        prompt.push_str("<issue_content>\n");
517        let _ = writeln!(prompt, "Title: {}\n", issue.title);
518
519        // Truncate body if too long
520        let body = if issue.body.len() > MAX_BODY_LENGTH {
521            format!(
522                "{}...\n[Body truncated - original length: {} chars]",
523                &issue.body[..MAX_BODY_LENGTH],
524                issue.body.len()
525            )
526        } else if issue.body.is_empty() {
527            "[No description provided]".to_string()
528        } else {
529            issue.body.clone()
530        };
531        let _ = writeln!(prompt, "Body:\n{body}\n");
532
533        // Include existing labels
534        if !issue.labels.is_empty() {
535            let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
536        }
537
538        // Include recent comments (limited)
539        if !issue.comments.is_empty() {
540            prompt.push_str("Recent Comments:\n");
541            for comment in issue.comments.iter().take(MAX_COMMENTS) {
542                let comment_body = if comment.body.len() > 500 {
543                    format!("{}...", &comment.body[..500])
544                } else {
545                    comment.body.clone()
546                };
547                let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
548            }
549            prompt.push('\n');
550        }
551
552        // Include related issues from search (for context)
553        if !issue.repo_context.is_empty() {
554            prompt.push_str("Related Issues in Repository (for context):\n");
555            for related in issue.repo_context.iter().take(10) {
556                let _ = writeln!(
557                    prompt,
558                    "- #{} [{}] {}",
559                    related.number, related.state, related.title
560                );
561            }
562            prompt.push('\n');
563        }
564
565        // Include repository structure (source files)
566        if !issue.repo_tree.is_empty() {
567            prompt.push_str("Repository Structure (source files):\n");
568            for path in issue.repo_tree.iter().take(20) {
569                let _ = writeln!(prompt, "- {path}");
570            }
571            prompt.push('\n');
572        }
573
574        // Include available labels
575        if !issue.available_labels.is_empty() {
576            prompt.push_str("Available Labels:\n");
577            for label in issue.available_labels.iter().take(MAX_LABELS) {
578                let description = if label.description.is_empty() {
579                    String::new()
580                } else {
581                    format!(" - {}", label.description)
582                };
583                let _ = writeln!(
584                    prompt,
585                    "- {} (color: #{}){}",
586                    label.name, label.color, description
587                );
588            }
589            prompt.push('\n');
590        }
591
592        // Include available milestones
593        if !issue.available_milestones.is_empty() {
594            prompt.push_str("Available Milestones:\n");
595            for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
596                let description = if milestone.description.is_empty() {
597                    String::new()
598                } else {
599                    format!(" - {}", milestone.description)
600                };
601                let _ = writeln!(prompt, "- {}{}", milestone.title, description);
602            }
603            prompt.push('\n');
604        }
605
606        prompt.push_str("</issue_content>");
607
608        prompt
609    }
610
611    /// Builds the system prompt for issue creation/formatting.
612    #[must_use]
613    fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
614        let context = super::context::load_custom_guidance(custom_guidance);
615        format!(
616            r#"You are a GitHub issue formatting assistant. Your job is to take a raw issue title and body from a user and format them professionally for a GitHub repository.
617
618{context}
619
620Your response MUST be valid JSON with this exact schema:
621{{
622  "formatted_title": "Well-formatted issue title following conventional commit style",
623  "formatted_body": "Professionally formatted issue body with clear sections",
624  "suggested_labels": ["label1", "label2"]
625}}
626
627Guidelines:
628- formatted_title: Use conventional commit style (e.g., "feat: add search functionality", "fix: resolve memory leak in parser"). Keep it concise (under 72 characters). No period at the end.
629- formatted_body: Structure the body with clear sections:
630  * Start with a brief 1-2 sentence summary if not already present
631  * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
632  * Keep sentences clear and concise
633  * Use bullet points for lists
634  * Improve grammar and clarity
635  * Add relevant context if missing
636- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.
637
638Be professional but friendly. Maintain the user's intent while improving clarity and structure."#
639        )
640    }
641
642    /// Builds the user prompt for issue creation/formatting.
643    #[must_use]
644    fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
645        format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
646    }
647
648    /// Reviews a pull request using the provider's API.
649    ///
650    /// Analyzes PR metadata and file diffs to provide structured review feedback.
651    ///
652    /// # Arguments
653    ///
654    /// * `pr` - Pull request details including files and diffs
655    ///
656    /// # Errors
657    ///
658    /// Returns an error if:
659    /// - API request fails (network, timeout, rate limit)
660    /// - Response cannot be parsed as valid JSON
661    #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
662    async fn review_pr(
663        &self,
664        pr: &super::types::PrDetails,
665    ) -> Result<(super::types::PrReviewResponse, AiStats)> {
666        debug!(model = %self.model(), "Calling {} API for PR review", self.name());
667
668        // Build request
669        let request = ChatCompletionRequest {
670            model: self.model().to_string(),
671            messages: vec![
672                ChatMessage {
673                    role: "system".to_string(),
674                    content: Self::build_pr_review_system_prompt(None),
675                },
676                ChatMessage {
677                    role: "user".to_string(),
678                    content: Self::build_pr_review_user_prompt(pr),
679                },
680            ],
681            response_format: Some(ResponseFormat {
682                format_type: "json_object".to_string(),
683                json_schema: None,
684            }),
685            max_tokens: Some(self.max_tokens()),
686            temperature: Some(self.temperature()),
687        };
688
689        // Send request and parse JSON with retry logic
690        let (review, ai_stats) = self
691            .send_and_parse::<super::types::PrReviewResponse>(&request)
692            .await?;
693
694        debug!(
695            verdict = %review.verdict,
696            input_tokens = ai_stats.input_tokens,
697            output_tokens = ai_stats.output_tokens,
698            duration_ms = ai_stats.duration_ms,
699            "PR review complete with stats"
700        );
701
702        Ok((review, ai_stats))
703    }
704
705    /// Suggests labels for a pull request using the provider's API.
706    ///
707    /// Analyzes PR title, body, and file paths to suggest relevant labels.
708    ///
709    /// # Arguments
710    ///
711    /// * `title` - Pull request title
712    /// * `body` - Pull request description
713    /// * `file_paths` - List of file paths changed in the PR
714    ///
715    /// # Errors
716    ///
717    /// Returns an error if:
718    /// - API request fails (network, timeout, rate limit)
719    /// - Response cannot be parsed as valid JSON
720    #[instrument(skip(self), fields(title = %title))]
721    async fn suggest_pr_labels(
722        &self,
723        title: &str,
724        body: &str,
725        file_paths: &[String],
726    ) -> Result<(Vec<String>, AiStats)> {
727        debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
728
729        // Build request
730        let request = ChatCompletionRequest {
731            model: self.model().to_string(),
732            messages: vec![
733                ChatMessage {
734                    role: "system".to_string(),
735                    content: Self::build_pr_label_system_prompt(None),
736                },
737                ChatMessage {
738                    role: "user".to_string(),
739                    content: Self::build_pr_label_user_prompt(title, body, file_paths),
740                },
741            ],
742            response_format: Some(ResponseFormat {
743                format_type: "json_object".to_string(),
744                json_schema: None,
745            }),
746            max_tokens: Some(self.max_tokens()),
747            temperature: Some(self.temperature()),
748        };
749
750        // Send request and parse JSON with retry logic
751        let (response, ai_stats) = self
752            .send_and_parse::<super::types::PrLabelResponse>(&request)
753            .await?;
754
755        debug!(
756            label_count = response.suggested_labels.len(),
757            input_tokens = ai_stats.input_tokens,
758            output_tokens = ai_stats.output_tokens,
759            duration_ms = ai_stats.duration_ms,
760            "PR label suggestion complete with stats"
761        );
762
763        Ok((response.suggested_labels, ai_stats))
764    }
765
766    /// Builds the system prompt for PR review.
767    #[must_use]
768    fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
769        let context = super::context::load_custom_guidance(custom_guidance);
770        format!(
771            r#"You are a code review assistant. Analyze the provided pull request and provide structured review feedback.
772
773{context}
774
775Your response MUST be valid JSON with this exact schema:
776{{
777  "summary": "A 2-3 sentence summary of what the PR does and its impact",
778  "verdict": "approve|request_changes|comment",
779  "strengths": ["strength1", "strength2"],
780  "concerns": ["concern1", "concern2"],
781  "comments": [
782    {{
783      "file": "path/to/file.rs",
784      "line": 42,
785      "comment": "Specific feedback about this line",
786      "severity": "info|suggestion|warning|issue"
787    }}
788  ],
789  "suggestions": ["suggestion1", "suggestion2"],
790  "disclaimer": null
791}}
792
793Guidelines:
794- summary: Concise explanation of the changes and their purpose
795- verdict: Use "approve" for good PRs, "request_changes" for blocking issues, "comment" for feedback without blocking
796- strengths: What the PR does well (good patterns, clear code, etc.)
797- concerns: Potential issues or risks (bugs, performance, security, maintainability)
798- comments: Specific line-level feedback. Use severity:
799  - "info": Informational, no action needed
800  - "suggestion": Optional improvement
801  - "warning": Should consider changing
802  - "issue": Should be fixed before merge
803- suggestions: General improvements that are not blocking
804- disclaimer: Optional field. If the PR involves platform versions (iOS, Android, Node, Rust, Python, Java, etc.), include a disclaimer explaining that platform version validation may be inaccurate due to knowledge cutoffs. Otherwise, set to null.
805
806IMPORTANT - Platform Version Exclusions:
807DO NOT validate or flag platform versions (iOS, Android, Node, Rust, Python, Java, simulator availability, package versions, framework versions) as concerns or issues. These may be newer than your knowledge cutoff and flagging them creates false positives. If the PR involves platform versions, include a disclaimer field explaining that platform version validation was skipped due to knowledge cutoff limitations. Focus your review on code logic, patterns, and structure instead.
808
809Focus on:
8101. Correctness: Does the code do what it claims?
8112. Security: Any potential vulnerabilities?
8123. Performance: Any obvious inefficiencies?
8134. Maintainability: Is the code clear and well-structured?
8145. Testing: Are changes adequately tested?
815
816Be constructive and specific. Explain why something is an issue and how to fix it."#
817        )
818    }
819
820    /// Builds the user prompt for PR review.
821    #[must_use]
822    fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
823        use std::fmt::Write;
824
825        let mut prompt = String::new();
826
827        prompt.push_str("<pull_request>\n");
828        let _ = writeln!(prompt, "Title: {}\n", pr.title);
829        let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
830
831        // PR description
832        let body = if pr.body.is_empty() {
833            "[No description provided]".to_string()
834        } else if pr.body.len() > MAX_BODY_LENGTH {
835            format!(
836                "{}...\n[Description truncated - original length: {} chars]",
837                &pr.body[..MAX_BODY_LENGTH],
838                pr.body.len()
839            )
840        } else {
841            pr.body.clone()
842        };
843        let _ = writeln!(prompt, "Description:\n{body}\n");
844
845        // File changes with limits
846        prompt.push_str("Files Changed:\n");
847        let mut total_diff_size = 0;
848        let mut files_included = 0;
849        let mut files_skipped = 0;
850
851        for file in &pr.files {
852            // Check file count limit
853            if files_included >= MAX_FILES {
854                files_skipped += 1;
855                continue;
856            }
857
858            let _ = writeln!(
859                prompt,
860                "- {} ({}) +{} -{}\n",
861                file.filename, file.status, file.additions, file.deletions
862            );
863
864            // Include patch if available (truncate large patches)
865            if let Some(patch) = &file.patch {
866                const MAX_PATCH_LENGTH: usize = 2000;
867                let patch_content = if patch.len() > MAX_PATCH_LENGTH {
868                    format!(
869                        "{}...\n[Patch truncated - original length: {} chars]",
870                        &patch[..MAX_PATCH_LENGTH],
871                        patch.len()
872                    )
873                } else {
874                    patch.clone()
875                };
876
877                // Check if adding this patch would exceed total diff size limit
878                let patch_size = patch_content.len();
879                if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
880                    let _ = writeln!(
881                        prompt,
882                        "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
883                    );
884                    files_skipped += 1;
885                    continue;
886                }
887
888                let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
889                total_diff_size += patch_size;
890            }
891
892            files_included += 1;
893        }
894
895        // Add truncation message if files were skipped
896        if files_skipped > 0 {
897            let _ = writeln!(
898                prompt,
899                "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
900            );
901        }
902
903        prompt.push_str("</pull_request>");
904
905        prompt
906    }
907
908    /// Builds the system prompt for PR label suggestion.
909    #[must_use]
910    fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
911        let context = super::context::load_custom_guidance(custom_guidance);
912        format!(
913            r#"You are a GitHub label suggestion assistant. Analyze the provided pull request and suggest relevant labels.
914
915{context}
916
917Your response MUST be valid JSON with this exact schema:
918{{
919  "suggested_labels": ["label1", "label2", "label3"]
920}}
921
922Response format: json_object
923
924Guidelines:
925- suggested_labels: Suggest 1-3 relevant GitHub labels based on the PR content. Common labels include: bug, enhancement, documentation, feature, refactor, performance, security, testing, ci, dependencies. Choose labels that best describe the type of change.
926- Focus on the PR title, description, and file paths to determine appropriate labels.
927- Prefer specific labels over generic ones when possible.
928- Only suggest labels that are commonly used in GitHub repositories.
929
930Be concise and practical."#
931        )
932    }
933
934    /// Builds the user prompt for PR label suggestion.
935    #[must_use]
936    fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
937        use std::fmt::Write;
938
939        let mut prompt = String::new();
940
941        prompt.push_str("<pull_request>\n");
942        let _ = writeln!(prompt, "Title: {title}\n");
943
944        // PR description
945        let body_content = if body.is_empty() {
946            "[No description provided]".to_string()
947        } else if body.len() > MAX_BODY_LENGTH {
948            format!(
949                "{}...\n[Description truncated - original length: {} chars]",
950                &body[..MAX_BODY_LENGTH],
951                body.len()
952            )
953        } else {
954            body.to_string()
955        };
956        let _ = writeln!(prompt, "Description:\n{body_content}\n");
957
958        // File paths
959        if !file_paths.is_empty() {
960            prompt.push_str("Files Changed:\n");
961            for path in file_paths.iter().take(20) {
962                let _ = writeln!(prompt, "- {path}");
963            }
964            if file_paths.len() > 20 {
965                let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
966            }
967            prompt.push('\n');
968        }
969
970        prompt.push_str("</pull_request>");
971
972        prompt
973    }
974
975    /// Generate release notes from PR summaries.
976    ///
977    /// # Arguments
978    ///
979    /// * `prs` - List of PR summaries to synthesize
980    /// * `version` - Version being released
981    ///
982    /// # Returns
983    ///
984    /// Structured release notes with theme, highlights, and categorized changes.
985    #[instrument(skip(self, prs))]
986    async fn generate_release_notes(
987        &self,
988        prs: Vec<super::types::PrSummary>,
989        version: &str,
990    ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
991        let prompt = Self::build_release_notes_prompt(&prs, version);
992        let request = ChatCompletionRequest {
993            model: self.model().to_string(),
994            messages: vec![ChatMessage {
995                role: "user".to_string(),
996                content: prompt,
997            }],
998            response_format: Some(ResponseFormat {
999                format_type: "json_object".to_string(),
1000                json_schema: None,
1001            }),
1002            temperature: Some(0.7),
1003            max_tokens: Some(self.max_tokens()),
1004        };
1005
1006        let (parsed, ai_stats) = self
1007            .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
1008            .await?;
1009
1010        debug!(
1011            input_tokens = ai_stats.input_tokens,
1012            output_tokens = ai_stats.output_tokens,
1013            duration_ms = ai_stats.duration_ms,
1014            "Release notes generation complete with stats"
1015        );
1016
1017        Ok((parsed, ai_stats))
1018    }
1019
1020    /// Build the user prompt for release notes generation.
1021    #[must_use]
1022    fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
1023        let pr_list = prs
1024            .iter()
1025            .map(|pr| {
1026                format!(
1027                    "- #{}: {} (by @{})\n  {}",
1028                    pr.number,
1029                    pr.title,
1030                    pr.author,
1031                    pr.body.lines().next().unwrap_or("")
1032                )
1033            })
1034            .collect::<Vec<_>>()
1035            .join("\n");
1036
1037        format!(
1038            r#"Generate release notes for version {version} based on these merged PRs:
1039
1040{pr_list}
1041
1042Create a curated release notes document with:
10431. A theme/title that captures the essence of this release
10442. A 1-2 sentence narrative about the release
10453. 3-5 highlighted features
10464. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
10475. List of contributors
1048
1049Follow these conventions:
1050- No emojis
1051- Bold feature names with dash separator
1052- Include PR numbers in parentheses
1053- Group by user impact, not just commit type
1054- Filter CI/deps under Maintenance
1055
1056Your response MUST be valid JSON with this exact schema:
1057{{
1058  "theme": "Release theme title",
1059  "narrative": "1-2 sentence summary",
1060  "highlights": ["highlight1", "highlight2"],
1061  "features": ["feature1", "feature2"],
1062  "fixes": ["fix1", "fix2"],
1063  "improvements": ["improvement1"],
1064  "documentation": ["doc change1"],
1065  "maintenance": ["maintenance1"],
1066  "contributors": ["@author1", "@author2"]
1067}}"#
1068        )
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    struct TestProvider;
1077
1078    impl AiProvider for TestProvider {
1079        fn name(&self) -> &'static str {
1080            "test"
1081        }
1082
1083        fn api_url(&self) -> &'static str {
1084            "https://test.example.com"
1085        }
1086
1087        fn api_key_env(&self) -> &'static str {
1088            "TEST_API_KEY"
1089        }
1090
1091        fn http_client(&self) -> &Client {
1092            unimplemented!()
1093        }
1094
1095        fn api_key(&self) -> &SecretString {
1096            unimplemented!()
1097        }
1098
1099        fn model(&self) -> &'static str {
1100            "test-model"
1101        }
1102
1103        fn max_tokens(&self) -> u32 {
1104            2048
1105        }
1106
1107        fn temperature(&self) -> f32 {
1108            0.3
1109        }
1110    }
1111
1112    #[test]
1113    fn test_build_system_prompt_contains_json_schema() {
1114        let prompt = TestProvider::build_system_prompt(None);
1115        assert!(prompt.contains("summary"));
1116        assert!(prompt.contains("suggested_labels"));
1117        assert!(prompt.contains("clarifying_questions"));
1118        assert!(prompt.contains("potential_duplicates"));
1119        assert!(prompt.contains("status_note"));
1120    }
1121
1122    #[test]
1123    fn test_build_user_prompt_with_delimiters() {
1124        let issue = IssueDetails::builder()
1125            .owner("test".to_string())
1126            .repo("repo".to_string())
1127            .number(1)
1128            .title("Test issue".to_string())
1129            .body("This is the body".to_string())
1130            .labels(vec!["bug".to_string()])
1131            .comments(vec![])
1132            .url("https://github.com/test/repo/issues/1".to_string())
1133            .build();
1134
1135        let prompt = TestProvider::build_user_prompt(&issue);
1136        assert!(prompt.starts_with("<issue_content>"));
1137        assert!(prompt.ends_with("</issue_content>"));
1138        assert!(prompt.contains("Title: Test issue"));
1139        assert!(prompt.contains("This is the body"));
1140        assert!(prompt.contains("Existing Labels: bug"));
1141    }
1142
1143    #[test]
1144    fn test_build_user_prompt_truncates_long_body() {
1145        let long_body = "x".repeat(5000);
1146        let issue = IssueDetails::builder()
1147            .owner("test".to_string())
1148            .repo("repo".to_string())
1149            .number(1)
1150            .title("Test".to_string())
1151            .body(long_body)
1152            .labels(vec![])
1153            .comments(vec![])
1154            .url("https://github.com/test/repo/issues/1".to_string())
1155            .build();
1156
1157        let prompt = TestProvider::build_user_prompt(&issue);
1158        assert!(prompt.contains("[Body truncated"));
1159        assert!(prompt.contains("5000 chars"));
1160    }
1161
1162    #[test]
1163    fn test_build_user_prompt_empty_body() {
1164        let issue = IssueDetails::builder()
1165            .owner("test".to_string())
1166            .repo("repo".to_string())
1167            .number(1)
1168            .title("Test".to_string())
1169            .body(String::new())
1170            .labels(vec![])
1171            .comments(vec![])
1172            .url("https://github.com/test/repo/issues/1".to_string())
1173            .build();
1174
1175        let prompt = TestProvider::build_user_prompt(&issue);
1176        assert!(prompt.contains("[No description provided]"));
1177    }
1178
1179    #[test]
1180    fn test_build_create_system_prompt_contains_json_schema() {
1181        let prompt = TestProvider::build_create_system_prompt(None);
1182        assert!(prompt.contains("formatted_title"));
1183        assert!(prompt.contains("formatted_body"));
1184        assert!(prompt.contains("suggested_labels"));
1185    }
1186
1187    #[test]
1188    fn test_build_pr_review_user_prompt_respects_file_limit() {
1189        use super::super::types::{PrDetails, PrFile};
1190
1191        let mut files = Vec::new();
1192        for i in 0..25 {
1193            files.push(PrFile {
1194                filename: format!("file{i}.rs"),
1195                status: "modified".to_string(),
1196                additions: 10,
1197                deletions: 5,
1198                patch: Some(format!("patch content {i}")),
1199            });
1200        }
1201
1202        let pr = PrDetails {
1203            owner: "test".to_string(),
1204            repo: "repo".to_string(),
1205            number: 1,
1206            title: "Test PR".to_string(),
1207            body: "Description".to_string(),
1208            head_branch: "feature".to_string(),
1209            base_branch: "main".to_string(),
1210            url: "https://github.com/test/repo/pull/1".to_string(),
1211            files,
1212            labels: vec![],
1213        };
1214
1215        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1216        assert!(prompt.contains("files omitted due to size limits"));
1217        assert!(prompt.contains("MAX_FILES=20"));
1218    }
1219
1220    #[test]
1221    fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1222        use super::super::types::{PrDetails, PrFile};
1223
1224        // Create patches that will exceed the limit when combined
1225        // Each patch is ~30KB, so two will exceed 50KB limit
1226        let patch1 = "x".repeat(30_000);
1227        let patch2 = "y".repeat(30_000);
1228
1229        let files = vec![
1230            PrFile {
1231                filename: "file1.rs".to_string(),
1232                status: "modified".to_string(),
1233                additions: 100,
1234                deletions: 50,
1235                patch: Some(patch1),
1236            },
1237            PrFile {
1238                filename: "file2.rs".to_string(),
1239                status: "modified".to_string(),
1240                additions: 100,
1241                deletions: 50,
1242                patch: Some(patch2),
1243            },
1244        ];
1245
1246        let pr = PrDetails {
1247            owner: "test".to_string(),
1248            repo: "repo".to_string(),
1249            number: 1,
1250            title: "Test PR".to_string(),
1251            body: "Description".to_string(),
1252            head_branch: "feature".to_string(),
1253            base_branch: "main".to_string(),
1254            url: "https://github.com/test/repo/pull/1".to_string(),
1255            files,
1256            labels: vec![],
1257        };
1258
1259        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1260        // Both files should be listed
1261        assert!(prompt.contains("file1.rs"));
1262        assert!(prompt.contains("file2.rs"));
1263        // The second patch should be limited - verify the prompt doesn't contain both full patches
1264        // by checking that the total size is less than what two full 30KB patches would be
1265        assert!(prompt.len() < 65_000);
1266    }
1267
1268    #[test]
1269    fn test_build_pr_review_user_prompt_with_no_patches() {
1270        use super::super::types::{PrDetails, PrFile};
1271
1272        let files = vec![PrFile {
1273            filename: "file1.rs".to_string(),
1274            status: "added".to_string(),
1275            additions: 10,
1276            deletions: 0,
1277            patch: None,
1278        }];
1279
1280        let pr = PrDetails {
1281            owner: "test".to_string(),
1282            repo: "repo".to_string(),
1283            number: 1,
1284            title: "Test PR".to_string(),
1285            body: "Description".to_string(),
1286            head_branch: "feature".to_string(),
1287            base_branch: "main".to_string(),
1288            url: "https://github.com/test/repo/pull/1".to_string(),
1289            files,
1290            labels: vec![],
1291        };
1292
1293        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1294        assert!(prompt.contains("file1.rs"));
1295        assert!(prompt.contains("added"));
1296        assert!(!prompt.contains("files omitted"));
1297    }
1298
1299    #[test]
1300    fn test_build_pr_label_system_prompt_contains_json_schema() {
1301        let prompt = TestProvider::build_pr_label_system_prompt(None);
1302        assert!(prompt.contains("suggested_labels"));
1303        assert!(prompt.contains("json_object"));
1304        assert!(prompt.contains("bug"));
1305        assert!(prompt.contains("enhancement"));
1306    }
1307
1308    #[test]
1309    fn test_build_pr_label_user_prompt_with_title_and_body() {
1310        let title = "feat: add new feature";
1311        let body = "This PR adds a new feature";
1312        let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1313
1314        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1315        assert!(prompt.starts_with("<pull_request>"));
1316        assert!(prompt.ends_with("</pull_request>"));
1317        assert!(prompt.contains("feat: add new feature"));
1318        assert!(prompt.contains("This PR adds a new feature"));
1319        assert!(prompt.contains("src/main.rs"));
1320        assert!(prompt.contains("tests/test.rs"));
1321    }
1322
1323    #[test]
1324    fn test_build_pr_label_user_prompt_empty_body() {
1325        let title = "fix: bug fix";
1326        let body = "";
1327        let files = vec!["src/lib.rs".to_string()];
1328
1329        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1330        assert!(prompt.contains("[No description provided]"));
1331        assert!(prompt.contains("src/lib.rs"));
1332    }
1333
1334    #[test]
1335    fn test_build_pr_label_user_prompt_truncates_long_body() {
1336        let title = "test";
1337        let long_body = "x".repeat(5000);
1338        let files = vec![];
1339
1340        let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1341        assert!(prompt.contains("[Description truncated"));
1342        assert!(prompt.contains("5000 chars"));
1343    }
1344
1345    #[test]
1346    fn test_build_pr_label_user_prompt_respects_file_limit() {
1347        let title = "test";
1348        let body = "test";
1349        let mut files = Vec::new();
1350        for i in 0..25 {
1351            files.push(format!("file{i}.rs"));
1352        }
1353
1354        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1355        assert!(prompt.contains("file0.rs"));
1356        assert!(prompt.contains("file19.rs"));
1357        assert!(!prompt.contains("file20.rs"));
1358        assert!(prompt.contains("... and 5 more files"));
1359    }
1360
1361    #[test]
1362    fn test_build_pr_label_user_prompt_empty_files() {
1363        let title = "test";
1364        let body = "test";
1365        let files: Vec<String> = vec![];
1366
1367        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1368        assert!(prompt.contains("Title: test"));
1369        assert!(prompt.contains("Description:\ntest"));
1370        assert!(!prompt.contains("Files Changed:"));
1371    }
1372
1373    #[test]
1374    fn test_parse_ai_json_with_valid_json() {
1375        #[derive(serde::Deserialize)]
1376        struct TestResponse {
1377            message: String,
1378        }
1379
1380        let json = r#"{"message": "hello"}"#;
1381        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1382        assert!(result.is_ok());
1383        let response = result.unwrap();
1384        assert_eq!(response.message, "hello");
1385    }
1386
1387    #[test]
1388    fn test_parse_ai_json_with_truncated_json() {
1389        #[derive(Debug, serde::Deserialize)]
1390        #[allow(dead_code)]
1391        struct TestResponse {
1392            message: String,
1393        }
1394
1395        let json = r#"{"message": "hello"#;
1396        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1397        assert!(result.is_err());
1398        let err = result.unwrap_err();
1399        assert!(
1400            err.to_string()
1401                .contains("Truncated response from test-provider")
1402        );
1403    }
1404
1405    #[test]
1406    fn test_parse_ai_json_with_malformed_json() {
1407        #[derive(Debug, serde::Deserialize)]
1408        #[allow(dead_code)]
1409        struct TestResponse {
1410            message: String,
1411        }
1412
1413        let json = r#"{"message": invalid}"#;
1414        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1415        assert!(result.is_err());
1416        let err = result.unwrap_err();
1417        assert!(err.to_string().contains("Invalid JSON response from AI"));
1418    }
1419}