jitcatch 0.2.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.
- jitcatch/__init__.py +2 -0
- jitcatch/__main__.py +5 -0
- jitcatch/adapters/__init__.py +36 -0
- jitcatch/adapters/base.py +137 -0
- jitcatch/adapters/golang.py +88 -0
- jitcatch/adapters/java.py +136 -0
- jitcatch/adapters/javascript.py +165 -0
- jitcatch/adapters/python.py +187 -0
- jitcatch/assessors/__init__.py +94 -0
- jitcatch/assessors/failure_parse.py +126 -0
- jitcatch/assessors/llm_judge.py +75 -0
- jitcatch/assessors/rules.py +343 -0
- jitcatch/cli.py +338 -0
- jitcatch/config.py +259 -0
- jitcatch/engine.py +521 -0
- jitcatch/github.py +90 -0
- jitcatch/gitutil.py +231 -0
- jitcatch/hook.py +98 -0
- jitcatch/jsonutil.py +87 -0
- jitcatch/llm/__init__.py +21 -0
- jitcatch/llm/base.py +100 -0
- jitcatch/llm/providers.py +127 -0
- jitcatch/models.py +150 -0
- jitcatch/prompts.py +191 -0
- jitcatch/report.py +163 -0
- jitcatch/targeting.py +44 -0
- jitcatch/templates.py +76 -0
- jitcatch/userconfig.py +188 -0
- jitcatch-0.2.0.dist-info/METADATA +164 -0
- jitcatch-0.2.0.dist-info/RECORD +34 -0
- jitcatch-0.2.0.dist-info/WHEEL +5 -0
- jitcatch-0.2.0.dist-info/entry_points.txt +2 -0
- jitcatch-0.2.0.dist-info/licenses/LICENSE +21 -0
- jitcatch-0.2.0.dist-info/top_level.txt +1 -0
jitcatch/gitutil.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Git helpers: diff extraction and disposable worktrees for the parent and child revisions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
from .models import ChangeContext, ChangedFile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def git(repo: Path, *args: str, check: bool = True) -> str:
|
|
20
|
+
proc = subprocess.run(
|
|
21
|
+
["git", *args], cwd=repo, capture_output=True, text=True,
|
|
22
|
+
encoding="utf-8", errors="replace",
|
|
23
|
+
)
|
|
24
|
+
if check and proc.returncode != 0:
|
|
25
|
+
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
26
|
+
return proc.stdout
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def repo_root(start: Path) -> Path:
|
|
30
|
+
proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=start, capture_output=True, text=True)
|
|
31
|
+
if proc.returncode != 0:
|
|
32
|
+
raise GitError(f"{start} is not inside a git repository. jitcatch tests a change against an earlier "
|
|
33
|
+
"version, so it needs git: run `git init && git add -A && git commit -m init` first")
|
|
34
|
+
return Path(proc.stdout.strip())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------------- automatic change range
|
|
38
|
+
|
|
39
|
+
_OWN_FILES = (".jitcatch.toml", "jitcatch.toml", ".jitcatch/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def has_commits(repo: Path) -> bool:
|
|
43
|
+
return git(repo, "rev-parse", "--verify", "-q", "HEAD^{commit}", check=False).strip() != ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dirty_paths(repo: Path) -> list[str]:
|
|
47
|
+
"""Changed or untracked (non-ignored) paths, excluding jitcatch's own files."""
|
|
48
|
+
out = git(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
49
|
+
paths, parts, i = [], out.split("\0"), 0
|
|
50
|
+
while i < len(parts):
|
|
51
|
+
entry = parts[i]
|
|
52
|
+
i += 1
|
|
53
|
+
if len(entry) < 4:
|
|
54
|
+
continue
|
|
55
|
+
status, path = entry[:2], entry[3:]
|
|
56
|
+
if status[0] in "RC":
|
|
57
|
+
i += 1 # the original path follows a rename/copy entry
|
|
58
|
+
if not path.startswith(_OWN_FILES):
|
|
59
|
+
paths.append(path)
|
|
60
|
+
return paths
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_branch(repo: Path) -> str | None:
|
|
64
|
+
ref = git(repo, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD", check=False).strip()
|
|
65
|
+
if ref:
|
|
66
|
+
return ref
|
|
67
|
+
for cand in ("origin/main", "origin/master", "main", "master"):
|
|
68
|
+
if git(repo, "rev-parse", "--verify", "-q", f"{cand}^{{commit}}", check=False).strip():
|
|
69
|
+
return cand
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def snapshot_worktree(repo: Path) -> str:
|
|
74
|
+
"""Commit object for the current working tree (tracked changes + untracked, non-ignored files) on top of
|
|
75
|
+
HEAD, created with a throwaway index. No branch, ref, index or file of the user's checkout is touched."""
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="jcidx-") as d:
|
|
77
|
+
env = {**os.environ, "GIT_INDEX_FILE": str(Path(d) / "index"),
|
|
78
|
+
"GIT_AUTHOR_NAME": "jitcatch", "GIT_AUTHOR_EMAIL": "jitcatch@localhost",
|
|
79
|
+
"GIT_COMMITTER_NAME": "jitcatch", "GIT_COMMITTER_EMAIL": "jitcatch@localhost"}
|
|
80
|
+
|
|
81
|
+
def g(*args: str) -> str:
|
|
82
|
+
proc = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, env=env)
|
|
83
|
+
if proc.returncode != 0:
|
|
84
|
+
raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
85
|
+
return proc.stdout.strip()
|
|
86
|
+
|
|
87
|
+
g("read-tree", "HEAD")
|
|
88
|
+
g("add", "-A", "--", ".", *(f":(exclude){p.rstrip('/')}" for p in _OWN_FILES))
|
|
89
|
+
tree = g("write-tree")
|
|
90
|
+
return g("commit-tree", tree, "-p", "HEAD", "-m", "Uncommitted changes")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def auto_range(repo: Path) -> tuple[str, str, str]:
|
|
94
|
+
"""Pick what to test when no --base/--head is given. Returns (base, head, human description)."""
|
|
95
|
+
if not has_commits(repo):
|
|
96
|
+
raise GitError("this repository has no commits yet. jitcatch compares a change against an earlier "
|
|
97
|
+
"version: commit once (git add -A && git commit -m init), make a change, then run it again")
|
|
98
|
+
if dirty_paths(repo):
|
|
99
|
+
return "HEAD", snapshot_worktree(repo), "your uncommitted changes (compared with the last commit)"
|
|
100
|
+
head = rev_parse(repo, "HEAD")
|
|
101
|
+
default = default_branch(repo)
|
|
102
|
+
if default and merge_base(repo, rev_parse(repo, default), head) != head:
|
|
103
|
+
branch = git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
104
|
+
return default, "HEAD", f"commits on {branch} that are not in {default}"
|
|
105
|
+
if git(repo, "rev-parse", "--verify", "-q", "HEAD~1^{commit}", check=False).strip():
|
|
106
|
+
return "HEAD~1", "HEAD", "the last commit"
|
|
107
|
+
raise GitError("only one commit and no uncommitted changes, so there is nothing to compare yet: "
|
|
108
|
+
"make a change and run jitcatch again")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ensure_excluded(repo: Path, pattern: str = ".jitcatch/") -> None:
|
|
112
|
+
"""Keep jitcatch's output out of `git status` via .git/info/exclude (a local, untracked file)."""
|
|
113
|
+
rel = git(repo, "rev-parse", "--git-path", "info/exclude").strip()
|
|
114
|
+
p = Path(rel) if Path(rel).is_absolute() else repo / rel
|
|
115
|
+
lines = p.read_text(encoding="utf-8").splitlines() if p.exists() else []
|
|
116
|
+
if pattern not in lines:
|
|
117
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
119
|
+
f.write(("\n" if lines and lines[-1] else "") + pattern + "\n")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def rev_parse(repo: Path, rev: str) -> str:
|
|
123
|
+
return git(repo, "rev-parse", "--verify", f"{rev}^{{commit}}").strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def merge_base(repo: Path, a: str, b: str) -> str:
|
|
127
|
+
out = git(repo, "merge-base", a, b, check=False).strip()
|
|
128
|
+
return out or a
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def show_file(repo: Path, sha: str, path: str) -> str | None:
|
|
132
|
+
proc = subprocess.run(
|
|
133
|
+
["git", "show", f"{sha}:{path}"], cwd=repo, capture_output=True,
|
|
134
|
+
text=True, encoding="utf-8", errors="replace",
|
|
135
|
+
)
|
|
136
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def commit_messages(repo: Path, base: str, head: str) -> tuple[str, str]:
|
|
140
|
+
"""Return (title, description) derived from commits in base..head."""
|
|
141
|
+
out = git(repo, "log", "--format=%s%x1f%b%x1e", f"{base}..{head}", check=False)
|
|
142
|
+
entries = [e.strip() for e in out.split("\x1e") if e.strip()]
|
|
143
|
+
if not entries:
|
|
144
|
+
return "", ""
|
|
145
|
+
subjects, bodies = [], []
|
|
146
|
+
for e in reversed(entries): # oldest first
|
|
147
|
+
subj, _, body = e.partition("\x1f")
|
|
148
|
+
subjects.append(subj.strip())
|
|
149
|
+
if body.strip():
|
|
150
|
+
bodies.append(body.strip())
|
|
151
|
+
title = subjects[-1] if len(subjects) == 1 else subjects[0]
|
|
152
|
+
desc_parts = []
|
|
153
|
+
if len(subjects) > 1:
|
|
154
|
+
desc_parts.append("Commits:\n" + "\n".join(f"- {s}" for s in subjects))
|
|
155
|
+
desc_parts.extend(bodies)
|
|
156
|
+
return title, "\n\n".join(desc_parts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def change_context(repo: Path, base: str, head: str, title: str = "", description: str = "") -> ChangeContext:
|
|
160
|
+
head_sha = rev_parse(repo, head)
|
|
161
|
+
base_sha = merge_base(repo, rev_parse(repo, base), head_sha)
|
|
162
|
+
if not title and not description:
|
|
163
|
+
title, description = commit_messages(repo, base_sha, head_sha)
|
|
164
|
+
status_out = git(repo, "diff", "--name-status", "-M", base_sha, head_sha)
|
|
165
|
+
numstat_out = git(repo, "diff", "--numstat", "-M", base_sha, head_sha)
|
|
166
|
+
counts: dict[str, tuple[int, int]] = {}
|
|
167
|
+
for line in numstat_out.splitlines():
|
|
168
|
+
parts = line.split("\t")
|
|
169
|
+
if len(parts) >= 3:
|
|
170
|
+
a = int(parts[0]) if parts[0].isdigit() else 0
|
|
171
|
+
r = int(parts[1]) if parts[1].isdigit() else 0
|
|
172
|
+
counts[parts[-1]] = (a, r)
|
|
173
|
+
files: list[ChangedFile] = []
|
|
174
|
+
for line in status_out.splitlines():
|
|
175
|
+
parts = line.split("\t")
|
|
176
|
+
if not parts or not parts[0]:
|
|
177
|
+
continue
|
|
178
|
+
code = parts[0][0]
|
|
179
|
+
if code == "R" and len(parts) >= 3:
|
|
180
|
+
old, new = parts[1], parts[2]
|
|
181
|
+
else:
|
|
182
|
+
old = new = parts[-1]
|
|
183
|
+
patch = git(repo, "diff", "-M", base_sha, head_sha, "--", *({old, new}))
|
|
184
|
+
a, r = counts.get(new, counts.get(f"{old} => {new}", (0, 0)))
|
|
185
|
+
files.append(ChangedFile(path=new, old_path=old, status=code, patch=patch, added_lines=a, removed_lines=r))
|
|
186
|
+
full_patch = git(repo, "diff", "-M", base_sha, head_sha)
|
|
187
|
+
return ChangeContext(base_sha=base_sha, head_sha=head_sha, title=title, description=description,
|
|
188
|
+
files=files, full_patch=full_patch)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def worktree(repo: Path, sha: str, label: str, link_paths: list[str] | None = None) -> Iterator[Path]:
|
|
193
|
+
"""Create a detached worktree at `sha`; symlink heavy dependency dirs from the main checkout."""
|
|
194
|
+
tmp = Path(tempfile.mkdtemp(prefix=f"jcwt-{label}-"))
|
|
195
|
+
wt = tmp / "wt"
|
|
196
|
+
git(repo, "worktree", "add", "--detach", "--force", str(wt), sha)
|
|
197
|
+
try:
|
|
198
|
+
for rel in link_paths or []:
|
|
199
|
+
_link_dependency_dirs(repo, wt, rel)
|
|
200
|
+
yield wt
|
|
201
|
+
finally:
|
|
202
|
+
git(repo, "worktree", "remove", "--force", str(wt), check=False)
|
|
203
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
204
|
+
git(repo, "worktree", "prune", check=False)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _link_dependency_dirs(repo: Path, wt: Path, name: str) -> None:
|
|
208
|
+
"""Symlink every `name` directory (e.g. node_modules) that exists in the main checkout,
|
|
209
|
+
including nested ones in monorepos, unless the worktree already contains it."""
|
|
210
|
+
if "/" in name or os.sep in name:
|
|
211
|
+
candidates = [repo / name]
|
|
212
|
+
else:
|
|
213
|
+
candidates = []
|
|
214
|
+
for dirpath, dirnames, _ in os.walk(repo):
|
|
215
|
+
rel = Path(dirpath).relative_to(repo)
|
|
216
|
+
if rel.parts and rel.parts[0] in (".git", ".jitcatch"):
|
|
217
|
+
dirnames[:] = []
|
|
218
|
+
continue
|
|
219
|
+
if name in dirnames:
|
|
220
|
+
candidates.append(Path(dirpath) / name)
|
|
221
|
+
dirnames.remove(name) # don't descend into node_modules
|
|
222
|
+
# don't descend into other dependency/build dirs
|
|
223
|
+
dirnames[:] = [d for d in dirnames if d not in (".git", "node_modules", ".venv", "venv", "target", "build", "dist")]
|
|
224
|
+
for src in candidates:
|
|
225
|
+
if not src.exists():
|
|
226
|
+
continue
|
|
227
|
+
dst = wt / src.relative_to(repo)
|
|
228
|
+
if dst.exists() or dst.is_symlink():
|
|
229
|
+
continue
|
|
230
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
os.symlink(src, dst, target_is_directory=src.is_dir())
|
jitcatch/hook.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""git pre-push hook installation and execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .gitutil import git
|
|
11
|
+
|
|
12
|
+
ZERO = "0" * 40
|
|
13
|
+
HOOK_TAG = "# jitcatch pre-push hook"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hooks_dir(repo: Path) -> Path:
|
|
17
|
+
"""Absolute hooks directory, honouring core.hooksPath."""
|
|
18
|
+
custom = git(repo, "config", "--get", "core.hooksPath", check=False).strip()
|
|
19
|
+
p = Path(custom) if custom else Path(git(repo, "rev-parse", "--git-path", "hooks").strip())
|
|
20
|
+
return p if p.is_absolute() else (repo / p).resolve()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install(repo: Path, blocking: bool, force: bool = False) -> Path:
|
|
24
|
+
d = hooks_dir(repo)
|
|
25
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
path = d / "pre-push"
|
|
27
|
+
if path.exists() and HOOK_TAG not in path.read_text(encoding="utf-8", errors="replace") and not force:
|
|
28
|
+
raise FileExistsError(f"{path} already exists and was not installed by jitcatch (use --force to overwrite)")
|
|
29
|
+
flag = " --blocking" if blocking else ""
|
|
30
|
+
path.write_text(
|
|
31
|
+
"#!/bin/sh\n"
|
|
32
|
+
f"{HOOK_TAG}\n"
|
|
33
|
+
"# Set JITCATCH_SKIP=1 to bypass for a single push.\n"
|
|
34
|
+
'[ "$JITCATCH_SKIP" = "1" ] && exit 0\n'
|
|
35
|
+
f'exec "{sys.executable}" -m jitcatch hook-run --remote "$1"{flag}\n',
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
)
|
|
38
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def uninstall(repo: Path) -> bool:
|
|
43
|
+
path = hooks_dir(repo) / "pre-push"
|
|
44
|
+
if path.exists() and HOOK_TAG in path.read_text(encoding="utf-8", errors="replace"):
|
|
45
|
+
path.unlink()
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ranges_from_stdin(repo: Path, remote: str, default_branch: str | None, stdin_text: str) -> list[tuple[str, str]]:
|
|
51
|
+
"""Parse pre-push stdin ("<local ref> <local sha> <remote ref> <remote sha>") into (base, head) pairs."""
|
|
52
|
+
out = []
|
|
53
|
+
for line in stdin_text.splitlines():
|
|
54
|
+
parts = line.split()
|
|
55
|
+
if len(parts) != 4:
|
|
56
|
+
continue
|
|
57
|
+
_lref, lsha, _rref, rsha = parts
|
|
58
|
+
if lsha == ZERO: # branch deletion
|
|
59
|
+
continue
|
|
60
|
+
if rsha != ZERO and _commit_exists(repo, rsha):
|
|
61
|
+
out.append((rsha, lsha)) # updating an existing remote branch
|
|
62
|
+
continue
|
|
63
|
+
base = _default_base(repo, remote, default_branch)
|
|
64
|
+
if base:
|
|
65
|
+
out.append((base, lsha))
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _commit_exists(repo: Path, sha: str) -> bool:
|
|
70
|
+
return subprocess.run(["git", "cat-file", "-e", f"{sha}^{{commit}}"], cwd=repo,
|
|
71
|
+
capture_output=True).returncode == 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_base(repo: Path, remote: str, default_branch: str | None) -> str | None:
|
|
75
|
+
cands = []
|
|
76
|
+
if default_branch:
|
|
77
|
+
if "/" not in default_branch:
|
|
78
|
+
cands.append(f"{remote}/{default_branch}")
|
|
79
|
+
cands.append(default_branch) # also accept a local branch / explicit ref
|
|
80
|
+
head_ref = git(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD", check=False).strip()
|
|
81
|
+
if head_ref:
|
|
82
|
+
cands.append(head_ref)
|
|
83
|
+
cands += [f"{remote}/main", f"{remote}/master"]
|
|
84
|
+
for c in cands:
|
|
85
|
+
if _commit_exists(repo, c):
|
|
86
|
+
return c
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stdin_text() -> str:
|
|
91
|
+
try:
|
|
92
|
+
return sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
93
|
+
except (OSError, ValueError):
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def env_skip() -> bool:
|
|
98
|
+
return os.environ.get("JITCATCH_SKIP") == "1"
|
jitcatch/jsonutil.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Robust extraction of JSON objects and code blocks from LLM output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_FENCE_RE = re.compile(r"```([a-zA-Z0-9_+\-]*)\s*\n(.*?)```", re.DOTALL)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LLMFormatError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extract_json(text: str) -> Any:
|
|
16
|
+
"""Return the first JSON object/array found in `text`.
|
|
17
|
+
|
|
18
|
+
Tries, in order: whole text, ```json fenced blocks, any fenced block, then the
|
|
19
|
+
first balanced {...} or [...] span (string-aware).
|
|
20
|
+
"""
|
|
21
|
+
stripped = text.strip()
|
|
22
|
+
for candidate in _candidates(stripped):
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(candidate)
|
|
25
|
+
except json.JSONDecodeError:
|
|
26
|
+
continue
|
|
27
|
+
raise LLMFormatError(f"No valid JSON found in model output: {text[:300]!r}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _candidates(text: str):
|
|
31
|
+
yield text
|
|
32
|
+
fences = _FENCE_RE.findall(text)
|
|
33
|
+
for lang, body in fences:
|
|
34
|
+
if lang.lower() == "json":
|
|
35
|
+
yield body.strip()
|
|
36
|
+
for _lang, body in fences:
|
|
37
|
+
yield body.strip()
|
|
38
|
+
span = _first_balanced(text)
|
|
39
|
+
if span is not None:
|
|
40
|
+
yield span
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _first_balanced(text: str) -> str | None:
|
|
44
|
+
openers = {"{": "}", "[": "]"}
|
|
45
|
+
for start, ch in enumerate(text):
|
|
46
|
+
if ch not in openers:
|
|
47
|
+
continue
|
|
48
|
+
stack = [openers[ch]]
|
|
49
|
+
in_str = False
|
|
50
|
+
esc = False
|
|
51
|
+
for i in range(start + 1, len(text)):
|
|
52
|
+
c = text[i]
|
|
53
|
+
if in_str:
|
|
54
|
+
if esc:
|
|
55
|
+
esc = False
|
|
56
|
+
elif c == "\\":
|
|
57
|
+
esc = True
|
|
58
|
+
elif c == '"':
|
|
59
|
+
in_str = False
|
|
60
|
+
continue
|
|
61
|
+
if c == '"':
|
|
62
|
+
in_str = True
|
|
63
|
+
elif c in openers:
|
|
64
|
+
stack.append(openers[c])
|
|
65
|
+
elif stack and c == stack[-1]:
|
|
66
|
+
stack.pop()
|
|
67
|
+
if not stack:
|
|
68
|
+
candidate = text[start : i + 1]
|
|
69
|
+
try:
|
|
70
|
+
json.loads(candidate)
|
|
71
|
+
return candidate
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
break
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def extract_code(text: str, preferred_langs: tuple[str, ...] = ()) -> str:
|
|
78
|
+
"""Return the largest fenced code block (preferring the given languages).
|
|
79
|
+
|
|
80
|
+
If no fence is present the whole text is returned stripped.
|
|
81
|
+
"""
|
|
82
|
+
fences = _FENCE_RE.findall(text)
|
|
83
|
+
if not fences:
|
|
84
|
+
return text.strip() + "\n"
|
|
85
|
+
preferred = [b for lang, b in fences if lang.lower() in preferred_langs]
|
|
86
|
+
pool = preferred or [b for _l, b in fences]
|
|
87
|
+
return max(pool, key=len).rstrip() + "\n"
|
jitcatch/llm/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..config import ProviderConfig
|
|
4
|
+
from .base import LLMClient, LLMError, LLMResponse, TokenLogprob
|
|
5
|
+
from .providers import AnthropicClient, OpenAIChatClient, OpenAIResponsesClient
|
|
6
|
+
|
|
7
|
+
_TYPES = {
|
|
8
|
+
"anthropic": AnthropicClient,
|
|
9
|
+
"openai_chat": OpenAIChatClient,
|
|
10
|
+
"openai_responses": OpenAIResponsesClient,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_client(cfg: ProviderConfig) -> LLMClient:
|
|
15
|
+
try:
|
|
16
|
+
return _TYPES[cfg.type](cfg)
|
|
17
|
+
except KeyError:
|
|
18
|
+
raise LLMError(f"unknown provider type {cfg.type!r}") from None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["LLMClient", "LLMError", "LLMResponse", "TokenLogprob", "build_client"]
|
jitcatch/llm/base.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Provider-agnostic LLM interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..config import ProviderConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TokenLogprob:
|
|
21
|
+
token: str
|
|
22
|
+
logprob: float
|
|
23
|
+
top: dict[str, float] = field(default_factory=dict) # alternative token -> logprob
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LLMResponse:
|
|
28
|
+
text: str
|
|
29
|
+
model: str
|
|
30
|
+
provider: str
|
|
31
|
+
logprobs: list[TokenLogprob] | None = None
|
|
32
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LLMClient:
|
|
36
|
+
"""Base class. Subclasses implement `_complete`."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, cfg: ProviderConfig):
|
|
39
|
+
self.cfg = cfg
|
|
40
|
+
self.name = cfg.name
|
|
41
|
+
|
|
42
|
+
def complete(self, system: str, user: str, *, max_tokens: int | None = None,
|
|
43
|
+
want_logprobs: bool = False) -> LLMResponse:
|
|
44
|
+
# Per-call budgets are minimums; the provider's configured max_tokens wins when larger. Thinking
|
|
45
|
+
# models (e.g. GLM-5.3, which always reasons) spend part of the budget on reasoning tokens, so a
|
|
46
|
+
# small hard-coded budget can end before any answer is produced. Logprob probes keep their exact
|
|
47
|
+
# budget: they only read the first answer token.
|
|
48
|
+
if want_logprobs and max_tokens:
|
|
49
|
+
budget = max_tokens
|
|
50
|
+
else:
|
|
51
|
+
budget = max(max_tokens or 0, self.cfg.max_tokens)
|
|
52
|
+
return self._complete(system, user, budget, want_logprobs)
|
|
53
|
+
|
|
54
|
+
def _complete(self, system: str, user: str, max_tokens: int, want_logprobs: bool) -> LLMResponse:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def _finalize(self, body: dict[str, Any], default_max_field: str) -> dict[str, Any]:
|
|
58
|
+
field_name = self.cfg.max_tokens_field or default_max_field
|
|
59
|
+
if field_name != default_max_field and default_max_field in body:
|
|
60
|
+
body[field_name] = body.pop(default_max_field)
|
|
61
|
+
body.update(self.cfg.extra_body)
|
|
62
|
+
for k in self.cfg.omit_params:
|
|
63
|
+
body.pop(k, None)
|
|
64
|
+
return body
|
|
65
|
+
|
|
66
|
+
# -- HTTP --------------------------------------------------------------------------
|
|
67
|
+
def _post_json(self, url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
|
68
|
+
data = json.dumps(body).encode("utf-8")
|
|
69
|
+
all_headers = {"Content-Type": "application/json", **headers, **self.cfg.extra_headers}
|
|
70
|
+
last_err: Exception | None = None
|
|
71
|
+
for attempt in range(self.cfg.max_retries + 1):
|
|
72
|
+
req = urllib.request.Request(url, data=data, headers=all_headers, method="POST")
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=self.cfg.timeout_s) as resp:
|
|
75
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
payload = e.read().decode("utf-8", errors="replace")[:2000]
|
|
78
|
+
last_err = LLMError(f"{self.name}: HTTP {e.code} from {url}: {payload}")
|
|
79
|
+
if e.code not in (408, 409, 429, 500, 502, 503, 504, 529):
|
|
80
|
+
raise last_err from None
|
|
81
|
+
retry_after = e.headers.get("retry-after") if e.headers else None
|
|
82
|
+
delay = _parse_retry_after(retry_after)
|
|
83
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
84
|
+
last_err = LLMError(f"{self.name}: request to {url} failed: {e}")
|
|
85
|
+
delay = None
|
|
86
|
+
except json.JSONDecodeError as e:
|
|
87
|
+
raise LLMError(f"{self.name}: non-JSON response from {url}: {e}") from None
|
|
88
|
+
if attempt < self.cfg.max_retries:
|
|
89
|
+
time.sleep(delay if delay is not None else min(60.0, (2 ** attempt) + random.random()))
|
|
90
|
+
assert last_err is not None
|
|
91
|
+
raise last_err
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_retry_after(v: str | None) -> float | None:
|
|
95
|
+
if not v:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
return min(120.0, float(v))
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Concrete clients: Anthropic Messages, OpenAI Chat Completions, OpenAI Responses.
|
|
2
|
+
|
|
3
|
+
Each also works with API-compatible servers (Z.ai Coding Plan, Ollama, LM Studio, vLLM, proxies)
|
|
4
|
+
by changing `base_url`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .base import LLMClient, LLMError, LLMResponse, TokenLogprob
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _join(base: str, path: str) -> str:
|
|
14
|
+
return base.rstrip("/") + "/" + path.lstrip("/")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnthropicClient(LLMClient):
|
|
18
|
+
"""POST {base_url}/v1/messages."""
|
|
19
|
+
|
|
20
|
+
def _complete(self, system: str, user: str, max_tokens: int, want_logprobs: bool) -> LLMResponse:
|
|
21
|
+
if want_logprobs:
|
|
22
|
+
raise LLMError(f"{self.name}: the Anthropic Messages API does not return token log-probabilities")
|
|
23
|
+
headers = {"anthropic-version": "2023-06-01"}
|
|
24
|
+
key = self.cfg.resolve_api_key()
|
|
25
|
+
if key:
|
|
26
|
+
if self.cfg.auth == "x-api-key":
|
|
27
|
+
headers["x-api-key"] = key
|
|
28
|
+
else:
|
|
29
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
30
|
+
body: dict[str, Any] = {
|
|
31
|
+
"model": self.cfg.model,
|
|
32
|
+
"max_tokens": max_tokens,
|
|
33
|
+
"system": system,
|
|
34
|
+
"messages": [{"role": "user", "content": user}],
|
|
35
|
+
}
|
|
36
|
+
if self.cfg.temperature is not None:
|
|
37
|
+
body["temperature"] = self.cfg.temperature
|
|
38
|
+
body = self._finalize(body, "max_tokens")
|
|
39
|
+
data = self._post_json(_join(self.cfg.base_url, "v1/messages"), body, headers)
|
|
40
|
+
parts = [b.get("text", "") for b in data.get("content", []) if b.get("type") == "text"]
|
|
41
|
+
if not parts:
|
|
42
|
+
raise LLMError(f"{self.name}: response had no text content: {str(data)[:500]}")
|
|
43
|
+
return LLMResponse(text="".join(parts), model=data.get("model", self.cfg.model),
|
|
44
|
+
provider=self.name, usage=data.get("usage", {}))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenAIChatClient(LLMClient):
|
|
48
|
+
"""POST {base_url}/chat/completions (OpenAI-compatible /v1 servers)."""
|
|
49
|
+
|
|
50
|
+
def _complete(self, system: str, user: str, max_tokens: int, want_logprobs: bool) -> LLMResponse:
|
|
51
|
+
headers = {}
|
|
52
|
+
key = self.cfg.resolve_api_key()
|
|
53
|
+
if key:
|
|
54
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
55
|
+
body: dict[str, Any] = {
|
|
56
|
+
"model": self.cfg.model,
|
|
57
|
+
"messages": [
|
|
58
|
+
{"role": "system", "content": system},
|
|
59
|
+
{"role": "user", "content": user},
|
|
60
|
+
],
|
|
61
|
+
"max_tokens": max_tokens,
|
|
62
|
+
}
|
|
63
|
+
if self.cfg.temperature is not None:
|
|
64
|
+
body["temperature"] = self.cfg.temperature
|
|
65
|
+
if want_logprobs:
|
|
66
|
+
if not self.cfg.logprobs:
|
|
67
|
+
raise LLMError(f"{self.name}: logprobs requested but provider has logprobs = false")
|
|
68
|
+
body["logprobs"] = True
|
|
69
|
+
body["top_logprobs"] = 5
|
|
70
|
+
body = self._finalize(body, "max_tokens")
|
|
71
|
+
data = self._post_json(_join(self.cfg.base_url, "chat/completions"), body, headers)
|
|
72
|
+
choices = data.get("choices") or []
|
|
73
|
+
if not choices:
|
|
74
|
+
raise LLMError(f"{self.name}: response had no choices: {str(data)[:500]}")
|
|
75
|
+
msg = choices[0].get("message") or {}
|
|
76
|
+
text = msg.get("content") or ""
|
|
77
|
+
if isinstance(text, list): # some servers return content parts
|
|
78
|
+
text = "".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
79
|
+
lps = None
|
|
80
|
+
lp_block = choices[0].get("logprobs")
|
|
81
|
+
if want_logprobs and lp_block and lp_block.get("content"):
|
|
82
|
+
lps = [
|
|
83
|
+
TokenLogprob(
|
|
84
|
+
token=t.get("token", ""),
|
|
85
|
+
logprob=float(t.get("logprob", 0.0)),
|
|
86
|
+
top={a.get("token", ""): float(a.get("logprob", 0.0)) for a in (t.get("top_logprobs") or [])},
|
|
87
|
+
)
|
|
88
|
+
for t in lp_block["content"]
|
|
89
|
+
]
|
|
90
|
+
return LLMResponse(text=text, model=data.get("model", self.cfg.model), provider=self.name,
|
|
91
|
+
logprobs=lps, usage=data.get("usage", {}))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class OpenAIResponsesClient(LLMClient):
|
|
95
|
+
"""POST {base_url}/responses."""
|
|
96
|
+
|
|
97
|
+
def _complete(self, system: str, user: str, max_tokens: int, want_logprobs: bool) -> LLMResponse:
|
|
98
|
+
if want_logprobs:
|
|
99
|
+
raise LLMError(f"{self.name}: logprob judging is only supported via openai_chat providers")
|
|
100
|
+
headers = {}
|
|
101
|
+
key = self.cfg.resolve_api_key()
|
|
102
|
+
if key:
|
|
103
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
104
|
+
body: dict[str, Any] = {
|
|
105
|
+
"model": self.cfg.model,
|
|
106
|
+
"instructions": system,
|
|
107
|
+
"input": user,
|
|
108
|
+
"max_output_tokens": max_tokens,
|
|
109
|
+
}
|
|
110
|
+
if self.cfg.temperature is not None:
|
|
111
|
+
body["temperature"] = self.cfg.temperature
|
|
112
|
+
body = self._finalize(body, "max_output_tokens")
|
|
113
|
+
data = self._post_json(_join(self.cfg.base_url, "responses"), body, headers)
|
|
114
|
+
text = data.get("output_text")
|
|
115
|
+
if not text:
|
|
116
|
+
chunks = []
|
|
117
|
+
for item in data.get("output") or []:
|
|
118
|
+
if item.get("type") != "message":
|
|
119
|
+
continue
|
|
120
|
+
for c in item.get("content") or []:
|
|
121
|
+
if c.get("type") == "output_text":
|
|
122
|
+
chunks.append(c.get("text", ""))
|
|
123
|
+
text = "".join(chunks)
|
|
124
|
+
if not text:
|
|
125
|
+
raise LLMError(f"{self.name}: response had no output_text: {str(data)[:500]}")
|
|
126
|
+
return LLMResponse(text=text, model=data.get("model", self.cfg.model), provider=self.name,
|
|
127
|
+
usage=data.get("usage", {}))
|