Skip to main content

si_git_commit/
llm.rs

1use crate::config::{Config, Provider};
2use crate::error::{Result, SiError};
3use crate::prompt::{build_user_prompt, parse_commit_message, SYSTEM_PROMPT};
4use serde::{Deserialize, Serialize};
5
6#[derive(Serialize, Deserialize, Clone)]
7struct ChatMessage {
8    role: String,
9    content: String,
10}
11
12/// OpenAI Chat Completions request using only current parameters.
13///
14/// Modern models (GPT-5 family, o-series, and late GPT-4.1) reject the
15/// legacy `max_tokens` field and custom `temperature` values. This struct
16/// therefore sends `max_completion_tokens` and lets each model use its
17/// default temperature — the exact fix for:
18/// `Unsupported parameter: 'max_tokens' is not supported with this model.
19///  Use 'max_completion_tokens' instead.`
20#[derive(Serialize)]
21struct OpenAiChatRequest {
22    model: String,
23    messages: Vec<ChatMessage>,
24    max_completion_tokens: u32,
25}
26
27#[derive(Deserialize)]
28struct OpenAiChatChoice {
29    message: ChatMessage,
30}
31
32#[derive(Deserialize)]
33struct OpenAiChatResponse {
34    choices: Vec<OpenAiChatChoice>,
35}
36
37/// Anthropic Messages request. Anthropic's current parameter is
38/// `max_tokens`, so it is kept here — per-provider parameter names.
39#[derive(Serialize)]
40struct AnthropicRequest {
41    model: String,
42    max_tokens: u32,
43    system: String,
44    messages: Vec<ChatMessage>,
45}
46
47#[derive(Deserialize)]
48struct AnthropicContentBlock {
49    #[serde(rename = "type")]
50    kind: String,
51    #[serde(default)]
52    text: String,
53}
54
55#[derive(Deserialize)]
56struct AnthropicResponse {
57    content: Vec<AnthropicContentBlock>,
58}
59
60pub struct Llm {
61    config: Config,
62}
63
64impl Llm {
65    pub fn new(config: Config) -> Self {
66        Self { config }
67    }
68
69    pub fn model(&self) -> &str {
70        &self.config.model
71    }
72
73    pub async fn gen_commit_message(
74        &self,
75        diff: &str,
76        stat: &str,
77    ) -> Result<(String, Option<String>)> {
78        let prompt = build_user_prompt(diff, stat, self.config.message_level);
79        let response = self.call_provider(&prompt).await?;
80        Ok(parse_commit_message(&response))
81    }
82
83    async fn call_provider(&self, prompt: &str) -> Result<String> {
84        match self.config.provider {
85            Provider::Openai | Provider::OpenAiCompatible => {
86                self.call_openai_compatible(prompt).await
87            }
88            Provider::Anthropic => self.call_anthropic(prompt).await,
89        }
90    }
91
92    fn http_client(&self) -> Result<reqwest::Client> {
93        reqwest::Client::builder()
94            .timeout(std::time::Duration::from_secs(60))
95            .build()
96            .map_err(SiError::from)
97    }
98
99    async fn call_openai_compatible(&self, prompt: &str) -> Result<String> {
100        let api_key = self.config.require_api_key()?;
101        let base = self.config.effective_base_url();
102        let url = format!("{base}/chat/completions");
103
104        let request = OpenAiChatRequest {
105            model: self.config.model.clone(),
106            messages: vec![
107                ChatMessage {
108                    role: "system".to_string(),
109                    content: SYSTEM_PROMPT.to_string(),
110                },
111                ChatMessage {
112                    role: "user".to_string(),
113                    content: prompt.to_string(),
114                },
115            ],
116            max_completion_tokens: self.config.max_completion_tokens,
117        };
118
119        let client = self.http_client()?;
120        let response = client
121            .post(&url)
122            .header("Authorization", format!("Bearer {api_key}"))
123            .header("Content-Type", "application/json")
124            .json(&request)
125            .send()
126            .await?;
127
128        if !response.status().is_success() {
129            let status = response.status();
130            let error_text = response
131                .text()
132                .await
133                .unwrap_or_else(|_| "Unknown error".to_string());
134            return Err(SiError::Api {
135                provider: self.config.provider.display_name().to_string(),
136                message: format!("{status}: {error_text}"),
137            });
138        }
139
140        let chat_response: OpenAiChatResponse = response.json().await?;
141        chat_response
142            .choices
143            .first()
144            .map(|choice| choice.message.content.clone())
145            .filter(|c| !c.trim().is_empty())
146            .ok_or_else(|| SiError::Api {
147                provider: self.config.provider.display_name().to_string(),
148                message: "empty response (no choices returned)".to_string(),
149            })
150    }
151
152    async fn call_anthropic(&self, prompt: &str) -> Result<String> {
153        let api_key = self.config.require_api_key()?;
154        let base = self.config.effective_base_url();
155        let url = format!("{base}/messages");
156
157        let request = AnthropicRequest {
158            model: self.config.model.clone(),
159            max_tokens: self.config.max_completion_tokens,
160            system: SYSTEM_PROMPT.to_string(),
161            messages: vec![ChatMessage {
162                role: "user".to_string(),
163                content: prompt.to_string(),
164            }],
165        };
166
167        let client = self.http_client()?;
168        let response = client
169            .post(&url)
170            .header("x-api-key", &api_key)
171            .header("anthropic-version", "2023-06-01")
172            .header("Content-Type", "application/json")
173            .json(&request)
174            .send()
175            .await?;
176
177        if !response.status().is_success() {
178            let status = response.status();
179            let error_text = response
180                .text()
181                .await
182                .unwrap_or_else(|_| "Unknown error".to_string());
183            return Err(SiError::Api {
184                provider: "Anthropic".to_string(),
185                message: format!("{status}: {error_text}"),
186            });
187        }
188
189        let parsed: AnthropicResponse = response.json().await?;
190        let text = parsed
191            .content
192            .into_iter()
193            .filter(|b| b.kind == "text")
194            .map(|b| b.text)
195            .collect::<Vec<_>>()
196            .join("");
197        if text.trim().is_empty() {
198            return Err(SiError::Api {
199                provider: "Anthropic".to_string(),
200                message: "empty response (no text blocks returned)".to_string(),
201            });
202        }
203        Ok(text)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn openai_request_uses_modern_parameters_only() {
213        // Covers both a still-supported 4.1-class model and a current
214        // GPT-5-class model: the payload must be identical in shape.
215        for model in ["gpt-4.1-mini", "gpt-5-mini", "gpt-5.6-luna"] {
216            let request = OpenAiChatRequest {
217                model: model.to_string(),
218                messages: vec![ChatMessage {
219                    role: "user".to_string(),
220                    content: "Write a commit message.".to_string(),
221                }],
222                max_completion_tokens: 800,
223            };
224
225            let body = serde_json::to_value(&request).expect("request should serialize");
226
227            assert_eq!(body["model"].as_str(), Some(model));
228            assert_eq!(body["max_completion_tokens"].as_u64(), Some(800));
229            assert!(
230                body.get("max_tokens").is_none(),
231                "legacy max_tokens must never be sent to OpenAI for {model}"
232            );
233            assert!(
234                body.get("temperature").is_none(),
235                "temperature must use the model default for {model}"
236            );
237        }
238    }
239
240    #[test]
241    fn anthropic_request_uses_max_tokens() {
242        let request = AnthropicRequest {
243            model: "claude-sonnet-4-6".to_string(),
244            max_tokens: 800,
245            system: SYSTEM_PROMPT.to_string(),
246            messages: vec![ChatMessage {
247                role: "user".to_string(),
248                content: "hi".to_string(),
249            }],
250        };
251        let body = serde_json::to_value(&request).expect("request should serialize");
252        assert_eq!(body["max_tokens"].as_u64(), Some(800));
253        assert!(body.get("max_completion_tokens").is_none());
254    }
255}