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.
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/PKG-INFO +1 -1
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/docs/git-cai.txt +5 -3
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/docs/man/git-cai.1 +7 -5
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/_version.py +3 -3
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/gitutils.py +39 -10
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/llm.py +62 -7
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/PKG-INFO +1 -1
- git_cai_cli-0.17.4/src/git_cai_cli.egg-info/scm_version.json +8 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_gitutils.py +50 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_llm.py +192 -0
- git_cai_cli-0.17.3/src/git_cai_cli.egg-info/scm_version.json +0 -8
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.caiignore +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.gitattributes +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/cd/.SRCINFO +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/cd/PKGBUILD +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/_version.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/cai_config.ci.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/ci/tokens.ci.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/python-tests.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/release.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.github/workflows/release_aur.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.gitignore +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.bandit.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.checkov.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.flake8 +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.ls-lint.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.markdownlint.json +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.proselintrc.json +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.pylintrc +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/.yamllint.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/check_git_branch_name.sh +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/lychee.toml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.linters/pyrightconfig.json +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.markdownlintignore +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.mega-linter.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.semgrepignore +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/.trivyignore +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/CLAUDE.md +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/LICENSE +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/Makefile +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/README.md +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/cai_config.yml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/pyproject.toml +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/setup.cfg +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/__init__.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/__init__.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/cli.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/helptext.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/cli/modes.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/__init__.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/changelog.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/completion.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/config.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/doctor.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/explain.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/generation.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/init.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/languages.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/options.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/pr.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/prompts_fallback.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/release.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/secrets.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/spinner.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/split.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/squash.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/stats.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/core/validate.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli/main.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/SOURCES.txt +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/dependency_links.txt +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/entry_points.txt +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/requires.txt +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/scm_file_list.json +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/src/git_cai_cli.egg-info/top_level.txt +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/conftest.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_cli_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_config_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_gitutils_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_modes_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_options_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_pr_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/integration/test_squash_integration.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_amend.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_branch_context.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_changelog.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_classification.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_cli.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_completion.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_config.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_conventional.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_custom_provider.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_doctor.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_explain.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_generation.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_helptext.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_init.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_main.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_modes.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_options.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_pr.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_print.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_prompt_loading.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_release.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_secrets.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_set_config.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_signoff.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_spinner.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_split.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_squash.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_stats.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/tests/unit/test_validate.py +0 -0
- {git_cai_cli-0.17.3 → git_cai_cli-0.17.4}/uv.lock +0 -0
|
@@ -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,
|
|
1103
|
-
|
|
1104
|
-
|
|
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-
|
|
5
|
+
.\" Date: 2026-11-06
|
|
6
6
|
.\" Manual: \ \&
|
|
7
7
|
.\" Source: \ \&
|
|
8
8
|
.\" Language: English
|
|
9
9
|
.\"
|
|
10
|
-
.TH "GIT\-CAI" "1" "2026-11-
|
|
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,
|
|
1833
|
-
|
|
1834
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 17,
|
|
21
|
+
__version__ = version = '0.17.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 17, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
"""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|