omni_dev/claude/
client.rs1use crate::claude::{error::ClaudeError, prompts};
4use crate::data::{amendments::AmendmentFile, RepositoryView, RepositoryViewForAI};
5use anyhow::{Context, Result};
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use tracing::debug;
9
10#[derive(Serialize)]
12struct Message {
13 role: String,
14 content: String,
15}
16
17#[derive(Serialize)]
19struct ClaudeRequest {
20 model: String,
21 max_tokens: i32,
22 system: String,
23 messages: Vec<Message>,
24}
25
26#[derive(Deserialize)]
28struct Content {
29 #[serde(rename = "type")]
30 content_type: String,
31 text: String,
32}
33
34#[derive(Deserialize)]
36struct ClaudeResponse {
37 content: Vec<Content>,
38}
39
40pub struct ClaudeClient {
42 client: Client,
43 api_key: String,
44 model: String,
45}
46
47impl ClaudeClient {
48 pub fn new(model: String) -> Result<Self> {
50 let api_key = std::env::var("CLAUDE_API_KEY")
51 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
52 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
53
54 let client = Client::new();
55 Ok(Self {
56 client,
57 api_key,
58 model,
59 })
60 }
61
62 pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
64 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
66 .context("Failed to enhance repository view with diff content")?;
67
68 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
70 .context("Failed to serialize repository view to YAML")?;
71
72 let user_prompt = prompts::generate_user_prompt(&repo_yaml);
74
75 let request = ClaudeRequest {
77 model: self.model.clone(),
78 max_tokens: 4000,
79 system: prompts::SYSTEM_PROMPT.to_string(),
80 messages: vec![Message {
81 role: "user".to_string(),
82 content: user_prompt,
83 }],
84 };
85
86 let response = self
90 .client
91 .post("https://api.anthropic.com/v1/messages")
92 .header("x-api-key", &self.api_key)
93 .header("anthropic-version", "2023-06-01")
94 .header("content-type", "application/json")
95 .json(&request)
96 .send()
97 .await
98 .map_err(|e| ClaudeError::NetworkError(e.to_string()))?;
99
100 if !response.status().is_success() {
101 let status = response.status();
102 let error_text = response.text().await.unwrap_or_default();
103 return Err(
104 ClaudeError::ApiRequestFailed(format!("HTTP {}: {}", status, error_text)).into(),
105 );
106 }
107
108 let claude_response: ClaudeResponse = response
109 .json()
110 .await
111 .map_err(|e| ClaudeError::InvalidResponseFormat(e.to_string()))?;
112
113 let content = claude_response
115 .content
116 .first()
117 .filter(|c| c.content_type == "text")
118 .map(|c| c.text.as_str())
119 .ok_or_else(|| {
120 ClaudeError::InvalidResponseFormat("No text content in response".to_string())
121 })?;
122
123 self.parse_amendment_response(content)
127 }
128
129 pub async fn generate_contextual_amendments(
131 &self,
132 repo_view: &RepositoryView,
133 context: &crate::data::context::CommitContext,
134 ) -> Result<AmendmentFile> {
135 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
137 .context("Failed to enhance repository view with diff content")?;
138
139 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
141 .context("Failed to serialize repository view to YAML")?;
142
143 let system_prompt = prompts::generate_contextual_system_prompt(context);
145 let user_prompt = prompts::generate_contextual_user_prompt(&repo_yaml, context);
146
147 match &context.project.commit_guidelines {
149 Some(guidelines) => {
150 debug!(length = guidelines.len(), "Project commit guidelines found");
151 debug!(guidelines = %guidelines, "Commit guidelines content");
152 }
153 None => {
154 debug!("No project commit guidelines found");
155 }
156 }
157
158 let request = ClaudeRequest {
160 model: self.model.clone(),
161 max_tokens: if context.is_significant_change() {
162 6000
163 } else {
164 4000
165 },
166 system: system_prompt,
167 messages: vec![Message {
168 role: "user".to_string(),
169 content: user_prompt,
170 }],
171 };
172
173 let response = self
175 .client
176 .post("https://api.anthropic.com/v1/messages")
177 .header("x-api-key", &self.api_key)
178 .header("anthropic-version", "2023-06-01")
179 .header("content-type", "application/json")
180 .json(&request)
181 .send()
182 .await
183 .map_err(|e| ClaudeError::NetworkError(e.to_string()))?;
184
185 if !response.status().is_success() {
186 let status = response.status();
187 let error_text = response.text().await.unwrap_or_default();
188 return Err(
189 ClaudeError::ApiRequestFailed(format!("HTTP {}: {}", status, error_text)).into(),
190 );
191 }
192
193 let claude_response: ClaudeResponse = response
194 .json()
195 .await
196 .map_err(|e| ClaudeError::InvalidResponseFormat(e.to_string()))?;
197
198 let content = claude_response
200 .content
201 .first()
202 .filter(|c| c.content_type == "text")
203 .map(|c| c.text.as_str())
204 .ok_or_else(|| {
205 ClaudeError::InvalidResponseFormat("No text content in response".to_string())
206 })?;
207
208 self.parse_amendment_response(content)
212 }
213
214 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
216 let yaml_content = if content.contains("```yaml") {
218 content
219 .split("```yaml")
220 .nth(1)
221 .and_then(|s| s.split("```").next())
222 .unwrap_or(content)
223 .trim()
224 } else if content.contains("```") {
225 content
227 .split("```")
228 .nth(1)
229 .and_then(|s| s.split("```").next())
230 .unwrap_or(content)
231 .trim()
232 } else {
233 content.trim()
234 };
235
236 let amendment_file: AmendmentFile = serde_yaml::from_str(yaml_content).map_err(|e| {
238 debug!(
239 error = %e,
240 content_length = content.len(),
241 yaml_length = yaml_content.len(),
242 "YAML parsing failed"
243 );
244 debug!(content = %content, "Raw Claude response");
245 debug!(yaml = %yaml_content, "Extracted YAML content");
246
247 if yaml_content.lines().any(|line| line.contains('\t')) {
249 ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
250 } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
251 ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
252 } else {
253 ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {}", e))
254 }
255 })?;
256
257 amendment_file
259 .validate()
260 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
261
262 Ok(amendment_file)
263 }
264}