diffrag 0.2.3__tar.gz → 0.3.0__tar.gz
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.
- {diffrag-0.2.3 → diffrag-0.3.0}/PKG-INFO +1 -1
- {diffrag-0.2.3 → diffrag-0.3.0}/pyproject.toml +1 -1
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/openai_compat.py +54 -31
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/cli/main.py +1 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/config/settings.py +7 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/data/config.example.toml +4 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/reviewer.py +25 -6
- {diffrag-0.2.3 → diffrag-0.3.0}/LICENSE +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/README.md +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/__main__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/base.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/ollama.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/cli/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/config/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/git.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/models.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/exceptions.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/indexer.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/splitter.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/py.typed +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/__init__.py +0 -0
- {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/prompts.py +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""OpenAI-compatible REST client (works with OpenWebUI, Ollama /v1, OpenAI, etc.)."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
6
|
+
import random
|
|
5
7
|
|
|
6
8
|
import httpx
|
|
7
9
|
|
|
@@ -10,6 +12,9 @@ from .base import ChatMessage, CompletionResponse, EmbeddingResponse
|
|
|
10
12
|
|
|
11
13
|
log = logging.getLogger(__name__)
|
|
12
14
|
|
|
15
|
+
_MAX_RETRIES = 4
|
|
16
|
+
_RETRY_BASE_DELAY = 1.0
|
|
17
|
+
|
|
13
18
|
|
|
14
19
|
class OpenAICompatClient:
|
|
15
20
|
"""HTTP client for any OpenAI-compatible ``/chat/completions`` + ``/embeddings`` API.
|
|
@@ -86,37 +91,55 @@ class OpenAICompatClient:
|
|
|
86
91
|
content = ""
|
|
87
92
|
model = self._model
|
|
88
93
|
prompt_tokens = completion_tokens = 0
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if not
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
94
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
95
|
+
content = ""
|
|
96
|
+
model = self._model
|
|
97
|
+
prompt_tokens = completion_tokens = 0
|
|
98
|
+
try:
|
|
99
|
+
async with (
|
|
100
|
+
httpx.AsyncClient(timeout=self._timeout) as client,
|
|
101
|
+
client.stream("POST", url, json=payload, headers=self._headers()) as response,
|
|
102
|
+
):
|
|
103
|
+
if not response.is_success:
|
|
104
|
+
await response.aread()
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
async for line in response.aiter_lines():
|
|
107
|
+
if not line.startswith("data: "):
|
|
108
|
+
continue
|
|
109
|
+
chunk_str = line[6:]
|
|
110
|
+
if chunk_str == "[DONE]":
|
|
111
|
+
break
|
|
112
|
+
try:
|
|
113
|
+
chunk = json.loads(chunk_str)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
model = chunk.get("model", model)
|
|
117
|
+
for choice in chunk.get("choices", []):
|
|
118
|
+
content += choice.get("delta", {}).get("content", "")
|
|
119
|
+
if usage := chunk.get("usage"):
|
|
120
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
121
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
122
|
+
break # success
|
|
123
|
+
except httpx.HTTPStatusError as exc:
|
|
124
|
+
if exc.response.status_code == 429 and attempt < _MAX_RETRIES:
|
|
125
|
+
delay = float(
|
|
126
|
+
exc.response.headers.get("retry-after", _RETRY_BASE_DELAY * 2**attempt)
|
|
127
|
+
)
|
|
128
|
+
delay += random.uniform(0, delay * 0.1)
|
|
129
|
+
log.warning(
|
|
130
|
+
"Rate-limited (429). Waiting %.1f s before retry %d/%d",
|
|
131
|
+
delay,
|
|
132
|
+
attempt + 1,
|
|
133
|
+
_MAX_RETRIES,
|
|
134
|
+
)
|
|
135
|
+
await asyncio.sleep(delay)
|
|
136
|
+
else:
|
|
137
|
+
raise AIClientError(
|
|
138
|
+
f"API request to {url} failed with status {exc.response.status_code}: "
|
|
139
|
+
f"{exc.response.text[:200]}"
|
|
140
|
+
) from exc
|
|
141
|
+
except httpx.RequestError as exc:
|
|
142
|
+
raise AIClientError(f"Network error reaching {url}: {exc}") from exc
|
|
120
143
|
|
|
121
144
|
return CompletionResponse(
|
|
122
145
|
content=content,
|
|
@@ -117,18 +117,25 @@ class ReviewSettings:
|
|
|
117
117
|
max_prompt_tokens: Approximate token budget per diff chunk before splitting.
|
|
118
118
|
verbosity: Detail level of generated reviews. One of ``"brief"``,
|
|
119
119
|
``"standard"`` (default), or ``"detailed"``.
|
|
120
|
+
concurrency: Maximum number of file reviews sent to the AI concurrently.
|
|
121
|
+
Higher values reduce wall-clock time but increase peak API load.
|
|
120
122
|
"""
|
|
121
123
|
|
|
122
124
|
base_branch: str = "main"
|
|
123
125
|
similarity_top_k: int = 5
|
|
124
126
|
max_prompt_tokens: int = 6000
|
|
125
127
|
verbosity: str = "standard"
|
|
128
|
+
concurrency: int = 3
|
|
126
129
|
|
|
127
130
|
def __post_init__(self) -> None:
|
|
128
131
|
if self.verbosity not in ("brief", "standard", "detailed"):
|
|
129
132
|
raise ConfigurationError(
|
|
130
133
|
f"Invalid verbosity {self.verbosity!r}. Choose 'brief', 'standard', or 'detailed'."
|
|
131
134
|
)
|
|
135
|
+
if self.concurrency < 1:
|
|
136
|
+
raise ConfigurationError(
|
|
137
|
+
f"Invalid concurrency {self.concurrency!r}. Must be 1 or greater."
|
|
138
|
+
)
|
|
132
139
|
|
|
133
140
|
|
|
134
141
|
@dataclass
|
|
@@ -51,3 +51,7 @@ similarity_top_k = 5
|
|
|
51
51
|
max_prompt_tokens = 6000
|
|
52
52
|
# Detail level of generated reviews: "brief" | "standard" | "detailed"
|
|
53
53
|
verbosity = "standard"
|
|
54
|
+
# Maximum number of file reviews sent to the AI simultaneously.
|
|
55
|
+
# Higher values reduce wall-clock time; lower values reduce peak API load.
|
|
56
|
+
# Set to 1 to restore the original sequential behaviour.
|
|
57
|
+
concurrency = 3
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""CodeReviewer: orchestrates diff extraction, RAG retrieval, and LLM review."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from dataclasses import dataclass, field
|
|
@@ -79,6 +80,7 @@ class CodeReviewer:
|
|
|
79
80
|
indexer: Vector index for context retrieval.
|
|
80
81
|
similarity_top_k: Number of context chunks retrieved per diff chunk.
|
|
81
82
|
max_prompt_tokens: Approximate token budget per diff before hunk-splitting.
|
|
83
|
+
concurrency: Maximum number of file reviews sent to the AI simultaneously.
|
|
82
84
|
"""
|
|
83
85
|
|
|
84
86
|
def __init__(
|
|
@@ -88,12 +90,14 @@ class CodeReviewer:
|
|
|
88
90
|
similarity_top_k: int = 5,
|
|
89
91
|
max_prompt_tokens: int = 6000,
|
|
90
92
|
verbosity: str = "standard",
|
|
93
|
+
concurrency: int = 3,
|
|
91
94
|
) -> None:
|
|
92
95
|
self._ai = ai_client
|
|
93
96
|
self._indexer = indexer
|
|
94
97
|
self._top_k = similarity_top_k
|
|
95
98
|
self._max_prompt_tokens = max_prompt_tokens
|
|
96
99
|
self._verbosity = verbosity
|
|
100
|
+
self._concurrency = concurrency
|
|
97
101
|
|
|
98
102
|
# ------------------------------------------------------------------
|
|
99
103
|
# Public API
|
|
@@ -157,13 +161,28 @@ class CodeReviewer:
|
|
|
157
161
|
repo.path, on_progress=on_progress, commit_hash=commit_hash
|
|
158
162
|
)
|
|
159
163
|
|
|
164
|
+
completed = 0
|
|
165
|
+
sem = asyncio.Semaphore(self._concurrency)
|
|
166
|
+
|
|
167
|
+
async def _bounded(fd: FileDiff) -> list[FileReview]:
|
|
168
|
+
nonlocal completed
|
|
169
|
+
async with sem:
|
|
170
|
+
_p(f"Reviewing {fd.path}...")
|
|
171
|
+
log.info("Reviewing %s (%s)", fd.path, fd.status)
|
|
172
|
+
result = await self._review_file(fd)
|
|
173
|
+
completed += 1
|
|
174
|
+
_p(f"Reviewed {fd.path} ({completed}/{n})")
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
gathered = await asyncio.gather(
|
|
178
|
+
*[_bounded(fd) for fd in diff_result.file_diffs],
|
|
179
|
+
return_exceptions=True,
|
|
180
|
+
)
|
|
160
181
|
file_reviews: list[FileReview] = []
|
|
161
|
-
for
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
file_reviews.extend(reviews)
|
|
166
|
-
_p(f"Reviewed {file_diff.path} ({i}/{n})")
|
|
182
|
+
for r in gathered:
|
|
183
|
+
if isinstance(r, BaseException):
|
|
184
|
+
raise r
|
|
185
|
+
file_reviews.extend(r)
|
|
167
186
|
|
|
168
187
|
if n > 1:
|
|
169
188
|
_p("Consolidating review...")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|