aptu_core/ai/
openrouter.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `OpenRouter` API client for AI-assisted issue triage.
4//!
5//! Provides functionality to analyze GitHub issues using the `OpenRouter` API
6//! with structured JSON output.
7
8use std::env;
9use std::time::Duration;
10
11use anyhow::{Context, Result};
12use backon::Retryable;
13use reqwest::Client;
14use secrecy::{ExposeSecret, SecretString};
15use tracing::{debug, instrument, warn};
16
17use super::types::{
18    ChatCompletionRequest, ChatCompletionResponse, ChatMessage, IssueDetails, ResponseFormat,
19    TriageResponse,
20};
21use super::{AiResponse, OPENROUTER_API_KEY_ENV, OPENROUTER_API_URL};
22use crate::config::AiConfig;
23use crate::error::AptuError;
24use crate::history::AiStats;
25use crate::retry::{is_retryable_anyhow, retry_backoff};
26
27/// `OpenRouter` account credits status.
28#[derive(Debug, Clone)]
29pub struct CreditsStatus {
30    /// Available credits in USD.
31    pub credits: f64,
32}
33
34impl CreditsStatus {
35    /// Returns a human-readable status message.
36    #[must_use]
37    pub fn message(&self) -> String {
38        format!("OpenRouter credits: ${:.4}", self.credits)
39    }
40}
41
42/// Maximum length for issue body to stay within token limits.
43const MAX_BODY_LENGTH: usize = 4000;
44
45/// Maximum number of comments to include in the prompt.
46const MAX_COMMENTS: usize = 5;
47
48/// `OpenRouter` API client for issue triage.
49///
50/// Holds HTTP client, API key, and model configuration for reuse across multiple requests.
51/// Enables connection pooling and cleaner API.
52pub struct OpenRouterClient {
53    /// HTTP client with configured timeout.
54    http: Client,
55    /// API key for `OpenRouter` authentication.
56    api_key: SecretString,
57    /// Model name (e.g., "mistralai/devstral-2512:free").
58    model: String,
59}
60
61impl OpenRouterClient {
62    /// Creates a new `OpenRouter` client from configuration.
63    ///
64    /// Validates the model against cost control settings and fetches the API key
65    /// from the environment.
66    ///
67    /// # Arguments
68    ///
69    /// * `config` - AI configuration with model, timeout, and cost control settings
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if:
74    /// - Model is not in free tier and `allow_paid_models` is false
75    /// - `OPENROUTER_API_KEY` environment variable is not set
76    /// - HTTP client creation fails
77    pub fn new(config: &AiConfig) -> Result<Self> {
78        // Validate model against cost control
79        if !config.allow_paid_models && !super::is_free_model(&config.model) {
80            anyhow::bail!(
81                "Model '{}' is not in the free tier.\n\
82                 To use paid models, set `allow_paid_models = true` in your config file:\n\
83                 {}\n\n\
84                 Or use a free model like: mistralai/devstral-2512:free",
85                config.model,
86                crate::config::config_file_path().display()
87            );
88        }
89
90        // Get API key from environment
91        let api_key = env::var(OPENROUTER_API_KEY_ENV).with_context(|| {
92            format!(
93                "Missing {OPENROUTER_API_KEY_ENV} environment variable.\n\
94                 Set it with: export {OPENROUTER_API_KEY_ENV}=your_api_key\n\
95                 Get a free key at: https://openrouter.ai/keys"
96            )
97        })?;
98
99        // Create HTTP client with timeout
100        let http = Client::builder()
101            .timeout(Duration::from_secs(config.timeout_seconds))
102            .build()
103            .context("Failed to create HTTP client")?;
104
105        Ok(Self {
106            http,
107            api_key: SecretString::new(api_key.into()),
108            model: config.model.clone(),
109        })
110    }
111
112    /// Creates a new `OpenRouter` client with a provided API key.
113    ///
114    /// This constructor allows callers to provide an API key directly,
115    /// enabling multi-platform credential resolution (e.g., from iOS keychain via FFI).
116    ///
117    /// # Arguments
118    ///
119    /// * `api_key` - `OpenRouter` API key as a `SecretString`
120    /// * `config` - AI configuration with model, timeout, and cost control settings
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if:
125    /// - Model is not in free tier and `allow_paid_models` is false
126    /// - HTTP client creation fails
127    pub fn with_api_key(api_key: SecretString, config: &AiConfig) -> Result<Self> {
128        // Validate model against cost control
129        if !config.allow_paid_models && !super::is_free_model(&config.model) {
130            anyhow::bail!(
131                "Model '{}' is not in the free tier.\n\
132                 To use paid models, set `allow_paid_models = true` in your config file:\n\
133                 {}\n\n\
134                 Or use a free model like: mistralai/devstral-2512:free",
135                config.model,
136                crate::config::config_file_path().display()
137            );
138        }
139
140        // Create HTTP client with timeout
141        let http = Client::builder()
142            .timeout(Duration::from_secs(config.timeout_seconds))
143            .build()
144            .context("Failed to create HTTP client")?;
145
146        Ok(Self {
147            http,
148            api_key,
149            model: config.model.clone(),
150        })
151    }
152
153    /// Sends a chat completion request to the `OpenRouter` API with retry logic.
154    ///
155    /// Handles HTTP headers, error responses (401, 429), and automatic retries
156    /// with exponential backoff.
157    ///
158    /// # Arguments
159    ///
160    /// * `request` - The chat completion request to send
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if:
165    /// - Network request fails
166    /// - API returns an error status code
167    /// - Response cannot be parsed as JSON
168    async fn send_request(
169        &self,
170        request: &ChatCompletionRequest,
171    ) -> Result<ChatCompletionResponse> {
172        let completion: ChatCompletionResponse = (|| async {
173            let response = self
174                .http
175                .post(OPENROUTER_API_URL)
176                .header(
177                    "Authorization",
178                    format!("Bearer {}", self.api_key.expose_secret()),
179                )
180                .header("Content-Type", "application/json")
181                .header(
182                    "HTTP-Referer",
183                    "https://github.com/clouatre-labs/project-aptu",
184                )
185                .header("X-Title", "Aptu CLI")
186                .json(request)
187                .send()
188                .await
189                .context("Failed to send request to OpenRouter API")?;
190
191            // Check for HTTP errors
192            let status = response.status();
193            if !status.is_success() {
194                if status.as_u16() == 401 {
195                    anyhow::bail!(
196                        "Invalid OpenRouter API key. Check your {OPENROUTER_API_KEY_ENV} environment variable."
197                    );
198                } else if status.as_u16() == 429 {
199                    warn!("Rate limited by OpenRouter API");
200                    // Parse Retry-After header (seconds), default to 0 if not present
201                    let retry_after = response
202                        .headers()
203                        .get("Retry-After")
204                        .and_then(|h| h.to_str().ok())
205                        .and_then(|s| s.parse::<u64>().ok())
206                        .unwrap_or(0);
207                    debug!(retry_after, "Parsed Retry-After header");
208                    return Err(AptuError::RateLimited {
209                        provider: "openrouter".to_string(),
210                        retry_after,
211                    }
212                    .into());
213                }
214                let error_body = response.text().await.unwrap_or_default();
215                anyhow::bail!(
216                    "OpenRouter API error (HTTP {}): {}",
217                    status.as_u16(),
218                    error_body
219                );
220            }
221
222            // Parse response
223            let completion: ChatCompletionResponse = response
224                .json()
225                .await
226                .context("Failed to parse OpenRouter API response")?;
227
228            Ok(completion)
229        })
230        .retry(retry_backoff())
231        .when(is_retryable_anyhow)
232        .notify(|err, dur| warn!(error = %err, delay = ?dur, "Retrying after error"))
233        .await?;
234
235        Ok(completion)
236    }
237
238    /// Analyzes a GitHub issue using the `OpenRouter` API.
239    ///
240    /// Returns a structured triage response with summary, labels, questions, duplicates, and usage stats.
241    ///
242    /// # Arguments
243    ///
244    /// * `issue` - Issue details to analyze
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if:
249    /// - API request fails (network, timeout, rate limit)
250    /// - Response cannot be parsed as valid JSON
251    #[instrument(skip(self, issue), fields(issue_number = issue.number, repo = %format!("{}/{}", issue.owner, issue.repo)))]
252    #[allow(clippy::too_many_lines)]
253    pub async fn analyze_issue(&self, issue: &IssueDetails) -> Result<AiResponse> {
254        debug!(model = %self.model, "Calling OpenRouter API");
255
256        // Start timing (outside retry loop to measure total time including retries)
257        let start = std::time::Instant::now();
258
259        // Build request
260        let request = ChatCompletionRequest {
261            model: self.model.clone(),
262            messages: vec![
263                ChatMessage {
264                    role: "system".to_string(),
265                    content: build_system_prompt(),
266                },
267                ChatMessage {
268                    role: "user".to_string(),
269                    content: build_user_prompt(issue),
270                },
271            ],
272            response_format: Some(ResponseFormat {
273                format_type: "json_object".to_string(),
274            }),
275            max_tokens: Some(1024),
276            temperature: Some(0.3),
277        };
278
279        // Make API request with retry logic
280        let completion = self.send_request(&request).await?;
281
282        // Calculate duration (total time including any retries)
283        #[allow(clippy::cast_possible_truncation)]
284        let duration_ms = start.elapsed().as_millis() as u64;
285
286        // Extract message content
287        let content = completion
288            .choices
289            .first()
290            .map(|c| c.message.content.clone())
291            .context("No response from AI model")?;
292
293        debug!(response_length = content.len(), "Received AI response");
294
295        // Parse JSON response
296        let triage: TriageResponse = serde_json::from_str(&content).with_context(|| {
297            format!("Failed to parse AI response as JSON. Raw response:\n{content}")
298        })?;
299
300        // Build AI stats from usage info (trust API's cost field)
301        let (input_tokens, output_tokens, cost_usd) = if let Some(usage) = completion.usage {
302            (usage.prompt_tokens, usage.completion_tokens, usage.cost)
303        } else {
304            // If no usage info, default to 0
305            debug!("No usage information in API response");
306            (0, 0, None)
307        };
308
309        let ai_stats = AiStats {
310            model: self.model.clone(),
311            input_tokens,
312            output_tokens,
313            duration_ms,
314            cost_usd,
315        };
316
317        debug!(
318            input_tokens,
319            output_tokens,
320            duration_ms,
321            ?cost_usd,
322            "AI analysis complete"
323        );
324
325        Ok(AiResponse {
326            triage,
327            stats: ai_stats,
328        })
329    }
330
331    /// Creates a formatted GitHub issue using the `OpenRouter` API.
332    ///
333    /// Takes raw issue title and body, formats them using AI (conventional commit style,
334    /// structured body), and returns the formatted content with suggested labels.
335    ///
336    /// # Arguments
337    ///
338    /// * `title` - Raw issue title from user
339    /// * `body` - Raw issue body/description from user
340    /// * `repo` - Repository name for context (owner/repo format)
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if:
345    /// - API request fails (network, timeout, rate limit)
346    /// - Response cannot be parsed as valid JSON
347    #[instrument(skip(self), fields(repo = %repo))]
348    pub async fn create_issue(
349        &self,
350        title: &str,
351        body: &str,
352        repo: &str,
353    ) -> Result<super::types::CreateIssueResponse> {
354        debug!(model = %self.model, "Calling OpenRouter API for issue creation");
355
356        // Start timing
357        let start = std::time::Instant::now();
358
359        // Build request
360        let request = ChatCompletionRequest {
361            model: self.model.clone(),
362            messages: vec![
363                ChatMessage {
364                    role: "system".to_string(),
365                    content: build_create_system_prompt(),
366                },
367                ChatMessage {
368                    role: "user".to_string(),
369                    content: build_create_user_prompt(title, body, repo),
370                },
371            ],
372            response_format: Some(ResponseFormat {
373                format_type: "json_object".to_string(),
374            }),
375            max_tokens: Some(1024),
376            temperature: Some(0.3),
377        };
378
379        // Make API request with retry logic
380        let completion = self.send_request(&request).await?;
381
382        // Extract message content
383        let content = completion
384            .choices
385            .first()
386            .map(|c| c.message.content.clone())
387            .context("No response from AI model")?;
388
389        debug!(response_length = content.len(), "Received AI response");
390
391        // Parse JSON response
392        let create_response: super::types::CreateIssueResponse = serde_json::from_str(&content)
393            .with_context(|| {
394                format!("Failed to parse AI response as JSON. Raw response:\n{content}")
395            })?;
396
397        #[allow(clippy::cast_possible_truncation)]
398        let _duration_ms = start.elapsed().as_millis() as u64;
399
400        debug!(
401            title_len = create_response.formatted_title.len(),
402            body_len = create_response.formatted_body.len(),
403            labels = create_response.suggested_labels.len(),
404            "Issue formatting complete"
405        );
406
407        Ok(create_response)
408    }
409}
410
411/// Builds the system prompt for issue triage.
412fn build_system_prompt() -> String {
413    r##"You are an OSS issue triage assistant. Analyze the provided GitHub issue and provide structured triage information.
414
415Your response MUST be valid JSON with this exact schema:
416{
417  "summary": "A 2-3 sentence summary of what the issue is about and its impact",
418  "suggested_labels": ["label1", "label2"],
419  "clarifying_questions": ["question1", "question2"],
420  "potential_duplicates": ["#123", "#456"],
421  "related_issues": [
422    {
423      "number": 789,
424      "title": "Related issue title",
425      "reason": "Brief explanation of why this is related"
426    }
427  ],
428  "status_note": "Optional note about issue status (e.g., claimed, in-progress)",
429  "contributor_guidance": {
430    "beginner_friendly": true,
431    "reasoning": "1-2 sentence explanation of beginner-friendliness assessment"
432  },
433  "implementation_approach": "Optional suggestions for implementation based on repository structure",
434  "suggested_milestone": "Optional milestone title for the issue"
435}
436
437Guidelines:
438- summary: Concise explanation of the problem/request and why it matters
439- suggested_labels: Prefer labels from the Available Labels list provided. Choose from: bug, enhancement, documentation, question, good first issue, help wanted, duplicate, invalid, wontfix. If a more specific label exists in the repository, use it instead of generic ones.
440  - good first issue: Consider this label when the issue has: clear and well-defined scope, minimal codebase knowledge required, isolated change with minimal dependencies, good documentation or examples in the repository, no complex architectural understanding needed.
441  - help wanted: Consider this label when the issue has: well-defined requirements and acceptance criteria, maintainer capacity is limited, issue is not blocked by other work, suitable for external contributors with domain knowledge.
442- clarifying_questions: Only include if the issue lacks critical information. Leave empty array if issue is clear. Skip questions already answered in comments.
443- 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.
444- 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.
445- 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. IMPORTANT: If issue is claimed, do NOT suggest 'help wanted' label.
446- 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.
447- 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.
448- 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.
449
450Be helpful, concise, and actionable. Focus on what a maintainer needs to know."##.to_string()
451}
452
453/// Builds the user prompt containing the issue details.
454fn build_user_prompt(issue: &IssueDetails) -> String {
455    use std::fmt::Write;
456
457    let mut prompt = String::new();
458
459    prompt.push_str("<issue_content>\n");
460    let _ = writeln!(prompt, "Title: {}\n", issue.title);
461
462    // Truncate body if too long
463    let body = if issue.body.len() > MAX_BODY_LENGTH {
464        format!(
465            "{}...\n[Body truncated - original length: {} chars]",
466            &issue.body[..MAX_BODY_LENGTH],
467            issue.body.len()
468        )
469    } else if issue.body.is_empty() {
470        "[No description provided]".to_string()
471    } else {
472        issue.body.clone()
473    };
474    let _ = writeln!(prompt, "Body:\n{body}\n");
475
476    // Include existing labels
477    if !issue.labels.is_empty() {
478        let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
479    }
480
481    // Include recent comments (limited)
482    if !issue.comments.is_empty() {
483        prompt.push_str("Recent Comments:\n");
484        for comment in issue.comments.iter().take(MAX_COMMENTS) {
485            let comment_body = if comment.body.len() > 500 {
486                format!("{}...", &comment.body[..500])
487            } else {
488                comment.body.clone()
489            };
490            let _ = writeln!(prompt, "- @{}: {}", comment.author, comment_body);
491        }
492        prompt.push('\n');
493    }
494
495    // Include related issues from search (for context)
496    if !issue.repo_context.is_empty() {
497        prompt.push_str("Related Issues in Repository (for context):\n");
498        for related in issue.repo_context.iter().take(10) {
499            let _ = writeln!(
500                prompt,
501                "- #{} [{}] {}",
502                related.number, related.state, related.title
503            );
504        }
505        prompt.push('\n');
506    }
507
508    // Include repository structure (source files)
509    if !issue.repo_tree.is_empty() {
510        prompt.push_str("Repository Structure (source files):\n");
511        for path in issue.repo_tree.iter().take(20) {
512            let _ = writeln!(prompt, "- {path}");
513        }
514        prompt.push('\n');
515    }
516
517    // Include available labels
518    if !issue.available_labels.is_empty() {
519        prompt.push_str("Available Labels:\n");
520        for label in issue.available_labels.iter().take(30) {
521            let description = if label.description.is_empty() {
522                String::new()
523            } else {
524                format!(" - {}", label.description)
525            };
526            let _ = writeln!(
527                prompt,
528                "- {} (color: #{}){}",
529                label.name, label.color, description
530            );
531        }
532        prompt.push('\n');
533    }
534
535    // Include available milestones
536    if !issue.available_milestones.is_empty() {
537        prompt.push_str("Available Milestones:\n");
538        for milestone in issue.available_milestones.iter().take(10) {
539            let description = if milestone.description.is_empty() {
540                String::new()
541            } else {
542                format!(" - {}", milestone.description)
543            };
544            let _ = writeln!(prompt, "- {}{}", milestone.title, description);
545        }
546        prompt.push('\n');
547    }
548
549    prompt.push_str("</issue_content>");
550
551    prompt
552}
553
554/// Builds the system prompt for issue creation/formatting.
555fn build_create_system_prompt() -> String {
556    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.
557
558Your response MUST be valid JSON with this exact schema:
559{
560  "formatted_title": "Well-formatted issue title following conventional commit style",
561  "formatted_body": "Professionally formatted issue body with clear sections",
562  "suggested_labels": ["label1", "label2"]
563}
564
565Guidelines:
566- 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.
567- formatted_body: Structure the body with clear sections:
568  * Start with a brief 1-2 sentence summary if not already present
569  * Use markdown formatting with headers (## Summary, ## Details, ## Steps to Reproduce, ## Expected Behavior, ## Actual Behavior, ## Context, etc.)
570  * Keep sentences clear and concise
571  * Use bullet points for lists
572  * Improve grammar and clarity
573  * Add relevant context if missing
574- suggested_labels: Suggest up to 3 relevant GitHub labels. Common ones: bug, enhancement, documentation, question, good first issue, help wanted, duplicate, invalid, wontfix. Choose based on the issue content.
575
576Be professional but friendly. Maintain the user's intent while improving clarity and structure."#.to_string()
577}
578
579/// Builds the user prompt for issue creation/formatting.
580fn build_create_user_prompt(title: &str, body: &str, _repo: &str) -> String {
581    format!("Please format this GitHub issue:\n\nTitle: {title}\n\nBody:\n{body}")
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn test_build_system_prompt_contains_json_schema() {
590        let prompt = build_system_prompt();
591        assert!(prompt.contains("summary"));
592        assert!(prompt.contains("suggested_labels"));
593        assert!(prompt.contains("clarifying_questions"));
594        assert!(prompt.contains("potential_duplicates"));
595        assert!(prompt.contains("status_note"));
596    }
597
598    #[test]
599    fn test_build_system_prompt_contains_claim_detection_keywords() {
600        let prompt = build_system_prompt();
601        assert!(prompt.contains("claimed") || prompt.contains("working on"));
602        assert!(prompt.contains("help wanted"));
603    }
604
605    #[test]
606    fn test_triage_response_with_status_note() {
607        let json = r#"{
608            "summary": "Test summary",
609            "suggested_labels": ["bug"],
610            "status_note": "Issue claimed by @user"
611        }"#;
612
613        let triage: TriageResponse = serde_json::from_str(json).unwrap();
614        assert_eq!(
615            triage.status_note,
616            Some("Issue claimed by @user".to_string())
617        );
618    }
619
620    #[test]
621    fn test_triage_response_without_status_note() {
622        let json = r#"{
623            "summary": "Test summary",
624            "suggested_labels": ["bug"]
625        }"#;
626
627        let triage: TriageResponse = serde_json::from_str(json).unwrap();
628        assert_eq!(triage.status_note, None);
629    }
630
631    #[test]
632    fn test_build_user_prompt_with_delimiters() {
633        let issue = IssueDetails {
634            owner: "test".to_string(),
635            repo: "repo".to_string(),
636            number: 1,
637            title: "Test issue".to_string(),
638            body: "This is the body".to_string(),
639            labels: vec!["bug".to_string()],
640            comments: vec![],
641            url: "https://github.com/test/repo/issues/1".to_string(),
642            repo_context: Vec::new(),
643            repo_tree: Vec::new(),
644            available_labels: Vec::new(),
645            available_milestones: Vec::new(),
646            viewer_permission: None,
647        };
648
649        let prompt = build_user_prompt(&issue);
650        assert!(prompt.starts_with("<issue_content>"));
651        assert!(prompt.ends_with("</issue_content>"));
652        assert!(prompt.contains("Title: Test issue"));
653        assert!(prompt.contains("This is the body"));
654        assert!(prompt.contains("Existing Labels: bug"));
655    }
656
657    #[test]
658    fn test_build_user_prompt_truncates_long_body() {
659        let long_body = "x".repeat(5000);
660        let issue = IssueDetails {
661            owner: "test".to_string(),
662            repo: "repo".to_string(),
663            number: 1,
664            title: "Test".to_string(),
665            body: long_body,
666            labels: vec![],
667            comments: vec![],
668            url: "https://github.com/test/repo/issues/1".to_string(),
669            repo_context: Vec::new(),
670            repo_tree: Vec::new(),
671            available_labels: Vec::new(),
672            available_milestones: Vec::new(),
673            viewer_permission: None,
674        };
675
676        let prompt = build_user_prompt(&issue);
677        assert!(prompt.contains("[Body truncated"));
678        assert!(prompt.contains("5000 chars"));
679    }
680
681    #[test]
682    fn test_build_user_prompt_empty_body() {
683        let issue = IssueDetails {
684            owner: "test".to_string(),
685            repo: "repo".to_string(),
686            number: 1,
687            title: "Test".to_string(),
688            body: String::new(),
689            labels: vec![],
690            comments: vec![],
691            url: "https://github.com/test/repo/issues/1".to_string(),
692            repo_context: Vec::new(),
693            repo_tree: Vec::new(),
694            available_labels: Vec::new(),
695            available_milestones: Vec::new(),
696            viewer_permission: None,
697        };
698
699        let prompt = build_user_prompt(&issue);
700        assert!(prompt.contains("[No description provided]"));
701    }
702
703    #[test]
704    fn test_triage_response_full() {
705        let json = r##"{
706            "summary": "This is a test summary.",
707            "suggested_labels": ["bug", "enhancement"],
708            "clarifying_questions": ["What version?"],
709            "potential_duplicates": ["#123"],
710            "status_note": "Issue claimed by @user",
711            "contributor_guidance": {
712                "beginner_friendly": true,
713                "reasoning": "Small scope, well-defined problem statement."
714            }
715        }"##;
716
717        let triage: TriageResponse = serde_json::from_str(json).unwrap();
718        assert_eq!(triage.summary, "This is a test summary.");
719        assert_eq!(triage.suggested_labels, vec!["bug", "enhancement"]);
720        assert_eq!(triage.clarifying_questions, vec!["What version?"]);
721        assert_eq!(triage.potential_duplicates, vec!["#123"]);
722        assert_eq!(
723            triage.status_note,
724            Some("Issue claimed by @user".to_string())
725        );
726        assert!(triage.contributor_guidance.is_some());
727        let guidance = triage.contributor_guidance.unwrap();
728        assert!(guidance.beginner_friendly);
729        assert_eq!(
730            guidance.reasoning,
731            "Small scope, well-defined problem statement."
732        );
733    }
734
735    #[test]
736    fn test_triage_response_minimal() {
737        let json = r#"{
738            "summary": "Summary only.",
739            "suggested_labels": ["bug"]
740        }"#;
741
742        let triage: TriageResponse = serde_json::from_str(json).unwrap();
743        assert_eq!(triage.summary, "Summary only.");
744        assert_eq!(triage.suggested_labels, vec!["bug"]);
745        assert!(triage.clarifying_questions.is_empty());
746        assert!(triage.potential_duplicates.is_empty());
747        assert_eq!(triage.status_note, None);
748        assert!(triage.contributor_guidance.is_none());
749    }
750
751    #[test]
752    fn test_triage_response_partial() {
753        let json = r#"{
754            "summary": "Test summary",
755            "suggested_labels": ["enhancement"],
756            "contributor_guidance": {
757                "beginner_friendly": false,
758                "reasoning": "Requires deep knowledge of the compiler internals."
759            }
760        }"#;
761
762        let triage: TriageResponse = serde_json::from_str(json).unwrap();
763        assert_eq!(triage.summary, "Test summary");
764        assert_eq!(triage.suggested_labels, vec!["enhancement"]);
765        assert!(triage.clarifying_questions.is_empty());
766        assert!(triage.potential_duplicates.is_empty());
767        assert_eq!(triage.status_note, None);
768        assert!(triage.contributor_guidance.is_some());
769        let guidance = triage.contributor_guidance.unwrap();
770        assert!(!guidance.beginner_friendly);
771        assert_eq!(
772            guidance.reasoning,
773            "Requires deep knowledge of the compiler internals."
774        );
775    }
776
777    #[test]
778    fn test_is_free_model() {
779        use super::super::is_free_model;
780        assert!(is_free_model("mistralai/devstral-2512:free"));
781        assert!(is_free_model("google/gemini-2.0-flash-exp:free"));
782        assert!(!is_free_model("openai/gpt-4"));
783        assert!(!is_free_model("anthropic/claude-sonnet-4"));
784    }
785
786    #[test]
787    fn test_build_system_prompt_contains_contributor_guidance() {
788        let prompt = build_system_prompt();
789        assert!(prompt.contains("contributor_guidance"));
790        assert!(prompt.contains("beginner_friendly"));
791        assert!(prompt.contains("reasoning"));
792        assert!(prompt.contains("scope"));
793        assert!(prompt.contains("file count"));
794        assert!(prompt.contains("required knowledge"));
795    }
796
797    #[test]
798    fn test_build_system_prompt_contains_discoverability_label_criteria() {
799        let prompt = build_system_prompt();
800        assert!(prompt.contains("good first issue"));
801        assert!(prompt.contains("help wanted"));
802        assert!(prompt.contains("isolated change"));
803        assert!(prompt.contains("minimal codebase knowledge"));
804        assert!(prompt.contains("well-defined requirements"));
805        assert!(prompt.contains("external contributors"));
806    }
807}