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 let types_desc = format_types_description(config);
2295
2296 let parts = templates::render_fast_prompt(&templates::FastPromptParams {
2297 variant: "default",
2298 stat,
2299 diff,
2300 scope_candidates: scope_candidates_str,
2301 user_context,
2302 types_description: Some(&types_desc),
2303 })?;
2304
2305 let fast_schema = strict_json_schema(
2306 serde_json::json!({
2307 "type": {
2308 "type": "string",
2309 "enum": type_enum,
2310 "description": "Conventional commit type"
2311 },
2312 "scope": {
2313 "type": "string",
2314 "description": "Optional scope. Omit if unclear or cross-cutting."
2315 },
2316 "summary": {
2317 "type": "string",
2318 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
2319 },
2320 "details": {
2321 "type": "array",
2322 "items": { "type": "string" },
2323 "description": "0-3 past-tense detail sentences ending with period"
2324 }
2325 }),
2326 &["type", "summary", "details"],
2327 );
2328
2329 let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
2330 operation: "fast",
2331 model: model_name,
2332 max_tokens: 500,
2333 temperature: config.temperature,
2334 prompt_family: "fast",
2335 prompt_variant: "default",
2336 system_prompt: &parts.system,
2337 user_prompt: &parts.user,
2338 tool_name: "create_fast_commit",
2339 tool_description: "Generate a conventional commit from the given diff",
2340 schema: &fast_schema,
2341 progress_label: Some("fast commit"),
2342 debug: Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
2343 cacheable: true,
2344 })
2345 .await?;
2346
2347 build_fast_commit(response.output, config)
2348}
2349
2350fn build_fast_commit(
2352 output: FastCommitOutput,
2353 config: &CommitConfig,
2354) -> Result<ConventionalCommit> {
2355 let commit_type = CommitType::new(&output.commit_type)?;
2356 let scope = coerce_optional_scope(output.scope.as_deref());
2357 let summary = CommitSummary::new(&output.summary, config.summary_hard_limit)?;
2358 Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2359}
2360#[cfg(test)]
2361mod tests {
2362 use super::*;
2363 use crate::config::CommitConfig;
2364
2365 #[test]
2366 fn test_strict_json_schema_disallows_extra_properties() {
2367 let schema =
2368 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2369 assert_eq!(schema["type"], "object");
2370 assert_eq!(schema["required"], serde_json::json!(["summary"]));
2371 assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2372 }
2373
2374 #[test]
2375 fn test_openai_response_format_uses_strict_json_schema() {
2376 let schema =
2377 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2378 let response_format = openai_response_format("commit_summary", schema.clone());
2379
2380 assert_eq!(response_format["type"], "json_schema");
2381 assert_eq!(response_format["json_schema"]["name"], "commit_summary");
2382 assert_eq!(response_format["json_schema"]["strict"], serde_json::json!(true));
2383 assert_eq!(response_format["json_schema"]["schema"], schema);
2384 }
2385
2386 #[test]
2387 fn test_env_flag_value_enabled_uses_boolean_semantics() {
2388 assert!(!env_flag_value_enabled(None));
2389 assert!(!env_flag_value_enabled(Some("")));
2390 assert!(!env_flag_value_enabled(Some("0")));
2391 assert!(!env_flag_value_enabled(Some("false")));
2392 assert!(!env_flag_value_enabled(Some("NO")));
2393 assert!(!env_flag_value_enabled(Some("off")));
2394 assert!(env_flag_value_enabled(Some("1")));
2395 assert!(env_flag_value_enabled(Some("true")));
2396 assert!(env_flag_value_enabled(Some("yes")));
2397 assert!(env_flag_value_enabled(Some("anything")));
2398 }
2399 #[test]
2400 fn test_format_llm_progress_uses_operation_label_and_request_shape() {
2401 let schema =
2402 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2403 let spec = OneShotSpec {
2404 operation: "map-reduce/map",
2405 model: "claude-sonnet-4.5",
2406 max_tokens: 1500,
2407 temperature: 0.0,
2408 prompt_family: "map",
2409 prompt_variant: "default",
2410 system_prompt: "system",
2411 user_prompt: "user",
2412 tool_name: "create_file_observation",
2413 tool_description: "Extract observations",
2414 schema: &schema,
2415 progress_label: Some("map file 2/5 src/lib.rs"),
2416 debug: None,
2417 cacheable: false,
2418 };
2419
2420 assert_eq!(
2421 format_llm_query_progress(
2422 &spec,
2423 ResolvedApiMode::ChatCompletions,
2424 OneShotRequestKind::ToolCalling
2425 ),
2426 "LLM query: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default, chat \
2427 completions, tool call, prompt ~3 tokens/10 chars, max 1500 output tokens)"
2428 );
2429 assert_eq!(
2430 format_llm_response_progress(
2431 &spec,
2432 reqwest::StatusCode::OK,
2433 std::time::Duration::from_millis(1234),
2434 2048,
2435 ),
2436 "LLM response: map file 2/5 src/lib.rs \u{2190} claude-sonnet-4.5 (HTTP 200, 1.2s, 2.0KB)"
2437 );
2438 assert_eq!(
2439 format_llm_cache_progress(&spec),
2440 "LLM cache hit: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default)"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_context_length_error_detection() {
2446 assert!(is_context_length_error(
2447 r#"{"error":{"message":"Your input exceeds the context window of this model. (code=context_length_exceeded)"}}"#,
2448 ));
2449 assert!(is_context_length_error("This model's maximum context length is 128000 tokens.",));
2450 assert!(!is_context_length_error("upstream temporarily overloaded"));
2451 }
2452
2453 #[tokio::test]
2454 async fn test_retry_api_call_does_not_retry_context_length_errors() {
2455 use std::sync::atomic::{AtomicUsize, Ordering};
2456
2457 let config = CommitConfig { max_retries: 3, initial_backoff_ms: 1, ..Default::default() };
2458 let attempts = AtomicUsize::new(0);
2459
2460 let result = retry_api_call::<()>(&config, async || {
2461 attempts.fetch_add(1, Ordering::SeqCst);
2462 Err::<(bool, Option<()>), CommitGenError>(CommitGenError::ApiContextLengthExceeded {
2463 operation: "analysis".to_string(),
2464 model: "codex".to_string(),
2465 status: 502,
2466 body: "context_length_exceeded".to_string(),
2467 })
2468 })
2469 .await;
2470
2471 assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2472 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2473 }
2474
2475 #[tokio::test]
2476 async fn test_context_length_error_clears_structured_output_probe() {
2477 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2478 let addr = listener.local_addr().unwrap();
2479 let server = std::thread::spawn(move || {
2480 use std::io::{Read, Write};
2481
2482 let (mut stream, _) = listener.accept().unwrap();
2483 let mut request = [0_u8; 4096];
2484 let _ = stream.read(&mut request);
2485 let body = r#"{"error":{"message":"context_length_exceeded"}}"#;
2486 let response = format!(
2487 "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: \
2488 {}\r\n\r\n{}",
2489 body.len(),
2490 body
2491 );
2492 stream.write_all(response.as_bytes()).unwrap();
2493 });
2494
2495 let model = "gpt-4o-mini-probe-clear-test";
2496 let config = CommitConfig {
2497 api_base_url: format!("http://{addr}"),
2498 max_retries: 3,
2499 initial_backoff_ms: 1,
2500 ..Default::default()
2501 };
2502 let schema =
2503 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2504
2505 let result = run_oneshot::<SummaryOutput>(&config, &OneShotSpec {
2506 operation: "summary",
2507 model,
2508 max_tokens: 32,
2509 temperature: 0.0,
2510 prompt_family: "summary",
2511 prompt_variant: "default",
2512 system_prompt: "Summarize.",
2513 user_prompt: "A large diff.",
2514 tool_name: "create_commit_summary",
2515 tool_description: "Create a commit summary",
2516 schema: &schema,
2517 progress_label: Some("summary"),
2518 debug: None,
2519 cacheable: false,
2520 })
2521 .await;
2522
2523 server.join().unwrap();
2524 assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2525
2526 let mode = config.resolved_api_mode(model);
2527 let key = structured_output_cache_key(&config, model, mode);
2528 assert!(
2529 !STRUCTURED_OUTPUT_CAPABILITIES
2530 .states
2531 .lock()
2532 .contains_key(&key)
2533 );
2534 }
2535
2536 #[test]
2537 fn test_anthropic_output_format_wraps_schema() {
2538 let schema =
2539 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2540 let output_format = anthropic_output_format(schema.clone());
2541
2542 assert_eq!(output_format["type"], "json_schema");
2543 assert_eq!(output_format["schema"], schema);
2544 }
2545
2546 #[test]
2547 fn test_extract_json_from_content_code_block() {
2548 let content = r#"Here is the payload:
2549
2550```json
2551{"summary":"added support"}
2552```
2553"#;
2554 assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2555 }
2556
2557 #[test]
2558 fn test_should_fallback_to_tool_for_structured_output_errors() {
2559 assert!(should_fallback_to_tool(
2560 reqwest::StatusCode::BAD_REQUEST,
2561 "Unknown parameter: response_format",
2562 ));
2563 assert!(!should_fallback_to_tool(
2564 reqwest::StatusCode::UNAUTHORIZED,
2565 "Unknown parameter: response_format",
2566 ));
2567 }
2568
2569 #[test]
2570 fn test_build_fast_commit_coerces_invalid_scope_output() {
2571 let commit = build_fast_commit(
2572 FastCommitOutput {
2573 commit_type: "chore".to_string(),
2574 scope: Some(".".to_string()),
2575 summary: "updated tooling".to_string(),
2576 details: vec![],
2577 },
2578 &CommitConfig::default(),
2579 )
2580 .unwrap();
2581
2582 assert!(commit.scope.is_none());
2583 }
2584
2585 #[test]
2586 fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2587 let commit = build_fast_commit(
2588 FastCommitOutput {
2589 commit_type: "chore".to_string(),
2590 scope: Some(".github/Release Notes".to_string()),
2591 summary: "updated tooling".to_string(),
2592 details: vec![],
2593 },
2594 &CommitConfig::default(),
2595 )
2596 .unwrap();
2597
2598 assert_eq!(
2599 commit.scope.as_ref().map(crate::types::Scope::as_str),
2600 Some("github/release-notes")
2601 );
2602 }
2603
2604 #[test]
2605 fn test_is_anthropic_model_recognizes_common_names() {
2606 assert!(is_anthropic_model("claude-haiku-4-5"));
2607 assert!(is_anthropic_model("anthropic/claude-sonnet-4.5"));
2608 assert!(is_anthropic_model("bedrock/anthropic.claude-3-5-sonnet"));
2609 assert!(!is_anthropic_model("gpt-4o-mini"));
2610 }
2611
2612 #[test]
2613 fn test_should_attempt_structured_output_skips_claude_on_unofficial_base() {
2614 let config = CommitConfig::default();
2615 assert!(!should_attempt_structured_output(&config, "claude-haiku-4-5"));
2616 assert!(should_attempt_structured_output(&config, "gpt-4o-mini"));
2617 }
2618
2619 #[test]
2620 fn test_should_attempt_structured_output_skips_codex_spark_models() {
2621 let config = CommitConfig::default();
2622 assert!(!should_attempt_structured_output(&config, "gpt-5.3-codex-spark"));
2623 assert!(!should_attempt_structured_output(&config, "openai/gpt-5.3-codex-spark",));
2624 }
2625
2626 #[test]
2627 fn test_should_attempt_structured_output_allows_claude_on_official_anthropic_base() {
2628 let config = CommitConfig {
2629 api_base_url: "https://api.anthropic.com/v1".to_string(),
2630 ..CommitConfig::default()
2631 };
2632 assert!(should_attempt_structured_output(&config, "claude-haiku-4-5"));
2633 }
2634
2635 #[test]
2636 fn test_structured_output_capability_cache_skips_after_unsupported() {
2637 let config = CommitConfig::default();
2638 let mode = ResolvedApiMode::ChatCompletions;
2639 let model = "test-structured-skip-after-unsupported";
2640
2641 assert_eq!(
2642 begin_structured_output_attempt(&config, model, mode),
2643 StructuredOutputAttempt::Probe
2644 );
2645 assert!(update_structured_output_capability(
2646 &config,
2647 model,
2648 mode,
2649 Some(StructuredOutputCapability::Unsupported),
2650 ));
2651 assert_eq!(
2652 begin_structured_output_attempt(&config, model, mode),
2653 StructuredOutputAttempt::SkipUnsupported
2654 );
2655 }
2656
2657 #[test]
2658 fn test_structured_output_capability_cache_remembers_supported() {
2659 let config = CommitConfig::default();
2660 let mode = ResolvedApiMode::ChatCompletions;
2661 let model = "test-structured-remembers-supported";
2662
2663 assert_eq!(
2664 begin_structured_output_attempt(&config, model, mode),
2665 StructuredOutputAttempt::Probe
2666 );
2667 assert!(!update_structured_output_capability(
2668 &config,
2669 model,
2670 mode,
2671 Some(StructuredOutputCapability::Supported),
2672 ));
2673 assert_eq!(
2674 begin_structured_output_attempt(&config, model, mode),
2675 StructuredOutputAttempt::Supported
2676 );
2677 }
2678
2679 #[test]
2680 fn test_structured_output_capability_cache_is_mode_scoped() {
2681 let config = CommitConfig::default();
2682 let model = "test-structured-mode-scoped";
2683 assert_eq!(
2684 begin_structured_output_attempt(&config, model, ResolvedApiMode::ChatCompletions,),
2685 StructuredOutputAttempt::Probe
2686 );
2687 assert!(update_structured_output_capability(
2688 &config,
2689 model,
2690 ResolvedApiMode::ChatCompletions,
2691 Some(StructuredOutputCapability::Unsupported),
2692 ));
2693 assert_eq!(
2694 begin_structured_output_attempt(&config, model, ResolvedApiMode::AnthropicMessages,),
2695 StructuredOutputAttempt::Probe
2696 );
2697 }
2698
2699 #[test]
2700 fn test_parse_oneshot_response_prefers_tool_payload() {
2701 let response_text = serde_json::json!({
2702 "choices": [{
2703 "message": {
2704 "tool_calls": [{
2705 "function": {
2706 "name": "create_commit_summary",
2707 "arguments": "{\"summary\":\"added feature\"}"
2708 }
2709 }],
2710 "content": "{\"summary\":\"ignored\"}"
2711 }
2712 }]
2713 })
2714 .to_string();
2715
2716 let result = parse_oneshot_response::<SummaryOutput>(
2717 ResolvedApiMode::ChatCompletions,
2718 OneShotRequestKind::ToolCalling,
2719 "create_commit_summary",
2720 "summary",
2721 &response_text,
2722 );
2723
2724 match result {
2725 OneShotParseOutcome::Success(response) => {
2726 assert_eq!(response.source, OneShotSource::ToolCall);
2727 assert_eq!(response.output.summary, "added feature");
2728 },
2729 OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2730 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2731 }
2732 }
2733
2734 #[test]
2735 fn test_parse_oneshot_response_falls_back_to_content_json() {
2736 let response_text = serde_json::json!({
2737 "choices": [{
2738 "message": {
2739 "tool_calls": [{
2740 "function": {
2741 "name": "create_commit_summary",
2742 "arguments": "{invalid json}"
2743 }
2744 }],
2745 "content": "{\"summary\":\"added fallback\"}"
2746 }
2747 }]
2748 })
2749 .to_string();
2750
2751 let result = parse_oneshot_response::<SummaryOutput>(
2752 ResolvedApiMode::ChatCompletions,
2753 OneShotRequestKind::ToolCalling,
2754 "create_commit_summary",
2755 "summary",
2756 &response_text,
2757 );
2758
2759 match result {
2760 OneShotParseOutcome::Success(response) => {
2761 assert_eq!(response.source, OneShotSource::OutputJsonParse);
2762 assert_eq!(response.output.summary, "added fallback");
2763 },
2764 OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2765 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2766 }
2767 }
2768
2769 #[test]
2770 fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2771 let response_text = serde_json::json!({
2772 "choices": [{
2773 "message": {
2774 "content": "updated gemini-image tests for CustomToolContext and array headers"
2775 }
2776 }]
2777 })
2778 .to_string();
2779
2780 let result = parse_oneshot_response::<SummaryOutput>(
2781 ResolvedApiMode::ChatCompletions,
2782 OneShotRequestKind::ToolCalling,
2783 "create_commit_summary",
2784 "summary",
2785 &response_text,
2786 );
2787
2788 match result {
2789 OneShotParseOutcome::Success(response) => {
2790 assert_eq!(response.source, OneShotSource::PlainTextContent);
2791 assert_eq!(
2792 response.output.summary,
2793 "updated gemini-image tests for CustomToolContext and array headers"
2794 );
2795 },
2796 OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2797 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2798 }
2799 }
2800
2801 #[test]
2802 fn test_validate_summary_quality_valid() {
2803 let stat = "src/main.rs | 10 +++++++---\n";
2804 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2805 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2806 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2807 }
2808
2809 #[test]
2810 fn test_validate_summary_quality_invalid_verb() {
2811 let stat = "src/main.rs | 10 +++++++---\n";
2812 let result = validate_summary_quality("adding new feature", "feat", stat);
2813 assert!(result.is_err());
2814 assert!(result.unwrap_err().contains("past-tense verb"));
2815 }
2816
2817 #[test]
2818 fn test_validate_summary_quality_type_repetition() {
2819 let stat = "src/main.rs | 10 +++++++---\n";
2820 let result = validate_summary_quality("feat new feature", "feat", stat);
2822 assert!(result.is_err());
2823 assert!(result.unwrap_err().contains("past-tense verb"));
2824
2825 let result = validate_summary_quality("fix bug", "fix", stat);
2827 assert!(result.is_err());
2828 assert!(result.unwrap_err().contains("past-tense verb"));
2830 }
2831
2832 #[test]
2833 fn test_validate_summary_quality_empty() {
2834 let stat = "src/main.rs | 10 +++++++---\n";
2835 let result = validate_summary_quality("", "feat", stat);
2836 assert!(result.is_err());
2837 assert!(result.unwrap_err().contains("empty"));
2838 }
2839
2840 #[test]
2841 fn test_validate_summary_quality_markdown_type_mismatch() {
2842 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2843 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2845 }
2846
2847 #[test]
2848 fn test_validate_summary_quality_no_code_files() {
2849 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2850 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2852 }
2853
2854 #[test]
2855 fn test_fallback_from_details_with_first_detail() {
2856 let config = CommitConfig::default();
2857 let details = vec![
2858 "Added authentication middleware.".to_string(),
2859 "Updated error handling.".to_string(),
2860 ];
2861 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2862 assert_eq!(result.as_str(), "Added authentication middleware");
2864 }
2865
2866 #[test]
2867 fn test_fallback_from_details_strips_type_word() {
2868 let config = CommitConfig::default();
2869 let details = vec!["Featuring new oauth flow.".to_string()];
2870 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2871 assert!(result.as_str().starts_with("added"));
2874 }
2875
2876 #[test]
2877 fn test_fallback_from_details_no_details() {
2878 let config = CommitConfig::default();
2879 let details: Vec<String> = vec![];
2880 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2881 assert!(result.as_str().starts_with("added"));
2883 }
2884
2885 #[test]
2886 fn test_fallback_from_details_adds_verb() {
2887 let config = CommitConfig::default();
2888 let details = vec!["configuration for oauth".to_string()];
2889 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2890 assert_eq!(result.as_str(), "added configuration for oauth");
2891 }
2892
2893 #[test]
2894 fn test_fallback_from_details_preserves_existing_verb() {
2895 let config = CommitConfig::default();
2896 let details = vec!["fixed authentication bug".to_string()];
2897 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2898 assert_eq!(result.as_str(), "fixed authentication bug");
2899 }
2900
2901 #[test]
2902 fn test_fallback_from_details_type_specific_verbs() {
2903 let config = CommitConfig::default();
2904 let details = vec!["module structure".to_string()];
2905
2906 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2907 assert_eq!(result.as_str(), "restructured module structure");
2908
2909 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2910 assert_eq!(result.as_str(), "documented module structure");
2911
2912 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2913 assert_eq!(result.as_str(), "tested module structure");
2914
2915 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2916 assert_eq!(result.as_str(), "optimized module structure");
2917 }
2918
2919 #[test]
2920 fn test_fallback_summary_with_stat() {
2921 let config = CommitConfig::default();
2922 let stat = "src/main.rs | 10 +++++++---\n";
2923 let details = vec![];
2924 let result = fallback_summary(stat, &details, "feat", &config);
2925 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2926 }
2927
2928 #[test]
2929 fn test_fallback_summary_with_details() {
2930 let config = CommitConfig::default();
2931 let stat = "";
2932 let details = vec!["First detail here.".to_string()];
2933 let result = fallback_summary(stat, &details, "feat", &config);
2934 assert_eq!(result.as_str(), "First detail here");
2936 }
2937
2938 #[test]
2939 fn test_fallback_summary_no_stat_no_details() {
2940 let config = CommitConfig::default();
2941 let result = fallback_summary("", &[], "feat", &config);
2942 assert_eq!(result.as_str(), "Updated files");
2944 }
2945
2946 #[test]
2947 fn test_fallback_summary_type_word_overlap() {
2948 let config = CommitConfig::default();
2949 let details = vec!["refactor was performed".to_string()];
2950 let result = fallback_summary("", &details, "refactor", &config);
2951 assert_eq!(result.as_str(), "restructured change");
2953 }
2954
2955 #[test]
2956 fn test_fallback_summary_length_limit() {
2957 let config = CommitConfig::default();
2958 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
2960 let result = fallback_summary("", &details, "feat", &config);
2961 assert!(result.len() <= 50);
2963 }
2964}