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/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"
@@ -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", {}))