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#[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#[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 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}