1use 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#[derive(Debug, Clone)]
29pub struct CreditsStatus {
30 pub credits: f64,
32}
33
34impl CreditsStatus {
35 #[must_use]
37 pub fn message(&self) -> String {
38 format!("OpenRouter credits: ${:.4}", self.credits)
39 }
40}
41
42const MAX_BODY_LENGTH: usize = 4000;
44
45const MAX_COMMENTS: usize = 5;
47
48pub struct OpenRouterClient {
53 http: Client,
55 api_key: SecretString,
57 model: String,
59}
60
61impl OpenRouterClient {
62 pub fn new(config: &AiConfig) -> Result<Self> {
78 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 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 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 pub fn with_api_key(api_key: SecretString, config: &AiConfig) -> Result<Self> {
128 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 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 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 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 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 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 #[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 let start = std::time::Instant::now();
258
259 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 let completion = self.send_request(&request).await?;
281
282 #[allow(clippy::cast_possible_truncation)]
284 let duration_ms = start.elapsed().as_millis() as u64;
285
286 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 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 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 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 #[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 let start = std::time::Instant::now();
358
359 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 let completion = self.send_request(&request).await?;
381
382 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 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
411fn 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
453fn 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 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 if !issue.labels.is_empty() {
478 let _ = writeln!(prompt, "Existing Labels: {}\n", issue.labels.join(", "));
479 }
480
481 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 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 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 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 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
554fn 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
579fn 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}