secondlook 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.
- codereview/__init__.py +1 -0
- codereview/config/__init__.py +0 -0
- codereview/config/config.py +75 -0
- codereview/main.py +42 -0
- codereview/prompt/__init__.py +0 -0
- codereview/prompt/prompt.py +38 -0
- codereview/provider/__init__.py +0 -0
- codereview/provider/base.py +137 -0
- codereview/provider/claude.py +77 -0
- codereview/provider/deepseek.py +75 -0
- codereview/provider/gemini.py +73 -0
- codereview/provider/ollama.py +73 -0
- codereview/provider/openai.py +81 -0
- codereview/provider/vertexai.py +149 -0
- codereview/tool/__init__.py +0 -0
- codereview/tool/review.py +316 -0
- secondlook-0.1.0.dist-info/METADATA +82 -0
- secondlook-0.1.0.dist-info/RECORD +20 -0
- secondlook-0.1.0.dist-info/WHEEL +4 -0
- secondlook-0.1.0.dist-info/entry_points.txt +2 -0
codereview/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderConfig(BaseModel):
|
|
15
|
+
model: str = ""
|
|
16
|
+
api_key: str = ""
|
|
17
|
+
endpoint: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DefaultsConfig(BaseModel):
|
|
21
|
+
mode: str = "raw"
|
|
22
|
+
focus: str = "all"
|
|
23
|
+
max_tokens: int = 2048
|
|
24
|
+
timeout_sec: float = 60.0
|
|
25
|
+
max_retries: int = 3
|
|
26
|
+
provider: str = "gemini"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Config(BaseModel):
|
|
30
|
+
default_provider: str = "gemini"
|
|
31
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
|
|
32
|
+
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expand_env(value: str) -> str:
|
|
36
|
+
if not isinstance(value, str):
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
def _replacer(match: re.Match) -> str:
|
|
40
|
+
return os.environ.get(match.group(1), "")
|
|
41
|
+
|
|
42
|
+
return re.sub(r"\$\{(\w+)\}", _replacer, value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_config_path(path: str | None) -> str:
|
|
46
|
+
if path:
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
env_path = os.environ.get("CODEREVIEW_CONFIG")
|
|
50
|
+
if env_path:
|
|
51
|
+
return env_path
|
|
52
|
+
|
|
53
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
54
|
+
return str(Path(xdg) / "codereview" / "config.yaml")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: str | None = None) -> Config:
|
|
58
|
+
config_path = _find_config_path(path)
|
|
59
|
+
|
|
60
|
+
if os.path.exists(config_path):
|
|
61
|
+
logger.info("Loading config from %s", config_path)
|
|
62
|
+
with open(config_path, encoding="utf-8") as f:
|
|
63
|
+
raw = yaml.safe_load(f) or {}
|
|
64
|
+
else:
|
|
65
|
+
logger.info("No config file found at %s, using defaults", config_path)
|
|
66
|
+
raw = {}
|
|
67
|
+
|
|
68
|
+
if "providers" in raw:
|
|
69
|
+
for name, provider in raw["providers"].items():
|
|
70
|
+
if isinstance(provider, dict):
|
|
71
|
+
for key in ("api_key", "model", "endpoint"):
|
|
72
|
+
if key in provider and isinstance(provider[key], str):
|
|
73
|
+
provider[key] = _expand_env(provider[key])
|
|
74
|
+
|
|
75
|
+
return Config(**raw)
|
codereview/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""secondlook — multi-provider code review via MCP."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
from mcp.server.stdio import stdio_server
|
|
9
|
+
|
|
10
|
+
from codereview.tool.review import register_tools
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("codereview")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
description="secondlook — multi-provider code review via MCP",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--config",
|
|
21
|
+
default=None,
|
|
22
|
+
help="Path to config file (default: ~/.config/codereview/config.yaml)",
|
|
23
|
+
)
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.WARNING,
|
|
28
|
+
format="%(levelname)s | %(name)s | %(message)s",
|
|
29
|
+
stream=sys.stderr,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
server = Server("codereview")
|
|
33
|
+
register_tools(server, args.config)
|
|
34
|
+
|
|
35
|
+
logger.info("codereview-mcp starting")
|
|
36
|
+
import asyncio
|
|
37
|
+
|
|
38
|
+
asyncio.run(stdio_server(server))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Prompt templates for each review mode."""
|
|
2
|
+
|
|
3
|
+
RAW = """You are a code reviewer. Review the following code for bugs,
|
|
4
|
+
security issues, performance problems, and style violations.
|
|
5
|
+
Be concise. Use severity markers: [CRITICAL], [HIGH], [MEDIUM], [LOW].
|
|
6
|
+
Reference exact line numbers when possible."""
|
|
7
|
+
|
|
8
|
+
VERIFY_DIFF = """You are a diff verifier. A developer proposed this change.
|
|
9
|
+
Review it for: correctness, edge cases, regressions, test gaps.
|
|
10
|
+
Do NOT suggest alternative approaches unless the proposed
|
|
11
|
+
approach is actively harmful. Flag false fixes with [FALSE-FIX]."""
|
|
12
|
+
|
|
13
|
+
SECOND_OPINION = """You are a second-opinion reviewer. A primary model analyzed
|
|
14
|
+
this diff and provided reasoning. Critique the reasoning:
|
|
15
|
+
- Did it miss edge cases?
|
|
16
|
+
- Did it suggest correct but suboptimal solutions?
|
|
17
|
+
- Did it hallucinate any behavior?
|
|
18
|
+
- Is there a simpler approach?
|
|
19
|
+
Be adversarial. Assume the primary model might be wrong."""
|
|
20
|
+
|
|
21
|
+
COMPARE = """You are a code reviewer from {provider_name}. Review the following
|
|
22
|
+
code/diff for bugs, security issues, performance problems, and style.
|
|
23
|
+
Be concise. Use severity: [CRITICAL], [HIGH], [MEDIUM], [LOW].
|
|
24
|
+
Reference exact line numbers when possible.
|
|
25
|
+
|
|
26
|
+
Your findings will be compared against reviews from other providers.
|
|
27
|
+
Focus on what YOU uniquely catch. Be specific."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_prompt(mode: str, provider_name: str = "") -> str:
|
|
31
|
+
"""Return the system prompt for a given review mode."""
|
|
32
|
+
prompts = {
|
|
33
|
+
"raw": RAW,
|
|
34
|
+
"verify-diff": VERIFY_DIFF,
|
|
35
|
+
"second-opinion": SECOND_OPINION,
|
|
36
|
+
"compare": COMPARE.format(provider_name=provider_name),
|
|
37
|
+
}
|
|
38
|
+
return prompts.get(mode, RAW)
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""LLM provider interface with retry, timeout, and structured errors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import httpx
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorKind(Enum):
|
|
14
|
+
AUTH = "auth"
|
|
15
|
+
RATE_LIMIT = "rate_limit"
|
|
16
|
+
TIMEOUT = "timeout"
|
|
17
|
+
SERVER = "server"
|
|
18
|
+
CLIENT = "client"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ReviewResult:
|
|
24
|
+
provider: str
|
|
25
|
+
model: str
|
|
26
|
+
findings: str = ""
|
|
27
|
+
error: str | None = None
|
|
28
|
+
error_kind: ErrorKind | None = None
|
|
29
|
+
tokens_used: int = 0
|
|
30
|
+
latency_ms: float = 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProviderConfig:
|
|
35
|
+
api_key: str = ""
|
|
36
|
+
model: str = ""
|
|
37
|
+
endpoint: str | None = None
|
|
38
|
+
max_tokens: int = 2048
|
|
39
|
+
timeout_sec: float = 60.0
|
|
40
|
+
max_retries: int = 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProviderError(Exception):
|
|
44
|
+
def __init__(self, message: str, kind: ErrorKind = ErrorKind.UNKNOWN) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.kind = kind
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Provider(ABC):
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: str = "",
|
|
53
|
+
model: str = "",
|
|
54
|
+
endpoint: str | None = None,
|
|
55
|
+
max_tokens: int = 2048,
|
|
56
|
+
timeout_sec: float = 60.0,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.api_key = api_key
|
|
60
|
+
self.model = model
|
|
61
|
+
self.endpoint = endpoint
|
|
62
|
+
self.max_tokens = max_tokens
|
|
63
|
+
self.timeout_sec = timeout_sec
|
|
64
|
+
self.max_retries = max_retries
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def name(self) -> str: ...
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str: ...
|
|
72
|
+
|
|
73
|
+
async def review(self, system_prompt: str, user_prompt: str) -> ReviewResult:
|
|
74
|
+
import time
|
|
75
|
+
|
|
76
|
+
t0 = time.monotonic()
|
|
77
|
+
last_error: Exception | None = None
|
|
78
|
+
|
|
79
|
+
for attempt in range(self.max_retries + 1):
|
|
80
|
+
try:
|
|
81
|
+
text = await asyncio.wait_for(
|
|
82
|
+
self._call_api(system_prompt, user_prompt),
|
|
83
|
+
timeout=self.timeout_sec,
|
|
84
|
+
)
|
|
85
|
+
latency = (time.monotonic() - t0) * 1000
|
|
86
|
+
logger.info(
|
|
87
|
+
"%s review completed in %.0fms (attempt %d)",
|
|
88
|
+
self.name,
|
|
89
|
+
latency,
|
|
90
|
+
attempt + 1,
|
|
91
|
+
)
|
|
92
|
+
return ReviewResult(
|
|
93
|
+
provider=self.name,
|
|
94
|
+
model=self.model,
|
|
95
|
+
findings=text,
|
|
96
|
+
latency_ms=latency,
|
|
97
|
+
)
|
|
98
|
+
except asyncio.TimeoutError:
|
|
99
|
+
last_error = ProviderError(
|
|
100
|
+
f"Request timed out after {self.timeout_sec}s",
|
|
101
|
+
ErrorKind.TIMEOUT,
|
|
102
|
+
)
|
|
103
|
+
logger.warning("%s timeout (attempt %d)", self.name, attempt + 1)
|
|
104
|
+
except httpx.TimeoutException:
|
|
105
|
+
last_error = ProviderError(
|
|
106
|
+
f"HTTP timeout after {self.timeout_sec}s",
|
|
107
|
+
ErrorKind.TIMEOUT,
|
|
108
|
+
)
|
|
109
|
+
logger.warning("%s HTTP timeout (attempt %d)", self.name, attempt + 1)
|
|
110
|
+
except ProviderError as e:
|
|
111
|
+
last_error = e
|
|
112
|
+
if e.kind == ErrorKind.AUTH:
|
|
113
|
+
break
|
|
114
|
+
if e.kind == ErrorKind.RATE_LIMIT and attempt < self.max_retries:
|
|
115
|
+
wait = 2 ** attempt
|
|
116
|
+
logger.warning("%s rate limited, retrying in %ds", self.name, wait)
|
|
117
|
+
await asyncio.sleep(wait)
|
|
118
|
+
continue
|
|
119
|
+
logger.warning("%s error (attempt %d): %s", self.name, attempt + 1, e)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
last_error = e
|
|
122
|
+
logger.warning("%s error (attempt %d): %s", self.name, attempt + 1, e)
|
|
123
|
+
|
|
124
|
+
if attempt < self.max_retries:
|
|
125
|
+
await asyncio.sleep(1 * (attempt + 1))
|
|
126
|
+
|
|
127
|
+
msg = str(last_error) if last_error else "unknown error"
|
|
128
|
+
kind = last_error.kind if isinstance(last_error, ProviderError) else ErrorKind.UNKNOWN
|
|
129
|
+
|
|
130
|
+
return ReviewResult(
|
|
131
|
+
provider=self.name,
|
|
132
|
+
model=self.model,
|
|
133
|
+
findings="",
|
|
134
|
+
error=msg,
|
|
135
|
+
error_kind=kind,
|
|
136
|
+
latency_ms=(time.monotonic() - t0) * 1000,
|
|
137
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Claude provider via Anthropic Messages API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from codereview.provider.base import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderError,
|
|
8
|
+
ErrorKind,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClaudeProvider(Provider):
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "claude"
|
|
18
|
+
|
|
19
|
+
def _parse_error(self, status: int, body: dict | str) -> ProviderError:
|
|
20
|
+
msg = ""
|
|
21
|
+
if isinstance(body, dict):
|
|
22
|
+
err = body.get("error", {})
|
|
23
|
+
msg = err.get("message", str(body))
|
|
24
|
+
if err.get("type") == "overloaded_error":
|
|
25
|
+
return ProviderError(f"Claude overloaded: {msg}", ErrorKind.RATE_LIMIT)
|
|
26
|
+
else:
|
|
27
|
+
msg = str(body)
|
|
28
|
+
|
|
29
|
+
if status == 401 or status == 403:
|
|
30
|
+
return ProviderError(f"Claude auth error: {msg}", ErrorKind.AUTH)
|
|
31
|
+
if status == 429 or status == 529:
|
|
32
|
+
return ProviderError("Claude rate limited / overloaded", ErrorKind.RATE_LIMIT)
|
|
33
|
+
if status >= 500:
|
|
34
|
+
return ProviderError(f"Claude server error ({status}): {msg}", ErrorKind.SERVER)
|
|
35
|
+
return ProviderError(f"Claude error ({status}): {msg}", ErrorKind.CLIENT)
|
|
36
|
+
|
|
37
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
38
|
+
endpoint = self.endpoint or "https://api.anthropic.com"
|
|
39
|
+
url = f"{endpoint}/v1/messages"
|
|
40
|
+
|
|
41
|
+
headers = {
|
|
42
|
+
"x-api-key": self.api_key,
|
|
43
|
+
"anthropic-version": "2023-06-01",
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
payload = {
|
|
48
|
+
"model": self.model,
|
|
49
|
+
"max_tokens": self.max_tokens,
|
|
50
|
+
"temperature": 0.2,
|
|
51
|
+
"system": system_prompt,
|
|
52
|
+
"messages": [{"role": "user", "content": user_prompt}],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
56
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
57
|
+
|
|
58
|
+
if resp.status_code != 200:
|
|
59
|
+
try:
|
|
60
|
+
body = resp.json()
|
|
61
|
+
except Exception:
|
|
62
|
+
body = resp.text
|
|
63
|
+
raise self._parse_error(resp.status_code, body)
|
|
64
|
+
|
|
65
|
+
data = resp.json()
|
|
66
|
+
content = data.get("content", [])
|
|
67
|
+
if not content:
|
|
68
|
+
raise ProviderError("Claude returned empty response", ErrorKind.SERVER)
|
|
69
|
+
|
|
70
|
+
stop_reason = data.get("stop_reason", "")
|
|
71
|
+
if stop_reason and stop_reason not in ("end_turn", "max_tokens"):
|
|
72
|
+
raise ProviderError(
|
|
73
|
+
f"Claude stopped: {stop_reason}",
|
|
74
|
+
ErrorKind.SERVER,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return content[0]["text"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""DeepSeek provider via OpenAI-compatible API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from codereview.provider.base import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderError,
|
|
8
|
+
ErrorKind,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeepSeekProvider(Provider):
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "deepseek"
|
|
18
|
+
|
|
19
|
+
def _parse_error(self, status: int, body: dict | str) -> ProviderError:
|
|
20
|
+
msg = str(body)
|
|
21
|
+
if isinstance(body, dict):
|
|
22
|
+
msg = body.get("error", {}).get("message", str(body))
|
|
23
|
+
code = body.get("error", {}).get("code", "")
|
|
24
|
+
if code == "insufficient_quota":
|
|
25
|
+
return ProviderError(f"DeepSeek quota: {msg}", ErrorKind.RATE_LIMIT)
|
|
26
|
+
if status == 401:
|
|
27
|
+
return ProviderError(f"DeepSeek auth error: {msg}", ErrorKind.AUTH)
|
|
28
|
+
if status == 429:
|
|
29
|
+
return ProviderError("DeepSeek rate limited", ErrorKind.RATE_LIMIT)
|
|
30
|
+
if status >= 500:
|
|
31
|
+
return ProviderError(f"DeepSeek server error ({status}): {msg}", ErrorKind.SERVER)
|
|
32
|
+
return ProviderError(f"DeepSeek error ({status}): {msg}", ErrorKind.CLIENT)
|
|
33
|
+
|
|
34
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
35
|
+
endpoint = self.endpoint or "https://api.deepseek.com"
|
|
36
|
+
url = f"{endpoint}/v1/chat/completions"
|
|
37
|
+
|
|
38
|
+
headers = {
|
|
39
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
payload = {
|
|
44
|
+
"model": self.model,
|
|
45
|
+
"max_tokens": self.max_tokens,
|
|
46
|
+
"temperature": 0.2,
|
|
47
|
+
"messages": [
|
|
48
|
+
{"role": "system", "content": system_prompt},
|
|
49
|
+
{"role": "user", "content": user_prompt},
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
54
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
55
|
+
|
|
56
|
+
if resp.status_code != 200:
|
|
57
|
+
try:
|
|
58
|
+
body = resp.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
body = resp.text
|
|
61
|
+
raise self._parse_error(resp.status_code, body)
|
|
62
|
+
|
|
63
|
+
data = resp.json()
|
|
64
|
+
choices = data.get("choices", [])
|
|
65
|
+
if not choices:
|
|
66
|
+
raise ProviderError("DeepSeek returned empty response", ErrorKind.SERVER)
|
|
67
|
+
|
|
68
|
+
finish_reason = choices[0].get("finish_reason", "")
|
|
69
|
+
if finish_reason and finish_reason not in ("stop", "length"):
|
|
70
|
+
raise ProviderError(
|
|
71
|
+
f"DeepSeek stopped: {finish_reason}",
|
|
72
|
+
ErrorKind.SERVER,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return choices[0]["message"]["content"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Gemini provider via Google AI API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from codereview.provider.base import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderError,
|
|
8
|
+
ErrorKind,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GeminiProvider(Provider):
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "gemini"
|
|
18
|
+
|
|
19
|
+
def _parse_error(self, status: int, body: dict | str) -> ProviderError:
|
|
20
|
+
msg = str(body) if isinstance(body, str) else body.get("error", {}).get("message", str(body))
|
|
21
|
+
if status == 401 or status == 403:
|
|
22
|
+
return ProviderError(f"Gemini auth error: {msg}", ErrorKind.AUTH)
|
|
23
|
+
if status == 429:
|
|
24
|
+
return ProviderError("Gemini rate limited", ErrorKind.RATE_LIMIT)
|
|
25
|
+
if status >= 500:
|
|
26
|
+
return ProviderError(f"Gemini server error ({status}): {msg}", ErrorKind.SERVER)
|
|
27
|
+
return ProviderError(f"Gemini error ({status}): {msg}", ErrorKind.CLIENT)
|
|
28
|
+
|
|
29
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
30
|
+
endpoint = self.endpoint or "https://generativelanguage.googleapis.com"
|
|
31
|
+
url = f"{endpoint}/v1beta/models/{self.model}:generateContent"
|
|
32
|
+
params = {}
|
|
33
|
+
if self.api_key:
|
|
34
|
+
params["key"] = self.api_key
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
"systemInstruction": {"parts": [{"text": system_prompt}]},
|
|
38
|
+
"contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
|
|
39
|
+
"generationConfig": {
|
|
40
|
+
"maxOutputTokens": self.max_tokens,
|
|
41
|
+
"temperature": 0.2,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
46
|
+
resp = await client.post(url, params=params, json=payload)
|
|
47
|
+
|
|
48
|
+
if resp.status_code != 200:
|
|
49
|
+
try:
|
|
50
|
+
body = resp.json()
|
|
51
|
+
except Exception:
|
|
52
|
+
body = resp.text
|
|
53
|
+
raise self._parse_error(resp.status_code, body)
|
|
54
|
+
|
|
55
|
+
data = resp.json()
|
|
56
|
+
candidates = data.get("candidates", [])
|
|
57
|
+
if not candidates:
|
|
58
|
+
raise ProviderError("Gemini returned empty response", ErrorKind.SERVER)
|
|
59
|
+
|
|
60
|
+
finish_reason = candidates[0].get("finishReason", "")
|
|
61
|
+
if finish_reason and finish_reason not in ("STOP", "MAX_TOKENS"):
|
|
62
|
+
raise ProviderError(
|
|
63
|
+
f"Gemini stopped unexpectedly: {finish_reason}",
|
|
64
|
+
ErrorKind.SERVER,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return candidates[0]["content"]["parts"][0]["text"]
|
|
69
|
+
except (KeyError, IndexError, TypeError):
|
|
70
|
+
raise ProviderError(
|
|
71
|
+
"Gemini returned unexpected response structure",
|
|
72
|
+
ErrorKind.SERVER,
|
|
73
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Ollama provider for local models via OpenAI-compatible API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from codereview.provider.base import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderError,
|
|
8
|
+
ErrorKind,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OllamaProvider(Provider):
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "ollama"
|
|
18
|
+
|
|
19
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
20
|
+
endpoint = self.endpoint or "http://localhost:11434"
|
|
21
|
+
url = f"{endpoint}/v1/chat/completions"
|
|
22
|
+
|
|
23
|
+
headers = {"Content-Type": "application/json"}
|
|
24
|
+
|
|
25
|
+
payload = {
|
|
26
|
+
"model": self.model,
|
|
27
|
+
"max_tokens": self.max_tokens,
|
|
28
|
+
"temperature": 0.2,
|
|
29
|
+
"messages": [
|
|
30
|
+
{"role": "system", "content": system_prompt},
|
|
31
|
+
{"role": "user", "content": user_prompt},
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
36
|
+
try:
|
|
37
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
38
|
+
except httpx.ConnectError:
|
|
39
|
+
raise ProviderError(
|
|
40
|
+
"Cannot connect to Ollama. Is it running? Try: ollama serve",
|
|
41
|
+
ErrorKind.SERVER,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if resp.status_code != 200:
|
|
45
|
+
try:
|
|
46
|
+
body = resp.json()
|
|
47
|
+
msg = body.get("error", {}).get("message", resp.text)
|
|
48
|
+
except Exception:
|
|
49
|
+
msg = resp.text
|
|
50
|
+
|
|
51
|
+
if resp.status_code == 404 and "not found" in msg.lower():
|
|
52
|
+
raise ProviderError(
|
|
53
|
+
f"Model '{self.model}' not found. Pull it: ollama pull {self.model}",
|
|
54
|
+
ErrorKind.CLIENT,
|
|
55
|
+
)
|
|
56
|
+
raise ProviderError(
|
|
57
|
+
f"Ollama error ({resp.status_code}): {msg}",
|
|
58
|
+
ErrorKind.SERVER if resp.status_code >= 500 else ErrorKind.CLIENT,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
data = resp.json()
|
|
62
|
+
choices = data.get("choices", [])
|
|
63
|
+
if not choices:
|
|
64
|
+
raise ProviderError("Ollama returned empty response", ErrorKind.SERVER)
|
|
65
|
+
|
|
66
|
+
finish_reason = choices[0].get("finish_reason", "")
|
|
67
|
+
if finish_reason and finish_reason not in ("stop", "length"):
|
|
68
|
+
raise ProviderError(
|
|
69
|
+
f"Ollama stopped: {finish_reason}",
|
|
70
|
+
ErrorKind.SERVER,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return choices[0]["message"]["content"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""OpenAI provider via Chat Completions API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import httpx
|
|
5
|
+
from codereview.provider.base import (
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderError,
|
|
8
|
+
ErrorKind,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAIProvider(Provider):
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return "openai"
|
|
18
|
+
|
|
19
|
+
def _parse_error(self, status: int, body: dict | str) -> ProviderError:
|
|
20
|
+
msg = ""
|
|
21
|
+
if isinstance(body, dict):
|
|
22
|
+
err = body.get("error", {})
|
|
23
|
+
msg = err.get("message", str(body))
|
|
24
|
+
code = err.get("code", "")
|
|
25
|
+
if code == "insufficient_quota":
|
|
26
|
+
if status == 429:
|
|
27
|
+
return ProviderError(f"OpenAI rate/quota: {msg}", ErrorKind.RATE_LIMIT)
|
|
28
|
+
return ProviderError(f"OpenAI quota error (billing): {msg}", ErrorKind.AUTH)
|
|
29
|
+
else:
|
|
30
|
+
msg = str(body)
|
|
31
|
+
|
|
32
|
+
if status == 401:
|
|
33
|
+
return ProviderError(f"OpenAI auth error: {msg}", ErrorKind.AUTH)
|
|
34
|
+
if status == 429:
|
|
35
|
+
return ProviderError(f"OpenAI rate limited: {msg}", ErrorKind.RATE_LIMIT)
|
|
36
|
+
if status >= 500:
|
|
37
|
+
return ProviderError(f"OpenAI server error ({status}): {msg}", ErrorKind.SERVER)
|
|
38
|
+
return ProviderError(f"OpenAI error ({status}): {msg}", ErrorKind.CLIENT)
|
|
39
|
+
|
|
40
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
41
|
+
endpoint = self.endpoint or "https://api.openai.com"
|
|
42
|
+
url = f"{endpoint}/v1/chat/completions"
|
|
43
|
+
|
|
44
|
+
headers = {
|
|
45
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
payload = {
|
|
50
|
+
"model": self.model,
|
|
51
|
+
"max_tokens": self.max_tokens,
|
|
52
|
+
"temperature": 0.2,
|
|
53
|
+
"messages": [
|
|
54
|
+
{"role": "system", "content": system_prompt},
|
|
55
|
+
{"role": "user", "content": user_prompt},
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
60
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
61
|
+
|
|
62
|
+
if resp.status_code != 200:
|
|
63
|
+
try:
|
|
64
|
+
body = resp.json()
|
|
65
|
+
except Exception:
|
|
66
|
+
body = resp.text
|
|
67
|
+
raise self._parse_error(resp.status_code, body)
|
|
68
|
+
|
|
69
|
+
data = resp.json()
|
|
70
|
+
choices = data.get("choices", [])
|
|
71
|
+
if not choices:
|
|
72
|
+
raise ProviderError("OpenAI returned empty response", ErrorKind.SERVER)
|
|
73
|
+
|
|
74
|
+
finish_reason = choices[0].get("finish_reason", "")
|
|
75
|
+
if finish_reason and finish_reason not in ("stop", "length"):
|
|
76
|
+
raise ProviderError(
|
|
77
|
+
f"OpenAI stopped: {finish_reason}",
|
|
78
|
+
ErrorKind.SERVER,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return choices[0]["message"]["content"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Vertex AI provider via Google Cloud platform.
|
|
2
|
+
|
|
3
|
+
Auth via gcloud ADC (Application Default Credentials):
|
|
4
|
+
gcloud auth application-default login
|
|
5
|
+
gcloud config set project YOUR_PROJECT
|
|
6
|
+
|
|
7
|
+
Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import httpx
|
|
13
|
+
from codereview.provider.base import (
|
|
14
|
+
Provider,
|
|
15
|
+
ProviderError,
|
|
16
|
+
ErrorKind,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VertexAIProvider(Provider):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str = "",
|
|
26
|
+
model: str = "gemini-2.5-pro",
|
|
27
|
+
endpoint: str | None = None,
|
|
28
|
+
max_tokens: int = 2048,
|
|
29
|
+
timeout_sec: float = 60.0,
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(api_key, model, endpoint, max_tokens, timeout_sec, max_retries)
|
|
33
|
+
self._project: str = ""
|
|
34
|
+
self._location: str = "us-central1"
|
|
35
|
+
self._credentials: object | None = None
|
|
36
|
+
|
|
37
|
+
if self.endpoint:
|
|
38
|
+
parts = self.endpoint.split(",")
|
|
39
|
+
if len(parts) >= 1 and parts[0].strip():
|
|
40
|
+
self._project = parts[0].strip()
|
|
41
|
+
if len(parts) >= 2 and parts[1].strip():
|
|
42
|
+
self._location = parts[1].strip()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
return "vertexai"
|
|
47
|
+
|
|
48
|
+
async def _get_credentials(self) -> tuple[str, str]:
|
|
49
|
+
if self._credentials is not None and self._project:
|
|
50
|
+
from google.auth.transport.requests import Request
|
|
51
|
+
await asyncio.to_thread(self._credentials.refresh, Request())
|
|
52
|
+
return self._credentials.token, self._project
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from google.auth import default
|
|
56
|
+
from google.auth.transport.requests import Request
|
|
57
|
+
|
|
58
|
+
credentials, project = default()
|
|
59
|
+
if not credentials:
|
|
60
|
+
raise ProviderError(
|
|
61
|
+
"No Google Cloud credentials found. Run: gcloud auth application-default login",
|
|
62
|
+
ErrorKind.AUTH,
|
|
63
|
+
)
|
|
64
|
+
if not project and not self._project:
|
|
65
|
+
raise ProviderError(
|
|
66
|
+
"No GCP project configured. Run: gcloud config set project YOUR_PROJECT",
|
|
67
|
+
ErrorKind.AUTH,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await asyncio.to_thread(credentials.refresh, Request())
|
|
71
|
+
self._credentials = credentials
|
|
72
|
+
if project:
|
|
73
|
+
self._project = project
|
|
74
|
+
|
|
75
|
+
return credentials.token, self._project
|
|
76
|
+
except ProviderError:
|
|
77
|
+
raise
|
|
78
|
+
except ImportError:
|
|
79
|
+
raise ProviderError(
|
|
80
|
+
"google-auth not installed. Run: pip install codereview-mcp[vertexai]",
|
|
81
|
+
ErrorKind.AUTH,
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise ProviderError(
|
|
85
|
+
f"Failed to get GCP credentials: {e}",
|
|
86
|
+
ErrorKind.AUTH,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
|
|
90
|
+
token, project = await self._get_credentials()
|
|
91
|
+
|
|
92
|
+
location = self._location
|
|
93
|
+
url = (
|
|
94
|
+
f"https://{location}-aiplatform.googleapis.com"
|
|
95
|
+
f"/v1/projects/{project}/locations/{location}"
|
|
96
|
+
f"/publishers/google/models/{self.model}:generateContent"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
headers = {
|
|
100
|
+
"Authorization": f"Bearer {token}",
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
payload = {
|
|
105
|
+
"systemInstruction": {"parts": [{"text": system_prompt}]},
|
|
106
|
+
"contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
|
|
107
|
+
"generationConfig": {
|
|
108
|
+
"maxOutputTokens": self.max_tokens,
|
|
109
|
+
"temperature": 0.2,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
|
|
114
|
+
resp = await client.post(url, headers=headers, json=payload)
|
|
115
|
+
|
|
116
|
+
if resp.status_code != 200:
|
|
117
|
+
try:
|
|
118
|
+
body = resp.json()
|
|
119
|
+
msg = body.get("error", {}).get("message", resp.text)
|
|
120
|
+
except Exception:
|
|
121
|
+
msg = resp.text
|
|
122
|
+
|
|
123
|
+
if resp.status_code == 401 or resp.status_code == 403:
|
|
124
|
+
raise ProviderError(f"Vertex AI auth error: {msg}", ErrorKind.AUTH)
|
|
125
|
+
if resp.status_code == 429:
|
|
126
|
+
raise ProviderError("Vertex AI rate limited", ErrorKind.RATE_LIMIT)
|
|
127
|
+
if resp.status_code >= 500:
|
|
128
|
+
raise ProviderError(f"Vertex AI server error ({resp.status_code}): {msg}", ErrorKind.SERVER)
|
|
129
|
+
raise ProviderError(f"Vertex AI error ({resp.status_code}): {msg}", ErrorKind.CLIENT)
|
|
130
|
+
|
|
131
|
+
data = resp.json()
|
|
132
|
+
candidates = data.get("candidates", [])
|
|
133
|
+
if not candidates:
|
|
134
|
+
raise ProviderError("Vertex AI returned empty response", ErrorKind.SERVER)
|
|
135
|
+
|
|
136
|
+
finish_reason = candidates[0].get("finishReason", "")
|
|
137
|
+
if finish_reason and finish_reason not in ("STOP", "MAX_TOKENS"):
|
|
138
|
+
raise ProviderError(
|
|
139
|
+
f"Vertex AI stopped: {finish_reason}",
|
|
140
|
+
ErrorKind.SERVER,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return candidates[0]["content"]["parts"][0]["text"]
|
|
145
|
+
except (KeyError, IndexError, TypeError):
|
|
146
|
+
raise ProviderError(
|
|
147
|
+
"Vertex AI returned unexpected response structure",
|
|
148
|
+
ErrorKind.SERVER,
|
|
149
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""MCP tool registration and handler for code review."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mcp.server import Server
|
|
9
|
+
from mcp.types import Tool, TextContent
|
|
10
|
+
|
|
11
|
+
from codereview.config.config import load_config, Config
|
|
12
|
+
from codereview.prompt.prompt import get_prompt
|
|
13
|
+
from codereview.provider.base import Provider, ReviewResult, ErrorKind
|
|
14
|
+
from codereview.provider.gemini import GeminiProvider
|
|
15
|
+
from codereview.provider.claude import ClaudeProvider
|
|
16
|
+
from codereview.provider.openai import OpenAIProvider
|
|
17
|
+
from codereview.provider.deepseek import DeepSeekProvider
|
|
18
|
+
from codereview.provider.ollama import OllamaProvider
|
|
19
|
+
from codereview.provider.vertexai import VertexAIProvider
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
PROVIDER_MAP: dict[str, type[Provider]] = {
|
|
24
|
+
"gemini": GeminiProvider,
|
|
25
|
+
"claude": ClaudeProvider,
|
|
26
|
+
"openai": OpenAIProvider,
|
|
27
|
+
"deepseek": DeepSeekProvider,
|
|
28
|
+
"vertexai": VertexAIProvider,
|
|
29
|
+
"ollama": OllamaProvider,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
SEVERITY_RE = re.compile(r"\[(CRITICAL|HIGH|MEDIUM|LOW)\]", re.IGNORECASE)
|
|
33
|
+
MAX_CONCURRENT = 5
|
|
34
|
+
_review_semaphore = asyncio.Semaphore(MAX_CONCURRENT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_provider(name: str, cfg: Config) -> Provider | None:
|
|
38
|
+
if name not in cfg.providers:
|
|
39
|
+
logger.warning("Provider '%s' not in config", name)
|
|
40
|
+
return None
|
|
41
|
+
pc = cfg.providers[name]
|
|
42
|
+
cls = PROVIDER_MAP.get(name)
|
|
43
|
+
if cls is None:
|
|
44
|
+
return None
|
|
45
|
+
return cls(
|
|
46
|
+
api_key=pc.api_key,
|
|
47
|
+
model=pc.model or cls.__name__.replace("Provider", "").lower(),
|
|
48
|
+
endpoint=pc.endpoint,
|
|
49
|
+
max_tokens=cfg.defaults.max_tokens,
|
|
50
|
+
timeout_sec=cfg.defaults.timeout_sec,
|
|
51
|
+
max_retries=cfg.defaults.max_retries,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_files(files: list[str]) -> str:
|
|
56
|
+
parts: list[str] = []
|
|
57
|
+
for f in files:
|
|
58
|
+
path = Path(f)
|
|
59
|
+
if path.exists() and path.is_file():
|
|
60
|
+
try:
|
|
61
|
+
content = path.read_text()
|
|
62
|
+
parts.append(f"--- {f} ---\n{content}")
|
|
63
|
+
except UnicodeDecodeError:
|
|
64
|
+
parts.append(f"--- {f} ---\n[BINARY FILE — skipped]")
|
|
65
|
+
logger.warning("Binary file skipped: %s", f)
|
|
66
|
+
else:
|
|
67
|
+
parts.append(f"--- {f} ---\n[FILE NOT FOUND]")
|
|
68
|
+
logger.warning("File not found: %s", f)
|
|
69
|
+
return "\n\n".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _build_user_prompt(
|
|
73
|
+
mode: str,
|
|
74
|
+
files_content: str,
|
|
75
|
+
diff: str = "",
|
|
76
|
+
explanation: str = "",
|
|
77
|
+
focus: str = "all",
|
|
78
|
+
) -> str:
|
|
79
|
+
base = f"Review the following:\n\n{files_content}"
|
|
80
|
+
|
|
81
|
+
if diff:
|
|
82
|
+
base += f"\n\n## Proposed Diff\n\n{diff}"
|
|
83
|
+
|
|
84
|
+
if mode == "second-opinion" and explanation:
|
|
85
|
+
base += f"\n\n## Primary Model's Reasoning\n\n{explanation}"
|
|
86
|
+
base += "\n\nCritique the reasoning above. What did it miss?"
|
|
87
|
+
|
|
88
|
+
if mode == "verify-diff":
|
|
89
|
+
base += "\n\nIs this diff correct? Does it introduce bugs, regressions, or edge case failures?"
|
|
90
|
+
|
|
91
|
+
if focus and focus != "all":
|
|
92
|
+
base += f"\n\nFocus your review on: {focus}."
|
|
93
|
+
|
|
94
|
+
return base
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _count_severities(text: str) -> dict[str, int]:
|
|
98
|
+
counts: dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "total": 0}
|
|
99
|
+
for match in SEVERITY_RE.finditer(text):
|
|
100
|
+
sev = match.group(1).upper()
|
|
101
|
+
counts[sev] += 1
|
|
102
|
+
counts["total"] += 1
|
|
103
|
+
return counts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_single(result: ReviewResult) -> str:
|
|
107
|
+
header = f"## Code Review — {result.provider} ({result.model})"
|
|
108
|
+
timing = f"*{result.latency_ms:.0f}ms*"
|
|
109
|
+
return f"{header} {timing}\n\n{result.findings}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _format_compare(explanation: str, results: list[ReviewResult]) -> str:
|
|
113
|
+
if not results:
|
|
114
|
+
return "No results."
|
|
115
|
+
|
|
116
|
+
lines = ["## Multi-Provider Code Review\n"]
|
|
117
|
+
|
|
118
|
+
if explanation:
|
|
119
|
+
lines.append(f"**Primary reasoning:** {explanation[:300]}\n")
|
|
120
|
+
|
|
121
|
+
# Individual reviews
|
|
122
|
+
for r in results:
|
|
123
|
+
if r.error:
|
|
124
|
+
lines.append(f"### {r.provider} — ERROR ({r.error_kind.value if r.error_kind else 'unknown'})")
|
|
125
|
+
lines.append(f"```\n{r.error}\n```\n")
|
|
126
|
+
else:
|
|
127
|
+
lines.append(f"### {r.provider} ({r.model}) — {r.latency_ms:.0f}ms\n")
|
|
128
|
+
lines.append(r.findings)
|
|
129
|
+
lines.append("")
|
|
130
|
+
|
|
131
|
+
# Severity summary table
|
|
132
|
+
lines.append("---\n")
|
|
133
|
+
lines.append("## Summary\n")
|
|
134
|
+
|
|
135
|
+
sevs: dict[str, dict[str, int]] = {}
|
|
136
|
+
for r in results:
|
|
137
|
+
if not r.error:
|
|
138
|
+
sevs[r.provider] = _count_severities(r.findings)
|
|
139
|
+
|
|
140
|
+
all_providers = list(sevs.keys())
|
|
141
|
+
if all_providers:
|
|
142
|
+
header = "| Provider | CRITICAL | HIGH | MEDIUM | LOW | Total |"
|
|
143
|
+
sep = "|----------|----------|------|--------|-----|-------|"
|
|
144
|
+
lines.append(header)
|
|
145
|
+
lines.append(sep)
|
|
146
|
+
for p in all_providers:
|
|
147
|
+
c = sevs[p]
|
|
148
|
+
row = f"| {p} | {c.get('CRITICAL', 0)} | {c.get('HIGH', 0)} | {c.get('MEDIUM', 0)} | {c.get('LOW', 0)} | {c['total']} |"
|
|
149
|
+
lines.append(row)
|
|
150
|
+
|
|
151
|
+
lines.append("")
|
|
152
|
+
total_issues = sum(sevs[p]["total"] for p in all_providers)
|
|
153
|
+
provider_count = len(all_providers)
|
|
154
|
+
avg = total_issues / provider_count if provider_count > 0 else 0
|
|
155
|
+
lines.append(
|
|
156
|
+
f"**{total_issues} total issues across {provider_count} providers "
|
|
157
|
+
f"(avg {avg:.1f}/provider).** "
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Timing table
|
|
161
|
+
lines.append("\n### Latency")
|
|
162
|
+
lines.append("| Provider | Time |")
|
|
163
|
+
lines.append("|----------|------|")
|
|
164
|
+
for r in results:
|
|
165
|
+
if r.error:
|
|
166
|
+
lines.append(f"| {r.provider} | ⚠ {r.latency_ms:.0f}ms (failed) |")
|
|
167
|
+
else:
|
|
168
|
+
lines.append(f"| {r.provider} | {r.latency_ms:.0f}ms |")
|
|
169
|
+
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _run_single_review(
|
|
174
|
+
mode: str,
|
|
175
|
+
provider_name: str,
|
|
176
|
+
system_prompt: str,
|
|
177
|
+
user_prompt: str,
|
|
178
|
+
cfg: Config,
|
|
179
|
+
) -> ReviewResult:
|
|
180
|
+
provider = _make_provider(provider_name, cfg)
|
|
181
|
+
if provider is None:
|
|
182
|
+
return ReviewResult(
|
|
183
|
+
provider=provider_name,
|
|
184
|
+
model="?",
|
|
185
|
+
error=f"Provider '{provider_name}' not configured. Add it to config.yaml.",
|
|
186
|
+
error_kind=ErrorKind.AUTH,
|
|
187
|
+
)
|
|
188
|
+
logger.info("Running review with %s (mode=%s)", provider_name, mode)
|
|
189
|
+
async with _review_semaphore:
|
|
190
|
+
return await provider.review(system_prompt, user_prompt)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def register_tools(server: Server, config_path: str | None = None) -> None:
|
|
194
|
+
cfg = load_config(config_path)
|
|
195
|
+
|
|
196
|
+
@server.list_tools()
|
|
197
|
+
async def list_tools() -> list[Tool]:
|
|
198
|
+
return [
|
|
199
|
+
Tool(
|
|
200
|
+
name="code_review",
|
|
201
|
+
description=(
|
|
202
|
+
"Review code using a secondary LLM provider. "
|
|
203
|
+
"Supports raw review, diff verification, second-opinion critique, "
|
|
204
|
+
"and multi-provider comparison with severity-tracking matrix."
|
|
205
|
+
),
|
|
206
|
+
inputSchema={
|
|
207
|
+
"type": "object",
|
|
208
|
+
"properties": {
|
|
209
|
+
"files": {
|
|
210
|
+
"type": "array",
|
|
211
|
+
"items": {"type": "string"},
|
|
212
|
+
"description": "File paths or inline code snippets to review.",
|
|
213
|
+
},
|
|
214
|
+
"diff": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"description": "Git diff or proposed change to verify.",
|
|
217
|
+
},
|
|
218
|
+
"explanation": {
|
|
219
|
+
"type": "string",
|
|
220
|
+
"description": "Primary model's reasoning to critique (second-opinion mode).",
|
|
221
|
+
},
|
|
222
|
+
"provider": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"enum": list(PROVIDER_MAP.keys()),
|
|
225
|
+
"description": "Which LLM to use for review. Defaults to config default.",
|
|
226
|
+
},
|
|
227
|
+
"providers": {
|
|
228
|
+
"type": "array",
|
|
229
|
+
"items": {"type": "string", "enum": list(PROVIDER_MAP.keys())},
|
|
230
|
+
"description": "List of providers for compare mode. Runs all concurrently.",
|
|
231
|
+
},
|
|
232
|
+
"mode": {
|
|
233
|
+
"type": "string",
|
|
234
|
+
"enum": ["raw", "verify-diff", "second-opinion", "compare"],
|
|
235
|
+
"description": "Review mode.",
|
|
236
|
+
},
|
|
237
|
+
"focus": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"enum": ["bugs", "security", "performance", "style", "all"],
|
|
240
|
+
"description": "Review focus area.",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
"required": ["files", "mode"],
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
@server.call_tool()
|
|
249
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
250
|
+
if name != "code_review":
|
|
251
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
252
|
+
|
|
253
|
+
files: list[str] = arguments.get("files", [])
|
|
254
|
+
if isinstance(files, str):
|
|
255
|
+
files = [files]
|
|
256
|
+
diff = arguments.get("diff", "")
|
|
257
|
+
explanation = arguments.get("explanation", "")
|
|
258
|
+
provider_name = arguments.get("provider", cfg.default_provider)
|
|
259
|
+
providers_list = arguments.get("providers", [])
|
|
260
|
+
if isinstance(providers_list, str):
|
|
261
|
+
providers_list = [s.strip() for s in providers_list.split(",") if s.strip()]
|
|
262
|
+
mode = arguments.get("mode", "raw")
|
|
263
|
+
focus = arguments.get("focus", "all")
|
|
264
|
+
|
|
265
|
+
if not files:
|
|
266
|
+
return [TextContent(type="text", text="Error: 'files' is required.")]
|
|
267
|
+
|
|
268
|
+
if mode == "compare" and not providers_list:
|
|
269
|
+
return [TextContent(type="text", text="Error: 'providers' list required for compare mode.")]
|
|
270
|
+
|
|
271
|
+
files_content = _read_files(files)
|
|
272
|
+
user_prompt = _build_user_prompt(mode, files_content, diff, explanation, focus)
|
|
273
|
+
|
|
274
|
+
# Compare mode: run multiple providers concurrently
|
|
275
|
+
if mode == "compare" and providers_list:
|
|
276
|
+
tasks = [
|
|
277
|
+
_run_single_review(
|
|
278
|
+
"compare",
|
|
279
|
+
p,
|
|
280
|
+
get_prompt("compare", p),
|
|
281
|
+
user_prompt,
|
|
282
|
+
cfg,
|
|
283
|
+
)
|
|
284
|
+
for p in providers_list
|
|
285
|
+
]
|
|
286
|
+
results: list[ReviewResult] = await asyncio.gather(*tasks, return_exceptions=True)
|
|
287
|
+
parsed: list[ReviewResult] = []
|
|
288
|
+
for r in results:
|
|
289
|
+
if isinstance(r, Exception):
|
|
290
|
+
parsed.append(ReviewResult(
|
|
291
|
+
provider="unknown",
|
|
292
|
+
model="?",
|
|
293
|
+
error=str(r),
|
|
294
|
+
))
|
|
295
|
+
else:
|
|
296
|
+
parsed.append(r)
|
|
297
|
+
output = _format_compare(explanation, parsed)
|
|
298
|
+
return [TextContent(type="text", text=output)]
|
|
299
|
+
|
|
300
|
+
# Single-provider mode
|
|
301
|
+
system_prompt = get_prompt(mode, provider_name)
|
|
302
|
+
result = await _run_single_review(mode, provider_name, system_prompt, user_prompt, cfg)
|
|
303
|
+
|
|
304
|
+
if result.error:
|
|
305
|
+
error_text = (
|
|
306
|
+
f"## Code Review — ERROR\n\n"
|
|
307
|
+
f"**Provider:** {result.provider}\n"
|
|
308
|
+
f"**Error:** {result.error}\n"
|
|
309
|
+
)
|
|
310
|
+
if result.error_kind == ErrorKind.AUTH:
|
|
311
|
+
error_text += "\nCheck your API key in `~/.config/codereview/config.yaml`."
|
|
312
|
+
elif result.error_kind == ErrorKind.RATE_LIMIT:
|
|
313
|
+
error_text += "\nRate limited. Wait and retry."
|
|
314
|
+
return [TextContent(type="text", text=error_text)]
|
|
315
|
+
|
|
316
|
+
return [TextContent(type="text", text=_format_single(result))]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secondlook
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-provider code review via MCP. Get a second look on your code from any LLM.
|
|
5
|
+
Author: Cleverson Lopes Ledur
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ai,claude,code-review,deepseek,gemini,llm,mcp,multi-provider,openai,second-opinion
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: httpx>=0.28
|
|
15
|
+
Requires-Dist: mcp>=1.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.12; extra == 'dev'
|
|
21
|
+
Provides-Extra: vertexai
|
|
22
|
+
Requires-Dist: google-auth>=2.0; extra == 'vertexai'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# secondlook
|
|
26
|
+
|
|
27
|
+
Multi-provider code review via MCP. Get a second look on your code from any LLM.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uvx secondlook
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Add to Claude Code / Cursor MCP config:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"codereview": {
|
|
41
|
+
"command": "uvx",
|
|
42
|
+
"args": ["secondlook"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Create `~/.config/codereview/config.yaml`:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
default_provider: gemini
|
|
54
|
+
|
|
55
|
+
providers:
|
|
56
|
+
gemini:
|
|
57
|
+
model: gemini-2.5-pro
|
|
58
|
+
api_key: ${GEMINI_API_KEY}
|
|
59
|
+
claude:
|
|
60
|
+
model: claude-sonnet-4-20250514
|
|
61
|
+
api_key: ${ANTHROPIC_API_KEY}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then in your editor:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
> code_review files:["src/auth.go"] provider:"gemini"
|
|
68
|
+
> code_review files:["src/auth.go"] providers:["gemini","claude","deepseek"] mode:"compare"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Modes
|
|
72
|
+
|
|
73
|
+
| Mode | Use case |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `raw` | Fresh review from a secondary LLM |
|
|
76
|
+
| `verify-diff` | "Is Claude's proposed change correct?" |
|
|
77
|
+
| `second-opinion` | "Critique Claude's reasoning on this fix" |
|
|
78
|
+
| `compare` | Run 2+ models, merge findings, show agreement matrix |
|
|
79
|
+
|
|
80
|
+
## Providers
|
|
81
|
+
|
|
82
|
+
Gemini | Claude | OpenAI | DeepSeek | Vertex AI | Ollama (local)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
codereview/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
codereview/main.py,sha256=L_ZaWtaGB9Riiuxism8e47isxaVjpAb6VKtBm9NfopU,953
|
|
3
|
+
codereview/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
codereview/config/config.py,sha256=nKTw30awgfLbar7aL-_1G7qJGGMBjf7ESfsTlVXPpRc,2020
|
|
5
|
+
codereview/prompt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
codereview/prompt/prompt.py,sha256=X_CQhOYwYnOmPu7PcpGcwBS7rFTJ79hLVvdZGvQr3g0,1621
|
|
7
|
+
codereview/provider/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
codereview/provider/base.py,sha256=t-CD4_F5C_H7NUNINPl8uqf5eNUbXYWswZ1gAm94LHk,4266
|
|
9
|
+
codereview/provider/claude.py,sha256=NOx9wBDE3U8mytf67YFGfSAj-m5C17_ZZBz55IFB-SU,2650
|
|
10
|
+
codereview/provider/deepseek.py,sha256=dIV0hKEUI1X0XlHPJOOppI_Dmtiyjzx05WitjL7-6Ug,2661
|
|
11
|
+
codereview/provider/gemini.py,sha256=0ob5Bo1_Lc5E4vNr-JM2ZEyuowbEDYBAWNAELvEmEos,2726
|
|
12
|
+
codereview/provider/ollama.py,sha256=YbsTbGzJrg6YFgGatyfZRDwkfydQsXA_tGtsJk4jFmU,2508
|
|
13
|
+
codereview/provider/openai.py,sha256=5DdV7YGTq9p8db6S8Fjin7U_CJuzOCt_H4q3ie6vCYc,2822
|
|
14
|
+
codereview/provider/vertexai.py,sha256=XwPIVeJBD3MhTGKm9C_Z_kwFgpqRWL90J2Ky8mB8jaU,5422
|
|
15
|
+
codereview/tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
codereview/tool/review.py,sha256=LkTA4GuWEhY210Z_Ks02V3Ox-hSyQ129hBtxzLa7MTc,11723
|
|
17
|
+
secondlook-0.1.0.dist-info/METADATA,sha256=afGl-AAnuI_D687CQajTbttEGonuUk73KOF_J51iOVU,1976
|
|
18
|
+
secondlook-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
19
|
+
secondlook-0.1.0.dist-info/entry_points.txt,sha256=nX04tGYhvgrGojCDZNk-A2ZHmTkDxRXjgz5FdCVeDZM,52
|
|
20
|
+
secondlook-0.1.0.dist-info/RECORD,,
|