Skip to main content

ai/
commit.rs

1use anyhow::{anyhow, bail, Result};
2use maplit::hashmap;
3use mustache;
4use async_openai::Client;
5
6use crate::{config, debug_output, openai, profile};
7use crate::model::Model;
8use crate::config::AppConfig;
9use crate::multi_step_integration::{generate_commit_message_local, generate_commit_message_multi_step};
10
11/// The instruction template included at compile time
12const INSTRUCTION_TEMPLATE: &str = include_str!("../resources/prompt.md");
13
14/// Returns the instruction template for the AI model.
15/// This template guides the model in generating appropriate commit messages.
16///
17/// # Returns
18/// * `Result<String>` - The rendered template or an error
19///
20/// Note: This function is public only for testing purposes
21#[doc(hidden)]
22pub fn get_instruction_template() -> Result<String> {
23  profile!("Generate instruction template");
24  let max_length = config::APP_CONFIG
25    .max_commit_length
26    .unwrap_or(72)
27    .to_string();
28  let template = mustache::compile_str(INSTRUCTION_TEMPLATE)
29    .map_err(|e| anyhow!("Template compilation error: {}", e))?
30    .render_to_string(&hashmap! {
31      "max_length" => max_length
32    })
33    .map_err(|e| anyhow!("Template rendering error: {}", e))?;
34  Ok(template)
35}
36
37/// Creates an OpenAI request for commit message generation.
38///
39/// # Arguments
40/// * `diff` - The git diff to generate a commit message for
41/// * `max_tokens` - Maximum number of tokens allowed for the response
42/// * `model` - The AI model to use for generation
43///
44/// # Returns
45/// * `Result<openai::Request>` - The prepared request
46///
47/// Note: This function is public only for testing purposes
48#[doc(hidden)]
49pub fn create_commit_request(diff: String, max_tokens: usize, model: Model) -> Result<openai::Request> {
50  profile!("Prepare OpenAI request");
51  let template = get_instruction_template()?;
52  Ok(openai::Request {
53    system: template,
54    prompt: diff,
55    max_tokens: max_tokens.try_into().unwrap_or(u16::MAX),
56    model
57  })
58}
59
60/// Generates a commit message using the AI model.
61/// Now uses the multi-step approach by default with fallback to single-step.
62///
63/// # Arguments
64/// * `diff` - The git diff to generate a commit message for
65/// * `max_tokens` - Maximum number of tokens allowed for the response
66/// * `model` - The AI model to use for generation
67/// * `settings` - Optional application settings to customize the request
68///
69/// # Returns
70/// * `Result<openai::Response>` - The generated commit message or an error
71///
72/// # Errors
73/// Returns an error if:
74/// - max_tokens is 0
75/// - OpenAI API call fails
76pub async fn generate(patch: String, remaining_tokens: usize, model: Model, settings: Option<&AppConfig>) -> Result<openai::Response> {
77  profile!("Generate commit message");
78
79  if remaining_tokens == 0 {
80    bail!("Maximum token count must be greater than zero")
81  }
82
83  // Try multi-step approach first
84  let max_length = settings
85    .and_then(|s| s.max_commit_length)
86    .or(config::APP_CONFIG.max_commit_length);
87
88  // Check if we have a valid API key configuration
89  let has_valid_api_key = if let Some(custom_settings) = settings {
90    custom_settings
91      .openai_api_key
92      .as_ref()
93      .map(|key| !key.is_empty() && key != "<PLACE HOLDER FOR YOUR API KEY>")
94      .unwrap_or(false)
95  } else {
96    // Check environment variable or config
97    config::APP_CONFIG
98      .openai_api_key
99      .as_ref()
100      .map(|key| !key.is_empty() && key != "<PLACE HOLDER FOR YOUR API KEY>")
101      .unwrap_or(false)
102      || std::env::var("OPENAI_API_KEY")
103        .map(|key| !key.is_empty())
104        .unwrap_or(false)
105  };
106
107  if !has_valid_api_key {
108    bail!("OpenAI API key not configured. Please set your API key using:\n  git-ai config set openai-api-key <your-key>\nor set the OPENAI_API_KEY environment variable.");
109  }
110
111  // Use custom settings if provided
112  if let Some(custom_settings) = settings {
113    if let Some(api_key) = &custom_settings.openai_api_key {
114      if !api_key.is_empty() && api_key != "<PLACE HOLDER FOR YOUR API KEY>" {
115        match openai::create_openai_config(custom_settings) {
116          Ok(config) => {
117            let client = Client::with_config(config);
118            let model_str = model.to_string();
119
120            match generate_commit_message_multi_step(&client, &model_str, &patch, max_length).await {
121              Ok(message) => return Ok(openai::Response { response: message }),
122              Err(e) => {
123                // Check if it's an API key error
124                if e.to_string().contains("invalid_api_key") || e.to_string().contains("Incorrect API key") {
125                  bail!("Invalid OpenAI API key. Please check your API key configuration.");
126                }
127                log::warn!("Multi-step generation with custom settings failed: {e}");
128                if let Some(session) = debug_output::debug_session() {
129                  session.set_multi_step_error(e.to_string());
130                }
131              }
132            }
133          }
134          Err(e) => {
135            // If config creation fails due to API key, propagate the error
136            return Err(e);
137          }
138        }
139      }
140    }
141  } else {
142    // Default path (no per-request settings): build the client from the stored
143    // configuration so a key set via `git-ai config set openai-api-key` (or a custom
144    // `openai-base-url`) is actually used. Previously this branch only consulted the
145    // `OPENAI_API_KEY` environment variable, so a config-file key was silently ignored
146    // and every commit fell through to the local programmatic generator ("Update <file>").
147    // Fall back to the environment variable when the config holds no usable key.
148    let client = match openai::create_openai_config(&config::APP_CONFIG) {
149      Ok(config) => Some(Client::with_config(config)),
150      Err(_) => match std::env::var("OPENAI_API_KEY") {
151        Ok(key) if !key.is_empty() => Some(Client::new()),
152        _ => None
153      }
154    };
155
156    if let Some(client) = client {
157      let model_str = model.to_string();
158
159      match generate_commit_message_multi_step(&client, &model_str, &patch, max_length).await {
160        Ok(message) => return Ok(openai::Response { response: message }),
161        Err(e) => {
162          // Check if it's an API key error
163          if e.to_string().contains("invalid_api_key") || e.to_string().contains("Incorrect API key") {
164            bail!("Invalid OpenAI API key. Please check your API key configuration.");
165          }
166          log::warn!("Multi-step generation failed: {e}");
167          if let Some(session) = debug_output::debug_session() {
168            session.set_multi_step_error(e.to_string());
169          }
170        }
171      }
172    }
173  }
174
175  // Try local multi-step generation
176  match generate_commit_message_local(&patch, max_length) {
177    Ok(message) => return Ok(openai::Response { response: message }),
178    Err(e) => {
179      log::warn!("Local multi-step generation failed: {e}");
180    }
181  }
182
183  // Mark that we're using single-step fallback
184  if let Some(session) = debug_output::debug_session() {
185    session.set_single_step_success(true);
186  }
187
188  // Fallback to original single-step approach
189  let request = create_commit_request(patch, remaining_tokens, model)?;
190
191  // Use custom settings if provided, otherwise use global config
192  match settings {
193    Some(custom_settings) => {
194      // Create a client with custom settings
195      match openai::create_openai_config(custom_settings) {
196        Ok(config) => openai::call_with_config(request, config).await,
197        Err(e) => Err(e)
198      }
199    }
200    None => {
201      // Use the default global config
202      openai::call(request).await
203    }
204  }
205}
206
207pub fn token_used(model: &Model) -> Result<usize> {
208  get_instruction_token_count(model)
209}
210
211/// Calculates the number of tokens used by the instruction template.
212///
213/// # Arguments
214/// * `model` - The AI model to use for token counting
215///
216/// # Returns
217/// * `Result<usize>` - The number of tokens used or an error
218pub fn get_instruction_token_count(model: &Model) -> Result<usize> {
219  profile!("Calculate instruction tokens");
220  let template = get_instruction_template()?;
221  model.count_tokens(&template)
222}
223
224#[cfg(test)]
225mod tests {
226  use super::*;
227
228  #[tokio::test]
229  async fn test_missing_api_key_error() {
230    // Create settings with no API key
231    let settings = AppConfig {
232      openai_api_key:    None,
233      openai_base_url:   None,
234      model:             Some("gpt-4.1-mini".to_string()),
235      max_tokens:        Some(1024),
236      max_commit_length: Some(72),
237      timeout:           Some(30)
238    };
239
240    // Temporarily clear the environment variable
241    let original_key = std::env::var("OPENAI_API_KEY").ok();
242    std::env::remove_var("OPENAI_API_KEY");
243
244    // Test that generate returns an error for missing API key
245    let result = generate(
246      "diff --git a/test.txt b/test.txt\n+Hello World".to_string(),
247      1024,
248      Model::GPT41Mini,
249      Some(&settings)
250    )
251    .await;
252
253    // Restore original environment variable if it existed
254    if let Some(key) = original_key {
255      std::env::set_var("OPENAI_API_KEY", key);
256    }
257
258    assert!(result.is_err());
259    let error_message = result.unwrap_err().to_string();
260    assert!(
261      error_message.contains("OpenAI API key not configured"),
262      "Expected error message about missing API key, got: {}",
263      error_message
264    );
265  }
266
267  #[tokio::test]
268  async fn test_invalid_api_key_error() {
269    // Create settings with invalid API key
270    let settings = AppConfig {
271      openai_api_key:    Some("<PLACE HOLDER FOR YOUR API KEY>".to_string()),
272      openai_base_url:   None,
273      model:             Some("gpt-4.1-mini".to_string()),
274      max_tokens:        Some(1024),
275      max_commit_length: Some(72),
276      timeout:           Some(30)
277    };
278
279    // Test that generate returns an error for invalid API key
280    let result = generate(
281      "diff --git a/test.txt b/test.txt\n+Hello World".to_string(),
282      1024,
283      Model::GPT41Mini,
284      Some(&settings)
285    )
286    .await;
287
288    assert!(result.is_err());
289    let error_message = result.unwrap_err().to_string();
290    assert!(
291      error_message.contains("OpenAI API key not configured"),
292      "Expected error message about invalid API key, got: {}",
293      error_message
294    );
295  }
296}