Skip to main content

ai/
openai.rs

1use std::time::{Duration, Instant};
2
3use async_openai::types::chat::{
4  ChatCompletionNamedToolChoice, ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs, ChatCompletionToolChoiceOption, ChatCompletionTools, CreateChatCompletionRequestArgs
5};
6use async_openai::config::OpenAIConfig;
7use async_openai::Client;
8use async_openai::error::OpenAIError;
9use anyhow::{anyhow, Context, Result};
10use reqwest;
11use futures::future::join_all;
12
13use crate::{commit, config, debug_output, function_calling, profile};
14use crate::model::Model;
15use crate::config::AppConfig;
16use crate::multi_step_integration::generate_commit_message_multi_step;
17
18const MAX_ATTEMPTS: usize = 3;
19
20#[derive(Debug, Clone, PartialEq)]
21pub struct Response {
22  pub response: String
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct Request {
27  pub prompt:     String,
28  pub system:     String,
29  pub max_tokens: u16,
30  pub model:      Model
31}
32
33/// Generates an improved commit message using the provided prompt and diff
34/// Now uses a simplified approach that doesn't require parsing the diff
35pub async fn generate_commit_message(diff: &str) -> Result<String> {
36  profile!("Generate commit message (simplified)");
37
38  // Try to use the simplified approach with OpenAI
39  if let Ok(api_key) = std::env::var("OPENAI_API_KEY") {
40    if !api_key.is_empty() {
41      // Use the commit function directly without parsing
42      match commit::generate(diff.to_string(), 256, Model::GPT41Mini, None).await {
43        Ok(response) => return Ok(response.response.trim().to_string()),
44        Err(e) => {
45          log::warn!("Direct generation failed, falling back to local: {e}");
46        }
47      }
48    }
49  }
50
51  // Fallback to local generation (simplified version)
52  // Count basic statistics from the diff
53  let mut lines_added = 0;
54  let mut lines_removed = 0;
55  let mut files_mentioned = std::collections::HashSet::new();
56
57  for line in diff.lines() {
58    if line.starts_with("diff --git") {
59      // Extract file path from diff --git line
60      let parts: Vec<&str> = line.split_whitespace().collect();
61      if parts.len() >= 4 {
62        let path = parts[3].trim_start_matches("b/");
63        files_mentioned.insert(path);
64      }
65    } else if line.starts_with("+++") || line.starts_with("---") {
66      if let Some(file) = line.split_whitespace().nth(1) {
67        let cleaned = file.trim_start_matches("a/").trim_start_matches("b/");
68        if cleaned != "/dev/null" {
69          files_mentioned.insert(cleaned);
70        }
71      }
72    } else if line.starts_with('+') && !line.starts_with("+++") {
73      lines_added += 1;
74    } else if line.starts_with('-') && !line.starts_with("---") {
75      lines_removed += 1;
76    }
77  }
78
79  // Track in debug session
80  if let Some(session) = debug_output::debug_session() {
81    session.set_total_files_parsed(files_mentioned.len());
82  }
83
84  // Generate a simple commit message based on the diff
85  let message = match files_mentioned.len().cmp(&1) {
86    std::cmp::Ordering::Equal => {
87      let file = files_mentioned
88        .iter()
89        .next()
90        .ok_or_else(|| anyhow::anyhow!("No files mentioned in commit message"))?;
91      if lines_added > 0 && lines_removed == 0 {
92        format!(
93          "Add {} to {}",
94          if lines_added == 1 {
95            "content"
96          } else {
97            "new content"
98          },
99          file
100        )
101      } else if lines_removed > 0 && lines_added == 0 {
102        format!("Remove content from {file}")
103      } else {
104        format!("Update {file}")
105      }
106    }
107    std::cmp::Ordering::Greater => format!("Update {} files", files_mentioned.len()),
108    std::cmp::Ordering::Less => "Update files".to_string()
109  };
110
111  Ok(message.trim().to_string())
112}
113
114/// Creates an OpenAI configuration from application settings
115pub fn create_openai_config(settings: &AppConfig) -> Result<OpenAIConfig> {
116  // Treat whitespace-only base URLs as unset.
117  let base_url = settings
118    .openai_base_url
119    .as_deref()
120    .map(str::trim)
121    .filter(|s| !s.is_empty());
122
123  let api_key = settings.openai_api_key.as_deref().unwrap_or("").trim();
124  let key_missing = api_key.is_empty() || api_key == "<PLACE HOLDER FOR YOUR API KEY>";
125
126  // A custom endpoint (e.g. a local ollama `/v1` server) usually needs no real key, so
127  // supply a placeholder when one isn't configured. The default OpenAI endpoint still
128  // requires a real key.
129  let effective_key = if key_missing {
130    match base_url {
131      Some(_) => "sk-no-key-required",
132      None => return Err(anyhow!("OpenAI API key not configured"))
133    }
134  } else {
135    api_key
136  };
137
138  let mut config = OpenAIConfig::new().with_api_key(effective_key);
139  if let Some(base_url) = base_url {
140    config = config.with_api_base(base_url);
141  }
142
143  Ok(config)
144}
145
146/// Outcome of checking whether a model exists at the configured endpoint.
147#[derive(Debug, PartialEq, Eq)]
148pub enum ModelVerification {
149  /// The model is usable (present in the listing, or a known/deprecated alias).
150  Acceptable,
151  /// The endpoint responded and the model is definitively absent.
152  Absent
153}
154
155/// Pure decision logic for model verification, decoupled from any network IO so it
156/// can be unit-tested with an injected list of available model ids.
157///
158/// * `candidate` - the model name the user is trying to set.
159/// * `available_ids` - model ids returned by the endpoint's `/models` listing.
160/// * `known_or_deprecated` - true if `candidate` maps to a built-in/deprecated model
161///   (those are always acceptable regardless of what the endpoint advertises).
162pub fn classify_model(candidate: &str, available_ids: &[String], known_or_deprecated: bool) -> ModelVerification {
163  if known_or_deprecated {
164    return ModelVerification::Acceptable;
165  }
166
167  let candidate = candidate.trim();
168  if available_ids.iter().any(|id| id == candidate) {
169    ModelVerification::Acceptable
170  } else {
171    ModelVerification::Absent
172  }
173}
174
175/// Verifies that `candidate` exists at the configured endpoint before it is persisted.
176///
177/// Semantics (see F2):
178/// * endpoint responds and model is present (or it's a known/deprecated alias) -> `Ok(())`.
179/// * endpoint responds and model is definitively absent -> `Err(..)` (caller must not save).
180/// * endpoint unreachable / unauthorized / no key configured (can't verify) -> `log::warn!`
181///   and `Ok(())` so offline users are not hard-blocked.
182pub async fn verify_model_exists(settings: &AppConfig, candidate: &str, known_or_deprecated: bool) -> Result<()> {
183  // Known/deprecated aliases are always valid and need no round-trip.
184  if known_or_deprecated {
185    return Ok(());
186  }
187
188  // Without a usable key/config we cannot verify; warn and allow.
189  let config = match create_openai_config(settings) {
190    Ok(config) => config,
191    Err(e) => {
192      log::warn!("Could not verify model '{candidate}' (no usable OpenAI config: {e}); allowing it.");
193      return Ok(());
194    }
195  };
196
197  let client = Client::with_config(config);
198  match client.models().list().await {
199    Ok(list) => {
200      let ids: Vec<String> = list.data.into_iter().map(|m| m.id).collect();
201      match classify_model(candidate, &ids, known_or_deprecated) {
202        ModelVerification::Acceptable => Ok(()),
203        ModelVerification::Absent =>
204          Err(anyhow!(
205            "Model '{candidate}' is not available at the configured endpoint. \
206           Run `git ai config set model <name>` with a model the endpoint offers."
207          )),
208      }
209    }
210    Err(e) => {
211      log::warn!("Could not verify model '{candidate}' (endpoint unreachable/unauthorized: {e}); allowing it.");
212      Ok(())
213    }
214  }
215}
216
217/// Truncates text to fit within token limits
218fn truncate_to_fit(text: &str, max_tokens: usize, model: &Model) -> Result<String> {
219  profile!("Truncate to fit");
220
221  // Fast path: if text is small, just return it
222  if text.len() < 1000 {
223    return Ok(text.to_string());
224  }
225
226  let token_count = model.count_tokens(text)?;
227  if token_count <= max_tokens {
228    return Ok(text.to_string());
229  }
230
231  // Collect character indices to ensure we slice at valid UTF-8 boundaries
232  let char_indices: Vec<(usize, char)> = text.char_indices().collect();
233  if char_indices.is_empty() {
234    return Ok(String::new());
235  }
236
237  // Binary search for the right truncation point
238  let mut low = 0;
239  let mut high = char_indices.len();
240  let mut best_fit = String::new();
241
242  while low < high {
243    let mid = (low + high) / 2;
244
245    // Get the byte index for this character position
246    let byte_index = if mid < char_indices.len() {
247      char_indices[mid].0
248    } else {
249      text.len()
250    };
251
252    let truncated = &text[..byte_index];
253
254    // Find the last complete line
255    if let Some(last_newline_pos) = truncated.rfind('\n') {
256      // Ensure we're at a valid UTF-8 boundary for the newline position
257      let candidate = &text[..last_newline_pos];
258      let candidate_tokens = model.count_tokens(candidate)?;
259
260      if candidate_tokens <= max_tokens {
261        best_fit = candidate.to_string();
262        // Find the character index after the newline
263        let next_char_idx = char_indices
264          .iter()
265          .position(|(idx, _)| *idx > last_newline_pos)
266          .unwrap_or(char_indices.len());
267        low = next_char_idx;
268      } else {
269        // Find the character index of the newline
270        let newline_char_idx = char_indices
271          .iter()
272          .rposition(|(idx, _)| *idx <= last_newline_pos)
273          .unwrap_or(0);
274        high = newline_char_idx;
275      }
276    } else {
277      high = mid;
278    }
279  }
280
281  if best_fit.is_empty() {
282    // If we couldn't find a good truncation point, just take what we can
283    model.truncate(text, max_tokens)
284  } else {
285    Ok(best_fit)
286  }
287}
288
289/// Calls the OpenAI API with the provided configuration
290pub async fn call_with_config(request: Request, config: OpenAIConfig) -> Result<Response> {
291  profile!("OpenAI API call with custom config");
292
293  // Always try multi-step approach first (it's now the default)
294  let client = Client::with_config(config.clone());
295  let model = request.model.to_string();
296
297  match generate_commit_message_multi_step(&client, &model, &request.prompt, config::APP_CONFIG.max_commit_length).await {
298    Ok(message) => return Ok(Response { response: message }),
299    Err(e) => {
300      // Check if it's an API key error and propagate it
301      if e.to_string().contains("invalid_api_key") || e.to_string().contains("Incorrect API key") {
302        return Err(e);
303      }
304      log::warn!("Multi-step approach failed, falling back to single-step: {e}");
305    }
306  }
307
308  // Original single-step implementation as fallback
309  // Create client with timeout if specified
310  let client = if let Some(timeout) = config::APP_CONFIG.timeout {
311    let http_client = reqwest::ClientBuilder::new()
312      .timeout(Duration::from_secs(timeout as u64))
313      .build()?;
314    Client::with_config(config).with_http_client(http_client)
315  } else {
316    Client::with_config(config)
317  };
318
319  // Calculate available tokens using model's context size
320  let system_tokens = request.model.count_tokens(&request.system)?;
321  let model_context_size = request.model.context_size();
322  let available_tokens = model_context_size.saturating_sub(system_tokens + request.max_tokens as usize);
323
324  // Truncate prompt if needed
325  let truncated_prompt = truncate_to_fit(&request.prompt, available_tokens, &request.model)?;
326
327  // Create the commit function tool
328  let commit_tool = function_calling::create_commit_function_tool(config::APP_CONFIG.max_commit_length)?;
329
330  let chat_request = CreateChatCompletionRequestArgs::default()
331    .max_completion_tokens(request.max_tokens as u32)
332    .model(request.model.to_string())
333    .messages([
334      ChatCompletionRequestSystemMessageArgs::default()
335        .content(request.system)
336        .build()?
337        .into(),
338      ChatCompletionRequestUserMessageArgs::default()
339        .content(truncated_prompt)
340        .build()?
341        .into()
342    ])
343    .tools(vec![ChatCompletionTools::Function(commit_tool)])
344    .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("commit")))
345    .build()?;
346
347  let mut last_error = None;
348
349  for attempt in 1..=MAX_ATTEMPTS {
350    log::debug!("OpenAI API attempt {attempt} of {MAX_ATTEMPTS}");
351
352    // Track API call duration
353    let api_start = Instant::now();
354
355    match client.chat().create(chat_request.clone()).await {
356      Ok(response) => {
357        let api_duration = api_start.elapsed();
358
359        // Record API duration in debug session
360        if let Some(session) = debug_output::debug_session() {
361          session.set_api_duration(api_duration);
362        }
363
364        log::debug!("OpenAI API call successful on attempt {attempt}");
365
366        // Extract the response
367        let choice = response
368          .choices
369          .into_iter()
370          .next()
371          .context("No response choices available")?;
372
373        // Check if the model used function calling
374        if let Some(tool_calls) = &choice.message.tool_calls {
375          // Process multiple tool calls in parallel
376          let tool_futures: Vec<_> = tool_calls
377            .iter()
378            .filter_map(|tool_call| {
379              match tool_call {
380                async_openai::types::chat::ChatCompletionMessageToolCalls::Function(call) if call.function.name == "commit" =>
381                  Some(call.function.arguments.clone()),
382                _ => None
383              }
384            })
385            .map(|args| async move { function_calling::parse_commit_function_response(&args) })
386            .collect();
387
388          // Execute all tool calls in parallel
389          let results = join_all(tool_futures).await;
390
391          // Process results and handle errors
392          let mut commit_messages = Vec::new();
393          for (i, result) in results.into_iter().enumerate() {
394            match result {
395              Ok(commit_args) => {
396                // Record commit results in debug session
397                if let Some(session) = debug_output::debug_session() {
398                  session.set_commit_result(commit_args.message.clone(), commit_args.reasoning.clone());
399                  session.set_files_analyzed(commit_args.clone());
400                }
401                commit_messages.push(commit_args.message);
402              }
403              Err(e) => {
404                log::warn!("Failed to parse tool call {i}: {e}");
405              }
406            }
407          }
408
409          // Return the first successful commit message or combine them if multiple
410          if !commit_messages.is_empty() {
411            // For now, return the first message. You could also combine them if needed
412            return Ok(Response {
413              response: commit_messages
414                .into_iter()
415                .next()
416                .ok_or_else(|| anyhow::anyhow!("No commit messages generated"))?
417            });
418          }
419        }
420
421        // Fallback to regular message content if no tool call
422        let content = choice
423          .message
424          .content
425          .clone()
426          .context("No response content available")?;
427
428        return Ok(Response { response: content });
429      }
430      Err(e) => {
431        last_error = Some(e);
432        log::warn!("OpenAI API attempt {attempt} failed");
433
434        // Check if it's an API key error - fail immediately without retrying
435        if let Some(OpenAIError::ApiError(ref api_err)) = last_error.as_ref() {
436          if api_err.api_error.code.as_deref() == Some("invalid_api_key") {
437            let error_msg = format!("Invalid OpenAI API key: {}", api_err.api_error.message);
438            log::error!("{error_msg}");
439            return Err(anyhow!(error_msg));
440          }
441        }
442
443        if attempt < MAX_ATTEMPTS {
444          let delay = Duration::from_millis(500 * attempt as u64);
445          log::debug!("Retrying after {delay:?}");
446          tokio::time::sleep(delay).await;
447        }
448      }
449    }
450  }
451
452  // All attempts failed
453  match last_error {
454    Some(OpenAIError::ApiError(api_err)) => {
455      let error_msg = format!(
456        "OpenAI API error: {} (type: {:?}, code: {:?})",
457        api_err.api_error.message,
458        api_err.api_error.r#type.as_deref().unwrap_or("unknown"),
459        api_err.api_error.code.as_deref().unwrap_or("unknown")
460      );
461      log::error!("{error_msg}");
462      Err(anyhow!(error_msg))
463    }
464    Some(e) => {
465      log::error!("OpenAI request failed: {e}");
466      Err(anyhow!("OpenAI request failed: {}", e))
467    }
468    None => Err(anyhow!("OpenAI request failed after {} attempts", MAX_ATTEMPTS))
469  }
470}
471
472/// Calls the OpenAI API with default configuration from settings
473pub async fn call(request: Request) -> Result<Response> {
474  profile!("OpenAI API call");
475
476  // Create OpenAI configuration using our settings
477  let config = create_openai_config(&config::APP_CONFIG)?;
478
479  // Use the call_with_config function with the default config
480  call_with_config(request, config).await
481}
482
483#[cfg(test)]
484mod tests {
485  use async_openai::config::{Config, OpenAIConfig};
486
487  use super::*;
488
489  fn settings_with(api_key: Option<&str>, base_url: Option<&str>) -> AppConfig {
490    AppConfig {
491      openai_api_key:    api_key.map(|s| s.to_string()),
492      openai_base_url:   base_url.map(|s| s.to_string()),
493      model:             Some("gpt-4.1-mini".to_string()),
494      max_tokens:        Some(1024),
495      max_commit_length: Some(72),
496      timeout:           Some(30)
497    }
498  }
499
500  /// F1: when no base URL is set, the config keeps async-openai's default api_base.
501  #[test]
502  fn test_create_openai_config_omits_base_when_absent() {
503    let settings = settings_with(Some("sk-test-key"), None);
504    let config = create_openai_config(&settings).unwrap();
505    let default_base = OpenAIConfig::new().api_base().to_string();
506    assert_eq!(config.api_base(), default_base);
507  }
508
509  /// F1: when a base URL is set, the config applies it via with_api_base.
510  #[test]
511  fn test_create_openai_config_applies_base_when_present() {
512    let settings = settings_with(Some("sk-test-key"), Some("http://localhost:11434/v1"));
513    let config = create_openai_config(&settings).unwrap();
514    assert_eq!(config.api_base(), "http://localhost:11434/v1");
515  }
516
517  /// F1: an empty base URL string is treated as unset.
518  #[test]
519  fn test_create_openai_config_ignores_empty_base() {
520    let settings = settings_with(Some("sk-test-key"), Some(""));
521    let config = create_openai_config(&settings).unwrap();
522    let default_base = OpenAIConfig::new().api_base().to_string();
523    assert_eq!(config.api_base(), default_base);
524  }
525
526  /// F2: known/deprecated names are acceptable regardless of the endpoint listing.
527  #[test]
528  fn test_classify_model_known_is_acceptable() {
529    assert_eq!(classify_model("gpt-4.1", &[], true), ModelVerification::Acceptable);
530  }
531
532  /// F2: an unknown model present in the endpoint listing is acceptable.
533  #[test]
534  fn test_classify_model_present_is_acceptable() {
535    let ids = vec!["llama3.1:8b".to_string(), "mistral".to_string()];
536    assert_eq!(classify_model("llama3.1:8b", &ids, false), ModelVerification::Acceptable);
537  }
538
539  /// F2: an unknown model definitively absent from the listing is Absent.
540  #[test]
541  fn test_classify_model_absent() {
542    let ids = vec!["llama3.1:8b".to_string()];
543    assert_eq!(classify_model("nonexistent-model", &ids, false), ModelVerification::Absent);
544  }
545
546  /// F2: when there is no usable config (no/placeholder key) we cannot verify, so allow.
547  #[tokio::test]
548  async fn test_verify_model_exists_allows_when_no_config() {
549    let settings = settings_with(None, None);
550    // Unknown model, but no key means we can't verify -> warn + allow (Ok).
551    assert!(verify_model_exists(&settings, "some-model", false)
552      .await
553      .is_ok());
554  }
555
556  /// F2: known/deprecated names short-circuit without any network round-trip.
557  #[tokio::test]
558  async fn test_verify_model_exists_known_short_circuits() {
559    let settings = settings_with(None, None);
560    assert!(verify_model_exists(&settings, "gpt-4.1", true)
561      .await
562      .is_ok());
563  }
564}