lgit-cli 3.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/api.py ADDED
@@ -0,0 +1,1077 @@
1
+ """Async LLM API client and conventional-commit generation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import time
9
+ from collections.abc import Iterable, Mapping
10
+ from dataclasses import dataclass, replace
11
+ from enum import StrEnum
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urljoin
16
+
17
+ import httpx
18
+ from jinja2 import Template
19
+
20
+ from . import cache as llm_cache
21
+ from . import profile
22
+ from .errors import ApiContextLengthExceeded, ApiError, LgitError
23
+ from .markdown_output import (
24
+ analysis_from_mapping,
25
+ fallback_summary,
26
+ parse_changelog_response,
27
+ parse_compose_binding_markdown,
28
+ parse_compose_intent_markdown,
29
+ parse_conventional_analysis_markdown,
30
+ parse_fast_commit_markdown,
31
+ parse_summary_markdown,
32
+ )
33
+ from .markdown_output import (
34
+ strip_type_prefix as strip_markdown_type_prefix,
35
+ )
36
+ from .models import CommitSummary, ConventionalAnalysis, ConventionalCommit, ResolvedApiMode, resolve_model_name
37
+ from .normalization import post_process_commit_message
38
+ from .validation import is_past_tense_first_word, validate_summary_quality
39
+
40
+ ANTHROPIC_REQUIRED_MAX_TOKENS = 16_384
41
+ _CONTEXT_LENGTH_MARKERS = (
42
+ "context_length_exceeded",
43
+ "context window",
44
+ "maximum context length",
45
+ "exceeds the context",
46
+ "input exceeds",
47
+ "prompt is too long",
48
+ "too many tokens",
49
+ )
50
+ _JSON_CACHE_PREFIX = "\x00json:"
51
+
52
+
53
+ class OneShotSource(StrEnum):
54
+ """Origin of a parsed one-shot response."""
55
+
56
+ TOOL_CALL = "tool_call"
57
+ OUTPUT_JSON_PARSE = "output_json_parse"
58
+ PLAIN_TEXT_CONTENT = "plain_text_content"
59
+ CACHE = "cache"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class OneShotDebug:
64
+ """Optional raw request/response debug output target."""
65
+
66
+ dir: str | Path | None = None
67
+ prefix: str | None = None
68
+ name: str = "oneshot"
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class OneShotSpec:
73
+ """Complete description of a single LLM tool or markdown request."""
74
+
75
+ operation: str
76
+ model: str | None = None
77
+ prompt_family: str = "custom"
78
+ prompt_variant: str = "default"
79
+ system_prompt: str = ""
80
+ user_prompt: str = ""
81
+ tool_name: str = "create_response"
82
+ tool_description: str = "Return the requested structured response"
83
+ schema: Mapping[str, Any] | None = None
84
+ progress_label: str | None = None
85
+ debug: OneShotDebug | Mapping[str, Any] | str | Path | None = None
86
+ cacheable: bool = True
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class OneShotResponse:
91
+ """Parsed output and metadata from a one-shot LLM request."""
92
+
93
+ output: Any
94
+ source: OneShotSource
95
+ text_content: str | None = None
96
+ stop_reason: str | None = None
97
+
98
+
99
+ def strict_json_schema(properties: Mapping[str, Any], required: list[str] | tuple[str, ...]) -> dict[str, Any]:
100
+ """Build a strict object JSON schema with no additional properties."""
101
+
102
+ return {"type": "object", "properties": dict(properties), "required": list(required), "additionalProperties": False}
103
+
104
+
105
+ async def run_oneshot(
106
+ config: Any,
107
+ prompt: str | OneShotSpec | Mapping[str, Any] | None = None,
108
+ *,
109
+ spec: OneShotSpec | Mapping[str, Any] | None = None,
110
+ system_prompt: str | None = None,
111
+ model: str | None = None,
112
+ schema: Mapping[str, Any] | None = None,
113
+ schema_name: str = "response",
114
+ tool_name: str | None = None,
115
+ tool_description: str | None = None,
116
+ operation: str | None = None,
117
+ prompt_family: str = "custom",
118
+ prompt_variant: str = "default",
119
+ temperature: float | None = None,
120
+ debug_label: str | None = None,
121
+ debug: OneShotDebug | Mapping[str, Any] | str | Path | None = None,
122
+ markdown_output: bool | None = None,
123
+ cache: bool = True,
124
+ cacheable: bool | None = None,
125
+ **_: Any,
126
+ ) -> Any:
127
+ """Run one LLM request, returning parsed output or a ``OneShotResponse`` for specs."""
128
+
129
+ del temperature
130
+ return_response = isinstance(prompt, OneShotSpec) or spec is not None or isinstance(prompt, Mapping)
131
+ built = _coerce_spec(
132
+ prompt,
133
+ spec=spec,
134
+ system_prompt=system_prompt,
135
+ model=model,
136
+ schema=schema,
137
+ schema_name=schema_name,
138
+ tool_name=tool_name,
139
+ tool_description=tool_description,
140
+ operation=operation,
141
+ prompt_family=prompt_family,
142
+ prompt_variant=prompt_variant,
143
+ debug_label=debug_label,
144
+ debug=debug,
145
+ cacheable=cache if cacheable is None else cacheable,
146
+ )
147
+ if not built.model:
148
+ built = replace(
149
+ built,
150
+ model=resolve_model_name(
151
+ str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))
152
+ ),
153
+ )
154
+ response = await _run_oneshot_response(config, built, markdown_output=markdown_output)
155
+ return response if return_response else response.output
156
+
157
+
158
+ async def generate_conventional_analysis(
159
+ config: Any,
160
+ stat: str,
161
+ diff: str,
162
+ scope_candidates: str = "",
163
+ *,
164
+ user_context: str | None = None,
165
+ recent_commits: str | None = None,
166
+ common_scopes: str | None = None,
167
+ project_context: str | None = None,
168
+ debug_output: str | Path | None = None,
169
+ debug_prefix: str | None = None,
170
+ ) -> ConventionalAnalysis:
171
+ """Generate a structured conventional-commit analysis for a diff."""
172
+
173
+ variant = (
174
+ "markdown"
175
+ if bool(getattr(config, "markdown_output", True))
176
+ else str(getattr(config, "analysis_prompt_variant", "default"))
177
+ )
178
+ system_prompt, user_prompt = render_prompt(
179
+ "analysis",
180
+ variant,
181
+ {
182
+ "project_context": project_context or "",
183
+ "types_description": format_types_description(config),
184
+ "stat": stat,
185
+ "scope_candidates": scope_candidates,
186
+ "common_scopes": common_scopes or "",
187
+ "recent_commits": recent_commits or "",
188
+ "diff": diff,
189
+ },
190
+ )
191
+ if user_context:
192
+ user_prompt = f"{user_prompt}\n\n<user_context>\n{user_context}\n</user_context>"
193
+ type_enum = list(getattr(config, "types", {}) or {"chore": None})
194
+ spec = OneShotSpec(
195
+ operation="analysis",
196
+ model=resolve_model_name(str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))),
197
+ prompt_family="analysis",
198
+ prompt_variant=variant,
199
+ system_prompt=system_prompt,
200
+ user_prompt=user_prompt,
201
+ tool_name="create_conventional_analysis",
202
+ tool_description="Create conventional commit analysis from a git diff",
203
+ schema=build_analysis_schema(type_enum, config),
204
+ progress_label="analysis",
205
+ debug=OneShotDebug(debug_output, debug_prefix, "analysis") if debug_output else None,
206
+ cacheable=True,
207
+ )
208
+ response = await _run_oneshot_response(config, spec)
209
+ return _coerce_analysis(response.output, response.text_content, default_type=type_enum[0] if type_enum else "chore")
210
+
211
+
212
+ def strip_type_prefix(summary: str, commit_type: str | None = None, scope: str | None = None) -> str:
213
+ """Strip Rust-equivalent conventional type prefixes from a summary."""
214
+
215
+ if commit_type is None:
216
+ return strip_markdown_type_prefix(summary)
217
+ text = str(summary).strip()
218
+ commit_type_lower = commit_type.lower()
219
+ prefixes = []
220
+ if scope:
221
+ prefixes.append(f"{commit_type}({scope}): ")
222
+ prefixes.append(f"{commit_type}: ")
223
+ for prefix in prefixes:
224
+ if text.lower().startswith(prefix.lower()):
225
+ return text[len(prefix) :].strip()
226
+ match = re.match(r"^([a-z][a-z0-9-]*)(?:\(([^)]*)\))?:\s+(.*)$", text, re.IGNORECASE)
227
+ if match and match.group(1).lower() == commit_type_lower:
228
+ return match.group(3).strip()
229
+ return text
230
+
231
+
232
+ def summary_from_holistic_analysis(analysis: ConventionalAnalysis, config: Any, stat: str = "") -> str | None:
233
+ """Return a hard-limit-validated holistic summary from analysis, or None."""
234
+
235
+ del stat
236
+ if not analysis.summary or not str(analysis.summary).strip():
237
+ return None
238
+ summary = strip_type_prefix(
239
+ str(analysis.summary).strip(),
240
+ str(analysis.commit_type),
241
+ None if analysis.scope is None else str(analysis.scope),
242
+ ).rstrip(" .")
243
+ if not summary:
244
+ return None
245
+ return str(CommitSummary.from_raw(summary, max_length=int(getattr(config, "summary_hard_limit", 128))))
246
+
247
+
248
+ async def generate_summary_from_analysis(
249
+ config: Any,
250
+ analysis: ConventionalAnalysis,
251
+ stat: str = "",
252
+ *,
253
+ user_context: str | None = None,
254
+ debug_output: str | Path | None = None,
255
+ debug_prefix: str | None = None,
256
+ ) -> str:
257
+ """Generate a concise summary from structured analysis details."""
258
+
259
+ commit_type = str(analysis.commit_type)
260
+ scope = None if analysis.scope is None else str(analysis.scope)
261
+ prefix_len = len(commit_type) + 2 + (len(scope) + 2 if scope else 0)
262
+ chars = max(20, int(getattr(config, "summary_guideline", 72)) - prefix_len)
263
+ variant = (
264
+ "markdown"
265
+ if bool(getattr(config, "markdown_output", True))
266
+ else str(getattr(config, "summary_prompt_variant", "default"))
267
+ )
268
+ details = "\n".join(f"- {detail}" for detail in analysis.body_texts()) or f"- {analysis.summary or ''}"
269
+ system_prompt, user_prompt = render_prompt(
270
+ "summary",
271
+ variant,
272
+ {
273
+ "commit_type": commit_type,
274
+ "scope": scope,
275
+ "chars": chars,
276
+ "user_context": user_context or "",
277
+ "details": details,
278
+ "stat": stat,
279
+ },
280
+ )
281
+ spec = OneShotSpec(
282
+ operation="summary",
283
+ model=resolve_model_name(str(getattr(config, "summary_model", getattr(config, "model", "claude-haiku-4-5")))),
284
+ prompt_family="summary",
285
+ prompt_variant=variant,
286
+ system_prompt=system_prompt,
287
+ user_prompt=user_prompt,
288
+ tool_name="create_commit_summary",
289
+ tool_description="Compose a git commit summary line from detail statements",
290
+ schema=strict_json_schema(
291
+ {
292
+ "summary": {
293
+ "type": "string",
294
+ "description": f"Single line summary, target {getattr(config, 'summary_guideline', 72)} chars, hard limit {getattr(config, 'summary_hard_limit', 128)}.",
295
+ "maxLength": int(getattr(config, "summary_hard_limit", 128)),
296
+ }
297
+ },
298
+ ["summary"],
299
+ ),
300
+ progress_label="summary",
301
+ debug=OneShotDebug(debug_output, debug_prefix, "summary") if debug_output else None,
302
+ cacheable=True,
303
+ )
304
+ try:
305
+ response = await _run_oneshot_response(config, spec)
306
+ summary = _summary_from_output(response.output, response.text_content)
307
+ except Exception:
308
+ summary = ""
309
+ summary = strip_type_prefix(
310
+ summary
311
+ or analysis.summary
312
+ or fallback_summary(
313
+ stat, analysis.body_texts(), limit=int(getattr(config, "summary_hard_limit", 128)), commit_type=commit_type
314
+ )
315
+ )
316
+ if not validate_summary_quality(summary, commit_type, stat).ok:
317
+ summary = _fallback_summary_for_commit(
318
+ stat, analysis.body_texts(), commit_type, int(getattr(config, "summary_hard_limit", 128))
319
+ )
320
+ return summary[: int(getattr(config, "summary_hard_limit", 128))].rstrip(" .")
321
+
322
+
323
+ async def generate_analysis_with_map_reduce(
324
+ config: Any, stat: str, diff: str, scope_candidates: str = "", **kwargs: Any
325
+ ) -> ConventionalAnalysis:
326
+ """Generate analysis directly or through map-reduce for large diffs."""
327
+
328
+ from . import style
329
+ from .map_reduce import run_map_reduce, should_use_map_reduce
330
+ from .tokens import create_token_counter
331
+
332
+ counter = kwargs.pop("counter", None) or create_token_counter(config)
333
+ if should_use_map_reduce(diff, config, counter):
334
+ count_sync = getattr(counter, "count_sync", None)
335
+ token_count = int(count_sync(diff)) if callable(count_sync) else max(1, len(diff) // 4)
336
+ style.print_info(f"Large diff detected ({token_count} tokens), using map-reduce...")
337
+ return await run_map_reduce(config, stat, diff, scope_candidates, counter=counter, **kwargs)
338
+ return await generate_conventional_analysis(config, stat, diff, scope_candidates, **kwargs)
339
+
340
+
341
+ async def generate_fast_commit(
342
+ config: Any,
343
+ stat: str,
344
+ diff: str,
345
+ scope_candidates: str = "",
346
+ *,
347
+ user_context: str | None = None,
348
+ debug_output: str | Path | None = None,
349
+ debug_prefix: str | None = None,
350
+ ) -> ConventionalCommit:
351
+ """Generate a complete conventional commit in one model call."""
352
+
353
+ variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
354
+ system_prompt, user_prompt = render_prompt(
355
+ "fast",
356
+ variant,
357
+ {
358
+ "stat": stat,
359
+ "diff": diff,
360
+ "scope_candidates": scope_candidates,
361
+ "user_context": user_context or "",
362
+ "types_description": format_types_description(config),
363
+ },
364
+ )
365
+ type_enum = list(getattr(config, "types", {}) or {"chore": None})
366
+ spec = OneShotSpec(
367
+ operation="fast",
368
+ model=resolve_model_name(str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))),
369
+ prompt_family="fast",
370
+ prompt_variant=variant,
371
+ system_prompt=system_prompt,
372
+ user_prompt=user_prompt,
373
+ tool_name="create_fast_commit",
374
+ tool_description="Create a compact conventional commit message",
375
+ schema=strict_json_schema(
376
+ {
377
+ "type": {"type": "string", "enum": type_enum, "description": "Conventional commit type"},
378
+ "scope": {"type": "string", "description": "Optional scope. Omit if unclear."},
379
+ "summary": {"type": "string", "description": "Compact past-tense summary, no prefix or period"},
380
+ "details": {"type": "array", "items": {"type": "string"}, "description": "0-3 detail sentences"},
381
+ },
382
+ ["type", "summary", "details"],
383
+ ),
384
+ progress_label="fast",
385
+ debug=OneShotDebug(debug_output, debug_prefix, "fast") if debug_output else None,
386
+ cacheable=True,
387
+ )
388
+ response = await _run_oneshot_response(config, spec)
389
+ commit = _coerce_fast_commit(
390
+ response.output, response.text_content, default_type=type_enum[0] if type_enum else "chore"
391
+ )
392
+ return post_process_commit_message(commit, config)
393
+
394
+
395
+ def _fallback_summary_for_commit(stat: str, details: Iterable[str], commit_type: str, limit: int) -> str:
396
+ details_list = [str(detail) for detail in details]
397
+ candidate = fallback_summary(stat, details_list, limit=limit, commit_type=commit_type)
398
+ if validate_summary_quality(candidate, commit_type, stat).ok:
399
+ return candidate
400
+ first_detail = details_list[0].strip().rstrip(".") if details_list else ""
401
+ cleaned = first_detail or strip_type_prefix(candidate).strip()
402
+ for variant in (commit_type, f"{commit_type}ed", f"{commit_type}d"):
403
+ prefix = f"{variant.lower()} "
404
+ if cleaned.lower().startswith(prefix):
405
+ cleaned = cleaned[len(variant) :].strip()
406
+ break
407
+ verb = {
408
+ "feat": "added",
409
+ "fix": "fixed",
410
+ "refactor": "restructured",
411
+ "docs": "documented",
412
+ "test": "tested",
413
+ "perf": "optimized",
414
+ "build": "updated",
415
+ "ci": "updated",
416
+ "chore": "updated",
417
+ "style": "formatted",
418
+ "revert": "reverted",
419
+ }.get(commit_type, "changed")
420
+ first_word = cleaned.split(maxsplit=1)[0] if cleaned else ""
421
+ prefixed = cleaned if first_word and is_past_tense_first_word(first_word) else f"{verb} {cleaned or 'files'}"
422
+ try:
423
+ return str(CommitSummary.from_raw(prefixed, max_length=limit))
424
+ except LgitError:
425
+ return fallback_summary("", details_list, limit=limit, commit_type=commit_type)
426
+
427
+
428
+ def render_prompt(family: str, variant: str, context: Mapping[str, Any]) -> tuple[str, str]:
429
+ """Render a prompt through ``lgit.templates`` with resource fallback."""
430
+
431
+ try:
432
+ from . import templates
433
+
434
+ rendered = _render_with_template_helper(templates, family, variant, context)
435
+ if rendered is not None:
436
+ return rendered
437
+ except ImportError, AttributeError:
438
+ pass
439
+ template_text = (
440
+ resources.files("lgit.resources").joinpath("prompts", family, f"{variant}.md").read_text(encoding="utf-8")
441
+ )
442
+ system, user = _split_prompt(Template(template_text).render(**context))
443
+ return system, user
444
+
445
+
446
+ def format_types_description(config: Any) -> str:
447
+ """Format configured commit-type guidance for prompts."""
448
+
449
+ lines: list[str] = []
450
+ for name, type_config in (getattr(config, "types", {}) or {}).items():
451
+ description = getattr(type_config, "description", "")
452
+ hint = getattr(type_config, "hint", "")
453
+ line = f"- {name}: {description}".rstrip()
454
+ if hint:
455
+ line += f" ({hint})"
456
+ lines.append(line)
457
+ classifier_hint = str(getattr(config, "classifier_hint", "") or "").strip()
458
+ if classifier_hint:
459
+ lines.append(classifier_hint)
460
+ return "\n".join(lines)
461
+
462
+
463
+ def build_analysis_schema(type_enum: list[str], config: Any) -> dict[str, Any]:
464
+ """Return the strict schema used for conventional analysis calls."""
465
+
466
+ return strict_json_schema(
467
+ {
468
+ "type": {"type": "string", "enum": type_enum, "description": "Conventional commit type"},
469
+ "scope": {"type": "string", "description": "Optional scope. Omit if unclear."},
470
+ "summary": {
471
+ "type": "string",
472
+ "description": "Umbrella commit summary without type/scope prefix or trailing period",
473
+ "maxLength": int(getattr(config, "summary_hard_limit", 128)),
474
+ },
475
+ "details": {
476
+ "type": "array",
477
+ "items": {
478
+ "type": "object",
479
+ "properties": {
480
+ "text": {"type": "string"},
481
+ "changelog_category": {
482
+ "type": "string",
483
+ "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
484
+ },
485
+ "user_visible": {"type": "boolean"},
486
+ },
487
+ "required": ["text", "user_visible"],
488
+ "additionalProperties": False,
489
+ },
490
+ },
491
+ "issue_refs": {"type": "array", "items": {"type": "string"}},
492
+ },
493
+ ["type", "summary", "details", "issue_refs"],
494
+ )
495
+
496
+
497
+ def encode_cache_payload(source: OneShotSource | str, output: Any, text_content: str | None = None) -> str | None:
498
+ """Encode parsed output for stable cache storage."""
499
+
500
+ if str(source) in {OneShotSource.PLAIN_TEXT_CONTENT.value, OneShotSource.OUTPUT_JSON_PARSE.value} and text_content:
501
+ return text_content
502
+ try:
503
+ return _JSON_CACHE_PREFIX + json.dumps(output, ensure_ascii=False, separators=(",", ":"), default=_json_default)
504
+ except TypeError:
505
+ return None
506
+
507
+
508
+ def decode_cache_payload(
509
+ tool_name: str, operation: str, stored: str, markdown_output: bool
510
+ ) -> tuple[Any, str | None] | None:
511
+ """Decode a cached payload using JSON first, then markdown/plain-text fallback."""
512
+
513
+ is_raw = not stored.startswith(_JSON_CACHE_PREFIX)
514
+ payload = stored if is_raw else stored.removeprefix(_JSON_CACHE_PREFIX)
515
+ try:
516
+ output = _parse_json_payload(payload)
517
+ except json.JSONDecodeError, ValueError, TypeError:
518
+ try:
519
+ output = _parse_plain_text(tool_name, payload, markdown_output)
520
+ except json.JSONDecodeError, ValueError, TypeError:
521
+ return None
522
+ if output is None:
523
+ return None
524
+ return output, payload if is_raw else None
525
+
526
+
527
+ async def _run_oneshot_response(
528
+ config: Any, spec: OneShotSpec, *, markdown_output: bool | None = None
529
+ ) -> OneShotResponse:
530
+ markdown_mode = bool(getattr(config, "markdown_output", True) if markdown_output is None else markdown_output)
531
+ mode = _resolved_mode(config, spec.model or "")
532
+ cache_entry = _build_cache_entry(config, spec, markdown_mode)
533
+ if cache_entry is not None:
534
+ cache_obj, key = cache_entry
535
+ stored = cache_obj.get(key)
536
+ if stored is not None:
537
+ decoded = decode_cache_payload(spec.tool_name, spec.operation, stored, markdown_mode)
538
+ if decoded is not None:
539
+ output, text = decoded
540
+ profile.print_llm_progress(lambda: f"cache hit {spec.operation} ({spec.model})")
541
+ return OneShotResponse(output=output, source=OneShotSource.CACHE, text_content=text)
542
+
543
+ attempts = max(1, int(getattr(config, "max_retries", 3)))
544
+ request_json = ""
545
+ response_text = ""
546
+ last_error: Exception | None = None
547
+ last_retry_from_error = False
548
+ for attempt in range(1, attempts + 1):
549
+ try:
550
+ request, response_text = await _send_oneshot(config, spec, mode, markdown_mode)
551
+ request_json = json.dumps(request, ensure_ascii=False, default=_json_default)
552
+ if not response_text.strip():
553
+ raise _RetryableResponse("empty response body")
554
+ response = _parse_oneshot_response(mode, spec.tool_name, spec.operation, response_text, markdown_mode)
555
+ if cache_entry is not None:
556
+ payload = encode_cache_payload(response.source, response.output, response.text_content)
557
+ if payload is not None:
558
+ cache_entry[0].put(cache_entry[1], spec.model or "", spec.operation, request_json, payload)
559
+ return response
560
+ except ApiContextLengthExceeded:
561
+ raise
562
+ except _RetryableResponse as exc:
563
+ last_error = exc
564
+ last_retry_from_error = False
565
+ except (
566
+ httpx.TimeoutException,
567
+ httpx.TransportError,
568
+ LgitError,
569
+ json.JSONDecodeError,
570
+ ValueError,
571
+ TypeError,
572
+ ) as exc:
573
+ _record_failure(config, cache_entry, spec, request_json, response_text, exc)
574
+ last_error = exc
575
+ last_retry_from_error = True
576
+ if attempt < attempts:
577
+ await asyncio.sleep(max(0, int(getattr(config, "initial_backoff_ms", 1000))) / 1000 * (2 ** (attempt - 1)))
578
+ if last_retry_from_error and last_error is not None:
579
+ raise last_error
580
+ raise LgitError(f"Max retries exceeded for {spec.operation}: {last_error}")
581
+
582
+
583
+ async def _send_oneshot(
584
+ config: Any, spec: OneShotSpec, mode: ResolvedApiMode, markdown_mode: bool
585
+ ) -> tuple[dict[str, Any], str]:
586
+ timeout = httpx.Timeout(
587
+ float(getattr(config, "request_timeout_secs", 120)), connect=float(getattr(config, "connect_timeout_secs", 30))
588
+ )
589
+ headers = {"content-type": "application/json"}
590
+ api_key = getattr(config, "api_key", None)
591
+ if mode == ResolvedApiMode.CHAT_COMPLETIONS:
592
+ if api_key:
593
+ headers["authorization"] = f"Bearer {api_key}"
594
+ request = _openai_request(config, spec, markdown_mode)
595
+ url = urljoin(
596
+ str(getattr(config, "api_base_url", "http://localhost:4000")).rstrip("/") + "/", "chat/completions"
597
+ )
598
+ else:
599
+ headers["anthropic-version"] = "2023-06-01"
600
+ if api_key:
601
+ headers["x-api-key"] = str(api_key)
602
+ headers["authorization"] = f"Bearer {api_key}"
603
+ if _anthropic_prompt_caching_enabled(config):
604
+ headers["anthropic-beta"] = "prompt-caching-2024-07-31"
605
+ request = _anthropic_request(config, spec, markdown_mode)
606
+ url = _anthropic_messages_url(str(getattr(config, "api_base_url", "")))
607
+ _save_debug(spec.debug, "request", request)
608
+ profile.print_llm_progress(lambda: f"query {spec.operation} model={spec.model}")
609
+ start = time.monotonic()
610
+ async with httpx.AsyncClient(timeout=timeout) as client:
611
+ response = await client.post(url, headers=headers, json=request)
612
+ text = response.text
613
+ profile.print_llm_progress(
614
+ lambda: (
615
+ f"response {spec.operation} status={response.status_code} elapsed={time.monotonic() - start:.2f}s size={len(text)}B"
616
+ )
617
+ )
618
+ _save_debug_text(spec.debug, "response", text)
619
+ if not response.is_success and _is_context_length_error(text):
620
+ raise ApiContextLengthExceeded(
621
+ operation=spec.operation, model=spec.model or "", status=response.status_code, body=text
622
+ )
623
+ if 500 <= response.status_code <= 599:
624
+ raise _RetryableResponse(f"server error {response.status_code}: {text}")
625
+ if not response.is_success:
626
+ raise ApiError(status=response.status_code, body=text)
627
+ return request, text
628
+
629
+
630
+ def _openai_request(config: Any, spec: OneShotSpec, markdown_mode: bool) -> dict[str, Any]:
631
+ messages = []
632
+ if spec.system_prompt.strip():
633
+ messages.append({"role": "system", "content": spec.system_prompt})
634
+ messages.append({"role": "user", "content": spec.user_prompt})
635
+ request: dict[str, Any] = {"model": spec.model, "messages": messages}
636
+ if not markdown_mode:
637
+ request["tools"] = [_openai_tool(spec.tool_name, spec.tool_description, spec.schema or {})]
638
+ request["tool_choice"] = {"type": "function", "function": {"name": spec.tool_name}}
639
+ prompt_cache_key = _openai_prompt_cache_key(config, spec)
640
+ if prompt_cache_key:
641
+ request["prompt_cache_key"] = prompt_cache_key
642
+ return request
643
+
644
+
645
+ def _anthropic_request(config: Any, spec: OneShotSpec, markdown_mode: bool) -> dict[str, Any]:
646
+ prompt_caching = _anthropic_prompt_caching_enabled(config)
647
+ request: dict[str, Any] = {
648
+ "model": spec.model,
649
+ "max_tokens": ANTHROPIC_REQUIRED_MAX_TOKENS,
650
+ "messages": [{"role": "user", "content": [_anthropic_text(spec.user_prompt, prompt_caching)]}],
651
+ }
652
+ if spec.system_prompt.strip():
653
+ request["system"] = [_anthropic_text(spec.system_prompt, prompt_caching)]
654
+ if not markdown_mode:
655
+ request["tools"] = [_anthropic_tool(spec.tool_name, spec.tool_description, spec.schema or {}, prompt_caching)]
656
+ request["tool_choice"] = {"type": "tool", "name": spec.tool_name}
657
+ return request
658
+
659
+
660
+ def _parse_oneshot_response(
661
+ mode: ResolvedApiMode, tool_name: str, operation: str, response_text: str, markdown_mode: bool
662
+ ) -> OneShotResponse:
663
+ if mode == ResolvedApiMode.CHAT_COMPLETIONS:
664
+ body = json.loads(response_text)
665
+ choices = body.get("choices") or []
666
+ if not choices:
667
+ raise LgitError(f"API returned empty response for {operation}")
668
+ message = choices[0].get("message") or {}
669
+ if refusal := message.get("refusal"):
670
+ raise LgitError(f"Model refused {operation}: {refusal}")
671
+ last_error: Exception | None = None
672
+ for call in message.get("tool_calls") or []:
673
+ function = (call or {}).get("function") or {}
674
+ if str(function.get("name", "")).endswith(tool_name):
675
+ args = str(function.get("arguments") or "").strip()
676
+ if not args:
677
+ last_error = LgitError(f"Model returned empty function arguments for {operation}")
678
+ else:
679
+ try:
680
+ return OneShotResponse(
681
+ _parse_tool_arguments(args, operation), OneShotSource.TOOL_CALL, message.get("content")
682
+ )
683
+ except LgitError as exc:
684
+ last_error = exc
685
+ content = message.get("content")
686
+ if content is not None:
687
+ if not str(content).strip():
688
+ raise _RetryableResponse("empty content")
689
+ return _parse_content_fallback(tool_name, operation, str(content), markdown_mode)
690
+ if last_error is not None:
691
+ raise last_error
692
+ raise LgitError(f"No {operation} found in API response")
693
+
694
+ tool_input, text_content, stop_reason = _extract_anthropic_content(response_text, tool_name)
695
+ if tool_input is not None:
696
+ return OneShotResponse(tool_input, OneShotSource.TOOL_CALL, text_content or None, stop_reason)
697
+ if not text_content.strip():
698
+ raise _RetryableResponse("empty content")
699
+ response = _parse_content_fallback(tool_name, operation, text_content, markdown_mode)
700
+ return OneShotResponse(response.output, response.source, response.text_content, stop_reason)
701
+
702
+
703
+ def _parse_content_fallback(tool_name: str, operation: str, content: str, markdown_mode: bool) -> OneShotResponse:
704
+ try:
705
+ return OneShotResponse(_parse_json_payload(content), OneShotSource.OUTPUT_JSON_PARSE, content)
706
+ except (json.JSONDecodeError, ValueError, TypeError) as json_error:
707
+ try:
708
+ parsed = _parse_plain_text(tool_name, content, markdown_mode)
709
+ except (json.JSONDecodeError, ValueError, TypeError) as markdown_error:
710
+ raise LgitError(f"Failed to parse {operation} plain-text fallback: {markdown_error}") from markdown_error
711
+ if parsed is None:
712
+ raise LgitError(f"Failed to parse {operation} content JSON: {json_error}") from json_error
713
+ return OneShotResponse(parsed, OneShotSource.PLAIN_TEXT_CONTENT, content)
714
+
715
+
716
+ def _parse_plain_text(tool_name: str, content: str, markdown_mode: bool) -> Any:
717
+ text = _normalize_plain_text(content)
718
+ if not text:
719
+ return None
720
+ if tool_name == "create_conventional_analysis":
721
+ return _analysis_to_mapping(parse_conventional_analysis_markdown(text))
722
+ if tool_name == "create_fast_commit":
723
+ commit = parse_fast_commit_markdown(text)
724
+ return {
725
+ "type": str(commit.commit_type),
726
+ "scope": None if commit.scope is None else str(commit.scope),
727
+ "summary": str(commit.summary),
728
+ "details": list(commit.body),
729
+ }
730
+ if tool_name == "create_file_observations":
731
+ from .markdown_output import parse_file_observations_markdown
732
+
733
+ return {"files": parse_file_observations_markdown(text)}
734
+ if tool_name == "create_changelog_entries":
735
+ return parse_changelog_response(text)
736
+ if tool_name == "create_compose_intent_plan":
737
+ return parse_compose_intent_markdown(text)
738
+ if tool_name == "bind_compose_hunks":
739
+ return parse_compose_binding_markdown(text)
740
+ if tool_name == "create_commit_summary":
741
+ return {"summary": parse_summary_markdown(text) if markdown_mode else strip_type_prefix(text)}
742
+ return None
743
+
744
+
745
+ def _parse_tool_arguments(args: str, operation: str) -> Any:
746
+ try:
747
+ return _parse_json_payload(args)
748
+ except (json.JSONDecodeError, ValueError, TypeError) as exc:
749
+ raise LgitError(f"Failed to parse {operation} tool arguments: {exc}") from exc
750
+
751
+
752
+ def _parse_json_payload(text: str) -> Any:
753
+ candidate = _extract_json_from_content(text)
754
+ return json.loads(candidate)
755
+
756
+
757
+ def _extract_json_from_content(content: str) -> str:
758
+ trimmed = _normalize_plain_text(content)
759
+ if not trimmed:
760
+ return trimmed
761
+
762
+ start = trimmed.find("{")
763
+ end = trimmed.rfind("}")
764
+ if start >= 0 and end >= start:
765
+ return trimmed[start : end + 1]
766
+ start = trimmed.find("[")
767
+ end = trimmed.rfind("]")
768
+ if start >= 0 and end >= start:
769
+ return trimmed[start : end + 1]
770
+ return trimmed
771
+
772
+
773
+ def _normalize_plain_text(content: str) -> str:
774
+ trimmed = content.strip()
775
+ fenced = re.search(r"```(?:json|markdown|md)?\s*(.*?)```", trimmed, re.IGNORECASE | re.DOTALL)
776
+ return fenced.group(1).strip() if fenced else trimmed
777
+
778
+
779
+ def _extract_anthropic_content(response_text: str, tool_name: str) -> tuple[Any | None, str, str | None]:
780
+ value = json.loads(response_text)
781
+ stop_reason = value.get("stop_reason")
782
+ tool_input = None
783
+ text_parts: list[str] = []
784
+ for item in value.get("content") or []:
785
+ item_type = item.get("type", "")
786
+ if item_type == "tool_use" and item.get("name") == tool_name:
787
+ tool_input = item.get("input")
788
+ elif item_type == "text" and isinstance(item.get("text"), str):
789
+ text_parts.append(item["text"])
790
+ return tool_input, "\n".join(text_parts), None if stop_reason is None else str(stop_reason)
791
+
792
+
793
+ def _coerce_spec(prompt: str | OneShotSpec | Mapping[str, Any] | None, **kwargs: Any) -> OneShotSpec:
794
+ spec = kwargs.pop("spec", None)
795
+ if isinstance(spec, OneShotSpec):
796
+ return spec
797
+ if isinstance(prompt, OneShotSpec):
798
+ return prompt
799
+ if spec is None and isinstance(prompt, Mapping):
800
+ spec = prompt
801
+ if isinstance(spec, Mapping):
802
+ values = dict(spec)
803
+ if "cache" in values and "cacheable" not in values:
804
+ values["cacheable"] = values.pop("cache")
805
+ return OneShotSpec(**{key: value for key, value in values.items() if key in OneShotSpec.__dataclass_fields__})
806
+ schema = kwargs["schema"] or strict_json_schema({"response": {"type": "string"}}, ["response"])
807
+ schema_name = kwargs["schema_name"]
808
+ inferred_tool = kwargs["tool_name"] or (
809
+ schema_name if schema_name.startswith(("create_", "bind_")) else f"create_{schema_name}"
810
+ )
811
+ return OneShotSpec(
812
+ operation=kwargs["operation"] or schema_name,
813
+ model=resolve_model_name(str(kwargs["model"] or "")) if kwargs["model"] else None,
814
+ prompt_family=kwargs["prompt_family"],
815
+ prompt_variant=kwargs["prompt_variant"],
816
+ system_prompt=kwargs["system_prompt"] or "",
817
+ user_prompt=str(prompt or ""),
818
+ tool_name=inferred_tool,
819
+ tool_description=kwargs["tool_description"] or f"Create {schema_name}",
820
+ schema=schema,
821
+ progress_label=kwargs["operation"] or schema_name,
822
+ debug=_coerce_debug(kwargs["debug"], kwargs["debug_label"] or schema_name),
823
+ cacheable=bool(kwargs["cacheable"]),
824
+ )
825
+
826
+
827
+ def _coerce_debug(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, name: str) -> OneShotDebug | None:
828
+ if debug is None:
829
+ return None
830
+ if isinstance(debug, OneShotDebug):
831
+ return debug
832
+ if isinstance(debug, Mapping):
833
+ return OneShotDebug(**debug)
834
+ return OneShotDebug(debug, None, name)
835
+
836
+
837
+ def _resolved_mode(config: Any, model: str) -> ResolvedApiMode:
838
+ resolver = getattr(config, "resolve_api_mode", None)
839
+ if callable(resolver):
840
+ return resolver(model)
841
+ return config.resolved_api_mode
842
+
843
+
844
+ def _build_cache_entry(config: Any, spec: OneShotSpec, markdown_mode: bool) -> tuple[Any, str] | None:
845
+ if not spec.cacheable:
846
+ return None
847
+ cache_obj = llm_cache.global_cache()
848
+ if cache_obj is None:
849
+ return None
850
+ mode = str(_resolved_mode(config, spec.model or ""))
851
+ key = llm_cache.compute_key(
852
+ llm_cache.CacheMaterial(
853
+ operation=spec.operation,
854
+ model=spec.model or "",
855
+ tool_name=spec.tool_name,
856
+ tool_description=spec.tool_description,
857
+ system_prompt=spec.system_prompt,
858
+ user_prompt=spec.user_prompt,
859
+ schema=spec.schema or {},
860
+ api_mode=mode,
861
+ markdown_output=markdown_mode,
862
+ )
863
+ )
864
+ return cache_obj, key
865
+
866
+
867
+ def _record_failure(
868
+ config: Any, cache_entry: tuple[Any, str] | None, spec: OneShotSpec, request: str, response: str, error: Exception
869
+ ) -> None:
870
+ sink = llm_cache.global_cache()
871
+ if sink is None and cache_entry is not None:
872
+ sink = cache_entry[0]
873
+ if sink is not None:
874
+ sink.put_failure(
875
+ cache_entry[1] if cache_entry else "", spec.model or "", spec.operation, request, response, str(error)
876
+ )
877
+
878
+
879
+ def _openai_tool(name: str, description: str, schema: Mapping[str, Any]) -> dict[str, Any]:
880
+ if "properties" not in schema:
881
+ raise LgitError("Schema must include top-level properties")
882
+ required = schema.get("required")
883
+ if not isinstance(required, (list, tuple)) or not all(isinstance(value, str) for value in required):
884
+ raise LgitError("Schema must include top-level required array of strings")
885
+ parameters = {
886
+ "type": "object",
887
+ "properties": dict(schema["properties"]),
888
+ "required": list(required),
889
+ "additionalProperties": False,
890
+ }
891
+ return {"type": "function", "function": {"name": name, "description": description, "parameters": parameters}}
892
+
893
+
894
+ def _anthropic_tool(name: str, description: str, schema: Mapping[str, Any], cache: bool) -> dict[str, Any]:
895
+ tool = {"name": name, "description": description, "input_schema": dict(schema)}
896
+ if cache:
897
+ tool["cache_control"] = {"type": "ephemeral"}
898
+ return tool
899
+
900
+
901
+ def _anthropic_text(text: str, cache: bool) -> dict[str, Any]:
902
+ content = {"type": "text", "text": text}
903
+ if cache:
904
+ content["cache_control"] = {"type": "ephemeral"}
905
+ return content
906
+
907
+
908
+ def _anthropic_prompt_caching_enabled(config: Any) -> bool:
909
+ return "anthropic.com" in str(getattr(config, "api_base_url", "")).lower()
910
+
911
+
912
+ def _anthropic_messages_url(base_url: str) -> str:
913
+ trimmed = base_url.rstrip("/")
914
+ return f"{trimmed}/messages" if trimmed.endswith("/v1") else f"{trimmed}/v1/messages"
915
+
916
+
917
+ def _openai_prompt_cache_key(config: Any, spec: OneShotSpec) -> str | None:
918
+ base_url = str(getattr(config, "api_base_url", "")).lower()
919
+ if not spec.system_prompt.strip() or "api.openai.com" not in base_url:
920
+ return None
921
+ return f"llm-git:v1:{spec.model}:{spec.prompt_family}:{spec.prompt_variant}"
922
+
923
+
924
+ def _is_context_length_error(body: str) -> bool:
925
+ lower = body.lower()
926
+ return any(marker in lower for marker in _CONTEXT_LENGTH_MARKERS)
927
+
928
+
929
+ def _save_debug(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, phase: str, value: Any) -> None:
930
+ if debug is None:
931
+ return
932
+ _save_debug_text(debug, phase, json.dumps(value, ensure_ascii=False, indent=2, default=_json_default))
933
+
934
+
935
+ def _save_debug_text(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, phase: str, text: str) -> None:
936
+ debug_obj = _coerce_debug(debug, "oneshot")
937
+ if debug_obj is None or debug_obj.dir is None:
938
+ return
939
+ directory = Path(debug_obj.dir)
940
+ directory.mkdir(parents=True, exist_ok=True)
941
+ prefix = f"{debug_obj.prefix}_" if debug_obj.prefix else ""
942
+ path = directory / f"{prefix}{debug_obj.name}_{phase}.json"
943
+ path.write_text(text, encoding="utf-8")
944
+
945
+
946
+ def _render_with_template_helper(
947
+ templates: Any, family: str, variant: str, context: Mapping[str, Any]
948
+ ) -> tuple[str, str] | None:
949
+ helper = getattr(templates, f"render_{family.replace('-', '_')}_prompt", None)
950
+ if not callable(helper):
951
+ return None
952
+ match family:
953
+ case "analysis" | "fast":
954
+ parts = helper(variant=variant, **dict(context))
955
+ case "summary":
956
+ parts = helper(
957
+ variant,
958
+ str(context.get("commit_type", "")),
959
+ str(context.get("scope") or ""),
960
+ str(context.get("chars", "")),
961
+ str(context.get("details", "")),
962
+ str(context.get("stat", "")),
963
+ context.get("user_context"),
964
+ )
965
+ case "map":
966
+ parts = helper(variant, context.get("files", ()), str(context.get("context_header", "")))
967
+ case "reduce":
968
+ parts = helper(
969
+ variant,
970
+ str(context.get("observations", "")),
971
+ str(context.get("stat", "")),
972
+ str(context.get("scope_candidates", "")),
973
+ context.get("types_description"),
974
+ )
975
+ case _:
976
+ return None
977
+ return str(parts.system), str(parts.user)
978
+
979
+
980
+ def _split_prompt(text: str) -> tuple[str, str]:
981
+ marker = "======USER======="
982
+ if marker in text:
983
+ system, user = text.split(marker, 1)
984
+ return system.strip(), user.strip()
985
+ return text.strip(), ""
986
+
987
+
988
+ def _coerce_analysis(output: Any, text_content: str | None, *, default_type: str) -> ConventionalAnalysis:
989
+ if isinstance(output, ConventionalAnalysis):
990
+ return output
991
+ if isinstance(output, Mapping):
992
+ return analysis_from_mapping(output, default_type=default_type)
993
+ if text_content:
994
+ return parse_conventional_analysis_markdown(text_content, default_type=default_type)
995
+ return parse_conventional_analysis_markdown(str(output), default_type=default_type)
996
+
997
+
998
+ def _summary_from_output(output: Any, text_content: str | None) -> str:
999
+ if isinstance(output, Mapping):
1000
+ value = output.get("summary")
1001
+ if value:
1002
+ return strip_type_prefix(str(value))
1003
+ if isinstance(output, str):
1004
+ return parse_summary_markdown(output)
1005
+ return parse_summary_markdown(text_content or "")
1006
+
1007
+
1008
+ def _coerce_fast_commit(output: Any, text_content: str | None, *, default_type: str) -> ConventionalCommit:
1009
+ if isinstance(output, ConventionalCommit):
1010
+ return output
1011
+ if isinstance(output, Mapping):
1012
+ analysis = analysis_from_mapping(output, default_type=default_type)
1013
+ return ConventionalCommit.from_raw(
1014
+ commit_type=str(analysis.commit_type),
1015
+ scope=None if analysis.scope is None else str(analysis.scope),
1016
+ summary=analysis.summary
1017
+ or fallback_summary(details=analysis.body_texts(), commit_type=str(analysis.commit_type)),
1018
+ body=analysis.body_texts(),
1019
+ )
1020
+ if text_content:
1021
+ return parse_fast_commit_markdown(text_content, default_type=default_type)
1022
+ return parse_fast_commit_markdown(str(output), default_type=default_type)
1023
+
1024
+
1025
+ def _analysis_to_mapping(analysis: ConventionalAnalysis) -> dict[str, Any]:
1026
+ return {
1027
+ "type": str(analysis.commit_type),
1028
+ "scope": None if analysis.scope is None else str(analysis.scope),
1029
+ "summary": analysis.summary or "",
1030
+ "details": [
1031
+ {
1032
+ "text": detail.text,
1033
+ **(
1034
+ {"changelog_category": detail.changelog_category.value}
1035
+ if detail.changelog_category is not None
1036
+ else {}
1037
+ ),
1038
+ "user_visible": detail.user_visible,
1039
+ }
1040
+ for detail in analysis.details
1041
+ ],
1042
+ "issue_refs": list(analysis.issue_refs),
1043
+ }
1044
+
1045
+
1046
+ def _json_default(value: Any) -> Any:
1047
+ if hasattr(value, "value"):
1048
+ return value.value
1049
+ if hasattr(value, "__dict__"):
1050
+ return vars(value)
1051
+ return str(value)
1052
+
1053
+
1054
+ class _RetryableResponse(Exception):
1055
+ pass
1056
+
1057
+
1058
+ __all__ = [
1059
+ "OneShotDebug",
1060
+ "OneShotResponse",
1061
+ "OneShotSource",
1062
+ "OneShotSpec",
1063
+ "build_analysis_schema",
1064
+ "decode_cache_payload",
1065
+ "encode_cache_payload",
1066
+ "fallback_summary",
1067
+ "format_types_description",
1068
+ "generate_analysis_with_map_reduce",
1069
+ "generate_conventional_analysis",
1070
+ "generate_fast_commit",
1071
+ "generate_summary_from_analysis",
1072
+ "summary_from_holistic_analysis",
1073
+ "render_prompt",
1074
+ "run_oneshot",
1075
+ "strict_json_schema",
1076
+ "strip_type_prefix",
1077
+ ]