1use std::{
2 collections::HashMap,
3 path::Path,
4 sync::{LazyLock, OnceLock},
5 time::Duration,
6};
7
8use parking_lot::{Condvar, Mutex};
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10
11use crate::{
12 config::{CommitConfig, ResolvedApiMode},
13 error::{CommitGenError, Result},
14 templates,
15 tokens::TokenCounter,
16 types::{
17 CommitSummary, CommitType, ConventionalAnalysis, ConventionalCommit, coerce_optional_scope,
18 },
19};
20
21static TRACE_ENABLED: LazyLock<bool> =
23 LazyLock::new(|| env_flag_value_enabled(std::env::var("LLM_GIT_TRACE").ok().as_deref()));
24
25static LLM_PROGRESS_ENABLED: LazyLock<bool> = LazyLock::new(|| {
30 env_flag_value_enabled(std::env::var("LLM_GIT_PROGRESS").ok().as_deref()) || trace_enabled()
31});
32
33fn env_flag_value_enabled(value: Option<&str>) -> bool {
34 let Some(value) = value else {
35 return false;
36 };
37
38 !matches!(value.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off")
39}
40
41fn trace_enabled() -> bool {
43 *TRACE_ENABLED
44}
45
46pub(crate) fn llm_progress_enabled() -> bool {
47 *LLM_PROGRESS_ENABLED
48}
49
50pub(crate) fn print_llm_progress(message: impl FnOnce() -> String) {
51 if llm_progress_enabled() {
52 crate::style::print_info(&message());
53 }
54}
55
56const fn api_mode_label(mode: ResolvedApiMode) -> &'static str {
57 match mode {
58 ResolvedApiMode::ChatCompletions => "chat completions",
59 ResolvedApiMode::AnthropicMessages => "Anthropic messages",
60 }
61}
62
63#[tracing::instrument(target = "lgit", name = "api.timed_send", skip_all, fields(operation = label, model))]
68pub async fn timed_send(
69 request_builder: reqwest::RequestBuilder,
70 label: &str,
71 model: &str,
72) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
73 let trace = trace_enabled();
74 let profile = crate::profile::enabled();
75 let start = std::time::Instant::now();
76
77 if profile {
78 tracing::info!(
79 target: crate::profile::TARGET,
80 event = "api_request_started",
81 operation = label,
82 model,
83 );
84 }
85
86 let response = match request_builder.send().await {
87 Ok(response) => response,
88 Err(error) => {
89 if profile {
90 let elapsed = start.elapsed();
91 tracing::warn!(
92 target: crate::profile::TARGET,
93 event = "api_request_failed",
94 operation = label,
95 model,
96 elapsed_ms = elapsed.as_secs_f64() * 1000.0,
97 elapsed_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
98 error = %error,
99 );
100 }
101 return Err(CommitGenError::HttpError(error));
102 },
103 };
104
105 let ttft = start.elapsed();
106 let status = response.status();
107 let content_length = response.content_length();
108
109 let body = match response.text().await {
110 Ok(body) => body,
111 Err(error) => {
112 if profile {
113 let elapsed = start.elapsed();
114 tracing::warn!(
115 target: crate::profile::TARGET,
116 event = "api_response_body_failed",
117 operation = label,
118 model,
119 status = status.as_u16(),
120 elapsed_ms = elapsed.as_secs_f64() * 1000.0,
121 elapsed_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
122 error = %error,
123 );
124 }
125 return Err(CommitGenError::HttpError(error));
126 },
127 };
128 let total = start.elapsed();
129
130 if profile {
131 tracing::info!(
132 target: crate::profile::TARGET,
133 event = "api_request_finished",
134 operation = label,
135 model,
136 status = status.as_u16(),
137 success = status.is_success(),
138 ttft_ms = ttft.as_secs_f64() * 1000.0,
139 ttft_us = u64::try_from(ttft.as_micros()).unwrap_or(u64::MAX),
140 total_ms = total.as_secs_f64() * 1000.0,
141 total_us = u64::try_from(total.as_micros()).unwrap_or(u64::MAX),
142 body_bytes = body.len(),
143 content_length_known = content_length.is_some(),
144 content_length_bytes = content_length.unwrap_or(0),
145 );
146 }
147
148 if trace {
149 let size_info = content_length.map_or_else(
150 || format!("{}B", body.len()),
151 |cl| format!("{}B (content-length: {cl})", body.len()),
152 );
153 if !crate::style::pipe_mode() {
155 print!("\r\x1b[K");
156 std::io::Write::flush(&mut std::io::stdout()).ok();
157 }
158 eprintln!(
159 "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
160 body={size_info}"
161 );
162 }
163
164 Ok((status, body))
165}
166
167#[derive(Default)]
171pub struct AnalysisContext<'a> {
172 pub user_context: Option<&'a str>,
174 pub recent_commits: Option<&'a str>,
176 pub common_scopes: Option<&'a str>,
178 pub project_context: Option<&'a str>,
180 pub debug_output: Option<&'a Path>,
182 pub debug_prefix: Option<&'a str>,
184}
185
186static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
188
189pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
194 CLIENT.get_or_init(|| {
195 reqwest::Client::builder()
196 .timeout(Duration::from_secs(config.request_timeout_secs))
197 .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
198 .build()
199 .expect("Failed to build HTTP client")
200 })
201}
202
203fn debug_filename(prefix: Option<&str>, name: &str) -> String {
204 match prefix {
205 Some(p) if !p.is_empty() => format!("{p}_{name}"),
206 _ => name.to_string(),
207 }
208}
209
210fn response_snippet(body: &str, limit: usize) -> String {
211 if body.is_empty() {
212 return "<empty response body>".to_string();
213 }
214 let mut snippet = body.trim().to_string();
215 if snippet.len() > limit {
216 snippet.truncate(limit);
217 snippet.push_str("...");
218 }
219 snippet
220}
221
222fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
223 let Some(dir) = debug_dir else {
224 return Ok(());
225 };
226
227 std::fs::create_dir_all(dir)?;
228 let path = dir.join(filename);
229 std::fs::write(&path, content)?;
230 Ok(())
231}
232
233fn anthropic_messages_url(base_url: &str) -> String {
234 let trimmed = base_url.trim_end_matches('/');
235 if trimmed.ends_with("/v1") {
236 format!("{trimmed}/messages")
237 } else {
238 format!("{trimmed}/v1/messages")
239 }
240}
241
242fn prompt_cache_control() -> PromptCacheControl {
243 PromptCacheControl { control_type: "ephemeral".to_string() }
244}
245
246fn anthropic_prompt_caching_enabled(config: &CommitConfig) -> bool {
247 config.api_base_url.to_lowercase().contains("anthropic.com")
248}
249
250fn append_anthropic_cache_beta_header(
251 request_builder: reqwest::RequestBuilder,
252 enable_cache: bool,
253) -> reqwest::RequestBuilder {
254 if enable_cache {
255 request_builder.header("anthropic-beta", "prompt-caching-2024-07-31")
256 } else {
257 request_builder
258 }
259}
260
261fn anthropic_text_content(text: String, cache: bool) -> AnthropicContent {
262 AnthropicContent {
263 content_type: "text".to_string(),
264 text,
265 cache_control: cache.then(prompt_cache_control),
266 }
267}
268
269fn anthropic_system_content(system_prompt: &str, cache: bool) -> Option<Vec<AnthropicContent>> {
270 if system_prompt.trim().is_empty() {
271 None
272 } else {
273 Some(vec![anthropic_text_content(system_prompt.to_string(), cache)])
274 }
275}
276
277fn supports_openai_prompt_cache_key(config: &CommitConfig) -> bool {
278 config
279 .api_base_url
280 .to_lowercase()
281 .contains("api.openai.com")
282}
283
284pub fn openai_prompt_cache_key(
286 config: &CommitConfig,
287 model_name: &str,
288 prompt_family: &str,
289 prompt_variant: &str,
290 system_prompt: &str,
291) -> Option<String> {
292 if system_prompt.trim().is_empty() || !supports_openai_prompt_cache_key(config) {
293 return None;
294 }
295
296 Some(format!("llm-git:v1:{model_name}:{prompt_family}:{prompt_variant}"))
297}
298
299pub fn strict_json_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
300 serde_json::json!({
301 "type": "object",
302 "properties": properties,
303 "required": required,
304 "additionalProperties": false
305 })
306}
307
308pub fn openai_response_format(name: &str, schema: serde_json::Value) -> serde_json::Value {
309 serde_json::json!({
310 "type": "json_schema",
311 "json_schema": {
312 "name": name,
313 "strict": true,
314 "schema": schema
315 }
316 })
317}
318
319pub fn anthropic_output_format(schema: serde_json::Value) -> serde_json::Value {
320 serde_json::json!({
321 "type": "json_schema",
322 "schema": schema
323 })
324}
325
326pub(crate) fn extract_json_from_content(content: &str) -> String {
327 let trimmed = content.trim();
328
329 if trimmed.is_empty() {
330 return String::new();
331 }
332
333 if let Some(start) = trimmed.find("```json") {
334 let after_marker = &trimmed[start + 7..];
335 if let Some(end) = after_marker.find("```") {
336 return after_marker[..end].trim().to_string();
337 }
338 }
339
340 if let Some(start) = trimmed.find("```") {
341 let after_marker = &trimmed[start + 3..];
342 let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
343 let after_newline = &after_marker[content_start..];
344 if let Some(end) = after_newline.find("```") {
345 return after_newline[..end].trim().to_string();
346 }
347 }
348
349 if let Some(start) = trimmed.find('{')
350 && let Some(end) = trimmed.rfind('}')
351 && end >= start
352 {
353 return trimmed[start..=end].to_string();
354 }
355
356 trimmed.to_string()
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum OneShotSource {
361 StructuredOutput,
362 ToolCall,
363 OutputJsonParse,
364 PlainTextContent,
365 Cache,
366}
367
368#[derive(Debug, Clone, Copy)]
369pub struct OneShotDebug<'a> {
370 pub dir: Option<&'a Path>,
371 pub prefix: Option<&'a str>,
372 pub name: &'a str,
373}
374
375#[derive(Debug, Clone, Copy)]
376pub struct OneShotSpec<'a> {
377 pub operation: &'a str,
378 pub model: &'a str,
379 pub max_tokens: u32,
380 pub temperature: f32,
381 pub prompt_family: &'a str,
382 pub prompt_variant: &'a str,
383 pub system_prompt: &'a str,
384 pub user_prompt: &'a str,
385 pub tool_name: &'a str,
386 pub tool_description: &'a str,
387 pub schema: &'a serde_json::Value,
388 pub progress_label: Option<&'a str>,
389 pub debug: Option<OneShotDebug<'a>>,
390 pub cacheable: bool,
393}
394
395#[derive(Debug)]
396pub struct OneShotResponse<T> {
397 pub output: T,
398 pub source: OneShotSource,
399 pub text_content: Option<String>,
400 pub stop_reason: Option<String>,
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum OneShotRequestKind {
405 StructuredOutput,
406 ToolCalling,
407}
408
409impl OneShotRequestKind {
410 const fn debug_label(self) -> &'static str {
411 match self {
412 Self::StructuredOutput => "structured",
413 Self::ToolCalling => "tool",
414 }
415 }
416
417 const fn progress_label(self) -> &'static str {
418 match self {
419 Self::StructuredOutput => "structured output",
420 Self::ToolCalling => "tool call",
421 }
422 }
423
424 const fn content_source(self) -> OneShotSource {
425 match self {
426 Self::StructuredOutput => OneShotSource::StructuredOutput,
427 Self::ToolCalling => OneShotSource::OutputJsonParse,
428 }
429 }
430}
431
432fn oneshot_progress_label<'a>(spec: &OneShotSpec<'a>) -> &'a str {
433 spec.progress_label.unwrap_or(spec.operation)
434}
435
436const fn estimate_prompt_text_tokens(spec: &OneShotSpec<'_>) -> usize {
437 spec
438 .system_prompt
439 .len()
440 .saturating_add(spec.user_prompt.len())
441 .saturating_add(3)
442 / 4
443}
444
445const fn prompt_text_chars(spec: &OneShotSpec<'_>) -> usize {
446 spec
447 .system_prompt
448 .len()
449 .saturating_add(spec.user_prompt.len())
450}
451
452fn format_count(count: usize) -> String {
453 if count >= 10_000 {
454 format!("{:.1}k", count as f64 / 1000.0)
455 } else {
456 count.to_string()
457 }
458}
459
460fn format_elapsed(elapsed: Duration) -> String {
461 if elapsed.as_secs() > 0 {
462 format!("{:.1}s", elapsed.as_secs_f64())
463 } else {
464 format!("{}ms", elapsed.as_millis())
465 }
466}
467
468fn format_bytes(bytes: usize) -> String {
469 if bytes >= 1024 * 1024 {
470 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
471 } else if bytes >= 1024 {
472 format!("{:.1}KB", bytes as f64 / 1024.0)
473 } else {
474 format!("{bytes}B")
475 }
476}
477
478fn format_llm_query_progress(
479 spec: &OneShotSpec<'_>,
480 mode: ResolvedApiMode,
481 kind: OneShotRequestKind,
482) -> String {
483 format!(
484 "LLM query: {} \u{2192} {} ({}/{}, {}, {}, prompt ~{} tokens/{} chars, max {} output tokens)",
485 oneshot_progress_label(spec),
486 spec.model,
487 spec.prompt_family,
488 spec.prompt_variant,
489 api_mode_label(mode),
490 kind.progress_label(),
491 format_count(estimate_prompt_text_tokens(spec)),
492 format_count(prompt_text_chars(spec)),
493 spec.max_tokens
494 )
495}
496
497fn format_llm_response_progress(
498 spec: &OneShotSpec<'_>,
499 status: reqwest::StatusCode,
500 elapsed: Duration,
501 body_bytes: usize,
502) -> String {
503 format!(
504 "LLM response: {} \u{2190} {} (HTTP {}, {}, {})",
505 oneshot_progress_label(spec),
506 spec.model,
507 status.as_u16(),
508 format_elapsed(elapsed),
509 format_bytes(body_bytes)
510 )
511}
512
513fn format_llm_cache_progress(spec: &OneShotSpec<'_>) -> String {
514 format!(
515 "LLM cache hit: {} \u{2192} {} ({}/{})",
516 oneshot_progress_label(spec),
517 spec.model,
518 spec.prompt_family,
519 spec.prompt_variant
520 )
521}
522
523enum OneShotRequestOutcome {
524 Response { request_json: String, response_text: String },
525 Retry,
526 FallbackToTool,
527}
528
529enum OneShotParseOutcome<T> {
530 Success(OneShotResponse<T>),
531 Retry,
532 Fatal(CommitGenError),
533}
534
535fn save_oneshot_debug<T: Serialize>(
536 debug: Option<OneShotDebug<'_>>,
537 kind: OneShotRequestKind,
538 phase: &str,
539 value: &T,
540) -> Result<()> {
541 let Some(debug) = debug else {
542 return Ok(());
543 };
544
545 let filename = debug_filename(
546 debug.prefix,
547 &format!("{}_{}_{}.json", debug.name, kind.debug_label(), phase),
548 );
549 let json = serde_json::to_string_pretty(value)?;
550 save_debug_output(debug.dir, &filename, &json)
551}
552
553fn save_oneshot_debug_text(
554 debug: Option<OneShotDebug<'_>>,
555 kind: OneShotRequestKind,
556 phase: &str,
557 text: &str,
558) -> Result<()> {
559 let Some(debug) = debug else {
560 return Ok(());
561 };
562
563 let filename = debug_filename(
564 debug.prefix,
565 &format!("{}_{}_{}.json", debug.name, kind.debug_label(), phase),
566 );
567 save_debug_output(debug.dir, &filename, text)
568}
569
570fn schema_properties(schema: &serde_json::Value) -> Result<serde_json::Value> {
571 schema
572 .get("properties")
573 .cloned()
574 .ok_or_else(|| CommitGenError::Other("Schema must include top-level properties".to_string()))
575}
576
577fn schema_required(schema: &serde_json::Value) -> Result<Vec<String>> {
578 schema
579 .get("required")
580 .and_then(|value| value.as_array())
581 .ok_or_else(|| {
582 CommitGenError::Other("Schema must include top-level required array".to_string())
583 })
584 .and_then(|values| {
585 values
586 .iter()
587 .map(|value| {
588 value.as_str().map(str::to_string).ok_or_else(|| {
589 CommitGenError::Other("Schema required entries must be strings".to_string())
590 })
591 })
592 .collect()
593 })
594}
595
596fn build_openai_tool(
597 tool_name: &str,
598 tool_description: &str,
599 schema: &serde_json::Value,
600) -> Result<Tool> {
601 Ok(Tool {
602 tool_type: "function".to_string(),
603 function: Function {
604 name: tool_name.to_string(),
605 description: tool_description.to_string(),
606 parameters: FunctionParameters {
607 param_type: "object".to_string(),
608 properties: schema_properties(schema)?,
609 required: schema_required(schema)?,
610 },
611 },
612 })
613}
614
615fn build_anthropic_tool(
616 tool_name: &str,
617 tool_description: &str,
618 schema: &serde_json::Value,
619 prompt_caching: bool,
620 kind: OneShotRequestKind,
621) -> AnthropicTool {
622 let mut tool = AnthropicTool {
623 name: tool_name.to_string(),
624 description: tool_description.to_string(),
625 input_schema: schema.clone(),
626 cache_control: None,
627 };
628
629 if kind == OneShotRequestKind::ToolCalling && prompt_caching {
630 tool.cache_control = Some(prompt_cache_control());
631 }
632
633 tool
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637enum StructuredOutputCapability {
638 Probing,
639 Supported,
640 Unsupported,
641}
642
643struct StructuredOutputCapabilityCache {
644 states: Mutex<HashMap<String, StructuredOutputCapability>>,
645 condvar: Condvar,
646}
647
648static STRUCTURED_OUTPUT_CAPABILITIES: LazyLock<StructuredOutputCapabilityCache> =
649 LazyLock::new(|| StructuredOutputCapabilityCache {
650 states: Mutex::new(HashMap::new()),
651 condvar: Condvar::new(),
652 });
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655enum StructuredOutputAttempt {
656 Probe,
657 Supported,
658 SkipUnsupported,
659}
660
661fn structured_output_cache_key(
662 config: &CommitConfig,
663 model: &str,
664 mode: ResolvedApiMode,
665) -> String {
666 format!(
667 "{:?}:{}:{}",
668 mode,
669 config.api_base_url.trim().to_lowercase(),
670 model.trim().to_lowercase()
671 )
672}
673
674fn begin_structured_output_attempt(
675 config: &CommitConfig,
676 model: &str,
677 mode: ResolvedApiMode,
678) -> StructuredOutputAttempt {
679 let key = structured_output_cache_key(config, model, mode);
680
681 loop {
682 let mut states = STRUCTURED_OUTPUT_CAPABILITIES.states.lock();
683 match states.get(&key).copied() {
684 Some(StructuredOutputCapability::Unsupported) => {
685 return StructuredOutputAttempt::SkipUnsupported;
686 },
687 Some(StructuredOutputCapability::Supported) => {
688 return StructuredOutputAttempt::Supported;
689 },
690 Some(StructuredOutputCapability::Probing) => {
691 STRUCTURED_OUTPUT_CAPABILITIES.condvar.wait(&mut states);
692 },
693 None => {
694 states.insert(key.clone(), StructuredOutputCapability::Probing);
695 return StructuredOutputAttempt::Probe;
696 },
697 }
698 }
699}
700
701fn update_structured_output_capability(
702 config: &CommitConfig,
703 model: &str,
704 mode: ResolvedApiMode,
705 state: Option<StructuredOutputCapability>,
706) -> bool {
707 let key = structured_output_cache_key(config, model, mode);
708 let mut states = STRUCTURED_OUTPUT_CAPABILITIES.states.lock();
709 let previous = match state {
710 Some(state) => states.insert(key, state),
711 None => states.remove(&key),
712 };
713 STRUCTURED_OUTPUT_CAPABILITIES.condvar.notify_all();
714
715 matches!(state, Some(StructuredOutputCapability::Unsupported))
716 && previous != Some(StructuredOutputCapability::Unsupported)
717}
718
719fn is_official_anthropic_base_url(api_base_url: &str) -> bool {
720 api_base_url
721 .trim()
722 .to_lowercase()
723 .contains("api.anthropic.com")
724}
725
726fn is_anthropic_model(model: &str) -> bool {
727 let lower = model.trim().to_lowercase();
728 lower.starts_with("claude")
729 || lower.starts_with("anthropic/")
730 || lower.contains("/claude")
731 || lower.contains("anthropic.claude")
732}
733
734fn prefers_tool_calling_over_structured_output(model: &str) -> bool {
735 let lower = model.trim().to_lowercase();
736 lower.contains("codex-spark")
737}
738
739fn should_attempt_structured_output(config: &CommitConfig, model: &str) -> bool {
740 if prefers_tool_calling_over_structured_output(model) {
741 return false;
742 }
743
744 !is_anthropic_model(model) || is_official_anthropic_base_url(&config.api_base_url)
745}
746
747fn should_fallback_to_tool(status: reqwest::StatusCode, body: &str) -> bool {
748 if matches!(status.as_u16(), 401 | 403 | 429) {
749 return false;
750 }
751
752 let lower = body.to_lowercase();
753 [
754 "response_format",
755 "output_format",
756 "output_config",
757 "structured output",
758 "structured_outputs",
759 "json_schema",
760 "responsejsonschema",
761 "response schema",
762 ]
763 .iter()
764 .any(|needle| lower.contains(needle))
765}
766
767fn is_context_length_error(body: &str) -> bool {
768 let lower = body.to_lowercase();
769 [
770 "context_length_exceeded",
771 "context window",
772 "maximum context length",
773 "exceeds the context",
774 "input exceeds",
775 "prompt is too long",
776 "too many tokens",
777 ]
778 .iter()
779 .any(|needle| lower.contains(needle))
780}
781
782async fn send_oneshot_request(
783 config: &CommitConfig,
784 spec: &OneShotSpec<'_>,
785 mode: ResolvedApiMode,
786 kind: OneShotRequestKind,
787 capture_request: bool,
788) -> Result<OneShotRequestOutcome> {
789 print_llm_progress(|| format_llm_query_progress(spec, mode, kind));
790 match mode {
791 ResolvedApiMode::ChatCompletions => {
792 let tool = build_openai_tool(spec.tool_name, spec.tool_description, spec.schema)?;
793 let prompt_cache_key = openai_prompt_cache_key(
794 config,
795 spec.model,
796 spec.prompt_family,
797 spec.prompt_variant,
798 spec.system_prompt,
799 );
800 let mut messages = Vec::new();
801 if !spec.system_prompt.trim().is_empty() {
802 messages.push(Message {
803 role: "system".to_string(),
804 content: spec.system_prompt.to_string(),
805 });
806 }
807 messages
808 .push(Message { role: "user".to_string(), content: spec.user_prompt.to_string() });
809
810 let request = ApiRequest {
811 model: spec.model.to_string(),
812 max_tokens: spec.max_tokens,
813 temperature: spec.temperature,
814 tools: if kind == OneShotRequestKind::ToolCalling {
815 vec![tool]
816 } else {
817 Vec::new()
818 },
819 tool_choice: (kind == OneShotRequestKind::ToolCalling)
820 .then(|| serde_json::json!("required")),
821 response_format: (kind == OneShotRequestKind::StructuredOutput)
822 .then(|| openai_response_format(spec.tool_name, spec.schema.clone())),
823 prompt_cache_key,
824 messages,
825 };
826
827 save_oneshot_debug(spec.debug, kind, "request", &request)?;
828
829 let client = get_client(config);
830 let mut request_builder = client
831 .post(format!("{}/chat/completions", config.api_base_url))
832 .header("content-type", "application/json");
833
834 if let Some(api_key) = &config.api_key {
835 request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
836 }
837
838 let request_json = if capture_request {
839 serde_json::to_string(&request).unwrap_or_default()
840 } else {
841 String::new()
842 };
843 let request_start = std::time::Instant::now();
844 let (status, response_text) =
845 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
846 print_llm_progress(|| {
847 format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
848 });
849 save_oneshot_debug_text(spec.debug, kind, "response", &response_text)?;
850 if !status.is_success() && is_context_length_error(&response_text) {
851 return Err(CommitGenError::ApiContextLengthExceeded {
852 operation: spec.operation.to_string(),
853 model: spec.model.to_string(),
854 status: status.as_u16(),
855 body: response_text,
856 });
857 }
858
859 if status.is_server_error() {
860 if kind == OneShotRequestKind::StructuredOutput
861 && should_fallback_to_tool(status, &response_text)
862 {
863 crate::style::warn(&format!(
864 "Structured output request failed for {} (HTTP {}). Falling back to tool \
865 calling.",
866 spec.operation, status
867 ));
868 return Ok(OneShotRequestOutcome::FallbackToTool);
869 }
870 eprintln!(
871 "{}",
872 crate::style::error(&format!("Server error {status}: {response_text}"))
873 );
874 return Ok(OneShotRequestOutcome::Retry);
875 }
876
877 if !status.is_success() {
878 if kind == OneShotRequestKind::StructuredOutput
879 && should_fallback_to_tool(status, &response_text)
880 {
881 crate::style::warn(&format!(
882 "Structured output request failed for {} (HTTP {}). Falling back to tool \
883 calling.",
884 spec.operation, status
885 ));
886 return Ok(OneShotRequestOutcome::FallbackToTool);
887 }
888 return Err(CommitGenError::ApiError {
889 status: status.as_u16(),
890 body: response_text,
891 });
892 }
893
894 if response_text.trim().is_empty() {
895 crate::style::warn(&format!(
896 "Model returned empty response body for {}; retrying.",
897 spec.operation
898 ));
899 return Ok(OneShotRequestOutcome::Retry);
900 }
901
902 Ok(OneShotRequestOutcome::Response { request_json, response_text })
903 },
904 ResolvedApiMode::AnthropicMessages => {
905 let prompt_caching = anthropic_prompt_caching_enabled(config);
906 let tools = if kind == OneShotRequestKind::ToolCalling {
907 vec![build_anthropic_tool(
908 spec.tool_name,
909 spec.tool_description,
910 spec.schema,
911 prompt_caching,
912 kind,
913 )]
914 } else {
915 Vec::new()
916 };
917 let request = AnthropicRequest {
918 model: spec.model.to_string(),
919 max_tokens: spec.max_tokens,
920 temperature: spec.temperature,
921 system: anthropic_system_content(spec.system_prompt, prompt_caching),
922 tools,
923 tool_choice: (kind == OneShotRequestKind::ToolCalling).then(|| AnthropicToolChoice {
924 choice_type: "tool".to_string(),
925 name: spec.tool_name.to_string(),
926 }),
927 output_format: (kind == OneShotRequestKind::StructuredOutput)
928 .then(|| anthropic_output_format(spec.schema.clone())),
929 messages: vec![AnthropicMessage {
930 role: "user".to_string(),
931 content: vec![anthropic_text_content(spec.user_prompt.to_string(), false)],
932 }],
933 };
934
935 save_oneshot_debug(spec.debug, kind, "request", &request)?;
936
937 let client = get_client(config);
938 let mut request_builder = append_anthropic_cache_beta_header(
939 client
940 .post(anthropic_messages_url(&config.api_base_url))
941 .header("content-type", "application/json")
942 .header("anthropic-version", "2023-06-01"),
943 prompt_caching,
944 );
945
946 if let Some(api_key) = &config.api_key {
947 request_builder = request_builder.header("x-api-key", api_key);
948 }
949
950 let request_json = if capture_request {
951 serde_json::to_string(&request).unwrap_or_default()
952 } else {
953 String::new()
954 };
955 let request_start = std::time::Instant::now();
956 let (status, response_text) =
957 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
958 print_llm_progress(|| {
959 format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
960 });
961 save_oneshot_debug_text(spec.debug, kind, "response", &response_text)?;
962 if !status.is_success() && is_context_length_error(&response_text) {
963 return Err(CommitGenError::ApiContextLengthExceeded {
964 operation: spec.operation.to_string(),
965 model: spec.model.to_string(),
966 status: status.as_u16(),
967 body: response_text,
968 });
969 }
970
971 if status.is_server_error() {
972 if kind == OneShotRequestKind::StructuredOutput
973 && should_fallback_to_tool(status, &response_text)
974 {
975 crate::style::warn(&format!(
976 "Structured output request failed for {} (HTTP {}). Falling back to tool \
977 calling.",
978 spec.operation, status
979 ));
980 return Ok(OneShotRequestOutcome::FallbackToTool);
981 }
982 eprintln!(
983 "{}",
984 crate::style::error(&format!("Server error {status}: {response_text}"))
985 );
986 return Ok(OneShotRequestOutcome::Retry);
987 }
988
989 if !status.is_success() {
990 if kind == OneShotRequestKind::StructuredOutput
991 && should_fallback_to_tool(status, &response_text)
992 {
993 crate::style::warn(&format!(
994 "Structured output request failed for {} (HTTP {}). Falling back to tool \
995 calling.",
996 spec.operation, status
997 ));
998 return Ok(OneShotRequestOutcome::FallbackToTool);
999 }
1000 return Err(CommitGenError::ApiError {
1001 status: status.as_u16(),
1002 body: response_text,
1003 });
1004 }
1005
1006 if response_text.trim().is_empty() {
1007 crate::style::warn(&format!(
1008 "Model returned empty response body for {}; retrying.",
1009 spec.operation
1010 ));
1011 return Ok(OneShotRequestOutcome::Retry);
1012 }
1013
1014 Ok(OneShotRequestOutcome::Response { request_json, response_text })
1015 },
1016 }
1017}
1018
1019fn parse_json_output<T: DeserializeOwned>(json_text: &str, error_label: &str) -> Result<T> {
1020 let candidate = extract_json_from_content(json_text);
1021 serde_json::from_str(&candidate).map_err(|e| {
1022 CommitGenError::Other(format!(
1023 "Failed to parse {error_label}: {e}. Content: {}",
1024 response_snippet(&candidate, 500)
1025 ))
1026 })
1027}
1028
1029fn normalize_plain_text_content(content: &str) -> String {
1030 let trimmed = content.trim();
1031
1032 if let Some(start) = trimmed.find("```") {
1033 let after_marker = &trimmed[start + 3..];
1034 let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
1035 let after_newline = &after_marker[content_start..];
1036 if let Some(end) = after_newline.find("```") {
1037 return after_newline[..end].trim().to_string();
1038 }
1039 }
1040
1041 trimmed.to_string()
1042}
1043
1044fn parse_plain_text_output<T: DeserializeOwned>(
1045 tool_name: &str,
1046 content: &str,
1047) -> Result<Option<T>> {
1048 let trimmed = normalize_plain_text_content(content);
1049 if trimmed.is_empty() {
1050 return Ok(None);
1051 }
1052
1053 let value = match tool_name {
1054 "create_commit_summary" => serde_json::json!({ "summary": trimmed }),
1055 _ => return Ok(None),
1056 };
1057
1058 serde_json::from_value(value).map(Some).map_err(|e| {
1059 CommitGenError::Other(format!(
1060 "Failed to parse {tool_name} plain-text fallback: {e}. Content: {}",
1061 response_snippet(&trimmed, 500)
1062 ))
1063 })
1064}
1065
1066fn extract_anthropic_content(
1067 response_text: &str,
1068 tool_name: &str,
1069) -> Result<(Option<serde_json::Value>, String, Option<String>)> {
1070 let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
1071 CommitGenError::Other(format!(
1072 "Failed to parse Anthropic response JSON: {e}. Response body: {}",
1073 response_snippet(response_text, 500)
1074 ))
1075 })?;
1076
1077 let stop_reason = value
1078 .get("stop_reason")
1079 .and_then(|v| v.as_str())
1080 .map(str::to_string);
1081
1082 let mut tool_input: Option<serde_json::Value> = None;
1083 let mut text_parts = Vec::new();
1084
1085 if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
1086 for item in content {
1087 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
1088 match item_type {
1089 "tool_use" => {
1090 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
1091 if name == tool_name
1092 && let Some(input) = item.get("input")
1093 {
1094 tool_input = Some(input.clone());
1095 }
1096 },
1097 "text" => {
1098 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
1099 text_parts.push(text.to_string());
1100 }
1101 },
1102 _ => {},
1103 }
1104 }
1105 }
1106
1107 Ok((tool_input, text_parts.join("\n"), stop_reason))
1108}
1109
1110fn parse_oneshot_response<T: DeserializeOwned>(
1111 mode: ResolvedApiMode,
1112 kind: OneShotRequestKind,
1113 tool_name: &str,
1114 operation: &str,
1115 response_text: &str,
1116) -> OneShotParseOutcome<T> {
1117 match mode {
1118 ResolvedApiMode::ChatCompletions => {
1119 let api_response: ApiResponse = match serde_json::from_str(response_text) {
1120 Ok(response) => response,
1121 Err(e) => {
1122 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
1123 "Failed to parse {operation} response JSON: {e}. Response body: {}",
1124 response_snippet(response_text, 500)
1125 )));
1126 },
1127 };
1128
1129 if api_response.choices.is_empty() {
1130 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
1131 "API returned empty response for {operation}"
1132 )));
1133 }
1134
1135 let message = &api_response.choices[0].message;
1136 if let Some(refusal) = &message.refusal {
1137 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
1138 "Model refused {operation}: {refusal}"
1139 )));
1140 }
1141
1142 let mut last_error: Option<CommitGenError> = None;
1143
1144 if let Some(tool_call) = message.tool_calls.first()
1145 && tool_call.function.name.ends_with(tool_name)
1146 {
1147 let args = tool_call.function.arguments.trim();
1148 if args.is_empty() {
1149 last_error = Some(CommitGenError::Other(format!(
1150 "Model returned empty function arguments for {operation}"
1151 )));
1152 } else {
1153 match serde_json::from_str::<T>(args) {
1154 Ok(output) => {
1155 return OneShotParseOutcome::Success(OneShotResponse {
1156 output,
1157 source: OneShotSource::ToolCall,
1158 text_content: message.content.clone(),
1159 stop_reason: None,
1160 });
1161 },
1162 Err(e) => {
1163 last_error = Some(CommitGenError::Other(format!(
1164 "Failed to parse {operation} tool arguments: {e}. Args: {}",
1165 response_snippet(args, 500)
1166 )));
1167 },
1168 }
1169 }
1170 }
1171
1172 if let Some(content) = &message.content {
1173 if content.trim().is_empty() {
1174 return OneShotParseOutcome::Retry;
1175 }
1176
1177 match parse_json_output::<T>(content, &format!("{operation} content JSON")) {
1178 Ok(output) => {
1179 return OneShotParseOutcome::Success(OneShotResponse {
1180 output,
1181 source: kind.content_source(),
1182 text_content: Some(content.clone()),
1183 stop_reason: None,
1184 });
1185 },
1186 Err(err) => match parse_plain_text_output::<T>(tool_name, content) {
1187 Ok(Some(output)) => {
1188 return OneShotParseOutcome::Success(OneShotResponse {
1189 output,
1190 source: OneShotSource::PlainTextContent,
1191 text_content: Some(content.clone()),
1192 stop_reason: None,
1193 });
1194 },
1195 Ok(None) => last_error = Some(err),
1196 Err(fallback_err) => last_error = Some(fallback_err),
1197 },
1198 }
1199 }
1200
1201 OneShotParseOutcome::Fatal(last_error.unwrap_or_else(|| {
1202 CommitGenError::Other(format!("No {operation} found in API response"))
1203 }))
1204 },
1205 ResolvedApiMode::AnthropicMessages => {
1206 let (tool_input, text_content, stop_reason) =
1207 match extract_anthropic_content(response_text, tool_name) {
1208 Ok(content) => content,
1209 Err(err) => return OneShotParseOutcome::Fatal(err),
1210 };
1211
1212 let mut last_error: Option<CommitGenError> = None;
1213
1214 if let Some(input) = tool_input {
1215 match serde_json::from_value::<T>(input) {
1216 Ok(output) => {
1217 return OneShotParseOutcome::Success(OneShotResponse {
1218 output,
1219 source: OneShotSource::ToolCall,
1220 text_content: (!text_content.is_empty()).then_some(text_content),
1221 stop_reason,
1222 });
1223 },
1224 Err(e) => {
1225 last_error = Some(CommitGenError::Other(format!(
1226 "Failed to parse {operation} tool input: {e}. Response body: {}",
1227 response_snippet(response_text, 500)
1228 )));
1229 },
1230 }
1231 }
1232
1233 if text_content.trim().is_empty() {
1234 return OneShotParseOutcome::Retry;
1235 }
1236
1237 match parse_json_output::<T>(&text_content, &format!("{operation} content JSON")) {
1238 Ok(output) => OneShotParseOutcome::Success(OneShotResponse {
1239 output,
1240 source: kind.content_source(),
1241 text_content: Some(text_content),
1242 stop_reason,
1243 }),
1244 Err(err) => match parse_plain_text_output::<T>(tool_name, &text_content) {
1245 Ok(Some(output)) => OneShotParseOutcome::Success(OneShotResponse {
1246 output,
1247 source: OneShotSource::PlainTextContent,
1248 text_content: Some(text_content),
1249 stop_reason,
1250 }),
1251 Ok(None) => OneShotParseOutcome::Fatal(last_error.unwrap_or(err)),
1252 Err(fallback_err) => OneShotParseOutcome::Fatal(last_error.unwrap_or(fallback_err)),
1253 },
1254 }
1255 },
1256 }
1257}
1258
1259#[tracing::instrument(target = "lgit", name = "api.run_oneshot", skip_all, fields(operation = spec.operation, model = spec.model, prompt_family = spec.prompt_family, prompt_variant = spec.prompt_variant))]
1260pub async fn run_oneshot<T>(
1261 config: &CommitConfig,
1262 spec: &OneShotSpec<'_>,
1263) -> Result<OneShotResponse<T>>
1264where
1265 T: DeserializeOwned + Serialize,
1266{
1267 let cache_entry = build_cache_entry(config, spec);
1268 if let Some((cache, key)) = cache_entry.as_ref()
1269 && let Some(stored) = cache.get(key)
1270 && let Ok(output) = serde_json::from_str::<T>(&stored)
1271 {
1272 print_llm_progress(|| format_llm_cache_progress(spec));
1273 return Ok(OneShotResponse {
1274 output,
1275 source: OneShotSource::Cache,
1276 text_content: None,
1277 stop_reason: None,
1278 });
1279 }
1280 let capture_request = cache_entry.is_some();
1284 let (response, request_json): (OneShotResponse<T>, Option<String>) =
1285 retry_api_call(config, async move || {
1286 let mode = config.resolved_api_mode(spec.model);
1287 let structured_attempt = if should_attempt_structured_output(config, spec.model) {
1288 begin_structured_output_attempt(config, spec.model, mode)
1289 } else {
1290 StructuredOutputAttempt::SkipUnsupported
1291 };
1292
1293 let structured_result = match structured_attempt {
1294 StructuredOutputAttempt::SkipUnsupported => None,
1295 StructuredOutputAttempt::Probe | StructuredOutputAttempt::Supported => {
1296 let request_result = send_oneshot_request(
1297 config,
1298 spec,
1299 mode,
1300 OneShotRequestKind::StructuredOutput,
1301 capture_request,
1302 )
1303 .await;
1304 let request_outcome = match request_result {
1305 Ok(outcome) => outcome,
1306 Err(err) => {
1307 if structured_attempt == StructuredOutputAttempt::Probe {
1308 let _ = update_structured_output_capability(config, spec.model, mode, None);
1309 }
1310 return Err(err);
1311 },
1312 };
1313
1314 match request_outcome {
1315 OneShotRequestOutcome::Response { request_json, response_text } => {
1316 if structured_attempt == StructuredOutputAttempt::Probe {
1317 let _ = update_structured_output_capability(
1318 config,
1319 spec.model,
1320 mode,
1321 Some(StructuredOutputCapability::Supported),
1322 );
1323 }
1324 Some((request_json, response_text))
1325 },
1326 OneShotRequestOutcome::Retry => {
1327 if structured_attempt == StructuredOutputAttempt::Probe {
1328 let _ = update_structured_output_capability(config, spec.model, mode, None);
1329 }
1330 return Ok((true, None));
1331 },
1332 OneShotRequestOutcome::FallbackToTool => {
1333 let first_detection = update_structured_output_capability(
1334 config,
1335 spec.model,
1336 mode,
1337 Some(StructuredOutputCapability::Unsupported),
1338 );
1339 if first_detection {
1340 crate::style::warn(&format!(
1341 "Structured outputs unsupported for model {}. Using tool calling for \
1342 the remainder of this run.",
1343 spec.model
1344 ));
1345 }
1346 None
1347 },
1348 }
1349 },
1350 };
1351
1352 if let Some((request_json, response_text)) = structured_result {
1353 match parse_oneshot_response::<T>(
1354 mode,
1355 OneShotRequestKind::StructuredOutput,
1356 spec.tool_name,
1357 spec.operation,
1358 &response_text,
1359 ) {
1360 OneShotParseOutcome::Success(output) => {
1361 if output.source == OneShotSource::PlainTextContent {
1362 let first_detection = update_structured_output_capability(
1363 config,
1364 spec.model,
1365 mode,
1366 Some(StructuredOutputCapability::Unsupported),
1367 );
1368 if first_detection {
1369 crate::style::warn(&format!(
1370 "Structured outputs unsupported for model {}. Using tool calling for \
1371 the remainder of this run.",
1372 spec.model
1373 ));
1374 }
1375 }
1376 return Ok((false, Some((output, Some(request_json)))));
1377 },
1378 OneShotParseOutcome::Retry => return Ok((true, None)),
1379 OneShotParseOutcome::Fatal(err) => {
1380 crate::style::warn(&format!(
1381 "Structured output parse failed for {}. Falling back to tool calling: {}",
1382 spec.operation, err
1383 ));
1384 },
1385 }
1386 }
1387
1388 let (request_json, response_text) = match send_oneshot_request(
1389 config,
1390 spec,
1391 mode,
1392 OneShotRequestKind::ToolCalling,
1393 capture_request,
1394 )
1395 .await?
1396 {
1397 OneShotRequestOutcome::Response { request_json, response_text } => {
1398 (request_json, response_text)
1399 },
1400 OneShotRequestOutcome::Retry => return Ok((true, None)),
1401 OneShotRequestOutcome::FallbackToTool => {
1402 return Err(CommitGenError::Other(format!(
1403 "Tool-calling fallback recursively requested for {}",
1404 spec.operation
1405 )));
1406 },
1407 };
1408
1409 match parse_oneshot_response::<T>(
1410 mode,
1411 OneShotRequestKind::ToolCalling,
1412 spec.tool_name,
1413 spec.operation,
1414 &response_text,
1415 ) {
1416 OneShotParseOutcome::Success(output) => Ok((false, Some((output, Some(request_json))))),
1417 OneShotParseOutcome::Retry => Ok((true, None)),
1418 OneShotParseOutcome::Fatal(err) => Err(err),
1419 }
1420 })
1421 .await?;
1422
1423 if let Some((cache, key)) = cache_entry.as_ref()
1424 && let Ok(payload) = serde_json::to_string(&response.output)
1425 {
1426 cache.put(key, spec.model, spec.operation, request_json.as_deref().unwrap_or(""), &payload);
1427 }
1428
1429 Ok(response)
1430}
1431
1432fn build_cache_entry(
1433 config: &CommitConfig,
1434 spec: &OneShotSpec<'_>,
1435) -> Option<(std::sync::Arc<crate::llm_cache::LlmCache>, String)> {
1436 if !spec.cacheable {
1437 return None;
1438 }
1439 let cache = crate::llm_cache::global()?;
1440 let mode = config.resolved_api_mode(spec.model);
1441 let api_mode = match mode {
1442 ResolvedApiMode::ChatCompletions => "chat-completions",
1443 ResolvedApiMode::AnthropicMessages => "anthropic-messages",
1444 };
1445 let key = crate::llm_cache::compute_key(&crate::llm_cache::CacheMaterial {
1446 operation: spec.operation,
1447 model: spec.model,
1448 tool_name: spec.tool_name,
1449 tool_description: spec.tool_description,
1450 system_prompt: spec.system_prompt,
1451 user_prompt: spec.user_prompt,
1452 schema: spec.schema,
1453 temperature: spec.temperature,
1454 max_tokens: spec.max_tokens,
1455 api_mode,
1456 });
1457 Some((cache, key))
1458}
1459
1460#[derive(Debug, Serialize)]
1461struct Message {
1462 role: String,
1463 content: String,
1464}
1465
1466#[derive(Debug, Serialize, Deserialize)]
1467struct FunctionParameters {
1468 #[serde(rename = "type")]
1469 param_type: String,
1470 properties: serde_json::Value,
1471 required: Vec<String>,
1472}
1473
1474#[derive(Debug, Serialize, Deserialize)]
1475struct Function {
1476 name: String,
1477 description: String,
1478 parameters: FunctionParameters,
1479}
1480
1481#[derive(Debug, Serialize, Deserialize)]
1482struct Tool {
1483 #[serde(rename = "type")]
1484 tool_type: String,
1485 function: Function,
1486}
1487
1488#[derive(Debug, Serialize)]
1489struct ApiRequest {
1490 model: String,
1491 max_tokens: u32,
1492 temperature: f32,
1493 #[serde(skip_serializing_if = "Vec::is_empty")]
1494 tools: Vec<Tool>,
1495 #[serde(skip_serializing_if = "Option::is_none")]
1496 tool_choice: Option<serde_json::Value>,
1497 #[serde(skip_serializing_if = "Option::is_none")]
1498 response_format: Option<serde_json::Value>,
1499 #[serde(skip_serializing_if = "Option::is_none")]
1500 prompt_cache_key: Option<String>,
1501 messages: Vec<Message>,
1502}
1503
1504#[derive(Debug, Serialize)]
1505struct AnthropicRequest {
1506 model: String,
1507 max_tokens: u32,
1508 temperature: f32,
1509 #[serde(skip_serializing_if = "Option::is_none")]
1510 system: Option<Vec<AnthropicContent>>,
1511 #[serde(skip_serializing_if = "Vec::is_empty")]
1512 tools: Vec<AnthropicTool>,
1513 #[serde(skip_serializing_if = "Option::is_none")]
1514 tool_choice: Option<AnthropicToolChoice>,
1515 #[serde(skip_serializing_if = "Option::is_none")]
1516 output_format: Option<serde_json::Value>,
1517 messages: Vec<AnthropicMessage>,
1518}
1519
1520#[derive(Debug, Clone, Serialize)]
1521struct PromptCacheControl {
1522 #[serde(rename = "type")]
1523 control_type: String,
1524}
1525
1526#[derive(Debug, Serialize)]
1527struct AnthropicTool {
1528 name: String,
1529 description: String,
1530 input_schema: serde_json::Value,
1531 #[serde(skip_serializing_if = "Option::is_none")]
1532 cache_control: Option<PromptCacheControl>,
1533}
1534
1535#[derive(Debug, Serialize)]
1536struct AnthropicToolChoice {
1537 #[serde(rename = "type")]
1538 choice_type: String,
1539 name: String,
1540}
1541
1542#[derive(Debug, Serialize)]
1543struct AnthropicMessage {
1544 role: String,
1545 content: Vec<AnthropicContent>,
1546}
1547
1548#[derive(Debug, Clone, Serialize)]
1549struct AnthropicContent {
1550 #[serde(rename = "type")]
1551 content_type: String,
1552 text: String,
1553 #[serde(skip_serializing_if = "Option::is_none")]
1554 cache_control: Option<PromptCacheControl>,
1555}
1556
1557#[derive(Debug, Deserialize)]
1558struct ToolCall {
1559 function: FunctionCall,
1560}
1561
1562#[derive(Debug, Deserialize)]
1563struct FunctionCall {
1564 name: String,
1565 arguments: String,
1566}
1567
1568#[derive(Debug, Deserialize)]
1569struct Choice {
1570 message: ResponseMessage,
1571}
1572
1573#[derive(Debug, Deserialize)]
1574struct ResponseMessage {
1575 #[serde(default)]
1576 tool_calls: Vec<ToolCall>,
1577 #[serde(default)]
1578 content: Option<String>,
1579 #[serde(default)]
1580 refusal: Option<String>,
1581}
1582
1583#[derive(Debug, Deserialize)]
1584struct ApiResponse {
1585 choices: Vec<Choice>,
1586}
1587
1588#[derive(Debug, Clone, Serialize, Deserialize)]
1589struct SummaryOutput {
1590 summary: String,
1591}
1592
1593#[derive(Debug, Clone, Serialize, Deserialize)]
1594struct FastCommitOutput {
1595 #[serde(rename = "type")]
1596 commit_type: String,
1597 #[serde(default)]
1598 scope: Option<String>,
1599 summary: String,
1600 #[serde(default)]
1601 details: Vec<String>,
1602}
1603
1604const fn should_retry_error(error: &CommitGenError) -> bool {
1605 !matches!(error, CommitGenError::ApiContextLengthExceeded { .. })
1606}
1607#[tracing::instrument(target = "lgit", name = "api.retry", skip_all, fields(max_retries = config.max_retries))]
1609pub async fn retry_api_call<T>(
1610 config: &CommitConfig,
1611 mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
1612) -> Result<T> {
1613 let mut attempt = 0;
1614
1615 loop {
1616 attempt += 1;
1617 if crate::profile::enabled() {
1618 tracing::info!(
1619 target: crate::profile::TARGET,
1620 event = "api_retry_attempt_started",
1621 attempt,
1622 max_retries = config.max_retries,
1623 );
1624 }
1625
1626 match f().await {
1627 Ok((false, Some(result))) => return Ok(result),
1628 Ok((false, None)) => {
1629 return Err(CommitGenError::Other("API call failed without result".to_string()));
1630 },
1631 Ok((true, _)) if attempt < config.max_retries => {
1632 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1633 if crate::profile::enabled() {
1634 tracing::warn!(
1635 target: crate::profile::TARGET,
1636 event = "api_retry_scheduled",
1637 attempt,
1638 max_retries = config.max_retries,
1639 backoff_ms,
1640 reason = "retryable_response",
1641 );
1642 }
1643 eprintln!(
1644 "{}",
1645 crate::style::warning(&format!(
1646 "Retry {}/{} after {}ms...",
1647 attempt, config.max_retries, backoff_ms
1648 ))
1649 );
1650 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1651 },
1652 Ok((true, _last_err)) => {
1653 return Err(CommitGenError::ApiRetryExhausted {
1654 retries: config.max_retries,
1655 source: Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
1656 });
1657 },
1658 Err(e) => {
1659 if !should_retry_error(&e) {
1660 return Err(e);
1661 }
1662
1663 if attempt < config.max_retries {
1664 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1665 if crate::profile::enabled() {
1666 tracing::warn!(
1667 target: crate::profile::TARGET,
1668 event = "api_retry_scheduled",
1669 attempt,
1670 max_retries = config.max_retries,
1671 backoff_ms,
1672 reason = "error",
1673 error = %e,
1674 );
1675 }
1676 eprintln!(
1677 "{}",
1678 crate::style::warning(&format!(
1679 "Error: {} - Retry {}/{} after {}ms...",
1680 e, attempt, config.max_retries, backoff_ms
1681 ))
1682 );
1683 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1684 continue;
1685 }
1686 return Err(e);
1687 },
1688 }
1689 }
1690}
1691
1692pub fn format_types_description(config: &CommitConfig) -> String {
1695 use std::fmt::Write;
1696 let mut out = String::from("Check types in order (first match wins):\n\n");
1697
1698 for (name, tc) in &config.types {
1699 let _ = writeln!(out, "**{name}**: {}", tc.description);
1700 if !tc.diff_indicators.is_empty() {
1701 let _ = writeln!(out, " Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
1702 }
1703 if !tc.file_patterns.is_empty() {
1704 let _ = writeln!(out, " File patterns: {}", tc.file_patterns.join(", "));
1705 }
1706 for ex in &tc.examples {
1707 let _ = writeln!(out, " - {ex}");
1708 }
1709 if !tc.hint.is_empty() {
1710 let _ = writeln!(out, " Note: {}", tc.hint);
1711 }
1712 out.push('\n');
1713 }
1714
1715 if !config.classifier_hint.is_empty() {
1716 let _ = writeln!(out, "\n{}", config.classifier_hint);
1717 }
1718
1719 out
1720}
1721
1722#[tracing::instrument(target = "lgit", name = "api.generate_conventional_analysis", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1724pub async fn generate_conventional_analysis<'a>(
1725 stat: &'a str,
1726 diff: &'a str,
1727 model_name: &'a str,
1728 scope_candidates_str: &'a str,
1729 ctx: &AnalysisContext<'a>,
1730 config: &'a CommitConfig,
1731) -> Result<ConventionalAnalysis> {
1732 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1733
1734 let analysis_schema = strict_json_schema(
1735 serde_json::json!({
1736 "type": {
1737 "type": "string",
1738 "enum": type_enum,
1739 "description": "Commit type based on change classification"
1740 },
1741 "scope": {
1742 "type": "string",
1743 "description": "Optional scope (module/component). Omit if unclear or multi-component."
1744 },
1745 "summary": {
1746 "type": "string",
1747 "description": format!(
1748 "Umbrella commit summary without type/scope prefix or trailing period; target {} chars, hard limit {}.",
1749 config.summary_guideline,
1750 config.summary_hard_limit
1751 ),
1752 "maxLength": config.summary_hard_limit
1753 },
1754 "details": {
1755 "type": "array",
1756 "description": "Array of 0-6 detail items with changelog metadata.",
1757 "items": {
1758 "type": "object",
1759 "properties": {
1760 "text": {
1761 "type": "string",
1762 "description": "Detail about change, starting with past-tense verb, ending with period"
1763 },
1764 "changelog_category": {
1765 "type": "string",
1766 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
1767 "description": "Changelog category if user-visible. Omit for internal changes."
1768 },
1769 "user_visible": {
1770 "type": "boolean",
1771 "description": "True if this change affects users/API and should appear in changelog"
1772 }
1773 },
1774 "required": ["text", "user_visible"]
1775 }
1776 },
1777 "issue_refs": {
1778 "type": "array",
1779 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
1780 "items": { "type": "string" }
1781 }
1782 }),
1783 &["type", "summary", "details", "issue_refs"],
1784 );
1785
1786 let types_desc = format_types_description(config);
1787 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
1788 variant: &config.analysis_prompt_variant,
1789 stat,
1790 diff,
1791 scope_candidates: scope_candidates_str,
1792 recent_commits: ctx.recent_commits,
1793 common_scopes: ctx.common_scopes,
1794 types_description: Some(&types_desc),
1795 project_context: ctx.project_context,
1796 })?;
1797
1798 let user_prompt = if let Some(user_ctx) = ctx.user_context {
1799 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
1800 } else {
1801 parts.user
1802 };
1803
1804 let response = run_oneshot::<ConventionalAnalysis>(config, &OneShotSpec {
1805 operation: "analysis",
1806 model: model_name,
1807 max_tokens: 1200,
1808 temperature: config.temperature,
1809 prompt_family: "analysis",
1810 prompt_variant: &config.analysis_prompt_variant,
1811 system_prompt: &parts.system,
1812 user_prompt: &user_prompt,
1813 tool_name: "create_conventional_analysis",
1814 tool_description: "Analyze changes and classify as conventional commit with type, scope, \
1815 summary, details, and metadata",
1816 schema: &analysis_schema,
1817 progress_label: Some("analysis"),
1818 debug: Some(OneShotDebug {
1819 dir: ctx.debug_output,
1820 prefix: ctx.debug_prefix,
1821 name: "analysis",
1822 }),
1823 cacheable: true,
1824 })
1825 .await?;
1826
1827 Ok(response.output)
1828}
1829
1830pub fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
1835 let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
1836 let prefix = format!("{commit_type}{scope_part}: ");
1837
1838 summary
1839 .strip_prefix(&prefix)
1840 .or_else(|| {
1841 let prefix_no_scope = format!("{commit_type}: ");
1843 summary.strip_prefix(&prefix_no_scope)
1844 })
1845 .unwrap_or(summary)
1846 .to_string()
1847}
1848
1849pub fn summary_from_holistic_analysis(
1854 analysis: &ConventionalAnalysis,
1855 config: &CommitConfig,
1856) -> Result<Option<CommitSummary>> {
1857 let Some(raw_summary) = analysis
1858 .summary
1859 .as_deref()
1860 .map(str::trim)
1861 .filter(|summary| !summary.is_empty())
1862 else {
1863 return Ok(None);
1864 };
1865
1866 let cleaned = strip_type_prefix(
1867 raw_summary,
1868 analysis.commit_type.as_str(),
1869 analysis.scope.as_ref().map(|scope| scope.as_str()),
1870 );
1871
1872 CommitSummary::new(cleaned, config.summary_hard_limit).map(Some)
1873}
1874
1875fn validate_summary_quality(
1877 summary: &str,
1878 commit_type: &str,
1879 stat: &str,
1880) -> std::result::Result<(), String> {
1881 use crate::validation::is_past_tense_verb;
1882
1883 let first_word = summary
1884 .split_whitespace()
1885 .next()
1886 .ok_or_else(|| "summary is empty".to_string())?;
1887
1888 let first_word_lower = first_word.to_lowercase();
1889
1890 if !is_past_tense_verb(&first_word_lower) {
1892 return Err(format!(
1893 "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
1894 ));
1895 }
1896
1897 if first_word_lower == commit_type {
1899 return Err(format!("repeats commit type '{commit_type}' in summary"));
1900 }
1901
1902 let file_exts: Vec<&str> = stat
1904 .lines()
1905 .filter_map(|line| {
1906 let path = line.split('|').next()?.trim();
1907 std::path::Path::new(path).extension()?.to_str()
1908 })
1909 .collect();
1910
1911 if !file_exts.is_empty() {
1912 let total = file_exts.len();
1913 let md_count = file_exts.iter().filter(|&&e| e == "md").count();
1914
1915 if md_count * 100 / total > 80 && commit_type != "docs" {
1917 crate::style::warn(&format!(
1918 "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
1919 md_count * 100 / total,
1920 commit_type
1921 ));
1922 }
1923
1924 let code_exts = [
1926 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
1928 "java", "kt", "kts", "scala", "groovy", "clj", "cljs", "cs", "fs", "vb", "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", "py", "pyx", "pxd", "pyi", "rb", "rake", "gemspec", "php", "go", "swift", "m", "mm", "lua", "sh", "bash", "zsh", "fish", "pl", "pm", "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
1941 "lisp", "cl", "el", "scm", "rkt", "jl", "r", "R", "dart", "cr", "d", "f", "f90", "f95", "f03", "f08", "ada", "adb", "ads", "cob", "cbl", "asm", "s", "S", "sql", "plsql", "pl", "pro", "re", "rei", "nix", "tf", "hcl", "sol", "move", "cairo",
1960 ];
1961 let code_count = file_exts
1962 .iter()
1963 .filter(|&&e| code_exts.contains(&e))
1964 .count();
1965 if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
1966 crate::style::warn(&format!(
1967 "Type mismatch: no code files changed but type is '{commit_type}'"
1968 ));
1969 }
1970 }
1971
1972 Ok(())
1973}
1974
1975#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
1977#[tracing::instrument(target = "lgit", name = "api.generate_summary_from_analysis", skip_all, fields(commit_type, scope = ?scope, detail_count = details.len(), model = %config.summary_model))]
1978pub async fn generate_summary_from_analysis<'a>(
1979 stat: &'a str,
1980 commit_type: &'a str,
1981 scope: Option<&'a str>,
1982 details: &'a [String],
1983 user_context: Option<&'a str>,
1984 config: &'a CommitConfig,
1985 debug_dir: Option<&'a Path>,
1986 debug_prefix: Option<&'a str>,
1987) -> Result<CommitSummary> {
1988 let mut validation_attempt = 0;
1989 let max_validation_retries = 1;
1990 let mut last_failure_reason: Option<String> = None;
1991
1992 loop {
1993 let additional_constraint = if let Some(reason) = &last_failure_reason {
1994 format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1995 } else {
1996 String::new()
1997 };
1998
1999 let bullet_points = details.join("\n");
2000 let details_str = if bullet_points.is_empty() {
2001 "None (no supporting detail points were generated)."
2002 } else {
2003 bullet_points.as_str()
2004 };
2005
2006 let scope_str = scope.unwrap_or("");
2007 let prefix_len =
2008 commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 };
2009 let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
2010
2011 let parts = templates::render_summary_prompt(
2012 &config.summary_prompt_variant,
2013 commit_type,
2014 scope_str,
2015 &max_summary_len.to_string(),
2016 details_str,
2017 stat.trim(),
2018 user_context,
2019 )?;
2020
2021 let user_prompt = format!("{}{additional_constraint}", parts.user);
2022 let summary_schema = strict_json_schema(
2023 serde_json::json!({
2024 "summary": {
2025 "type": "string",
2026 "description": format!(
2027 "Single line summary, target {} chars (hard limit {}), past tense verb first.",
2028 config.summary_guideline,
2029 config.summary_hard_limit
2030 ),
2031 "maxLength": config.summary_hard_limit
2032 }
2033 }),
2034 &["summary"],
2035 );
2036
2037 let response = run_oneshot::<SummaryOutput>(config, &OneShotSpec {
2038 operation: "summary",
2039 model: &config.summary_model,
2040 max_tokens: 200,
2041 temperature: config.temperature,
2042 prompt_family: "summary",
2043 prompt_variant: &config.summary_prompt_variant,
2044 system_prompt: &parts.system,
2045 user_prompt: &user_prompt,
2046 tool_name: "create_commit_summary",
2047 tool_description: "Compose a git commit summary line from detail statements",
2048 schema: &summary_schema,
2049 progress_label: Some("summary"),
2050 debug: Some(OneShotDebug {
2051 dir: debug_dir,
2052 prefix: debug_prefix,
2053 name: "summary",
2054 }),
2055 cacheable: true,
2056 })
2057 .await;
2058
2059 match response {
2060 Ok(response) => {
2061 let cleaned = strip_type_prefix(&response.output.summary, commit_type, scope);
2062 let summary = CommitSummary::new(cleaned, config.summary_hard_limit)?;
2063
2064 match validate_summary_quality(summary.as_str(), commit_type, stat) {
2065 Ok(()) => return Ok(summary),
2066 Err(reason) if validation_attempt < max_validation_retries => {
2067 crate::style::warn(&format!(
2068 "Validation failed (attempt {}/{}): {}",
2069 validation_attempt + 1,
2070 max_validation_retries + 1,
2071 reason
2072 ));
2073 last_failure_reason = Some(reason);
2074 validation_attempt += 1;
2075 },
2076 Err(reason) => {
2077 crate::style::warn(&format!(
2078 "Validation failed after {} retries: {}. Using fallback.",
2079 max_validation_retries + 1,
2080 reason
2081 ));
2082 return Ok(fallback_from_details_or_summary(
2083 details,
2084 summary.as_str(),
2085 commit_type,
2086 config,
2087 ));
2088 },
2089 }
2090 },
2091 Err(e) => return Err(e),
2092 }
2093 }
2094}
2095
2096fn fallback_from_details_or_summary(
2098 details: &[String],
2099 invalid_summary: &str,
2100 commit_type: &str,
2101 config: &CommitConfig,
2102) -> CommitSummary {
2103 let candidate = if let Some(first_detail) = details.first() {
2104 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
2106
2107 let type_word_variants =
2109 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
2110 for variant in &type_word_variants {
2111 if cleaned
2112 .to_lowercase()
2113 .starts_with(&format!("{} ", variant.to_lowercase()))
2114 {
2115 cleaned = cleaned[variant.len()..].trim().to_string();
2116 break;
2117 }
2118 }
2119
2120 cleaned
2121 } else {
2122 let mut cleaned = invalid_summary
2124 .split_whitespace()
2125 .skip(1) .collect::<Vec<_>>()
2127 .join(" ");
2128
2129 if cleaned.is_empty() {
2130 cleaned = fallback_summary("", details, commit_type, config)
2131 .as_str()
2132 .to_string();
2133 }
2134
2135 cleaned
2136 };
2137
2138 let with_verb = if candidate
2140 .split_whitespace()
2141 .next()
2142 .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
2143 {
2144 candidate
2145 } else {
2146 let verb = match commit_type {
2147 "feat" => "added",
2148 "fix" => "fixed",
2149 "refactor" => "restructured",
2150 "docs" => "documented",
2151 "test" => "tested",
2152 "perf" => "optimized",
2153 "build" | "ci" | "chore" => "updated",
2154 "style" => "formatted",
2155 "revert" => "reverted",
2156 _ => "changed",
2157 };
2158 format!("{verb} {candidate}")
2159 };
2160
2161 CommitSummary::new(with_verb, config.summary_hard_limit)
2162 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
2163}
2164
2165pub fn fallback_summary(
2167 stat: &str,
2168 details: &[String],
2169 commit_type: &str,
2170 config: &CommitConfig,
2171) -> CommitSummary {
2172 let mut candidate = if let Some(first) = details.first() {
2173 first.trim().trim_end_matches('.').to_string()
2174 } else {
2175 let primary_line = stat
2176 .lines()
2177 .map(str::trim)
2178 .find(|line| !line.is_empty())
2179 .unwrap_or("files");
2180
2181 let subject = primary_line
2182 .split('|')
2183 .next()
2184 .map(str::trim)
2185 .filter(|s| !s.is_empty())
2186 .unwrap_or("files");
2187
2188 if subject.eq_ignore_ascii_case("files") {
2189 "Updated files".to_string()
2190 } else {
2191 format!("Updated {subject}")
2192 }
2193 };
2194
2195 candidate = candidate
2196 .replace(['\n', '\r'], " ")
2197 .split_whitespace()
2198 .collect::<Vec<_>>()
2199 .join(" ")
2200 .trim()
2201 .trim_end_matches('.')
2202 .trim_end_matches(';')
2203 .trim_end_matches(':')
2204 .to_string();
2205
2206 if candidate.is_empty() {
2207 candidate = "Updated files".to_string();
2208 }
2209
2210 const CONSERVATIVE_MAX: usize = 50;
2213 while candidate.len() > CONSERVATIVE_MAX {
2214 if let Some(pos) = candidate.rfind(' ') {
2215 candidate.truncate(pos);
2216 candidate = candidate.trim_end_matches(',').trim().to_string();
2217 } else {
2218 candidate.truncate(CONSERVATIVE_MAX);
2219 break;
2220 }
2221 }
2222
2223 candidate = candidate.trim_end_matches('.').to_string();
2225
2226 if candidate
2229 .split_whitespace()
2230 .next()
2231 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
2232 {
2233 candidate = match commit_type {
2234 "refactor" => "restructured change".to_string(),
2235 "feat" => "added functionality".to_string(),
2236 "fix" => "fixed issue".to_string(),
2237 "docs" => "documented updates".to_string(),
2238 "test" => "tested changes".to_string(),
2239 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
2240 "perf" => "optimized performance".to_string(),
2241 "revert" => "reverted previous commit".to_string(),
2242 _ => "updated files".to_string(),
2243 };
2244 }
2245
2246 CommitSummary::new(candidate, config.summary_hard_limit)
2249 .expect("fallback summary should always be valid")
2250}
2251
2252#[tracing::instrument(target = "lgit", name = "api.generate_analysis_with_map_reduce", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
2257pub async fn generate_analysis_with_map_reduce<'a>(
2258 stat: &'a str,
2259 diff: &'a str,
2260 model_name: &'a str,
2261 scope_candidates_str: &'a str,
2262 ctx: &AnalysisContext<'a>,
2263 config: &'a CommitConfig,
2264 counter: &TokenCounter,
2265) -> Result<ConventionalAnalysis> {
2266 use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
2267
2268 if should_use_map_reduce(diff, config, counter) {
2269 crate::style::print_info(&format!(
2270 "Large diff detected ({} tokens), using map-reduce...",
2271 counter.count_sync(diff)
2272 ));
2273 run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
2274 } else {
2275 generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
2276 .await
2277 }
2278}
2279
2280#[tracing::instrument(target = "lgit", name = "api.generate_fast_commit", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
2284pub async fn generate_fast_commit(
2285 stat: &str,
2286 diff: &str,
2287 model_name: &str,
2288 scope_candidates_str: &str,
2289 user_context: Option<&str>,
2290 config: &CommitConfig,
2291 debug_dir: Option<&Path>,
2292) -> Result<ConventionalCommit> {
2293 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
2294
2295 let parts = templates::render_fast_prompt(&templates::FastPromptParams {
2296 variant: "default",
2297 stat,
2298 diff,
2299 scope_candidates: scope_candidates_str,
2300 user_context,
2301 })?;
2302
2303 let fast_schema = strict_json_schema(
2304 serde_json::json!({
2305 "type": {
2306 "type": "string",
2307 "enum": type_enum,
2308 "description": "Conventional commit type"
2309 },
2310 "scope": {
2311 "type": "string",
2312 "description": "Optional scope. Omit if unclear or cross-cutting."
2313 },
2314 "summary": {
2315 "type": "string",
2316 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
2317 },
2318 "details": {
2319 "type": "array",
2320 "items": { "type": "string" },
2321 "description": "0-3 past-tense detail sentences ending with period"
2322 }
2323 }),
2324 &["type", "summary", "details"],
2325 );
2326
2327 let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
2328 operation: "fast",
2329 model: model_name,
2330 max_tokens: 500,
2331 temperature: config.temperature,
2332 prompt_family: "fast",
2333 prompt_variant: "default",
2334 system_prompt: &parts.system,
2335 user_prompt: &parts.user,
2336 tool_name: "create_fast_commit",
2337 tool_description: "Generate a conventional commit from the given diff",
2338 schema: &fast_schema,
2339 progress_label: Some("fast commit"),
2340 debug: Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
2341 cacheable: true,
2342 })
2343 .await?;
2344
2345 build_fast_commit(response.output, config)
2346}
2347
2348fn build_fast_commit(
2350 output: FastCommitOutput,
2351 config: &CommitConfig,
2352) -> Result<ConventionalCommit> {
2353 let commit_type = CommitType::new(&output.commit_type)?;
2354 let scope = coerce_optional_scope(output.scope.as_deref());
2355 let summary = CommitSummary::new(&output.summary, config.summary_hard_limit)?;
2356 Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2357}
2358#[cfg(test)]
2359mod tests {
2360 use super::*;
2361 use crate::config::CommitConfig;
2362
2363 #[test]
2364 fn test_strict_json_schema_disallows_extra_properties() {
2365 let schema =
2366 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2367 assert_eq!(schema["type"], "object");
2368 assert_eq!(schema["required"], serde_json::json!(["summary"]));
2369 assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2370 }
2371
2372 #[test]
2373 fn test_openai_response_format_uses_strict_json_schema() {
2374 let schema =
2375 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2376 let response_format = openai_response_format("commit_summary", schema.clone());
2377
2378 assert_eq!(response_format["type"], "json_schema");
2379 assert_eq!(response_format["json_schema"]["name"], "commit_summary");
2380 assert_eq!(response_format["json_schema"]["strict"], serde_json::json!(true));
2381 assert_eq!(response_format["json_schema"]["schema"], schema);
2382 }
2383
2384 #[test]
2385 fn test_env_flag_value_enabled_uses_boolean_semantics() {
2386 assert!(!env_flag_value_enabled(None));
2387 assert!(!env_flag_value_enabled(Some("")));
2388 assert!(!env_flag_value_enabled(Some("0")));
2389 assert!(!env_flag_value_enabled(Some("false")));
2390 assert!(!env_flag_value_enabled(Some("NO")));
2391 assert!(!env_flag_value_enabled(Some("off")));
2392 assert!(env_flag_value_enabled(Some("1")));
2393 assert!(env_flag_value_enabled(Some("true")));
2394 assert!(env_flag_value_enabled(Some("yes")));
2395 assert!(env_flag_value_enabled(Some("anything")));
2396 }
2397 #[test]
2398 fn test_format_llm_progress_uses_operation_label_and_request_shape() {
2399 let schema =
2400 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2401 let spec = OneShotSpec {
2402 operation: "map-reduce/map",
2403 model: "claude-sonnet-4.5",
2404 max_tokens: 1500,
2405 temperature: 0.0,
2406 prompt_family: "map",
2407 prompt_variant: "default",
2408 system_prompt: "system",
2409 user_prompt: "user",
2410 tool_name: "create_file_observation",
2411 tool_description: "Extract observations",
2412 schema: &schema,
2413 progress_label: Some("map file 2/5 src/lib.rs"),
2414 debug: None,
2415 cacheable: false,
2416 };
2417
2418 assert_eq!(
2419 format_llm_query_progress(
2420 &spec,
2421 ResolvedApiMode::ChatCompletions,
2422 OneShotRequestKind::ToolCalling
2423 ),
2424 "LLM query: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default, chat \
2425 completions, tool call, prompt ~3 tokens/10 chars, max 1500 output tokens)"
2426 );
2427 assert_eq!(
2428 format_llm_response_progress(
2429 &spec,
2430 reqwest::StatusCode::OK,
2431 std::time::Duration::from_millis(1234),
2432 2048,
2433 ),
2434 "LLM response: map file 2/5 src/lib.rs \u{2190} claude-sonnet-4.5 (HTTP 200, 1.2s, 2.0KB)"
2435 );
2436 assert_eq!(
2437 format_llm_cache_progress(&spec),
2438 "LLM cache hit: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default)"
2439 );
2440 }
2441
2442 #[test]
2443 fn test_context_length_error_detection() {
2444 assert!(is_context_length_error(
2445 r#"{"error":{"message":"Your input exceeds the context window of this model. (code=context_length_exceeded)"}}"#,
2446 ));
2447 assert!(is_context_length_error("This model's maximum context length is 128000 tokens.",));
2448 assert!(!is_context_length_error("upstream temporarily overloaded"));
2449 }
2450
2451 #[tokio::test]
2452 async fn test_retry_api_call_does_not_retry_context_length_errors() {
2453 use std::sync::atomic::{AtomicUsize, Ordering};
2454
2455 let config = CommitConfig { max_retries: 3, initial_backoff_ms: 1, ..Default::default() };
2456 let attempts = AtomicUsize::new(0);
2457
2458 let result = retry_api_call::<()>(&config, async || {
2459 attempts.fetch_add(1, Ordering::SeqCst);
2460 Err::<(bool, Option<()>), CommitGenError>(CommitGenError::ApiContextLengthExceeded {
2461 operation: "analysis".to_string(),
2462 model: "codex".to_string(),
2463 status: 502,
2464 body: "context_length_exceeded".to_string(),
2465 })
2466 })
2467 .await;
2468
2469 assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2470 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2471 }
2472
2473 #[tokio::test]
2474 async fn test_context_length_error_clears_structured_output_probe() {
2475 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2476 let addr = listener.local_addr().unwrap();
2477 let server = std::thread::spawn(move || {
2478 use std::io::{Read, Write};
2479
2480 let (mut stream, _) = listener.accept().unwrap();
2481 let mut request = [0_u8; 4096];
2482 let _ = stream.read(&mut request);
2483 let body = r#"{"error":{"message":"context_length_exceeded"}}"#;
2484 let response = format!(
2485 "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: \
2486 {}\r\n\r\n{}",
2487 body.len(),
2488 body
2489 );
2490 stream.write_all(response.as_bytes()).unwrap();
2491 });
2492
2493 let model = "gpt-4o-mini-probe-clear-test";
2494 let config = CommitConfig {
2495 api_base_url: format!("http://{addr}"),
2496 max_retries: 3,
2497 initial_backoff_ms: 1,
2498 ..Default::default()
2499 };
2500 let schema =
2501 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2502
2503 let result = run_oneshot::<SummaryOutput>(&config, &OneShotSpec {
2504 operation: "summary",
2505 model,
2506 max_tokens: 32,
2507 temperature: 0.0,
2508 prompt_family: "summary",
2509 prompt_variant: "default",
2510 system_prompt: "Summarize.",
2511 user_prompt: "A large diff.",
2512 tool_name: "create_commit_summary",
2513 tool_description: "Create a commit summary",
2514 schema: &schema,
2515 progress_label: Some("summary"),
2516 debug: None,
2517 cacheable: false,
2518 })
2519 .await;
2520
2521 server.join().unwrap();
2522 assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2523
2524 let mode = config.resolved_api_mode(model);
2525 let key = structured_output_cache_key(&config, model, mode);
2526 assert!(
2527 !STRUCTURED_OUTPUT_CAPABILITIES
2528 .states
2529 .lock()
2530 .contains_key(&key)
2531 );
2532 }
2533
2534 #[test]
2535 fn test_anthropic_output_format_wraps_schema() {
2536 let schema =
2537 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2538 let output_format = anthropic_output_format(schema.clone());
2539
2540 assert_eq!(output_format["type"], "json_schema");
2541 assert_eq!(output_format["schema"], schema);
2542 }
2543
2544 #[test]
2545 fn test_extract_json_from_content_code_block() {
2546 let content = r#"Here is the payload:
2547
2548```json
2549{"summary":"added support"}
2550```
2551"#;
2552 assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2553 }
2554
2555 #[test]
2556 fn test_should_fallback_to_tool_for_structured_output_errors() {
2557 assert!(should_fallback_to_tool(
2558 reqwest::StatusCode::BAD_REQUEST,
2559 "Unknown parameter: response_format",
2560 ));
2561 assert!(!should_fallback_to_tool(
2562 reqwest::StatusCode::UNAUTHORIZED,
2563 "Unknown parameter: response_format",
2564 ));
2565 }
2566
2567 #[test]
2568 fn test_build_fast_commit_coerces_invalid_scope_output() {
2569 let commit = build_fast_commit(
2570 FastCommitOutput {
2571 commit_type: "chore".to_string(),
2572 scope: Some(".".to_string()),
2573 summary: "updated tooling".to_string(),
2574 details: vec![],
2575 },
2576 &CommitConfig::default(),
2577 )
2578 .unwrap();
2579
2580 assert!(commit.scope.is_none());
2581 }
2582
2583 #[test]
2584 fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2585 let commit = build_fast_commit(
2586 FastCommitOutput {
2587 commit_type: "chore".to_string(),
2588 scope: Some(".github/Release Notes".to_string()),
2589 summary: "updated tooling".to_string(),
2590 details: vec![],
2591 },
2592 &CommitConfig::default(),
2593 )
2594 .unwrap();
2595
2596 assert_eq!(
2597 commit.scope.as_ref().map(crate::types::Scope::as_str),
2598 Some("github/release-notes")
2599 );
2600 }
2601
2602 #[test]
2603 fn test_is_anthropic_model_recognizes_common_names() {
2604 assert!(is_anthropic_model("claude-haiku-4-5"));
2605 assert!(is_anthropic_model("anthropic/claude-sonnet-4.5"));
2606 assert!(is_anthropic_model("bedrock/anthropic.claude-3-5-sonnet"));
2607 assert!(!is_anthropic_model("gpt-4o-mini"));
2608 }
2609
2610 #[test]
2611 fn test_should_attempt_structured_output_skips_claude_on_unofficial_base() {
2612 let config = CommitConfig::default();
2613 assert!(!should_attempt_structured_output(&config, "claude-haiku-4-5"));
2614 assert!(should_attempt_structured_output(&config, "gpt-4o-mini"));
2615 }
2616
2617 #[test]
2618 fn test_should_attempt_structured_output_skips_codex_spark_models() {
2619 let config = CommitConfig::default();
2620 assert!(!should_attempt_structured_output(&config, "gpt-5.3-codex-spark"));
2621 assert!(!should_attempt_structured_output(&config, "openai/gpt-5.3-codex-spark",));
2622 }
2623
2624 #[test]
2625 fn test_should_attempt_structured_output_allows_claude_on_official_anthropic_base() {
2626 let config = CommitConfig {
2627 api_base_url: "https://api.anthropic.com/v1".to_string(),
2628 ..CommitConfig::default()
2629 };
2630 assert!(should_attempt_structured_output(&config, "claude-haiku-4-5"));
2631 }
2632
2633 #[test]
2634 fn test_structured_output_capability_cache_skips_after_unsupported() {
2635 let config = CommitConfig::default();
2636 let mode = ResolvedApiMode::ChatCompletions;
2637 let model = "test-structured-skip-after-unsupported";
2638
2639 assert_eq!(
2640 begin_structured_output_attempt(&config, model, mode),
2641 StructuredOutputAttempt::Probe
2642 );
2643 assert!(update_structured_output_capability(
2644 &config,
2645 model,
2646 mode,
2647 Some(StructuredOutputCapability::Unsupported),
2648 ));
2649 assert_eq!(
2650 begin_structured_output_attempt(&config, model, mode),
2651 StructuredOutputAttempt::SkipUnsupported
2652 );
2653 }
2654
2655 #[test]
2656 fn test_structured_output_capability_cache_remembers_supported() {
2657 let config = CommitConfig::default();
2658 let mode = ResolvedApiMode::ChatCompletions;
2659 let model = "test-structured-remembers-supported";
2660
2661 assert_eq!(
2662 begin_structured_output_attempt(&config, model, mode),
2663 StructuredOutputAttempt::Probe
2664 );
2665 assert!(!update_structured_output_capability(
2666 &config,
2667 model,
2668 mode,
2669 Some(StructuredOutputCapability::Supported),
2670 ));
2671 assert_eq!(
2672 begin_structured_output_attempt(&config, model, mode),
2673 StructuredOutputAttempt::Supported
2674 );
2675 }
2676
2677 #[test]
2678 fn test_structured_output_capability_cache_is_mode_scoped() {
2679 let config = CommitConfig::default();
2680 let model = "test-structured-mode-scoped";
2681 assert_eq!(
2682 begin_structured_output_attempt(&config, model, ResolvedApiMode::ChatCompletions,),
2683 StructuredOutputAttempt::Probe
2684 );
2685 assert!(update_structured_output_capability(
2686 &config,
2687 model,
2688 ResolvedApiMode::ChatCompletions,
2689 Some(StructuredOutputCapability::Unsupported),
2690 ));
2691 assert_eq!(
2692 begin_structured_output_attempt(&config, model, ResolvedApiMode::AnthropicMessages,),
2693 StructuredOutputAttempt::Probe
2694 );
2695 }
2696
2697 #[test]
2698 fn test_parse_oneshot_response_prefers_tool_payload() {
2699 let response_text = serde_json::json!({
2700 "choices": [{
2701 "message": {
2702 "tool_calls": [{
2703 "function": {
2704 "name": "create_commit_summary",
2705 "arguments": "{\"summary\":\"added feature\"}"
2706 }
2707 }],
2708 "content": "{\"summary\":\"ignored\"}"
2709 }
2710 }]
2711 })
2712 .to_string();
2713
2714 let result = parse_oneshot_response::<SummaryOutput>(
2715 ResolvedApiMode::ChatCompletions,
2716 OneShotRequestKind::ToolCalling,
2717 "create_commit_summary",
2718 "summary",
2719 &response_text,
2720 );
2721
2722 match result {
2723 OneShotParseOutcome::Success(response) => {
2724 assert_eq!(response.source, OneShotSource::ToolCall);
2725 assert_eq!(response.output.summary, "added feature");
2726 },
2727 OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2728 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2729 }
2730 }
2731
2732 #[test]
2733 fn test_parse_oneshot_response_falls_back_to_content_json() {
2734 let response_text = serde_json::json!({
2735 "choices": [{
2736 "message": {
2737 "tool_calls": [{
2738 "function": {
2739 "name": "create_commit_summary",
2740 "arguments": "{invalid json}"
2741 }
2742 }],
2743 "content": "{\"summary\":\"added fallback\"}"
2744 }
2745 }]
2746 })
2747 .to_string();
2748
2749 let result = parse_oneshot_response::<SummaryOutput>(
2750 ResolvedApiMode::ChatCompletions,
2751 OneShotRequestKind::ToolCalling,
2752 "create_commit_summary",
2753 "summary",
2754 &response_text,
2755 );
2756
2757 match result {
2758 OneShotParseOutcome::Success(response) => {
2759 assert_eq!(response.source, OneShotSource::OutputJsonParse);
2760 assert_eq!(response.output.summary, "added fallback");
2761 },
2762 OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2763 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2764 }
2765 }
2766
2767 #[test]
2768 fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2769 let response_text = serde_json::json!({
2770 "choices": [{
2771 "message": {
2772 "content": "updated gemini-image tests for CustomToolContext and array headers"
2773 }
2774 }]
2775 })
2776 .to_string();
2777
2778 let result = parse_oneshot_response::<SummaryOutput>(
2779 ResolvedApiMode::ChatCompletions,
2780 OneShotRequestKind::ToolCalling,
2781 "create_commit_summary",
2782 "summary",
2783 &response_text,
2784 );
2785
2786 match result {
2787 OneShotParseOutcome::Success(response) => {
2788 assert_eq!(response.source, OneShotSource::PlainTextContent);
2789 assert_eq!(
2790 response.output.summary,
2791 "updated gemini-image tests for CustomToolContext and array headers"
2792 );
2793 },
2794 OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2795 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2796 }
2797 }
2798
2799 #[test]
2800 fn test_validate_summary_quality_valid() {
2801 let stat = "src/main.rs | 10 +++++++---\n";
2802 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2803 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2804 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2805 }
2806
2807 #[test]
2808 fn test_validate_summary_quality_invalid_verb() {
2809 let stat = "src/main.rs | 10 +++++++---\n";
2810 let result = validate_summary_quality("adding new feature", "feat", stat);
2811 assert!(result.is_err());
2812 assert!(result.unwrap_err().contains("past-tense verb"));
2813 }
2814
2815 #[test]
2816 fn test_validate_summary_quality_type_repetition() {
2817 let stat = "src/main.rs | 10 +++++++---\n";
2818 let result = validate_summary_quality("feat new feature", "feat", stat);
2820 assert!(result.is_err());
2821 assert!(result.unwrap_err().contains("past-tense verb"));
2822
2823 let result = validate_summary_quality("fix bug", "fix", stat);
2825 assert!(result.is_err());
2826 assert!(result.unwrap_err().contains("past-tense verb"));
2828 }
2829
2830 #[test]
2831 fn test_validate_summary_quality_empty() {
2832 let stat = "src/main.rs | 10 +++++++---\n";
2833 let result = validate_summary_quality("", "feat", stat);
2834 assert!(result.is_err());
2835 assert!(result.unwrap_err().contains("empty"));
2836 }
2837
2838 #[test]
2839 fn test_validate_summary_quality_markdown_type_mismatch() {
2840 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2841 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2843 }
2844
2845 #[test]
2846 fn test_validate_summary_quality_no_code_files() {
2847 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2848 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2850 }
2851
2852 #[test]
2853 fn test_fallback_from_details_with_first_detail() {
2854 let config = CommitConfig::default();
2855 let details = vec![
2856 "Added authentication middleware.".to_string(),
2857 "Updated error handling.".to_string(),
2858 ];
2859 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2860 assert_eq!(result.as_str(), "Added authentication middleware");
2862 }
2863
2864 #[test]
2865 fn test_fallback_from_details_strips_type_word() {
2866 let config = CommitConfig::default();
2867 let details = vec!["Featuring new oauth flow.".to_string()];
2868 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2869 assert!(result.as_str().starts_with("added"));
2872 }
2873
2874 #[test]
2875 fn test_fallback_from_details_no_details() {
2876 let config = CommitConfig::default();
2877 let details: Vec<String> = vec![];
2878 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2879 assert!(result.as_str().starts_with("added"));
2881 }
2882
2883 #[test]
2884 fn test_fallback_from_details_adds_verb() {
2885 let config = CommitConfig::default();
2886 let details = vec!["configuration for oauth".to_string()];
2887 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2888 assert_eq!(result.as_str(), "added configuration for oauth");
2889 }
2890
2891 #[test]
2892 fn test_fallback_from_details_preserves_existing_verb() {
2893 let config = CommitConfig::default();
2894 let details = vec!["fixed authentication bug".to_string()];
2895 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2896 assert_eq!(result.as_str(), "fixed authentication bug");
2897 }
2898
2899 #[test]
2900 fn test_fallback_from_details_type_specific_verbs() {
2901 let config = CommitConfig::default();
2902 let details = vec!["module structure".to_string()];
2903
2904 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2905 assert_eq!(result.as_str(), "restructured module structure");
2906
2907 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2908 assert_eq!(result.as_str(), "documented module structure");
2909
2910 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2911 assert_eq!(result.as_str(), "tested module structure");
2912
2913 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2914 assert_eq!(result.as_str(), "optimized module structure");
2915 }
2916
2917 #[test]
2918 fn test_fallback_summary_with_stat() {
2919 let config = CommitConfig::default();
2920 let stat = "src/main.rs | 10 +++++++---\n";
2921 let details = vec![];
2922 let result = fallback_summary(stat, &details, "feat", &config);
2923 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2924 }
2925
2926 #[test]
2927 fn test_fallback_summary_with_details() {
2928 let config = CommitConfig::default();
2929 let stat = "";
2930 let details = vec!["First detail here.".to_string()];
2931 let result = fallback_summary(stat, &details, "feat", &config);
2932 assert_eq!(result.as_str(), "First detail here");
2934 }
2935
2936 #[test]
2937 fn test_fallback_summary_no_stat_no_details() {
2938 let config = CommitConfig::default();
2939 let result = fallback_summary("", &[], "feat", &config);
2940 assert_eq!(result.as_str(), "Updated files");
2942 }
2943
2944 #[test]
2945 fn test_fallback_summary_type_word_overlap() {
2946 let config = CommitConfig::default();
2947 let details = vec!["refactor was performed".to_string()];
2948 let result = fallback_summary("", &details, "refactor", &config);
2949 assert_eq!(result.as_str(), "restructured change");
2951 }
2952
2953 #[test]
2954 fn test_fallback_summary_length_limit() {
2955 let config = CommitConfig::default();
2956 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
2958 let result = fallback_summary("", &details, "feat", &config);
2959 assert!(result.len() <= 50);
2961 }
2962}