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            model: self.model().to_string(),
345            input_tokens,
346            output_tokens,
347            duration_ms,
348            cost_usd,
349            fallback_provider: None,
350        };
351
352        // Emit structured metrics
353        info!(
354            duration_ms,
355            input_tokens,
356            output_tokens,
357            cost_usd = ?cost_usd,
358            model = %self.model(),
359            "AI request completed"
360        );
361
362        Ok((parsed, ai_stats))
363    }
364
365    /// Analyzes a GitHub issue using the provider's API.
366    ///
367    /// Returns a structured triage response with summary, labels, questions, duplicates, and usage stats.
368    ///
369    /// # Arguments
370    ///
371    /// * `issue` - Issue details to analyze
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if:
376    /// - API request fails (network, timeout, rate limit)
377    /// - Response cannot be parsed as valid JSON
378    #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
379    async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
380        debug!(model = %self.model(), "Calling {} API", self.name());
381
382        // Build request
383        let request = ChatCompletionRequest {
384            model: self.model().to_string(),
385            messages: vec![
386                ChatMessage {
387                    role: "system".to_string(),
388                    content: Self::build_system_prompt(None),
389                },
390                ChatMessage {
391                    role: "user".to_string(),
392                    content: Self::build_user_prompt(issue),
393                },
394            ],
395            response_format: Some(ResponseFormat {
396                format_type: "json_object".to_string(),
397                json_schema: None,
398            }),
399            max_tokens: Some(self.max_tokens()),
400            temperature: Some(self.temperature()),
401        };
402
403        // Send request and parse JSON with retry logic
404        let (triage, ai_stats) = self.send_and_parse::<TriageResponse>(&request).await?;
405
406        debug!(
407            input_tokens = ai_stats.input_tokens,
408            output_tokens = ai_stats.output_tokens,
409            duration_ms = ai_stats.duration_ms,
410            cost_usd = ?ai_stats.cost_usd,
411            "AI analysis complete"
412        );
413
414        Ok(AiResponse {
415            triage,
416            stats: ai_stats,
417        })
418    }
419
420    /// Creates a formatted GitHub issue using the provider's API.
421    ///
422    /// Takes raw issue title and body, formats them using AI (conventional commit style,
423    /// structured body), and returns the formatted content with suggested labels.
424    ///
425    /// # Arguments
426    ///
427    /// * `title` - Raw issue title from user
428    /// * `body` - Raw issue body/description from user
429    /// * `repo` - Repository name for context (owner/repo format)
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if:
434    /// - API request fails (network, timeout, rate limit)
435    /// - Response cannot be parsed as valid JSON
436    #[instrument(skip(self), fields(repo = %repo))]
437    async fn create_issue(
438        &self,
439        title: &str,
440        body: &str,
441        repo: &str,
442    ) -> Result<(super::types::CreateIssueResponse, AiStats)> {
443        debug!(model = %self.model(), "Calling {} API for issue creation", self.name());
444
445        // Build request
446        let request = ChatCompletionRequest {
447            model: self.model().to_string(),
448            messages: vec![
449                ChatMessage {
450                    role: "system".to_string(),
451                    content: Self::build_create_system_prompt(None),
452                },
453                ChatMessage {
454                    role: "user".to_string(),
455                    content: Self::build_create_user_prompt(title, body, repo),
456                },
457            ],
458            response_format: Some(ResponseFormat {
459                format_type: "json_object".to_string(),
460                json_schema: None,
461            }),
462            max_tokens: Some(self.max_tokens()),
463            temperature: Some(self.temperature()),
464        };
465
466        // Send request and parse JSON with retry logic
467        let (create_response, ai_stats) = self
468            .send_and_parse::<super::types::CreateIssueResponse>(&request)
469            .await?;
470
471        debug!(
472            title_len = create_response.formatted_title.len(),
473            body_len = create_response.formatted_body.len(),
474            labels = create_response.suggested_labels.len(),
475            input_tokens = ai_stats.input_tokens,
476            output_tokens = ai_stats.output_tokens,
477            duration_ms = ai_stats.duration_ms,
478            "Issue formatting complete with stats"
479        );
480
481        Ok((create_response, ai_stats))
482    }
483
484    /// Builds the system prompt for issue triage.
485    #[must_use]
486    fn build_system_prompt(custom_guidance: Option<&str>) -> String {
487        let context = super::context::load_custom_guidance(custom_guidance);
488        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}";
489        let guidelines = "Guidelines:\n\
490- summary: Concise explanation of the problem/request and why it matters\n\
491- 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\
492- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.\n\
493- 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\
494- 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\
495- 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\
496- 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\
497- 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\
498- 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\
499\n\
500Be helpful, concise, and actionable. Focus on what a maintainer needs to know.";
501        format!(
502            "You are an OSS issue triage assistant. Analyze the provided GitHub issue and \
503             provide structured triage information.\n\n{context}\n\nYour response MUST be valid \
504             JSON with this exact schema:\n{schema}\n\n{guidelines}"
505        )
506    }
507
508    /// Builds the user prompt containing the issue details.
509    #[must_use]
510    fn build_user_prompt(issue: &IssueDetails) -> String {
511        use std::fmt::Write;
512
513        let mut prompt = String::new();
514
515        prompt.push_str("<issue_content>\n");
516        let _ = writeln!(prompt, "Title: {}\n", issue.title);
517
518        // Truncate body if too long
519        let body = if issue.body.len() > MAX_BODY_LENGTH {
520            format!(
521                "{}...\n[Body truncated - original length: {} chars]",
522                &issue.body[..MAX_BODY_LENGTH],
523                issue.body.len()
524            )
525        } else if issue.body.is_empty() {
526            "[No description provided]".to_string()
527        } else {
528            issue.body.clone()
529        };
530        let _ = writeln!(prompt, "Body:\n{body}\n");
531
532        // Include existing labels
533        if !issue.labels.is_empty() {
534            let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
535        }
536
537        // Include recent comments (limited)
538        if !issue.comments.is_empty() {
539            prompt.push_str("Recent Comments:\n");
540            for comment in issue.comments.iter().take(MAX_COMMENTS) {
541                let comment_body = if comment.body.len() > 500 {
542                    format!("{}...", &comment.body[..500])
543                } else {
544                    comment.body.clone()
545                };
546                let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
547            }
548            prompt.push('\n');
549        }
550
551        // Include related issues from search (for context)
552        if !issue.repo_context.is_empty() {
553            prompt.push_str("Related Issues in Repository (for context):\n");
554            for related in issue.repo_context.iter().take(10) {
555                let _ = writeln!(
556                    prompt,
557                    "- #{} [{}] {}",
558                    related.number, related.state, related.title
559                );
560            }
561            prompt.push('\n');
562        }
563
564        // Include repository structure (source files)
565        if !issue.repo_tree.is_empty() {
566            prompt.push_str("Repository Structure (source files):\n");
567            for path in issue.repo_tree.iter().take(20) {
568                let _ = writeln!(prompt, "- {path}");
569            }
570            prompt.push('\n');
571        }
572
573        // Include available labels
574        if !issue.available_labels.is_empty() {
575            prompt.push_str("Available Labels:\n");
576            for label in issue.available_labels.iter().take(MAX_LABELS) {
577                let description = if label.description.is_empty() {
578                    String::new()
579                } else {
580                    format!(" - {}", label.description)
581                };
582                let _ = writeln!(
583                    prompt,
584                    "- {} (color: #{}){}",
585                    label.name, label.color, description
586                );
587            }
588            prompt.push('\n');
589        }
590
591        // Include available milestones
592        if !issue.available_milestones.is_empty() {
593            prompt.push_str("Available Milestones:\n");
594            for milestone in issue.available_milestones.iter().take(MAX_MILESTONES) {
595                let description = if milestone.description.is_empty() {
596                    String::new()
597                } else {
598                    format!(" - {}", milestone.description)
599                };
600                let _ = writeln!(prompt, "- {}{}", milestone.title, description);
601            }
602            prompt.push('\n');
603        }
604
605        prompt.push_str("</issue_content>");
606
607        prompt
608    }
609
610    /// Builds the system prompt for issue creation/formatting.
611    #[must_use]
612    fn build_create_system_prompt(custom_guidance: Option<&str>) -> String {
613        let context = super::context::load_custom_guidance(custom_guidance);
614        format!(
615            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.
616
617{context}
618
619Your response MUST be valid JSON with this exact schema:
620{{
621  "formatted_title": "Well-formatted issue title following conventional commit style",
622  "formatted_body": "Professionally formatted issue body with clear sections",
623  "suggested_labels": ["label1", "label2"]
624}}
625
626Guidelines:
627- 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.
628- formatted_body: Structure the body with clear sections:
629  * Start with a brief 1-2 sentence summary if not already present
630  * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
631  * Keep sentences clear and concise
632  * Use bullet points for lists
633  * Improve grammar and clarity
634  * Add relevant context if missing
635- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, duplicate, invalid, wontfix. Choose based on the issue content.
636
637Be professional but friendly. Maintain the user's intent while improving clarity and structure."#
638        )
639    }
640
641    /// Builds the user prompt for issue creation/formatting.
642    #[must_use]
643    fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
644        format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
645    }
646
647    /// Reviews a pull request using the provider's API.
648    ///
649    /// Analyzes PR metadata and file diffs to provide structured review feedback.
650    ///
651    /// # Arguments
652    ///
653    /// * `pr` - Pull request details including files and diffs
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if:
658    /// - API request fails (network, timeout, rate limit)
659    /// - Response cannot be parsed as valid JSON
660    #[instrument(skip(self, pr), fields(pr_number = pr.number, repo = %format!("{}/{}", pr.owner, pr.repo)))]
661    async fn review_pr(
662        &self,
663        pr: &super::types::PrDetails,
664    ) -> Result<(super::types::PrReviewResponse, AiStats)> {
665        debug!(model = %self.model(), "Calling {} API for PR review", self.name());
666
667        // Build request
668        let request = ChatCompletionRequest {
669            model: self.model().to_string(),
670            messages: vec![
671                ChatMessage {
672                    role: "system".to_string(),
673                    content: Self::build_pr_review_system_prompt(None),
674                },
675                ChatMessage {
676                    role: "user".to_string(),
677                    content: Self::build_pr_review_user_prompt(pr),
678                },
679            ],
680            response_format: Some(ResponseFormat {
681                format_type: "json_object".to_string(),
682                json_schema: None,
683            }),
684            max_tokens: Some(self.max_tokens()),
685            temperature: Some(self.temperature()),
686        };
687
688        // Send request and parse JSON with retry logic
689        let (review, ai_stats) = self
690            .send_and_parse::<super::types::PrReviewResponse>(&request)
691            .await?;
692
693        debug!(
694            verdict = %review.verdict,
695            input_tokens = ai_stats.input_tokens,
696            output_tokens = ai_stats.output_tokens,
697            duration_ms = ai_stats.duration_ms,
698            "PR review complete with stats"
699        );
700
701        Ok((review, ai_stats))
702    }
703
704    /// Suggests labels for a pull request using the provider's API.
705    ///
706    /// Analyzes PR title, body, and file paths to suggest relevant labels.
707    ///
708    /// # Arguments
709    ///
710    /// * `title` - Pull request title
711    /// * `body` - Pull request description
712    /// * `file_paths` - List of file paths changed in the PR
713    ///
714    /// # Errors
715    ///
716    /// Returns an error if:
717    /// - API request fails (network, timeout, rate limit)
718    /// - Response cannot be parsed as valid JSON
719    #[instrument(skip(self), fields(title = %title))]
720    async fn suggest_pr_labels(
721        &self,
722        title: &str,
723        body: &str,
724        file_paths: &[String],
725    ) -> Result<(Vec<String>, AiStats)> {
726        debug!(model = %self.model(), "Calling {} API for PR label suggestion", self.name());
727
728        // Build request
729        let request = ChatCompletionRequest {
730            model: self.model().to_string(),
731            messages: vec![
732                ChatMessage {
733                    role: "system".to_string(),
734                    content: Self::build_pr_label_system_prompt(None),
735                },
736                ChatMessage {
737                    role: "user".to_string(),
738                    content: Self::build_pr_label_user_prompt(title, body, file_paths),
739                },
740            ],
741            response_format: Some(ResponseFormat {
742                format_type: "json_object".to_string(),
743                json_schema: None,
744            }),
745            max_tokens: Some(self.max_tokens()),
746            temperature: Some(self.temperature()),
747        };
748
749        // Send request and parse JSON with retry logic
750        let (response, ai_stats) = self
751            .send_and_parse::<super::types::PrLabelResponse>(&request)
752            .await?;
753
754        debug!(
755            label_count = response.suggested_labels.len(),
756            input_tokens = ai_stats.input_tokens,
757            output_tokens = ai_stats.output_tokens,
758            duration_ms = ai_stats.duration_ms,
759            "PR label suggestion complete with stats"
760        );
761
762        Ok((response.suggested_labels, ai_stats))
763    }
764
765    /// Builds the system prompt for PR review.
766    #[must_use]
767    fn build_pr_review_system_prompt(custom_guidance: Option<&str>) -> String {
768        let context = super::context::load_custom_guidance(custom_guidance);
769        format!(
770            r#"You are a code review assistant. Analyze the provided pull request and provide structured review feedback.
771
772{context}
773
774Your response MUST be valid JSON with this exact schema:
775{{
776  "summary": "A 2-3 sentence summary of what the PR does and its impact",
777  "verdict": "approve|request_changes|comment",
778  "strengths": ["strength1", "strength2"],
779  "concerns": ["concern1", "concern2"],
780  "comments": [
781    {{
782      "file": "path/to/file.rs",
783      "line": 42,
784      "comment": "Specific feedback about this line",
785      "severity": "info|suggestion|warning|issue"
786    }}
787  ],
788  "suggestions": ["suggestion1", "suggestion2"],
789  "disclaimer": null
790}}
791
792Guidelines:
793- summary: Concise explanation of the changes and their purpose
794- verdict: Use "approve" for good PRs, "request_changes" for blocking issues, "comment" for feedback without blocking
795- strengths: What the PR does well (good patterns, clear code, etc.)
796- concerns: Potential issues or risks (bugs, performance, security, maintainability)
797- comments: Specific line-level feedback. Use severity:
798  - "info": Informational, no action needed
799  - "suggestion": Optional improvement
800  - "warning": Should consider changing
801  - "issue": Should be fixed before merge
802- suggestions: General improvements that are not blocking
803- 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.
804
805IMPORTANT - Platform Version Exclusions:
806DO 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.
807
808Focus on:
8091. Correctness: Does the code do what it claims?
8102. Security: Any potential vulnerabilities?
8113. Performance: Any obvious inefficiencies?
8124. Maintainability: Is the code clear and well-structured?
8135. Testing: Are changes adequately tested?
814
815Be constructive and specific. Explain why something is an issue and how to fix it."#
816        )
817    }
818
819    /// Builds the user prompt for PR review.
820    #[must_use]
821    fn build_pr_review_user_prompt(pr: &super::types::PrDetails) -> String {
822        use std::fmt::Write;
823
824        let mut prompt = String::new();
825
826        prompt.push_str("<pull_request>\n");
827        let _ = writeln!(prompt, "Title: {}\n", pr.title);
828        let _ = writeln!(prompt, "Branch: {} -> {}\n", pr.head_branch, pr.base_branch);
829
830        // PR description
831        let body = if pr.body.is_empty() {
832            "[No description provided]".to_string()
833        } else if pr.body.len() > MAX_BODY_LENGTH {
834            format!(
835                "{}...\n[Description truncated - original length: {} chars]",
836                &pr.body[..MAX_BODY_LENGTH],
837                pr.body.len()
838            )
839        } else {
840            pr.body.clone()
841        };
842        let _ = writeln!(prompt, "Description:\n{body}\n");
843
844        // File changes with limits
845        prompt.push_str("Files Changed:\n");
846        let mut total_diff_size = 0;
847        let mut files_included = 0;
848        let mut files_skipped = 0;
849
850        for file in &pr.files {
851            // Check file count limit
852            if files_included >= MAX_FILES {
853                files_skipped += 1;
854                continue;
855            }
856
857            let _ = writeln!(
858                prompt,
859                "- {} ({}) +{} -{}\n",
860                file.filename, file.status, file.additions, file.deletions
861            );
862
863            // Include patch if available (truncate large patches)
864            if let Some(patch) = &file.patch {
865                const MAX_PATCH_LENGTH: usize = 2000;
866                let patch_content = if patch.len() > MAX_PATCH_LENGTH {
867                    format!(
868                        "{}...\n[Patch truncated - original length: {} chars]",
869                        &patch[..MAX_PATCH_LENGTH],
870                        patch.len()
871                    )
872                } else {
873                    patch.clone()
874                };
875
876                // Check if adding this patch would exceed total diff size limit
877                let patch_size = patch_content.len();
878                if total_diff_size + patch_size > MAX_TOTAL_DIFF_SIZE {
879                    let _ = writeln!(
880                        prompt,
881                        "```diff\n[Patch omitted - total diff size limit reached]\n```\n"
882                    );
883                    files_skipped += 1;
884                    continue;
885                }
886
887                let _ = writeln!(prompt, "```diff\n{patch_content}\n```\n");
888                total_diff_size += patch_size;
889            }
890
891            files_included += 1;
892        }
893
894        // Add truncation message if files were skipped
895        if files_skipped > 0 {
896            let _ = writeln!(
897                prompt,
898                "\n[{files_skipped} files omitted due to size limits (MAX_FILES={MAX_FILES}, MAX_TOTAL_DIFF_SIZE={MAX_TOTAL_DIFF_SIZE})]"
899            );
900        }
901
902        prompt.push_str("</pull_request>");
903
904        prompt
905    }
906
907    /// Builds the system prompt for PR label suggestion.
908    #[must_use]
909    fn build_pr_label_system_prompt(custom_guidance: Option<&str>) -> String {
910        let context = super::context::load_custom_guidance(custom_guidance);
911        format!(
912            r#"You are a GitHub label suggestion assistant. Analyze the provided pull request and suggest relevant labels.
913
914{context}
915
916Your response MUST be valid JSON with this exact schema:
917{{
918  "suggested_labels": ["label1", "label2", "label3"]
919}}
920
921Response format: json_object
922
923Guidelines:
924- 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.
925- Focus on the PR title, description, and file paths to determine appropriate labels.
926- Prefer specific labels over generic ones when possible.
927- Only suggest labels that are commonly used in GitHub repositories.
928
929Be concise and practical."#
930        )
931    }
932
933    /// Builds the user prompt for PR label suggestion.
934    #[must_use]
935    fn build_pr_label_user_prompt(title: &str, body: &str, file_paths: &[String]) -> String {
936        use std::fmt::Write;
937
938        let mut prompt = String::new();
939
940        prompt.push_str("<pull_request>\n");
941        let _ = writeln!(prompt, "Title: {title}\n");
942
943        // PR description
944        let body_content = if body.is_empty() {
945            "[No description provided]".to_string()
946        } else if body.len() > MAX_BODY_LENGTH {
947            format!(
948                "{}...\n[Description truncated - original length: {} chars]",
949                &body[..MAX_BODY_LENGTH],
950                body.len()
951            )
952        } else {
953            body.to_string()
954        };
955        let _ = writeln!(prompt, "Description:\n{body_content}\n");
956
957        // File paths
958        if !file_paths.is_empty() {
959            prompt.push_str("Files Changed:\n");
960            for path in file_paths.iter().take(20) {
961                let _ = writeln!(prompt, "- {path}");
962            }
963            if file_paths.len() > 20 {
964                let _ = writeln!(prompt, "- ... and {} more files", file_paths.len() - 20);
965            }
966            prompt.push('\n');
967        }
968
969        prompt.push_str("</pull_request>");
970
971        prompt
972    }
973
974    /// Generate release notes from PR summaries.
975    ///
976    /// # Arguments
977    ///
978    /// * `prs` - List of PR summaries to synthesize
979    /// * `version` - Version being released
980    ///
981    /// # Returns
982    ///
983    /// Structured release notes with theme, highlights, and categorized changes.
984    #[instrument(skip(self, prs))]
985    async fn generate_release_notes(
986        &self,
987        prs: Vec<super::types::PrSummary>,
988        version: &str,
989    ) -> Result<(super::types::ReleaseNotesResponse, AiStats)> {
990        let prompt = Self::build_release_notes_prompt(&prs, version);
991        let request = ChatCompletionRequest {
992            model: self.model().to_string(),
993            messages: vec![ChatMessage {
994                role: "user".to_string(),
995                content: prompt,
996            }],
997            response_format: Some(ResponseFormat {
998                format_type: "json_object".to_string(),
999                json_schema: None,
1000            }),
1001            temperature: Some(0.7),
1002            max_tokens: Some(self.max_tokens()),
1003        };
1004
1005        let (parsed, ai_stats) = self
1006            .send_and_parse::<super::types::ReleaseNotesResponse>(&request)
1007            .await?;
1008
1009        debug!(
1010            input_tokens = ai_stats.input_tokens,
1011            output_tokens = ai_stats.output_tokens,
1012            duration_ms = ai_stats.duration_ms,
1013            "Release notes generation complete with stats"
1014        );
1015
1016        Ok((parsed, ai_stats))
1017    }
1018
1019    /// Build the user prompt for release notes generation.
1020    #[must_use]
1021    fn build_release_notes_prompt(prs: &[super::types::PrSummary], version: &str) -> String {
1022        let pr_list = prs
1023            .iter()
1024            .map(|pr| {
1025                format!(
1026                    "- #{}: {} (by @{})\n  {}",
1027                    pr.number,
1028                    pr.title,
1029                    pr.author,
1030                    pr.body.lines().next().unwrap_or("")
1031                )
1032            })
1033            .collect::<Vec<_>>()
1034            .join("\n");
1035
1036        format!(
1037            r#"Generate release notes for version {version} based on these merged PRs:
1038
1039{pr_list}
1040
1041Create a curated release notes document with:
10421. A theme/title that captures the essence of this release
10432. A 1-2 sentence narrative about the release
10443. 3-5 highlighted features
10454. Categorized changes: Features, Fixes, Improvements, Documentation, Maintenance
10465. List of contributors
1047
1048Follow these conventions:
1049- No emojis
1050- Bold feature names with dash separator
1051- Include PR numbers in parentheses
1052- Group by user impact, not just commit type
1053- Filter CI/deps under Maintenance
1054
1055Your response MUST be valid JSON with this exact schema:
1056{{
1057  "theme": "Release theme title",
1058  "narrative": "1-2 sentence summary",
1059  "highlights": ["highlight1", "highlight2"],
1060  "features": ["feature1", "feature2"],
1061  "fixes": ["fix1", "fix2"],
1062  "improvements": ["improvement1"],
1063  "documentation": ["doc change1"],
1064  "maintenance": ["maintenance1"],
1065  "contributors": ["@author1", "@author2"]
1066}}"#
1067        )
1068    }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    struct TestProvider;
1076
1077    impl AiProvider for TestProvider {
1078        fn name(&self) -> &'static str {
1079            "test"
1080        }
1081
1082        fn api_url(&self) -> &'static str {
1083            "https://test.example.com"
1084        }
1085
1086        fn api_key_env(&self) -> &'static str {
1087            "TEST_API_KEY"
1088        }
1089
1090        fn http_client(&self) -> &Client {
1091            unimplemented!()
1092        }
1093
1094        fn api_key(&self) -> &SecretString {
1095            unimplemented!()
1096        }
1097
1098        fn model(&self) -> &'static str {
1099            "test-model"
1100        }
1101
1102        fn max_tokens(&self) -> u32 {
1103            2048
1104        }
1105
1106        fn temperature(&self) -> f32 {
1107            0.3
1108        }
1109    }
1110
1111    #[test]
1112    fn test_build_system_prompt_contains_json_schema() {
1113        let prompt = TestProvider::build_system_prompt(None);
1114        assert!(prompt.contains("summary"));
1115        assert!(prompt.contains("suggested_labels"));
1116        assert!(prompt.contains("clarifying_questions"));
1117        assert!(prompt.contains("potential_duplicates"));
1118        assert!(prompt.contains("status_note"));
1119    }
1120
1121    #[test]
1122    fn test_build_user_prompt_with_delimiters() {
1123        let issue = IssueDetails::builder()
1124            .owner("test".to_string())
1125            .repo("repo".to_string())
1126            .number(1)
1127            .title("Test issue".to_string())
1128            .body("This is the body".to_string())
1129            .labels(vec!["bug".to_string()])
1130            .comments(vec![])
1131            .url("https://github.com/test/repo/issues/1".to_string())
1132            .build();
1133
1134        let prompt = TestProvider::build_user_prompt(&issue);
1135        assert!(prompt.starts_with("<issue_content>"));
1136        assert!(prompt.ends_with("</issue_content>"));
1137        assert!(prompt.contains("Title: Test issue"));
1138        assert!(prompt.contains("This is the body"));
1139        assert!(prompt.contains("Existing Labels: bug"));
1140    }
1141
1142    #[test]
1143    fn test_build_user_prompt_truncates_long_body() {
1144        let long_body = "x".repeat(5000);
1145        let issue = IssueDetails::builder()
1146            .owner("test".to_string())
1147            .repo("repo".to_string())
1148            .number(1)
1149            .title("Test".to_string())
1150            .body(long_body)
1151            .labels(vec![])
1152            .comments(vec![])
1153            .url("https://github.com/test/repo/issues/1".to_string())
1154            .build();
1155
1156        let prompt = TestProvider::build_user_prompt(&issue);
1157        assert!(prompt.contains("[Body truncated"));
1158        assert!(prompt.contains("5000 chars"));
1159    }
1160
1161    #[test]
1162    fn test_build_user_prompt_empty_body() {
1163        let issue = IssueDetails::builder()
1164            .owner("test".to_string())
1165            .repo("repo".to_string())
1166            .number(1)
1167            .title("Test".to_string())
1168            .body(String::new())
1169            .labels(vec![])
1170            .comments(vec![])
1171            .url("https://github.com/test/repo/issues/1".to_string())
1172            .build();
1173
1174        let prompt = TestProvider::build_user_prompt(&issue);
1175        assert!(prompt.contains("[No description provided]"));
1176    }
1177
1178    #[test]
1179    fn test_build_create_system_prompt_contains_json_schema() {
1180        let prompt = TestProvider::build_create_system_prompt(None);
1181        assert!(prompt.contains("formatted_title"));
1182        assert!(prompt.contains("formatted_body"));
1183        assert!(prompt.contains("suggested_labels"));
1184    }
1185
1186    #[test]
1187    fn test_build_pr_review_user_prompt_respects_file_limit() {
1188        use super::super::types::{PrDetails, PrFile};
1189
1190        let mut files = Vec::new();
1191        for i in 0..25 {
1192            files.push(PrFile {
1193                filename: format!("file{i}.rs"),
1194                status: "modified".to_string(),
1195                additions: 10,
1196                deletions: 5,
1197                patch: Some(format!("patch content {i}")),
1198            });
1199        }
1200
1201        let pr = PrDetails {
1202            owner: "test".to_string(),
1203            repo: "repo".to_string(),
1204            number: 1,
1205            title: "Test PR".to_string(),
1206            body: "Description".to_string(),
1207            head_branch: "feature".to_string(),
1208            base_branch: "main".to_string(),
1209            url: "https://github.com/test/repo/pull/1".to_string(),
1210            files,
1211            labels: vec![],
1212        };
1213
1214        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1215        assert!(prompt.contains("files omitted due to size limits"));
1216        assert!(prompt.contains("MAX_FILES=20"));
1217    }
1218
1219    #[test]
1220    fn test_build_pr_review_user_prompt_respects_diff_size_limit() {
1221        use super::super::types::{PrDetails, PrFile};
1222
1223        // Create patches that will exceed the limit when combined
1224        // Each patch is ~30KB, so two will exceed 50KB limit
1225        let patch1 = "x".repeat(30_000);
1226        let patch2 = "y".repeat(30_000);
1227
1228        let files = vec![
1229            PrFile {
1230                filename: "file1.rs".to_string(),
1231                status: "modified".to_string(),
1232                additions: 100,
1233                deletions: 50,
1234                patch: Some(patch1),
1235            },
1236            PrFile {
1237                filename: "file2.rs".to_string(),
1238                status: "modified".to_string(),
1239                additions: 100,
1240                deletions: 50,
1241                patch: Some(patch2),
1242            },
1243        ];
1244
1245        let pr = PrDetails {
1246            owner: "test".to_string(),
1247            repo: "repo".to_string(),
1248            number: 1,
1249            title: "Test PR".to_string(),
1250            body: "Description".to_string(),
1251            head_branch: "feature".to_string(),
1252            base_branch: "main".to_string(),
1253            url: "https://github.com/test/repo/pull/1".to_string(),
1254            files,
1255            labels: vec![],
1256        };
1257
1258        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1259        // Both files should be listed
1260        assert!(prompt.contains("file1.rs"));
1261        assert!(prompt.contains("file2.rs"));
1262        // The second patch should be limited - verify the prompt doesn't contain both full patches
1263        // by checking that the total size is less than what two full 30KB patches would be
1264        assert!(prompt.len() < 65_000);
1265    }
1266
1267    #[test]
1268    fn test_build_pr_review_user_prompt_with_no_patches() {
1269        use super::super::types::{PrDetails, PrFile};
1270
1271        let files = vec![PrFile {
1272            filename: "file1.rs".to_string(),
1273            status: "added".to_string(),
1274            additions: 10,
1275            deletions: 0,
1276            patch: None,
1277        }];
1278
1279        let pr = PrDetails {
1280            owner: "test".to_string(),
1281            repo: "repo".to_string(),
1282            number: 1,
1283            title: "Test PR".to_string(),
1284            body: "Description".to_string(),
1285            head_branch: "feature".to_string(),
1286            base_branch: "main".to_string(),
1287            url: "https://github.com/test/repo/pull/1".to_string(),
1288            files,
1289            labels: vec![],
1290        };
1291
1292        let prompt = TestProvider::build_pr_review_user_prompt(&pr);
1293        assert!(prompt.contains("file1.rs"));
1294        assert!(prompt.contains("added"));
1295        assert!(!prompt.contains("files omitted"));
1296    }
1297
1298    #[test]
1299    fn test_build_pr_label_system_prompt_contains_json_schema() {
1300        let prompt = TestProvider::build_pr_label_system_prompt(None);
1301        assert!(prompt.contains("suggested_labels"));
1302        assert!(prompt.contains("json_object"));
1303        assert!(prompt.contains("bug"));
1304        assert!(prompt.contains("enhancement"));
1305    }
1306
1307    #[test]
1308    fn test_build_pr_label_user_prompt_with_title_and_body() {
1309        let title = "feat: add new feature";
1310        let body = "This PR adds a new feature";
1311        let files = vec!["src/main.rs".to_string(), "tests/test.rs".to_string()];
1312
1313        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1314        assert!(prompt.starts_with("<pull_request>"));
1315        assert!(prompt.ends_with("</pull_request>"));
1316        assert!(prompt.contains("feat: add new feature"));
1317        assert!(prompt.contains("This PR adds a new feature"));
1318        assert!(prompt.contains("src/main.rs"));
1319        assert!(prompt.contains("tests/test.rs"));
1320    }
1321
1322    #[test]
1323    fn test_build_pr_label_user_prompt_empty_body() {
1324        let title = "fix: bug fix";
1325        let body = "";
1326        let files = vec!["src/lib.rs".to_string()];
1327
1328        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1329        assert!(prompt.contains("[No description provided]"));
1330        assert!(prompt.contains("src/lib.rs"));
1331    }
1332
1333    #[test]
1334    fn test_build_pr_label_user_prompt_truncates_long_body() {
1335        let title = "test";
1336        let long_body = "x".repeat(5000);
1337        let files = vec![];
1338
1339        let prompt = TestProvider::build_pr_label_user_prompt(title, &long_body, &files);
1340        assert!(prompt.contains("[Description truncated"));
1341        assert!(prompt.contains("5000 chars"));
1342    }
1343
1344    #[test]
1345    fn test_build_pr_label_user_prompt_respects_file_limit() {
1346        let title = "test";
1347        let body = "test";
1348        let mut files = Vec::new();
1349        for i in 0..25 {
1350            files.push(format!("file{i}.rs"));
1351        }
1352
1353        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1354        assert!(prompt.contains("file0.rs"));
1355        assert!(prompt.contains("file19.rs"));
1356        assert!(!prompt.contains("file20.rs"));
1357        assert!(prompt.contains("... and 5 more files"));
1358    }
1359
1360    #[test]
1361    fn test_build_pr_label_user_prompt_empty_files() {
1362        let title = "test";
1363        let body = "test";
1364        let files: Vec<String> = vec![];
1365
1366        let prompt = TestProvider::build_pr_label_user_prompt(title, body, &files);
1367        assert!(prompt.contains("Title: test"));
1368        assert!(prompt.contains("Description:\ntest"));
1369        assert!(!prompt.contains("Files Changed:"));
1370    }
1371
1372    #[test]
1373    fn test_parse_ai_json_with_valid_json() {
1374        #[derive(serde::Deserialize)]
1375        struct TestResponse {
1376            message: String,
1377        }
1378
1379        let json = r#"{"message": "hello"}"#;
1380        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1381        assert!(result.is_ok());
1382        let response = result.unwrap();
1383        assert_eq!(response.message, "hello");
1384    }
1385
1386    #[test]
1387    fn test_parse_ai_json_with_truncated_json() {
1388        #[derive(Debug, serde::Deserialize)]
1389        #[allow(dead_code)]
1390        struct TestResponse {
1391            message: String,
1392        }
1393
1394        let json = r#"{"message": "hello"#;
1395        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1396        assert!(result.is_err());
1397        let err = result.unwrap_err();
1398        assert!(
1399            err.to_string()
1400                .contains("Truncated response from test-provider")
1401        );
1402    }
1403
1404    #[test]
1405    fn test_parse_ai_json_with_malformed_json() {
1406        #[derive(Debug, serde::Deserialize)]
1407        #[allow(dead_code)]
1408        struct TestResponse {
1409            message: String,
1410        }
1411
1412        let json = r#"{"message": invalid}"#;
1413        let result: Result<TestResponse> = parse_ai_json(json, "test-provider");
1414        assert!(result.is_err());
1415        let err = result.unwrap_err();
1416        assert!(err.to_string().contains("Invalid JSON response from AI"));
1417    }
1418}