git-cai-cli 0.17.3__tar.gz → 0.17.4__tar.gz

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 (113) hide show
  1. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/PKG-INFO +1 -1
  2. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/docs/git-cai.txt +5 -3
  3. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/docs/man/git-cai.1 +7 -5
  4. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/_version.py +3 -3
  5. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/gitutils.py +39 -10
  6. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/llm.py +62 -7
  7. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/PKG-INFO +1 -1
  8. git_cai_cli-0.17.4/src/git_cai_cli.egg-info/scm_version.json +8 -0
  9. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_gitutils.py +50 -0
  10. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_llm.py +192 -0
  11. git_cai_cli-0.17.3/src/git_cai_cli.egg-info/scm_version.json +0 -8
  12. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.caiignore +0 -0
  13. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.gitattributes +0 -0
  14. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/cd/.SRCINFO +0 -0
  15. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/cd/PKGBUILD +0 -0
  16. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/_version.py +0 -0
  17. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/cai_config.ci.yml +0 -0
  18. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/tokens.ci.yml +0 -0
  19. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/python-tests.yml +0 -0
  20. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/release.yml +0 -0
  21. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/release_aur.yml +0 -0
  22. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.gitignore +0 -0
  23. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.bandit.yml +0 -0
  24. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.checkov.yml +0 -0
  25. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.flake8 +0 -0
  26. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.ls-lint.yml +0 -0
  27. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.markdownlint.json +0 -0
  28. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.proselintrc.json +0 -0
  29. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.pylintrc +0 -0
  30. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.yamllint.yml +0 -0
  31. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/check_git_branch_name.sh +0 -0
  32. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/lychee.toml +0 -0
  33. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/pyrightconfig.json +0 -0
  34. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.markdownlintignore +0 -0
  35. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.mega-linter.yml +0 -0
  36. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.semgrepignore +0 -0
  37. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.trivyignore +0 -0
  38. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/CLAUDE.md +0 -0
  39. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/LICENSE +0 -0
  40. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/Makefile +0 -0
  41. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/README.md +0 -0
  42. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/cai_config.yml +0 -0
  43. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/pyproject.toml +0 -0
  44. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/setup.cfg +0 -0
  45. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/__init__.py +0 -0
  46. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/__init__.py +0 -0
  47. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/cli.py +0 -0
  48. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/helptext.py +0 -0
  49. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/modes.py +0 -0
  50. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/__init__.py +0 -0
  51. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/changelog.py +0 -0
  52. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/completion.py +0 -0
  53. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/config.py +0 -0
  54. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/doctor.py +0 -0
  55. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/explain.py +0 -0
  56. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/generation.py +0 -0
  57. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/init.py +0 -0
  58. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/languages.py +0 -0
  59. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/options.py +0 -0
  60. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/pr.py +0 -0
  61. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/prompts_fallback.py +0 -0
  62. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/release.py +0 -0
  63. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/secrets.py +0 -0
  64. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/spinner.py +0 -0
  65. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/split.py +0 -0
  66. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/squash.py +0 -0
  67. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/stats.py +0 -0
  68. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/validate.py +0 -0
  69. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/main.py +0 -0
  70. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/SOURCES.txt +0 -0
  71. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
  72. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
  73. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/requires.txt +0 -0
  74. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/scm_file_list.json +0 -0
  75. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/top_level.txt +0 -0
  76. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/conftest.py +0 -0
  77. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_cli_integration.py +0 -0
  78. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_config_integration.py +0 -0
  79. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_gitutils_integration.py +0 -0
  80. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_modes_integration.py +0 -0
  81. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_options_integration.py +0 -0
  82. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_pr_integration.py +0 -0
  83. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_squash_integration.py +0 -0
  84. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_amend.py +0 -0
  85. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_branch_context.py +0 -0
  86. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_changelog.py +0 -0
  87. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_classification.py +0 -0
  88. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_cli.py +0 -0
  89. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_completion.py +0 -0
  90. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_config.py +0 -0
  91. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_conventional.py +0 -0
  92. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_custom_provider.py +0 -0
  93. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_doctor.py +0 -0
  94. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_explain.py +0 -0
  95. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_generation.py +0 -0
  96. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_helptext.py +0 -0
  97. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_init.py +0 -0
  98. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_main.py +0 -0
  99. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_modes.py +0 -0
  100. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_options.py +0 -0
  101. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_pr.py +0 -0
  102. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_print.py +0 -0
  103. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_prompt_loading.py +0 -0
  104. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_release.py +0 -0
  105. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_secrets.py +0 -0
  106. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_set_config.py +0 -0
  107. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_signoff.py +0 -0
  108. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_spinner.py +0 -0
  109. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_split.py +0 -0
  110. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_squash.py +0 -0
  111. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_stats.py +0 -0
  112. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_validate.py +0 -0
  113. {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.17.3
3
+ Version: 0.17.4
4
4
  Summary: Use LLM to create git commit messages
5
5
  Author-email: Thorsten Foltz <thorsten.foltz@live.com>
6
6
  License-Expression: MIT
@@ -1099,9 +1099,11 @@ Available configuration keys:
1099
1099
  - `full_files` -- send full working-tree contents of staged files alongside
1100
1100
  the diff (`true`/`false`, default `false`)
1101
1101
  - `max_diff_bytes` -- maximum size (in UTF-8 bytes) of the diff/commit-log
1102
- sent to the LLM. When the input exceeds this, it is truncated and a marker
1103
- line is appended so the model knows it was cut. `0` (the default) means no
1104
- limit. Useful to avoid context-window errors on very large staged changes.
1102
+ sent to the LLM. When the input exceeds this, the largest files are shortened
1103
+ first so every file stays represented, and marker lines tell the model what
1104
+ was cut. `0` (the default) means no limit. You rarely need this for very
1105
+ large staged changes: when a provider rejects the input as too large, git-cai
1106
+ shortens it the same way, warns, and retries up to two times.
1105
1107
  - `pr_to_file` -- when running `--PR`, write the generated PR description
1106
1108
  to a Markdown file in the repository root instead of printing it to
1107
1109
  stdout (`true`/`false`, default `false`)
@@ -2,12 +2,12 @@
2
2
  .\" Title: git-cai
3
3
  .\" Author: Thorsten Foltz
4
4
  .\" Generator: Asciidoctor 2.0.26
5
- .\" Date: 2026-11-04
5
+ .\" Date: 2026-11-06
6
6
  .\" Manual: \ \&
7
7
  .\" Source: \ \&
8
8
  .\" Language: English
9
9
  .\"
10
- .TH "GIT\-CAI" "1" "2026-11-04" "\ \&" "\ \&"
10
+ .TH "GIT\-CAI" "1" "2026-11-06" "\ \&" "\ \&"
11
11
  .ie \n(.g .ds Aq \(aq
12
12
  .el .ds Aq '
13
13
  .ss \n[.ss] 0
@@ -1829,9 +1829,11 @@ the diff (\f(CRtrue\fP/\f(CRfalse\fP, default \f(CRfalse\fP)
1829
1829
  . IP \(bu 2.3
1830
1830
  .\}
1831
1831
  \f(CRmax_diff_bytes\fP \(em maximum size (in UTF\-8 bytes) of the diff/commit\-log
1832
- sent to the LLM. When the input exceeds this, it is truncated and a marker
1833
- line is appended so the model knows it was cut. \f(CR0\fP (the default) means no
1834
- limit. Useful to avoid context\-window errors on very large staged changes.
1832
+ sent to the LLM. When the input exceeds this, the largest files are shortened
1833
+ first so every file stays represented, and marker lines tell the model what
1834
+ was cut. \f(CR0\fP (the default) means no limit. You rarely need this for very
1835
+ large staged changes: when a provider rejects the input as too large, git\-cai
1836
+ shortens it the same way, warns, and retries up to two times.
1835
1837
  .RE
1836
1838
  .sp
1837
1839
  .RS 4
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.17.3'
22
- __version_tuple__ = version_tuple = (0, 17, 3)
21
+ __version__ = version = '0.17.4'
22
+ __version_tuple__ = version_tuple = (0, 17, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g4c48b7f68'
24
+ __commit_id__ = commit_id = 'gf30a44c67'
@@ -349,26 +349,55 @@ def git_diff_excluding(
349
349
  return result.stdout
350
350
 
351
351
 
352
+ # Where an independently trimmable part of an LLM payload starts: one file's
353
+ # diff, or a block appended after the diff. A stray match inside file content
354
+ # only creates one more part, which is harmless.
355
+ _PAYLOAD_PART_RE = re.compile(
356
+ r"^(?=diff --git "
357
+ r"|--- (?:Full file contents|Additional context from the author) ---)",
358
+ re.MULTILINE,
359
+ )
360
+
361
+
362
+ def shorten_diff(diff: str, max_bytes: int) -> str:
363
+ """Cut ``diff`` to about ``max_bytes`` UTF-8 bytes, largest parts first.
364
+
365
+ Parts below their fair share stay whole and the others split what is left,
366
+ so every file keeps its header and the start of its changes. Text without
367
+ file headers (a commit log) is one part, which makes this a plain tail cut.
368
+ Each cut adds a short marker, so the result can slightly exceed the limit.
369
+ """
370
+ parts = [part.encode("utf-8") for part in _PAYLOAD_PART_RE.split(diff) if part]
371
+ remaining = max_bytes
372
+ shares = [0] * len(parts)
373
+ by_size = sorted(range(len(parts)), key=lambda i: len(parts[i]))
374
+ for position, index in enumerate(by_size):
375
+ shares[index] = min(len(parts[index]), remaining // (len(parts) - position))
376
+ remaining -= shares[index]
377
+
378
+ shortened = []
379
+ for part, share in zip(parts, shares):
380
+ # errors="ignore" drops a multibyte character split by the byte slice.
381
+ text = part[:share].decode("utf-8", errors="ignore")
382
+ if share < len(part):
383
+ text += f"\n[... {len(part) - share} bytes omitted ...]\n"
384
+ shortened.append(text)
385
+ return "".join(shortened)
386
+
387
+
352
388
  def truncate_diff(diff: str, max_bytes: int) -> tuple[str, bool]:
353
- """Truncate ``diff`` to at most ``max_bytes`` UTF-8 bytes.
389
+ """Truncate ``diff`` to about ``max_bytes`` UTF-8 bytes.
354
390
 
355
391
  A ``max_bytes`` of 0 (or negative) means "no limit" and the diff is
356
392
  returned unchanged — preserving the default behavior. When truncation
357
393
  occurs, a marker line is appended so both the LLM and the user know the
358
394
  input was cut. Returns ``(possibly_truncated_diff, was_truncated)``.
359
395
  """
360
- if max_bytes <= 0:
361
- return diff, False
362
-
363
- encoded = diff.encode("utf-8")
364
- if len(encoded) <= max_bytes:
396
+ if max_bytes <= 0 or len(diff.encode("utf-8")) <= max_bytes:
365
397
  return diff, False
366
398
 
367
- # Cut on a UTF-8 safe boundary: decoding with errors="ignore" drops any
368
- # partial trailing multibyte sequence left by the byte-level slice.
369
- truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
370
399
  marker = f"\n\n[... diff truncated: exceeded max_diff_bytes={max_bytes} ...]"
371
- return truncated + marker, True
400
+ return shorten_diff(diff, max_bytes) + marker, True
372
401
 
373
402
 
374
403
  def apply_diff_limit(text: str, config: dict, *, label: str = "Input") -> str:
@@ -18,7 +18,11 @@ from urllib.parse import urlparse
18
18
 
19
19
  import requests
20
20
  from git_cai_cli.core.config import CONFIG_DIR, is_custom_provider
21
- from git_cai_cli.core.gitutils import classify_changed_paths, paths_from_diff
21
+ from git_cai_cli.core.gitutils import (
22
+ classify_changed_paths,
23
+ paths_from_diff,
24
+ shorten_diff,
25
+ )
22
26
  from git_cai_cli.core.languages import LANGUAGE_MAP
23
27
  from git_cai_cli.core.prompts_fallback import (
24
28
  HARDCODED_CHANGELOG_PROMPT,
@@ -30,6 +34,7 @@ from git_cai_cli.core.prompts_fallback import (
30
34
  HARDCODED_SPLIT_PROMPT,
31
35
  HARDCODED_SQUASH_PROMPT,
32
36
  )
37
+ from git_cai_cli.core.validate import _extract_api_error_message
33
38
  from requests.adapters import HTTPAdapter
34
39
  from urllib3.util.retry import Retry
35
40
 
@@ -179,6 +184,37 @@ def _http_post(*args, **kwargs):
179
184
  return response
180
185
 
181
186
 
187
+ # Model limits change too often for a table, and the provider's rejection
188
+ # names them, so an oversized request is shortened and retried instead.
189
+ _OVERFLOW_STATUS_CODES = (400, 413, 422)
190
+ _OVERFLOW_RE = re.compile(
191
+ r"too (?:long|large)|exceed|maximum (?:context|prompt)|reduce the length",
192
+ re.IGNORECASE,
193
+ )
194
+ # Skips numbers glued to a name or version, like a date in a model name.
195
+ _OVERFLOW_SIZE_RE = re.compile(r"(?<![\w.-])\d[\d,]{2,}\b")
196
+ _OVERFLOW_RETRIES = 2
197
+
198
+
199
+ def _overflow_shrink_factor(exc: requests.HTTPError) -> float | None:
200
+ """Return how much to shrink the input after a "too many tokens" rejection.
201
+
202
+ ``None`` means ``exc`` is some other error. Providers word the rejection
203
+ differently, but the first two numbers are the limit and the actual size,
204
+ and the smaller one is the limit. Without them, halve the input.
205
+ """
206
+ response = exc.response
207
+ if response is None or response.status_code not in _OVERFLOW_STATUS_CODES:
208
+ return None
209
+ message = _extract_api_error_message(response)
210
+ if not _OVERFLOW_RE.search(message):
211
+ return None
212
+ sizes = [int(n.replace(",", "")) for n in _OVERFLOW_SIZE_RE.findall(message)[:2]]
213
+ if len(sizes) < 2 or sizes[0] == sizes[1]:
214
+ return 0.5
215
+ return 0.9 * min(sizes) / max(sizes)
216
+
217
+
182
218
  def load_prompt_file(
183
219
  config_key: str,
184
220
  config: Dict[str, Any],
@@ -968,7 +1004,8 @@ class CommitMessageGenerator:
968
1004
  def _dispatch_generate(self, content: str, system_prompt: str) -> str:
969
1005
  """
970
1006
  Route to the right provider. ``system_prompt`` comes from the
971
- matching ``_build_*_prompt``; ``content`` is the git output.
1007
+ matching ``_build_*_prompt``; ``content`` is the git output. Input the
1008
+ provider rejects as too large is shortened and retried.
972
1009
  """
973
1010
  self._scan_for_secrets(content)
974
1011
 
@@ -987,12 +1024,30 @@ class CommitMessageGenerator:
987
1024
 
988
1025
  log.debug("Using provider '%s' for generation.", provider)
989
1026
 
990
- if openai_compatible:
991
- return self.generate_openai_compatible(
992
- content, provider, system_prompt_override=system_prompt
993
- )
1027
+ def call_provider(text: str) -> str:
1028
+ if openai_compatible:
1029
+ return self.generate_openai_compatible(
1030
+ text, provider, system_prompt_override=system_prompt
1031
+ )
1032
+ return model_dispatch[provider](text, system_prompt_override=system_prompt)
994
1033
 
995
- return model_dispatch[provider](content, system_prompt_override=system_prompt)
1034
+ sent = content
1035
+ for _ in range(_OVERFLOW_RETRIES):
1036
+ try:
1037
+ return call_provider(sent)
1038
+ except requests.HTTPError as exc:
1039
+ factor = _overflow_shrink_factor(exc)
1040
+ if factor is None:
1041
+ raise
1042
+ target = int(len(sent.encode("utf-8")) * factor)
1043
+ log.warning(
1044
+ "The input is too large for this model. Retrying with the largest "
1045
+ "files shortened to about %d bytes in total, so the result may "
1046
+ "miss details from the cut parts.",
1047
+ target,
1048
+ )
1049
+ sent = shorten_diff(content, target)
1050
+ return call_provider(sent)
996
1051
 
997
1052
  # ---------------------------
998
1053
  # MODEL CALLS
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-cai-cli
3
- Version: 0.17.3
3
+ Version: 0.17.4
4
4
  Summary: Use LLM to create git commit messages
5
5
  Author-email: Thorsten Foltz <thorsten.foltz@live.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.17.4",
3
+ "distance": 0,
4
+ "node": "gf30a44c6710bb1a22b8c320490b0812500e30f72",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-06"
8
+ }
@@ -17,6 +17,7 @@ import subprocess
17
17
  from pathlib import Path
18
18
  from unittest.mock import MagicMock, patch
19
19
 
20
+ import pytest
20
21
  from git_cai_cli.core.gitutils import (
21
22
  append_to_caiignore,
22
23
  changed_files_range,
@@ -108,6 +109,55 @@ def test_truncate_diff_handles_multibyte_boundary():
108
109
  assert "diff truncated" in out
109
110
 
110
111
 
112
+ def _file_diff(name: str, body: str) -> str:
113
+ return f"diff --git a/{name} b/{name}\n--- a/{name}\n+++ b/{name}\n{body}\n"
114
+
115
+
116
+ def test_truncate_diff_keeps_files_after_a_huge_one():
117
+ """A plain tail cut would lose every file sorted after the huge one."""
118
+ first = _file_diff("a.py", "+first change")
119
+ huge = _file_diff("docs.md", "+" + "x" * 100_000)
120
+ last = _file_diff("src/z.py", "+last change")
121
+
122
+ out, truncated = truncate_diff(first + huge + last, 10_000)
123
+
124
+ assert truncated is True
125
+ assert first in out
126
+ assert last in out
127
+ assert "diff --git a/docs.md b/docs.md" in out
128
+ assert len(out.encode("utf-8")) < 10_200
129
+
130
+
131
+ def test_truncate_diff_splits_budget_evenly_between_big_files():
132
+ diff = _file_diff("a.py", "+" + "x" * 100_000)
133
+ diff += _file_diff("b.py", "+" + "y" * 100_000)
134
+
135
+ out, _ = truncate_diff(diff, 10_000)
136
+
137
+ assert out.count("x") == pytest.approx(out.count("y"), abs=20)
138
+ assert 4_500 < out.count("x") < 5_000
139
+
140
+
141
+ def test_truncate_diff_keeps_author_context():
142
+ diff = _file_diff("big.py", "+" + "x" * 100_000)
143
+ context = "--- Additional context from the author ---\nwhy this changed"
144
+
145
+ out, _ = truncate_diff(f"{diff}\n\n{context}", 1_000)
146
+
147
+ assert context in out
148
+
149
+
150
+ def test_truncate_diff_cuts_full_file_dump_before_the_diff():
151
+ diff = _file_diff("a.py", "+change")
152
+ dump = "\n\n--- Full file contents ---\n" + "x" * 100_000
153
+
154
+ out, _ = truncate_diff(diff + dump, 1_000)
155
+
156
+ assert diff in out
157
+ assert "--- Full file contents ---" in out
158
+ assert "x" * 1_000 not in out
159
+
160
+
111
161
  # ------------------------------------------------------------------------------
112
162
  # find_git_root
113
163
  # ------------------------------------------------------------------------------
@@ -2,10 +2,13 @@
2
2
  Unit tests for git_cai_cli.core.llm module.
3
3
  """
4
4
 
5
+ import json
5
6
  import logging
7
+ import re
6
8
  from unittest.mock import MagicMock, patch
7
9
 
8
10
  import pytest
11
+ import requests
9
12
  from git_cai_cli.core.llm import (
10
13
  CommitMessageGenerator,
11
14
  _http_post,
@@ -136,6 +139,195 @@ def test_dispatch_invalid_model(generator):
136
139
  generator._dispatch_generate("diff", "prompt")
137
140
 
138
141
 
142
+ # oversized input: shorten and retry
143
+ OPENAI_OVERFLOW = (
144
+ "Input tokens exceed the configured limit of 922000 tokens. "
145
+ "Your messages resulted in 1346101 tokens. "
146
+ "Please reduce the length of the messages."
147
+ )
148
+
149
+
150
+ def _api_response(status, body):
151
+ response = requests.Response()
152
+ response.status_code = status
153
+ response._content = json.dumps(body).encode()
154
+ return response
155
+
156
+
157
+ def _rejected(message=OPENAI_OVERFLOW, status=400):
158
+ return _api_response(status, {"error": {"message": message}})
159
+
160
+
161
+ def _accepted():
162
+ return _api_response(200, {"choices": [{"message": {"content": "short message"}}]})
163
+
164
+
165
+ def _two_file_diff():
166
+ return "".join(
167
+ f"diff --git a/{name} b/{name}\n+{'x' * 50_000}\n"
168
+ for name in ("docs.md", "src/main.py")
169
+ )
170
+
171
+
172
+ def _dispatch(generator, responses, diff):
173
+ """Dispatch against canned responses; return the result and each sent input."""
174
+ mock_post = MagicMock(side_effect=responses)
175
+ with patch(f"{CommitMessageGenerator.__module__}._http_post", mock_post):
176
+ result = generator._dispatch_generate(diff, "sys")
177
+ return result, [
178
+ call.kwargs["json"]["messages"][-1]["content"]
179
+ for call in mock_post.call_args_list
180
+ ]
181
+
182
+
183
+ def test_overflow_retries_with_every_file_still_present(generator):
184
+ diff = _two_file_diff()
185
+
186
+ result, sent = _dispatch(generator, [_rejected(), _accepted()], diff)
187
+
188
+ assert result == "short message"
189
+ assert sent[0] == diff
190
+ assert len(sent) == 2
191
+ assert "diff --git a/docs.md b/docs.md" in sent[1]
192
+ assert "diff --git a/src/main.py b/src/main.py" in sent[1]
193
+
194
+
195
+ @pytest.mark.parametrize(
196
+ ("message", "status", "limit", "actual"),
197
+ [
198
+ (OPENAI_OVERFLOW, 400, 922_000, 1_346_101),
199
+ (
200
+ (
201
+ "Input tokens exceed the configured limit of 922,000 tokens. "
202
+ "Your messages resulted in 1,346,101 tokens."
203
+ ),
204
+ 400,
205
+ 922_000,
206
+ 1_346_101,
207
+ ),
208
+ (
209
+ "prompt is too long: 1346101 tokens > 1000000 maximum",
210
+ 400,
211
+ 1_000_000,
212
+ 1_346_101,
213
+ ),
214
+ (
215
+ (
216
+ "Request too large for model `grok-20250514` on tokens: "
217
+ "Limit 6000, Requested 12000"
218
+ ),
219
+ 413,
220
+ 6_000,
221
+ 12_000,
222
+ ),
223
+ (
224
+ (
225
+ "The input token count (1346101) exceeds the maximum number of "
226
+ "tokens allowed (1048576)."
227
+ ),
228
+ 400,
229
+ 1_048_576,
230
+ 1_346_101,
231
+ ),
232
+ (
233
+ (
234
+ "This model's maximum context length is 65536 tokens. However, you "
235
+ "requested 100000 tokens (99000 in the messages, 1000 in the "
236
+ "completion). Please reduce the length of the messages or completion."
237
+ ),
238
+ 400,
239
+ 65_536,
240
+ 100_000,
241
+ ),
242
+ (
243
+ (
244
+ "This model's maximum prompt length is 131072 but the request "
245
+ "contains 200000 tokens."
246
+ ),
247
+ 400,
248
+ 131_072,
249
+ 200_000,
250
+ ),
251
+ (
252
+ (
253
+ "Prompt contains 200000 tokens, too large for model with 131072 "
254
+ "maximum context length"
255
+ ),
256
+ 400,
257
+ 131_072,
258
+ 200_000,
259
+ ),
260
+ (
261
+ (
262
+ "Request too large for model `llama-3.3-70b` on tokens per minute "
263
+ "(TPM): Limit 6000, Requested 12000"
264
+ ),
265
+ 413,
266
+ 6_000,
267
+ 12_000,
268
+ ),
269
+ ],
270
+ )
271
+ def test_overflow_shrinks_by_what_the_provider_reports(
272
+ generator, message, status, limit, actual
273
+ ):
274
+ diff = _two_file_diff()
275
+
276
+ _, sent = _dispatch(generator, [_rejected(message, status), _accepted()], diff)
277
+
278
+ assert len(sent[1]) == pytest.approx(len(diff) * limit / actual * 0.9, abs=200)
279
+
280
+
281
+ def test_overflow_without_numbers_halves_the_input(generator):
282
+ diff = _two_file_diff()
283
+ rejected = _rejected("Request payload is too large", 413)
284
+
285
+ _, sent = _dispatch(generator, [rejected, _accepted()], diff)
286
+
287
+ assert len(sent[1]) == pytest.approx(len(diff) * 0.5, abs=200)
288
+
289
+
290
+ def test_overflow_second_retry_cuts_from_the_original_diff(generator):
291
+ """Cutting the first retry's text again would misreport what is omitted."""
292
+ second = "Limit of 900000 tokens exceeded. You sent 1000000 tokens."
293
+ diff = _two_file_diff()
294
+
295
+ _, sent = _dispatch(generator, [_rejected(), _rejected(second), _accepted()], diff)
296
+
297
+ assert len(sent) == 3
298
+ assert len(sent[2]) < len(sent[1])
299
+ omitted = sum(map(int, re.findall(r"(\d+) bytes omitted", sent[2])))
300
+ assert omitted == pytest.approx(len(diff) - len(sent[2]), abs=200)
301
+
302
+
303
+ def test_overflow_gives_up_after_two_retries(generator):
304
+ mock_post = MagicMock(side_effect=[_rejected() for _ in range(5)])
305
+
306
+ with (
307
+ patch(f"{CommitMessageGenerator.__module__}._http_post", mock_post),
308
+ pytest.raises(requests.HTTPError),
309
+ ):
310
+ generator._dispatch_generate(_two_file_diff(), "sys")
311
+
312
+ assert mock_post.call_count == 3
313
+
314
+
315
+ @pytest.mark.parametrize(
316
+ ("message", "status"),
317
+ [("Invalid model", 400), (OPENAI_OVERFLOW, 429), ("Bad gateway", 502)],
318
+ )
319
+ def test_other_errors_are_not_retried(generator, message, status):
320
+ mock_post = MagicMock(side_effect=[_rejected(message, status), _accepted()])
321
+
322
+ with (
323
+ patch(f"{CommitMessageGenerator.__module__}._http_post", mock_post),
324
+ pytest.raises(requests.HTTPError),
325
+ ):
326
+ generator._dispatch_generate(_two_file_diff(), "sys")
327
+
328
+ assert mock_post.call_count == 1
329
+
330
+
139
331
  # test openai
140
332
  def test_generate_openai(generator):
141
333
  """
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.17.3",
3
- "distance": 0,
4
- "node": "g4c48b7f689e10205b1178c36224eeef398baccfe",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-11-04"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes