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 ADDED
@@ -0,0 +1,3 @@
1
+ """CodeMop: AI code review for pull requests, with the model of your choice."""
2
+
3
+ __version__ = "0.1.0"
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."""
@@ -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
+ ...