codemop 0.1.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.
- codemop/__init__.py +3 -0
- codemop/cli.py +233 -0
- codemop/config.py +66 -0
- codemop/github/__init__.py +1 -0
- codemop/github/client.py +106 -0
- codemop/providers/__init__.py +59 -0
- codemop/providers/anthropic.py +114 -0
- codemop/providers/base.py +84 -0
- codemop/providers/openai_compatible.py +187 -0
- codemop/providers/pricing.py +45 -0
- codemop/review/__init__.py +1 -0
- codemop/review/chunks.py +131 -0
- codemop/review/diff.py +123 -0
- codemop/review/pipeline.py +86 -0
- codemop/review/placement.py +44 -0
- codemop/review/prompt.py +54 -0
- codemop/review/schema.py +39 -0
- codemop-0.1.0.dist-info/METADATA +351 -0
- codemop-0.1.0.dist-info/RECORD +22 -0
- codemop-0.1.0.dist-info/WHEEL +4 -0
- codemop-0.1.0.dist-info/entry_points.txt +2 -0
- codemop-0.1.0.dist-info/licenses/LICENSE +21 -0
codemop/__init__.py
ADDED
codemop/cli.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The codemop command.
|
|
3
|
+
|
|
4
|
+
codemop review owner/repo#123 review a pull request
|
|
5
|
+
codemop review https://github.com/owner/repo/pull/123
|
|
6
|
+
git diff main | codemop review - review a local diff
|
|
7
|
+
|
|
8
|
+
Exit status: 0 when every part of the diff was reviewed, 1 when some of it couldn't be
|
|
9
|
+
(see the report), 2 for usage errors.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import dataclasses
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess # nosec B404
|
|
18
|
+
import sys
|
|
19
|
+
import textwrap
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
|
|
24
|
+
from codemop import __version__
|
|
25
|
+
from codemop.config import (
|
|
26
|
+
CONFIG_FILE, DEFAULT_MIN_CONFIDENCE, ConfigError, RepoConfig, load_config_file, parse_config,
|
|
27
|
+
)
|
|
28
|
+
from codemop.github.client import (
|
|
29
|
+
DEFAULT_API_URL, GitHubError, PullRequestRef, fetch_pr_diff, fetch_repo_file, parse_pr_reference,
|
|
30
|
+
)
|
|
31
|
+
from codemop.providers import DEFAULT_MODELS, PROVIDERS, create_model
|
|
32
|
+
from codemop.providers.base import DEFAULT_CHUNK_TOKENS, Usage
|
|
33
|
+
from codemop.providers.pricing import PRICES_AS_OF
|
|
34
|
+
from codemop.review.chunks import DEFAULT_IGNORED_PATHS
|
|
35
|
+
from codemop.review.pipeline import ReviewReport, review_diff
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def github_token() -> Optional[str]:
|
|
39
|
+
"""GITHUB_TOKEN, or the token from a `gh auth login`, so private repos work without setup"""
|
|
40
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
41
|
+
gh = shutil.which("gh")
|
|
42
|
+
if token or not gh:
|
|
43
|
+
return token
|
|
44
|
+
# Fixed arguments and no shell, so nothing from the user reaches the command
|
|
45
|
+
result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True) # nosec B603
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
return None
|
|
48
|
+
return result.stdout.strip() or None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
parser = argparse.ArgumentParser(prog="codemop", description="AI code review for pull requests.")
|
|
53
|
+
parser.add_argument("--version", action="version", version=f"codemop {__version__}")
|
|
54
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
review = commands.add_parser(
|
|
57
|
+
"review", help="review a pull request or a diff",
|
|
58
|
+
description=(
|
|
59
|
+
"Review a pull request (owner/repo#123 or a PR URL), or a diff on stdin (-). "
|
|
60
|
+
f"How the repository is reviewed comes from its {CONFIG_FILE} (on the default branch; "
|
|
61
|
+
"for stdin, the current directory), unless overridden here. Which model reviews it is "
|
|
62
|
+
"up to you: --provider/--model, or CODEMOP_PROVIDER / CODEMOP_MODEL / CODEMOP_BASE_URL."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
review.add_argument("target", help="owner/repo#123, a pull request URL, or - to read a diff from stdin")
|
|
66
|
+
|
|
67
|
+
who = review.add_argument_group("model (chosen by you, never by the repository)")
|
|
68
|
+
who.add_argument("--provider", choices=PROVIDERS, default=os.environ.get("CODEMOP_PROVIDER", "anthropic"),
|
|
69
|
+
help="model provider (default: $CODEMOP_PROVIDER, or anthropic)")
|
|
70
|
+
who.add_argument("--model", default=os.environ.get("CODEMOP_MODEL"),
|
|
71
|
+
help="model name (default: $CODEMOP_MODEL, or the provider's: "
|
|
72
|
+
+ ", ".join(f"{p}: {m}" for p, m in DEFAULT_MODELS.items()) + ")")
|
|
73
|
+
who.add_argument("--base-url", default=os.environ.get("CODEMOP_BASE_URL"),
|
|
74
|
+
help="API base URL, for openai-compatible or a self-hosted endpoint (default: $CODEMOP_BASE_URL)")
|
|
75
|
+
who.add_argument("--api-key-env", help="environment variable holding the provider's API key")
|
|
76
|
+
who.add_argument("--effort", choices=["low", "medium", "high", "xhigh", "max"],
|
|
77
|
+
default=os.environ.get("CODEMOP_EFFORT"),
|
|
78
|
+
help="how hard Claude thinks: lower is cheaper and faster (default: $CODEMOP_EFFORT, or high)")
|
|
79
|
+
|
|
80
|
+
how = review.add_argument_group(f"review settings (override the repository's {CONFIG_FILE})")
|
|
81
|
+
how.add_argument("--config", type=Path, metavar="PATH",
|
|
82
|
+
help=f"use this config file instead of the repository's {CONFIG_FILE}")
|
|
83
|
+
how.add_argument("--min-confidence", type=float,
|
|
84
|
+
help=f"drop suggestions the model is less sure of (0-1, default: {DEFAULT_MIN_CONFIDENCE})")
|
|
85
|
+
how.add_argument("--chunk-tokens", type=int,
|
|
86
|
+
help=f"largest piece of diff sent in one request (default: {DEFAULT_CHUNK_TOKENS:,}; 8,000 for ollama)")
|
|
87
|
+
how.add_argument("--ignore", action="append", metavar="PATTERN",
|
|
88
|
+
help="another path pattern not to review (repeatable; added to the defaults: "
|
|
89
|
+
+ " ".join(DEFAULT_IGNORED_PATHS) + ")")
|
|
90
|
+
|
|
91
|
+
review.add_argument("--github-api-url", default=DEFAULT_API_URL, help="for GitHub Enterprise Server")
|
|
92
|
+
review.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _to_jsonable(value):
|
|
97
|
+
if hasattr(value, "model_dump"):
|
|
98
|
+
return value.model_dump(mode="json")
|
|
99
|
+
if dataclasses.is_dataclass(value):
|
|
100
|
+
return {f.name: _to_jsonable(getattr(value, f.name)) for f in dataclasses.fields(value)}
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
return [_to_jsonable(v) for v in value]
|
|
103
|
+
if isinstance(value, Enum):
|
|
104
|
+
return value.value
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def report_json(report: ReviewReport, target: str, config_source: str) -> str:
|
|
109
|
+
return json.dumps(
|
|
110
|
+
{"target": target, "config": config_source, "complete": report.complete, **_to_jsonable(report)},
|
|
111
|
+
indent=2,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def format_cost(cost: Optional[float], usage: Usage) -> str:
|
|
116
|
+
if not usage.input_tokens and not usage.output_tokens:
|
|
117
|
+
return "nothing spent"
|
|
118
|
+
if cost is None:
|
|
119
|
+
return "cost unknown for this model"
|
|
120
|
+
if cost == 0:
|
|
121
|
+
return "no cost (local model)"
|
|
122
|
+
return f"about ${cost:.2f}" if cost >= 0.01 else "under $0.01"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def report_text(report: ReviewReport, target: str, config_source: str) -> str:
|
|
126
|
+
settings = "" if config_source == "defaults" else f" (settings from {config_source})"
|
|
127
|
+
lines: List[str] = [f"CodeMop review of {target} with {report.model}{settings}", ""]
|
|
128
|
+
if not report.suggestions:
|
|
129
|
+
lines.append("No issues found." if report.complete else "No issues found in the parts that were reviewed.")
|
|
130
|
+
for s in report.suggestions:
|
|
131
|
+
location = f"{s.file_path}:{s.line}" + (f"-{s.end_line}" if s.end_line and s.end_line != s.line else "")
|
|
132
|
+
lines.append(f"{location} [{s.severity.value}, confidence {s.confidence:.2f}] {s.title}")
|
|
133
|
+
lines.extend(textwrap.wrap(s.explanation, width=88, initial_indent=" ", subsequent_indent=" "))
|
|
134
|
+
if s.suggested_code:
|
|
135
|
+
lines.append(" Suggested change:")
|
|
136
|
+
lines.extend(" " + code_line for code_line in s.suggested_code.splitlines())
|
|
137
|
+
lines.append("")
|
|
138
|
+
|
|
139
|
+
notes: List[str] = []
|
|
140
|
+
# The chunk that hit the fatal error, and those not started after it, share one note
|
|
141
|
+
stopped = [f for f in report.failed if report.stopped and (f.kind == "not_reviewed" or f.reason == report.stopped)]
|
|
142
|
+
if stopped:
|
|
143
|
+
paths = ", ".join(path for failed in stopped for path in failed.paths)
|
|
144
|
+
notes.append(f"Stopped early: {report.stopped} (not reviewed: {paths})")
|
|
145
|
+
for failed in report.failed:
|
|
146
|
+
if failed not in stopped:
|
|
147
|
+
notes.append(f"Not reviewed ({', '.join(failed.paths)}): {failed.reason}")
|
|
148
|
+
for skipped in report.skipped:
|
|
149
|
+
notes.append(f"Skipped {skipped.path}: {skipped.reason}")
|
|
150
|
+
if report.unplaced:
|
|
151
|
+
notes.append(f"Set aside {len(report.unplaced)} suggestion(s) that pointed at lines outside the diff")
|
|
152
|
+
if report.below_confidence:
|
|
153
|
+
notes.append(f"Dropped {report.below_confidence} suggestion(s) below the confidence threshold")
|
|
154
|
+
if notes:
|
|
155
|
+
lines += notes + [""]
|
|
156
|
+
|
|
157
|
+
u = report.usage
|
|
158
|
+
lines.append(f"{len(report.suggestions)} suggestion(s) · {report.chunks} chunk(s) · "
|
|
159
|
+
f"{u.input_tokens:,} input / {u.output_tokens:,} output tokens · "
|
|
160
|
+
f"{format_cost(report.cost, u)}" + ("" if not report.cost else f" (list prices as of {PRICES_AS_OF})"))
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def load_config(args, pr: Optional[PullRequestRef], token: Optional[str]) -> tuple[RepoConfig, str]:
|
|
165
|
+
"""The review settings and where they came from: --config, the repo's file, or the defaults"""
|
|
166
|
+
if args.config:
|
|
167
|
+
config = load_config_file(args.config)
|
|
168
|
+
if config is None:
|
|
169
|
+
raise ConfigError(f"{args.config} doesn't exist")
|
|
170
|
+
return config, str(args.config)
|
|
171
|
+
if pr is None:
|
|
172
|
+
config = load_config_file(Path(CONFIG_FILE))
|
|
173
|
+
return (config, CONFIG_FILE) if config else (RepoConfig(), "defaults")
|
|
174
|
+
text = await fetch_repo_file(pr.repo, CONFIG_FILE, token=token, api_url=args.github_api_url)
|
|
175
|
+
if text is None:
|
|
176
|
+
return RepoConfig(), "defaults"
|
|
177
|
+
return parse_config(text, source=f"{pr.repo}/{CONFIG_FILE}"), f"{pr.repo}/{CONFIG_FILE}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def run_review(args) -> int:
|
|
181
|
+
pr = None
|
|
182
|
+
token = None
|
|
183
|
+
if args.target != "-":
|
|
184
|
+
try:
|
|
185
|
+
pr = parse_pr_reference(args.target)
|
|
186
|
+
except ValueError as e:
|
|
187
|
+
print(f"codemop: {e}", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
token = github_token()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
config, config_source = await load_config(args, pr, token)
|
|
193
|
+
except (ConfigError, GitHubError) as e:
|
|
194
|
+
print(f"codemop: {e}", file=sys.stderr)
|
|
195
|
+
return 2 if isinstance(e, ConfigError) else 1
|
|
196
|
+
|
|
197
|
+
if pr is None:
|
|
198
|
+
diff, target = sys.stdin.read(), "stdin"
|
|
199
|
+
else:
|
|
200
|
+
target = str(pr)
|
|
201
|
+
try:
|
|
202
|
+
diff = await fetch_pr_diff(pr, token=token, api_url=args.github_api_url)
|
|
203
|
+
except GitHubError as e:
|
|
204
|
+
print(f"codemop: {e}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
model = create_model(
|
|
209
|
+
args.provider, args.model, base_url=args.base_url, api_key_env=args.api_key_env, effort=args.effort
|
|
210
|
+
)
|
|
211
|
+
except ValueError as e:
|
|
212
|
+
print(f"codemop: {e}", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
|
|
215
|
+
report = await review_diff(
|
|
216
|
+
diff, model,
|
|
217
|
+
chunk_tokens=args.chunk_tokens or config.chunk_tokens,
|
|
218
|
+
min_confidence=args.min_confidence if args.min_confidence is not None else config.min_confidence,
|
|
219
|
+
ignored_paths=[*DEFAULT_IGNORED_PATHS, *config.ignore, *(args.ignore or [])],
|
|
220
|
+
)
|
|
221
|
+
print(report_json(report, target, config_source) if args.json else report_text(report, target, config_source))
|
|
222
|
+
return 0 if report.complete else 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
226
|
+
args = build_parser().parse_args(argv)
|
|
227
|
+
if args.command == "review":
|
|
228
|
+
return asyncio.run(run_review(args))
|
|
229
|
+
return 2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(main())
|
codemop/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A repository's .codemop.yml: how its pull requests are reviewed.
|
|
3
|
+
|
|
4
|
+
It only controls review behaviour. Which provider and model run the review, and where
|
|
5
|
+
requests (and API keys) go, are decided by whoever runs CodeMop, never by the repository
|
|
6
|
+
being reviewed: otherwise reviewing someone else's PR could send your key to their server.
|
|
7
|
+
"""
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
13
|
+
|
|
14
|
+
CONFIG_FILE = ".codemop.yml"
|
|
15
|
+
DEFAULT_MIN_CONFIDENCE = 0.5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(Exception):
|
|
19
|
+
""".codemop.yml couldn't be used; the message says what's wrong with it"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RepoConfig(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
ignore: List[str] = Field(
|
|
26
|
+
default_factory=list,
|
|
27
|
+
description="Path patterns not to review, in addition to the defaults (lock files, minified files...)",
|
|
28
|
+
)
|
|
29
|
+
min_confidence: float = Field(
|
|
30
|
+
default=DEFAULT_MIN_CONFIDENCE, ge=0, le=1,
|
|
31
|
+
description="Drop suggestions the model is less sure of than this",
|
|
32
|
+
)
|
|
33
|
+
chunk_tokens: Optional[int] = Field(
|
|
34
|
+
default=None, ge=1000,
|
|
35
|
+
description="Largest piece of diff sent in one request (default: the model's own)",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_config(text: str, source: str = CONFIG_FILE) -> RepoConfig:
|
|
40
|
+
"""Read a .codemop.yml; an empty file means all defaults"""
|
|
41
|
+
try:
|
|
42
|
+
data = yaml.safe_load(text)
|
|
43
|
+
except yaml.YAMLError as e:
|
|
44
|
+
raise ConfigError(f"{source} isn't valid YAML: {e}")
|
|
45
|
+
if data is None:
|
|
46
|
+
return RepoConfig()
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
raise ConfigError(f"{source} should be a mapping of settings, like `min_confidence: 0.6`")
|
|
49
|
+
try:
|
|
50
|
+
return RepoConfig.model_validate(data)
|
|
51
|
+
except ValidationError as e:
|
|
52
|
+
problems = []
|
|
53
|
+
for error in e.errors():
|
|
54
|
+
field = ".".join(str(part) for part in error["loc"]) or "(top level)"
|
|
55
|
+
if error["type"] == "extra_forbidden":
|
|
56
|
+
problems.append(f"unknown setting `{field}` (settings: {', '.join(RepoConfig.model_fields)})")
|
|
57
|
+
else:
|
|
58
|
+
problems.append(f"`{field}`: {error['msg']}")
|
|
59
|
+
raise ConfigError(f"{source}: " + "; ".join(problems))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_config_file(path: Path) -> Optional[RepoConfig]:
|
|
63
|
+
"""The config at `path`, or None if there's no such file"""
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return None
|
|
66
|
+
return parse_config(path.read_text(), source=str(path))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Talking to GitHub: fetching PR diffs and (later) posting reviews."""
|
codemop/github/client.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fetching pull request diffs from the GitHub REST API.
|
|
3
|
+
|
|
4
|
+
Uses a token when one is given, which private repositories need (and which raises the
|
|
5
|
+
rate limit from 60 to 5,000 requests an hour for public ones).
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://api.github.com"
|
|
14
|
+
|
|
15
|
+
_PR_REFERENCE = re.compile(r"^(?P<repo>[\w.-]+/[\w.-]+)#(?P<number>\d+)$")
|
|
16
|
+
_PR_URL = re.compile(r"^https?://[^/]+/(?P<repo>[\w.-]+/[\w.-]+)/pull/(?P<number>\d+)(?:[/?#].*)?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitHubError(Exception):
|
|
20
|
+
"""A GitHub request failed; the message says what to do about it"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class PullRequestRef:
|
|
25
|
+
repo: str # owner/name
|
|
26
|
+
number: int
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
return f"{self.repo}#{self.number}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_pr_reference(text: str) -> PullRequestRef:
|
|
33
|
+
"""owner/repo#123, or a PR URL like https://github.com/owner/repo/pull/123"""
|
|
34
|
+
match = _PR_REFERENCE.match(text.strip()) or _PR_URL.match(text.strip())
|
|
35
|
+
if not match:
|
|
36
|
+
raise ValueError(f"Not a pull request: {text!r} (use owner/repo#123 or a PR URL)")
|
|
37
|
+
return PullRequestRef(match.group("repo"), int(match.group("number")))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _error_message(status: int, body: str, pr: PullRequestRef, has_token: bool) -> str:
|
|
41
|
+
if status == 401:
|
|
42
|
+
return f"GitHub rejected the token while fetching {pr}; check it's valid and hasn't expired"
|
|
43
|
+
if status == 403 and "rate limit" in body.lower():
|
|
44
|
+
hint = "" if has_token else "; set GITHUB_TOKEN (or log in with `gh auth login`) for a higher limit"
|
|
45
|
+
return f"GitHub API rate limit reached while fetching {pr}{hint}"
|
|
46
|
+
if status in (403, 404):
|
|
47
|
+
if has_token:
|
|
48
|
+
return (f"GitHub returned {status} for {pr}: the token needs read access to this "
|
|
49
|
+
"repository's pull requests and contents")
|
|
50
|
+
return (f"GitHub returned {status} for {pr}: if the repository is private, set "
|
|
51
|
+
"GITHUB_TOKEN (or log in with `gh auth login`)")
|
|
52
|
+
if status == 406:
|
|
53
|
+
return f"The diff for {pr} is too large for the GitHub API to return"
|
|
54
|
+
return f"GitHub returned {status} while fetching the diff for {pr}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _get(
|
|
58
|
+
url: str, accept: str, token: Optional[str], transport: Optional[httpx.AsyncBaseTransport]
|
|
59
|
+
) -> httpx.Response:
|
|
60
|
+
headers = {"Accept": accept, "X-GitHub-Api-Version": "2022-11-28"}
|
|
61
|
+
if token:
|
|
62
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
63
|
+
async with httpx.AsyncClient(headers=headers, timeout=60.0, transport=transport) as client:
|
|
64
|
+
try:
|
|
65
|
+
return await client.get(url)
|
|
66
|
+
except httpx.TransportError:
|
|
67
|
+
raise GitHubError(f"Couldn't connect to {url.split('/repos/')[0]}; check the network")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def fetch_pr_diff(
|
|
71
|
+
pr: PullRequestRef,
|
|
72
|
+
*,
|
|
73
|
+
token: Optional[str] = None,
|
|
74
|
+
api_url: str = DEFAULT_API_URL,
|
|
75
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""The PR's diff, from GET /repos/{owner}/{repo}/pulls/{number} as application/vnd.github.diff"""
|
|
78
|
+
url = f"{api_url.rstrip('/')}/repos/{pr.repo}/pulls/{pr.number}"
|
|
79
|
+
response = await _get(url, "application/vnd.github.diff", token, transport)
|
|
80
|
+
if response.status_code != 200:
|
|
81
|
+
raise GitHubError(_error_message(response.status_code, response.text, pr, bool(token)))
|
|
82
|
+
return response.text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def fetch_repo_file(
|
|
86
|
+
repo: str,
|
|
87
|
+
path: str,
|
|
88
|
+
*,
|
|
89
|
+
token: Optional[str] = None,
|
|
90
|
+
api_url: str = DEFAULT_API_URL,
|
|
91
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
92
|
+
) -> Optional[str]:
|
|
93
|
+
"""
|
|
94
|
+
A file's contents on the repository's default branch, or None if there's no such file.
|
|
95
|
+
|
|
96
|
+
(The default branch, not the PR's: a pull request mustn't be able to change how it's
|
|
97
|
+
reviewed.) A 404 also covers a private repository read without access; fetching its diff
|
|
98
|
+
reports that properly.
|
|
99
|
+
"""
|
|
100
|
+
url = f"{api_url.rstrip('/')}/repos/{repo}/contents/{path}"
|
|
101
|
+
response = await _get(url, "application/vnd.github.raw+json", token, transport)
|
|
102
|
+
if response.status_code == 404:
|
|
103
|
+
return None
|
|
104
|
+
if response.status_code != 200:
|
|
105
|
+
raise GitHubError(f"GitHub returned {response.status_code} reading {path} from {repo}")
|
|
106
|
+
return response.text
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model providers, one module each, behind the interface in providers.base.
|
|
3
|
+
|
|
4
|
+
create_model() is the one place that knows which providers exist.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from codemop.providers.base import ReviewModel
|
|
10
|
+
|
|
11
|
+
PROVIDERS = ("anthropic", "mistral", "openai", "openrouter", "ollama", "openai-compatible")
|
|
12
|
+
|
|
13
|
+
# Providers with a sensible default model; the rest need one named
|
|
14
|
+
DEFAULT_MODELS = {
|
|
15
|
+
"anthropic": "claude-opus-5-5",
|
|
16
|
+
"mistral": "codestral-latest",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def key_env(provider: str) -> Optional[str]:
|
|
21
|
+
"""The environment variable a provider's API key is read from by default (None: no key needed)"""
|
|
22
|
+
if provider == "anthropic":
|
|
23
|
+
return "ANTHROPIC_API_KEY"
|
|
24
|
+
from codemop.providers.openai_compatible import PRESETS
|
|
25
|
+
preset = PRESETS.get(provider)
|
|
26
|
+
return preset.key_env if preset else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_model(
|
|
30
|
+
provider: str = "anthropic",
|
|
31
|
+
model: Optional[str] = None,
|
|
32
|
+
*,
|
|
33
|
+
base_url: Optional[str] = None,
|
|
34
|
+
api_key: Optional[str] = None,
|
|
35
|
+
api_key_env: Optional[str] = None,
|
|
36
|
+
effort: Optional[str] = None,
|
|
37
|
+
) -> ReviewModel:
|
|
38
|
+
"""
|
|
39
|
+
The ReviewModel for a provider name (anthropic, mistral, openai, openrouter, ollama,
|
|
40
|
+
openai-compatible). The API key is `api_key` if given, else read from `api_key_env`, else
|
|
41
|
+
the provider's own variable (see key_env). `effort` (low, medium, high, xhigh, max) applies
|
|
42
|
+
to Claude models that support it; None keeps the adapter's default (high).
|
|
43
|
+
"""
|
|
44
|
+
if provider not in PROVIDERS:
|
|
45
|
+
raise ValueError(f"Unknown provider {provider!r}; choose one of: {', '.join(PROVIDERS)}")
|
|
46
|
+
model = model or DEFAULT_MODELS.get(provider)
|
|
47
|
+
if not model:
|
|
48
|
+
raise ValueError(f"Name a model for {provider} (e.g. --model ...)")
|
|
49
|
+
|
|
50
|
+
if provider == "anthropic":
|
|
51
|
+
from codemop.providers.anthropic import AnthropicModel
|
|
52
|
+
if not api_key and api_key_env:
|
|
53
|
+
api_key = os.environ.get(api_key_env)
|
|
54
|
+
return AnthropicModel(model, api_key=api_key, **({"effort": effort} if effort else {}))
|
|
55
|
+
|
|
56
|
+
from codemop.providers.openai_compatible import OpenAICompatibleModel
|
|
57
|
+
if provider == "openai-compatible" and not base_url:
|
|
58
|
+
raise ValueError("openai-compatible needs a base URL (e.g. --base-url http://localhost:8000/v1)")
|
|
59
|
+
return OpenAICompatibleModel(model, provider=provider, base_url=base_url, api_key=api_key, key_env=api_key_env)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude, through the official Anthropic SDK.
|
|
3
|
+
|
|
4
|
+
Uses structured output (messages.parse with the review schema), so Claude's answer is
|
|
5
|
+
constrained to the schema and validated on the way back.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import anthropic
|
|
10
|
+
import pydantic
|
|
11
|
+
|
|
12
|
+
from codemop.providers import DEFAULT_MODELS
|
|
13
|
+
from codemop.providers.base import DEFAULT_CHUNK_TOKENS, NoReview, Usage
|
|
14
|
+
from codemop.providers.pricing import ANTHROPIC_PRICES
|
|
15
|
+
from codemop.review.schema import ModelReview
|
|
16
|
+
|
|
17
|
+
# Models that accept fallbacks="default": if the model declines a request, the API re-runs it
|
|
18
|
+
# on Anthropic's recommended model for that refusal category instead of returning a refusal
|
|
19
|
+
_FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
20
|
+
_DEFAULT_FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
|
|
21
|
+
|
|
22
|
+
# Older models reject the effort setting
|
|
23
|
+
_NO_EFFORT_PREFIXES = ("claude-haiku-", "claude-sonnet-4-5", "claude-3")
|
|
24
|
+
|
|
25
|
+
KEY_HINT = "check ANTHROPIC_API_KEY is set to a valid key"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnthropicModel:
|
|
29
|
+
"""A ReviewModel backed by Claude"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
model: str = DEFAULT_MODELS["anthropic"],
|
|
34
|
+
*,
|
|
35
|
+
api_key: Optional[str] = None,
|
|
36
|
+
effort: Optional[str] = "high",
|
|
37
|
+
max_output_tokens: int = 16000,
|
|
38
|
+
chunk_tokens: int = DEFAULT_CHUNK_TOKENS,
|
|
39
|
+
client: Optional[anthropic.AsyncAnthropic] = None,
|
|
40
|
+
):
|
|
41
|
+
self.model = model
|
|
42
|
+
self.chunk_tokens = chunk_tokens
|
|
43
|
+
# Models that actually answered: with refusal fallbacks, another model can answer
|
|
44
|
+
self.served_models: set[str] = set()
|
|
45
|
+
self.effort = None if model.startswith(_NO_EFFORT_PREFIXES) else effort
|
|
46
|
+
self.max_output_tokens = max_output_tokens
|
|
47
|
+
# With no api_key the SDK finds credentials itself (ANTHROPIC_API_KEY, `ant auth login`...)
|
|
48
|
+
self._client = client or anthropic.AsyncAnthropic(api_key=api_key)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name(self) -> str:
|
|
52
|
+
return f"anthropic/{self.model}"
|
|
53
|
+
|
|
54
|
+
def cost(self, usage: Usage) -> Optional[float]:
|
|
55
|
+
price = ANTHROPIC_PRICES.get(self.model)
|
|
56
|
+
return price.cost(usage) if price else None
|
|
57
|
+
|
|
58
|
+
async def review(self, instructions: str, diff_text: str) -> tuple[ModelReview, Usage]:
|
|
59
|
+
request = dict(
|
|
60
|
+
model=self.model,
|
|
61
|
+
max_tokens=self.max_output_tokens,
|
|
62
|
+
system=instructions,
|
|
63
|
+
messages=[{"role": "user", "content": diff_text}],
|
|
64
|
+
output_format=ModelReview,
|
|
65
|
+
)
|
|
66
|
+
if self.effort:
|
|
67
|
+
request["output_config"] = {"effort": self.effort}
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if self.model in _DEFAULT_FALLBACK_MODELS:
|
|
71
|
+
response = await self._client.beta.messages.parse(
|
|
72
|
+
betas=[_FALLBACK_BETA], fallbacks="default", **request
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
response = await self._client.messages.parse(**request)
|
|
76
|
+
except pydantic.ValidationError:
|
|
77
|
+
raise NoReview(f"{self.name} returned a review that doesn't match the schema")
|
|
78
|
+
except TypeError as e:
|
|
79
|
+
# The SDK raises TypeError, before sending anything, when it finds no credentials
|
|
80
|
+
if "Could not resolve authentication method" not in str(e):
|
|
81
|
+
raise
|
|
82
|
+
raise NoReview("No Anthropic API key found: set ANTHROPIC_API_KEY", fatal=True)
|
|
83
|
+
except anthropic.AuthenticationError:
|
|
84
|
+
raise NoReview(f"Anthropic rejected the API key: {KEY_HINT}", fatal=True)
|
|
85
|
+
except anthropic.PermissionDeniedError:
|
|
86
|
+
raise NoReview(f"The Anthropic API key isn't allowed to use {self.model}", fatal=True)
|
|
87
|
+
except anthropic.NotFoundError:
|
|
88
|
+
raise NoReview(f"Model {self.model} wasn't found, or isn't available to this API key", fatal=True)
|
|
89
|
+
except anthropic.RateLimitError:
|
|
90
|
+
raise NoReview("Anthropic rate limit reached (after retries); try again later")
|
|
91
|
+
except anthropic.BadRequestError as e:
|
|
92
|
+
raise NoReview(f"Anthropic rejected the request: {e.message}")
|
|
93
|
+
except anthropic.APIStatusError as e:
|
|
94
|
+
raise NoReview(f"Anthropic returned an error ({e.status_code}); try again later")
|
|
95
|
+
except anthropic.APIConnectionError:
|
|
96
|
+
raise NoReview("Couldn't connect to the Anthropic API; check the network")
|
|
97
|
+
|
|
98
|
+
if getattr(response, "model", None):
|
|
99
|
+
self.served_models.add(response.model)
|
|
100
|
+
usage = Usage(
|
|
101
|
+
input_tokens=response.usage.input_tokens or 0,
|
|
102
|
+
output_tokens=response.usage.output_tokens or 0,
|
|
103
|
+
cache_read_tokens=response.usage.cache_read_input_tokens or 0,
|
|
104
|
+
cache_write_tokens=response.usage.cache_creation_input_tokens or 0,
|
|
105
|
+
)
|
|
106
|
+
if response.stop_reason == "refusal":
|
|
107
|
+
category = getattr(response.stop_details, "category", None)
|
|
108
|
+
detail = f" ({category})" if category else ""
|
|
109
|
+
raise NoReview.refused(self.name, usage, detail)
|
|
110
|
+
if response.stop_reason == "max_tokens":
|
|
111
|
+
raise NoReview.cut_off(self.name, self.max_output_tokens, usage)
|
|
112
|
+
if response.parsed_output is None:
|
|
113
|
+
raise NoReview(f"{self.name} returned no review", usage=usage)
|
|
114
|
+
return response.parsed_output, usage
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The interface every model provider implements.
|
|
3
|
+
|
|
4
|
+
The review code only talks to a ReviewModel; provider SDKs and HTTP details stay inside
|
|
5
|
+
their adapter modules.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal, Optional, Protocol
|
|
9
|
+
|
|
10
|
+
from codemop.review.schema import ModelReview
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Usage:
|
|
15
|
+
"""Tokens used by one or more requests"""
|
|
16
|
+
input_tokens: int = 0
|
|
17
|
+
output_tokens: int = 0
|
|
18
|
+
cache_read_tokens: int = 0
|
|
19
|
+
cache_write_tokens: int = 0
|
|
20
|
+
|
|
21
|
+
def __add__(self, other: "Usage") -> "Usage":
|
|
22
|
+
return Usage(
|
|
23
|
+
self.input_tokens + other.input_tokens,
|
|
24
|
+
self.output_tokens + other.output_tokens,
|
|
25
|
+
self.cache_read_tokens + other.cache_read_tokens,
|
|
26
|
+
self.cache_write_tokens + other.cache_write_tokens,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Why a chunk got no review: the model declined, its answer hit the output limit, or
|
|
31
|
+
# something else went wrong (the reason says what)
|
|
32
|
+
NoReviewKind = Literal["refused", "cut_off", "error"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NoReview(Exception):
|
|
36
|
+
"""
|
|
37
|
+
The model gave no usable review. The message says why, and what to do about it.
|
|
38
|
+
|
|
39
|
+
`fatal` means every request would fail the same way (a rejected key, an unknown model),
|
|
40
|
+
so the rest of the review should stop rather than repeat it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, reason: str, *, fatal: bool = False, usage: Usage = Usage(), kind: NoReviewKind = "error"):
|
|
44
|
+
super().__init__(reason)
|
|
45
|
+
self.reason = reason
|
|
46
|
+
self.fatal = fatal
|
|
47
|
+
self.usage = usage
|
|
48
|
+
self.kind = kind
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def refused(cls, model_name: str, usage: Usage, detail: str = "") -> "NoReview":
|
|
52
|
+
return cls(f"{model_name} declined to review this part of the diff{detail}", usage=usage, kind="refused")
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def cut_off(cls, model_name: str, max_output_tokens: int, usage: Usage) -> "NoReview":
|
|
56
|
+
return cls(
|
|
57
|
+
f"{model_name}'s review was cut off at {max_output_tokens} output tokens; "
|
|
58
|
+
"raise the output limit or use smaller chunks",
|
|
59
|
+
usage=usage, kind="cut_off",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Largest piece of diff to send in one request, unless a model says otherwise
|
|
64
|
+
DEFAULT_CHUNK_TOKENS = 40_000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ReviewModel(Protocol):
|
|
68
|
+
"""A model that can review one chunk of a diff"""
|
|
69
|
+
|
|
70
|
+
#: The largest piece of diff this model should be sent at once, in tokens
|
|
71
|
+
chunk_tokens: int
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def name(self) -> str:
|
|
75
|
+
"""provider/model, e.g. anthropic/claude-opus-5-5"""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
async def review(self, instructions: str, diff_text: str) -> tuple[ModelReview, Usage]:
|
|
79
|
+
"""Review `diff_text` following `instructions`; raises NoReview if there's no usable answer"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def cost(self, usage: Usage) -> Optional[float]:
|
|
83
|
+
"""Estimated US dollars for `usage` at list prices, or None if the price isn't known"""
|
|
84
|
+
...
|