omni_dev/claude/
client.rs1use crate::claude::{ai::bedrock::BedrockAiClient, ai::claude::ClaudeAiClient};
4use crate::claude::{ai::AiClient, error::ClaudeError, prompts};
5use crate::data::{
6 amendments::AmendmentFile, context::CommitContext, RepositoryView, RepositoryViewForAI,
7};
8use anyhow::{Context, Result};
9use tracing::debug;
10
11pub struct ClaudeClient {
13 ai_client: Box<dyn AiClient>,
15}
16
17impl ClaudeClient {
18 pub fn new(ai_client: Box<dyn AiClient>) -> Self {
20 Self { ai_client }
21 }
22
23 pub fn get_ai_client_metadata(&self) -> crate::claude::ai::AiClientMetadata {
25 self.ai_client.get_metadata()
26 }
27
28 pub fn from_env(model: String) -> Result<Self> {
30 let api_key = std::env::var("CLAUDE_API_KEY")
32 .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
33 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
34
35 let ai_client = ClaudeAiClient::new(model, api_key);
36 Ok(Self::new(Box::new(ai_client)))
37 }
38
39 pub async fn generate_amendments(&self, repo_view: &RepositoryView) -> Result<AmendmentFile> {
41 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
43 .context("Failed to enhance repository view with diff content")?;
44
45 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
47 .context("Failed to serialize repository view to YAML")?;
48
49 let user_prompt = prompts::generate_user_prompt(&repo_yaml);
51
52 let content = self
54 .ai_client
55 .send_request(prompts::SYSTEM_PROMPT, &user_prompt)
56 .await?;
57
58 self.parse_amendment_response(&content)
60 }
61
62 pub async fn generate_contextual_amendments(
64 &self,
65 repo_view: &RepositoryView,
66 context: &CommitContext,
67 ) -> Result<AmendmentFile> {
68 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
70 .context("Failed to enhance repository view with diff content")?;
71
72 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
74 .context("Failed to serialize repository view to YAML")?;
75
76 let provider = self.ai_client.get_metadata().provider;
78 let provider_name = if provider.to_lowercase().contains("openai")
79 || provider.to_lowercase().contains("ollama")
80 {
81 "openai"
82 } else {
83 "claude"
84 };
85 let system_prompt =
86 prompts::generate_contextual_system_prompt_for_provider(context, provider_name);
87 let user_prompt = prompts::generate_contextual_user_prompt(&repo_yaml, context);
88
89 match &context.project.commit_guidelines {
91 Some(guidelines) => {
92 debug!(length = guidelines.len(), "Project commit guidelines found");
93 debug!(guidelines = %guidelines, "Commit guidelines content");
94 }
95 None => {
96 debug!("No project commit guidelines found");
97 }
98 }
99
100 let content = self
102 .ai_client
103 .send_request(&system_prompt, &user_prompt)
104 .await?;
105
106 self.parse_amendment_response(&content)
108 }
109
110 fn parse_amendment_response(&self, content: &str) -> Result<AmendmentFile> {
112 let yaml_content = self.extract_yaml_from_response(content);
114
115 let amendment_file: AmendmentFile = crate::data::from_yaml(&yaml_content).map_err(|e| {
117 debug!(
118 error = %e,
119 content_length = content.len(),
120 yaml_length = yaml_content.len(),
121 "YAML parsing failed"
122 );
123 debug!(content = %content, "Raw Claude response");
124 debug!(yaml = %yaml_content, "Extracted YAML content");
125
126 if yaml_content.lines().any(|line| line.contains('\t')) {
128 ClaudeError::AmendmentParsingFailed("YAML parsing error: Found tab characters. YAML requires spaces for indentation.".to_string())
129 } else if yaml_content.lines().any(|line| line.trim().starts_with('-') && !line.trim().starts_with("- ")) {
130 ClaudeError::AmendmentParsingFailed("YAML parsing error: List items must have a space after the dash (- item).".to_string())
131 } else {
132 ClaudeError::AmendmentParsingFailed(format!("YAML parsing error: {}", e))
133 }
134 })?;
135
136 amendment_file
138 .validate()
139 .map_err(|e| ClaudeError::AmendmentParsingFailed(format!("Validation error: {}", e)))?;
140
141 Ok(amendment_file)
142 }
143
144 pub async fn generate_pr_content(
146 &self,
147 repo_view: &RepositoryView,
148 pr_template: &str,
149 ) -> Result<crate::cli::git::PrContent> {
150 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
152 .context("Failed to enhance repository view with diff content")?;
153
154 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
156 .context("Failed to serialize repository view to YAML")?;
157
158 let user_prompt = prompts::generate_pr_description_prompt(&repo_yaml, pr_template);
160
161 let content = self
163 .ai_client
164 .send_request(prompts::PR_GENERATION_SYSTEM_PROMPT, &user_prompt)
165 .await?;
166
167 let yaml_content = content.trim();
169
170 let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
172 "Failed to parse AI response as YAML. AI may have returned malformed output.",
173 )?;
174
175 Ok(pr_content)
176 }
177
178 pub async fn generate_pr_content_with_context(
180 &self,
181 repo_view: &RepositoryView,
182 pr_template: &str,
183 context: &crate::data::context::CommitContext,
184 ) -> Result<crate::cli::git::PrContent> {
185 let ai_repo_view = RepositoryViewForAI::from_repository_view(repo_view.clone())
187 .context("Failed to enhance repository view with diff content")?;
188
189 let repo_yaml = crate::data::to_yaml(&ai_repo_view)
191 .context("Failed to serialize repository view to YAML")?;
192
193 let provider = self.ai_client.get_metadata().provider;
195 let provider_name = if provider.to_lowercase().contains("openai")
196 || provider.to_lowercase().contains("ollama")
197 {
198 "openai"
199 } else {
200 "claude"
201 };
202 let system_prompt =
203 prompts::generate_pr_system_prompt_with_context_for_provider(context, provider_name);
204 let user_prompt =
205 prompts::generate_pr_description_prompt_with_context(&repo_yaml, pr_template, context);
206
207 let content = self
209 .ai_client
210 .send_request(&system_prompt, &user_prompt)
211 .await?;
212
213 let yaml_content = content.trim();
215
216 debug!(
217 content_length = content.len(),
218 yaml_content_length = yaml_content.len(),
219 yaml_content = %yaml_content,
220 "Extracted YAML content from AI response"
221 );
222
223 let pr_content: crate::cli::git::PrContent = crate::data::from_yaml(yaml_content).context(
225 "Failed to parse AI response as YAML. AI may have returned malformed output.",
226 )?;
227
228 debug!(
229 parsed_title = %pr_content.title,
230 parsed_description_length = pr_content.description.len(),
231 parsed_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
232 "Successfully parsed PR content from YAML"
233 );
234
235 Ok(pr_content)
236 }
237
238 fn extract_yaml_from_response(&self, content: &str) -> String {
240 let content = content.trim();
241
242 if content.starts_with("amendments:") {
244 return content.to_string();
245 }
246
247 if let Some(yaml_start) = content.find("```yaml") {
249 if let Some(yaml_content) = content[yaml_start + 7..].split("```").next() {
250 return yaml_content.trim().to_string();
251 }
252 }
253
254 if let Some(code_start) = content.find("```") {
256 if let Some(code_content) = content[code_start + 3..].split("```").next() {
257 let potential_yaml = code_content.trim();
258 if potential_yaml.starts_with("amendments:") {
260 return potential_yaml.to_string();
261 }
262 }
263 }
264
265 content.to_string()
267 }
268}
269
270pub fn create_default_claude_client(model: Option<String>) -> Result<ClaudeClient> {
272 use crate::claude::ai::openai::OpenAiAiClient;
273 use crate::utils::settings::{get_env_var, get_env_vars};
274
275 let use_openai = get_env_var("USE_OPENAI")
277 .map(|val| val == "true")
278 .unwrap_or(false);
279
280 let use_ollama = get_env_var("USE_OLLAMA")
281 .map(|val| val == "true")
282 .unwrap_or(false);
283
284 let use_bedrock = get_env_var("CLAUDE_CODE_USE_BEDROCK")
286 .map(|val| val == "true")
287 .unwrap_or(false);
288
289 debug!(
290 use_openai = use_openai,
291 use_ollama = use_ollama,
292 use_bedrock = use_bedrock,
293 "Client selection flags"
294 );
295
296 if use_ollama {
298 let ollama_model = model
299 .or_else(|| get_env_var("OLLAMA_MODEL").ok())
300 .unwrap_or_else(|| "llama2".to_string());
301 let base_url = get_env_var("OLLAMA_BASE_URL").ok();
302 let ai_client = OpenAiAiClient::new_ollama(ollama_model, base_url);
303 return Ok(ClaudeClient::new(Box::new(ai_client)));
304 }
305
306 if use_openai {
308 debug!("Creating OpenAI client");
309 let openai_model = model
310 .or_else(|| get_env_var("OPENAI_MODEL").ok())
311 .unwrap_or_else(|| "gpt-5".to_string());
312 debug!(openai_model = %openai_model, "Selected OpenAI model");
313
314 let api_key = get_env_vars(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"]).map_err(|e| {
315 debug!(error = ?e, "Failed to get OpenAI API key");
316 ClaudeError::ApiKeyNotFound
317 })?;
318 debug!("OpenAI API key found");
319
320 let ai_client = OpenAiAiClient::new_openai(openai_model, api_key);
321 debug!("OpenAI client created successfully");
322 return Ok(ClaudeClient::new(Box::new(ai_client)));
323 }
324
325 let claude_model = model
327 .or_else(|| get_env_var("ANTHROPIC_MODEL").ok())
328 .unwrap_or_else(|| "claude-opus-4-1-20250805".to_string());
329
330 if use_bedrock {
331 let skip_bedrock_auth = get_env_var("CLAUDE_CODE_SKIP_BEDROCK_AUTH")
333 .map(|val| val == "true")
334 .unwrap_or(false);
335
336 if skip_bedrock_auth {
337 let auth_token =
339 get_env_var("ANTHROPIC_AUTH_TOKEN").map_err(|_| ClaudeError::ApiKeyNotFound)?;
340
341 let base_url = get_env_var("ANTHROPIC_BEDROCK_BASE_URL")
342 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
343
344 let ai_client = BedrockAiClient::new(claude_model, auth_token, base_url);
345 return Ok(ClaudeClient::new(Box::new(ai_client)));
346 }
347 }
348
349 debug!("Falling back to Claude client");
351 let api_key = get_env_vars(&[
352 "CLAUDE_API_KEY",
353 "ANTHROPIC_API_KEY",
354 "ANTHROPIC_AUTH_TOKEN",
355 ])
356 .map_err(|_| ClaudeError::ApiKeyNotFound)?;
357
358 let ai_client = ClaudeAiClient::new(claude_model, api_key);
359 debug!("Claude client created successfully");
360 Ok(ClaudeClient::new(Box::new(ai_client)))
361}