diffrag 0.2.3__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.
diffrag/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """diffrag — AI-powered code review with RAG for git diffs.
2
+
3
+ Public API surface:
4
+
5
+ from diffrag import (
6
+ Settings,
7
+ GitRepository,
8
+ DiffResult,
9
+ FileDiff,
10
+ Hunk,
11
+ OpenAICompatClient,
12
+ OllamaClient,
13
+ RepoIndexer,
14
+ CodeReviewer,
15
+ CodeReview,
16
+ FileReview,
17
+ )
18
+ """
19
+
20
+ from .ai.base import AIClient, ChatMessage, CompletionResponse, EmbeddingClient, EmbeddingResponse
21
+ from .ai.ollama import OllamaClient
22
+ from .ai.openai_compat import OpenAICompatClient
23
+ from .config.settings import (
24
+ AISettings,
25
+ EmbeddingSettings,
26
+ IndexingSettings,
27
+ ReviewSettings,
28
+ Settings,
29
+ default_db_path,
30
+ user_config_path,
31
+ )
32
+ from .diff.git import GitRepository
33
+ from .diff.models import DiffResult, FileDiff, Hunk
34
+ from .exceptions import (
35
+ AIClientError,
36
+ AICodeReviewerError,
37
+ ConfigurationError,
38
+ GitError,
39
+ IndexingError,
40
+ )
41
+ from .indexing.indexer import ContextChunk, RepoIndexer
42
+ from .indexing.splitter import (
43
+ EXTENSION_MAP,
44
+ LANGUAGE_SPLITTERS,
45
+ CodeSplitter,
46
+ LanguageSplitter,
47
+ PythonASTSplitter,
48
+ SplitChunk,
49
+ )
50
+ from .review.reviewer import CodeReview, CodeReviewer, FileReview
51
+
52
+ try:
53
+ from importlib.metadata import PackageNotFoundError
54
+ from importlib.metadata import version as _version
55
+
56
+ __version__ = _version("diffrag")
57
+ except PackageNotFoundError:
58
+ __version__ = "unknown"
59
+
60
+ __all__ = [
61
+ # config
62
+ "AISettings",
63
+ "default_db_path",
64
+ "EmbeddingSettings",
65
+ "IndexingSettings",
66
+ "ReviewSettings",
67
+ "Settings",
68
+ "user_config_path",
69
+ # diff
70
+ "DiffResult",
71
+ "FileDiff",
72
+ "GitRepository",
73
+ "Hunk",
74
+ # ai
75
+ "AIClient",
76
+ "AIClientError",
77
+ "ChatMessage",
78
+ "CompletionResponse",
79
+ "EmbeddingClient",
80
+ "EmbeddingResponse",
81
+ "OllamaClient",
82
+ "OpenAICompatClient",
83
+ # indexing
84
+ "CodeSplitter",
85
+ "ContextChunk",
86
+ "EXTENSION_MAP",
87
+ "LANGUAGE_SPLITTERS",
88
+ "LanguageSplitter",
89
+ "PythonASTSplitter",
90
+ "RepoIndexer",
91
+ "SplitChunk",
92
+ # review
93
+ "CodeReview",
94
+ "CodeReviewer",
95
+ "FileReview",
96
+ # exceptions
97
+ "AICodeReviewerError",
98
+ "ConfigurationError",
99
+ "GitError",
100
+ "IndexingError",
101
+ ]
diffrag/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from diffrag.cli.main import cli
2
+
3
+ cli()
diffrag/ai/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """AI client protocols and concrete implementations."""
2
+
3
+ from .base import AIClient, ChatMessage, CompletionResponse, EmbeddingClient, EmbeddingResponse
4
+ from .ollama import OllamaClient
5
+ from .openai_compat import OpenAICompatClient
6
+
7
+ __all__ = [
8
+ "AIClient",
9
+ "ChatMessage",
10
+ "CompletionResponse",
11
+ "EmbeddingClient",
12
+ "EmbeddingResponse",
13
+ "OllamaClient",
14
+ "OpenAICompatClient",
15
+ ]
diffrag/ai/base.py ADDED
@@ -0,0 +1,106 @@
1
+ """Protocol definitions and shared data models for AI clients."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol, runtime_checkable
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ChatMessage:
9
+ """A single message in a chat conversation.
10
+
11
+ Attributes:
12
+ role: Speaker role — one of ``"system"``, ``"user"``, or ``"assistant"``.
13
+ content: Text content of the message.
14
+ """
15
+
16
+ role: str
17
+ content: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CompletionResponse:
22
+ """Response returned by a language model completion call.
23
+
24
+ Attributes:
25
+ content: Generated text from the model.
26
+ model: Model identifier reported by the server.
27
+ prompt_tokens: Number of tokens consumed by the prompt.
28
+ completion_tokens: Number of tokens in the generated response.
29
+ """
30
+
31
+ content: str
32
+ model: str
33
+ prompt_tokens: int
34
+ completion_tokens: int
35
+
36
+ @property
37
+ def total_tokens(self) -> int:
38
+ """Total tokens used (prompt + completion).
39
+
40
+ Returns:
41
+ Sum of prompt and completion token counts.
42
+ """
43
+ return self.prompt_tokens + self.completion_tokens
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class EmbeddingResponse:
48
+ """Response returned by an embedding model call.
49
+
50
+ Attributes:
51
+ embeddings: List of embedding vectors, one per input text.
52
+ model: Model identifier reported by the server.
53
+ total_tokens: Number of tokens processed.
54
+ """
55
+
56
+ embeddings: list[list[float]]
57
+ model: str
58
+ total_tokens: int
59
+
60
+
61
+ @runtime_checkable
62
+ class AIClient(Protocol):
63
+ """Protocol for language model completion clients.
64
+
65
+ Any object implementing this protocol can be used as the AI backend
66
+ for generating reviews and answers.
67
+ """
68
+
69
+ async def complete(
70
+ self,
71
+ messages: list[ChatMessage],
72
+ *,
73
+ temperature: float = 0.2,
74
+ max_tokens: int | None = None,
75
+ ) -> CompletionResponse:
76
+ """Send a chat completion request.
77
+
78
+ Args:
79
+ messages: Ordered list of messages forming the conversation.
80
+ temperature: Sampling temperature (0 = deterministic).
81
+ max_tokens: Maximum number of tokens to generate; ``None`` = model default.
82
+
83
+ Returns:
84
+ A :class:`CompletionResponse` with the generated text and usage info.
85
+ """
86
+ ...
87
+
88
+
89
+ @runtime_checkable
90
+ class EmbeddingClient(Protocol):
91
+ """Protocol for text embedding clients.
92
+
93
+ Any object implementing this protocol can be used to build and query
94
+ the vector index.
95
+ """
96
+
97
+ async def embed(self, texts: list[str]) -> EmbeddingResponse:
98
+ """Compute embeddings for a list of texts.
99
+
100
+ Args:
101
+ texts: Non-empty list of strings to embed.
102
+
103
+ Returns:
104
+ An :class:`EmbeddingResponse` with one vector per input text.
105
+ """
106
+ ...
diffrag/ai/ollama.py ADDED
@@ -0,0 +1,144 @@
1
+ """Native Ollama REST client using /api/chat and /api/embed."""
2
+
3
+ import logging
4
+
5
+ import httpx
6
+
7
+ from ..exceptions import AIClientError
8
+ from .base import ChatMessage, CompletionResponse, EmbeddingResponse
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class OllamaClient:
14
+ """Client for the native Ollama REST API (``/api/chat`` and ``/api/embed``).
15
+
16
+ Use this client when you need Ollama-specific parameters (e.g. ``num_ctx``).
17
+ For endpoints that expose an OpenAI-compatible ``/v1`` prefix, prefer
18
+ :class:`~diffrag.ai.openai_compat.OpenAICompatClient` instead.
19
+
20
+ Args:
21
+ base_url: URL of the Ollama server (default: ``http://localhost:11434``).
22
+ model: Model tag to use for requests.
23
+ timeout: Per-request timeout in seconds.
24
+ num_ctx: Context window size passed via Ollama's ``options`` field.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str = "http://localhost:11434",
30
+ model: str = "llama3.2",
31
+ timeout: float = 600.0,
32
+ num_ctx: int | None = None,
33
+ ) -> None:
34
+ self._base_url = base_url.rstrip("/")
35
+ self._model = model
36
+ self._timeout = timeout
37
+ self._num_ctx = num_ctx
38
+
39
+ # ------------------------------------------------------------------
40
+ # AIClient protocol
41
+ # ------------------------------------------------------------------
42
+
43
+ async def complete(
44
+ self,
45
+ messages: list[ChatMessage],
46
+ *,
47
+ temperature: float = 0.2,
48
+ max_tokens: int | None = None,
49
+ ) -> CompletionResponse:
50
+ """Send a chat completion request to Ollama's ``/api/chat`` endpoint.
51
+
52
+ Args:
53
+ messages: Ordered list of messages forming the conversation.
54
+ temperature: Sampling temperature.
55
+ max_tokens: Maximum tokens to generate (mapped to ``num_predict``).
56
+
57
+ Returns:
58
+ A :class:`CompletionResponse` with the generated text and usage info.
59
+
60
+ Raises:
61
+ AIClientError: If the HTTP request fails or returns a non-2xx status.
62
+ """
63
+ options: dict = {"temperature": temperature}
64
+ if self._num_ctx is not None:
65
+ options["num_ctx"] = self._num_ctx
66
+ if max_tokens is not None:
67
+ options["num_predict"] = max_tokens
68
+
69
+ payload = {
70
+ "model": self._model,
71
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
72
+ "stream": False,
73
+ "options": options,
74
+ }
75
+
76
+ log.debug("POST %s/api/chat model=%s", self._base_url, self._model)
77
+
78
+ data = await self._post("/api/chat", payload)
79
+
80
+ return CompletionResponse(
81
+ content=data["message"]["content"],
82
+ model=data.get("model", self._model),
83
+ prompt_tokens=data.get("prompt_eval_count", 0),
84
+ completion_tokens=data.get("eval_count", 0),
85
+ )
86
+
87
+ # ------------------------------------------------------------------
88
+ # EmbeddingClient protocol
89
+ # ------------------------------------------------------------------
90
+
91
+ async def embed(self, texts: list[str]) -> EmbeddingResponse:
92
+ """Compute embeddings via Ollama's ``/api/embed`` endpoint.
93
+
94
+ Args:
95
+ texts: Non-empty list of strings to embed.
96
+
97
+ Returns:
98
+ An :class:`EmbeddingResponse` with one vector per input text.
99
+
100
+ Raises:
101
+ AIClientError: If the HTTP request fails or returns a non-2xx status.
102
+ """
103
+ payload = {"model": self._model, "input": texts}
104
+
105
+ log.debug("POST %s/api/embed model=%s n=%d", self._base_url, self._model, len(texts))
106
+
107
+ data = await self._post("/api/embed", payload)
108
+
109
+ return EmbeddingResponse(
110
+ embeddings=data["embeddings"],
111
+ model=data.get("model", self._model),
112
+ total_tokens=0,
113
+ )
114
+
115
+ # ------------------------------------------------------------------
116
+ # Internal helpers
117
+ # ------------------------------------------------------------------
118
+
119
+ async def _post(self, path: str, payload: dict) -> dict:
120
+ """Execute a POST request and return the parsed JSON body.
121
+
122
+ Args:
123
+ path: URL path relative to ``base_url`` (must start with ``/``).
124
+ payload: JSON-serialisable request body.
125
+
126
+ Returns:
127
+ Parsed JSON response as a dict.
128
+
129
+ Raises:
130
+ AIClientError: On network errors or non-2xx HTTP responses.
131
+ """
132
+ url = f"{self._base_url}{path}"
133
+ try:
134
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
135
+ response = await client.post(url, json=payload)
136
+ response.raise_for_status()
137
+ return response.json()
138
+ except httpx.HTTPStatusError as exc:
139
+ raise AIClientError(
140
+ f"Ollama request to {url} failed with status {exc.response.status_code}: "
141
+ f"{exc.response.text[:200]}"
142
+ ) from exc
143
+ except httpx.RequestError as exc:
144
+ raise AIClientError(f"Network error reaching {url}: {exc}") from exc
@@ -0,0 +1,193 @@
1
+ """OpenAI-compatible REST client (works with OpenWebUI, Ollama /v1, OpenAI, etc.)."""
2
+
3
+ import json
4
+ import logging
5
+
6
+ import httpx
7
+
8
+ from ..exceptions import AIClientError
9
+ from .base import ChatMessage, CompletionResponse, EmbeddingResponse
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class OpenAICompatClient:
15
+ """HTTP client for any OpenAI-compatible ``/chat/completions`` + ``/embeddings`` API.
16
+
17
+ A single instance can be used for both completion and embedding requests.
18
+ Point it at OpenWebUI, Ollama's ``/v1`` prefix, or the real OpenAI endpoint.
19
+
20
+ Args:
21
+ base_url: Root URL of the API (e.g. ``http://localhost:3000``).
22
+ The ``/v1`` prefix should **not** be included; it is appended automatically.
23
+ model: Default model name sent with every request.
24
+ api_key: Bearer token for the ``Authorization`` header. Empty string disables auth.
25
+ timeout: Per-request timeout in seconds.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ base_url: str,
31
+ model: str,
32
+ api_key: str = "",
33
+ timeout: float = 120.0,
34
+ ) -> None:
35
+ self._base_url = base_url.rstrip("/")
36
+ self._model = model
37
+ self._api_key = api_key
38
+ self._timeout = timeout
39
+
40
+ # ------------------------------------------------------------------
41
+ # AIClient protocol
42
+ # ------------------------------------------------------------------
43
+
44
+ async def complete(
45
+ self,
46
+ messages: list[ChatMessage],
47
+ *,
48
+ temperature: float = 0.2,
49
+ max_tokens: int | None = None,
50
+ ) -> CompletionResponse:
51
+ """Send a chat completion request using streaming to avoid gateway timeouts.
52
+
53
+ The request uses ``stream: true`` so the gateway receives bytes as soon as
54
+ the model starts generating, preventing idle-timeout 504 errors. The full
55
+ response is accumulated internally; callers receive a single
56
+ :class:`CompletionResponse` as before.
57
+
58
+ Usage statistics are requested via ``stream_options: {include_usage: true}``.
59
+ Servers that do not support this option silently omit usage; token counts
60
+ default to 0 in that case.
61
+
62
+ Args:
63
+ messages: Ordered list of messages forming the conversation.
64
+ temperature: Sampling temperature.
65
+ max_tokens: Maximum tokens to generate; ``None`` uses the model default.
66
+
67
+ Returns:
68
+ A :class:`CompletionResponse` with the generated text and usage info.
69
+
70
+ Raises:
71
+ AIClientError: If the HTTP request fails or returns a non-2xx status.
72
+ """
73
+ url = f"{self._base_url}/v1/chat/completions"
74
+ payload: dict = {
75
+ "model": self._model,
76
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
77
+ "temperature": temperature,
78
+ "stream": True,
79
+ "stream_options": {"include_usage": True},
80
+ }
81
+ if max_tokens is not None:
82
+ payload["max_tokens"] = max_tokens
83
+
84
+ log.debug("POST %s/v1/chat/completions model=%s (streaming)", self._base_url, self._model)
85
+
86
+ content = ""
87
+ model = self._model
88
+ prompt_tokens = completion_tokens = 0
89
+ try:
90
+ async with (
91
+ httpx.AsyncClient(timeout=self._timeout) as client,
92
+ client.stream("POST", url, json=payload, headers=self._headers()) as response,
93
+ ):
94
+ if not response.is_success:
95
+ await response.aread()
96
+ response.raise_for_status()
97
+ async for line in response.aiter_lines():
98
+ if not line.startswith("data: "):
99
+ continue
100
+ chunk_str = line[6:]
101
+ if chunk_str == "[DONE]":
102
+ break
103
+ try:
104
+ chunk = json.loads(chunk_str)
105
+ except json.JSONDecodeError:
106
+ continue
107
+ model = chunk.get("model", model)
108
+ for choice in chunk.get("choices", []):
109
+ content += choice.get("delta", {}).get("content", "")
110
+ if usage := chunk.get("usage"):
111
+ prompt_tokens = usage.get("prompt_tokens", 0)
112
+ completion_tokens = usage.get("completion_tokens", 0)
113
+ except httpx.HTTPStatusError as exc:
114
+ raise AIClientError(
115
+ f"API request to {url} failed with status {exc.response.status_code}: "
116
+ f"{exc.response.text[:200]}"
117
+ ) from exc
118
+ except httpx.RequestError as exc:
119
+ raise AIClientError(f"Network error reaching {url}: {exc}") from exc
120
+
121
+ return CompletionResponse(
122
+ content=content,
123
+ model=model,
124
+ prompt_tokens=prompt_tokens,
125
+ completion_tokens=completion_tokens,
126
+ )
127
+
128
+ # ------------------------------------------------------------------
129
+ # EmbeddingClient protocol
130
+ # ------------------------------------------------------------------
131
+
132
+ async def embed(self, texts: list[str]) -> EmbeddingResponse:
133
+ """Compute embeddings for a list of texts.
134
+
135
+ Args:
136
+ texts: Non-empty list of strings to embed.
137
+
138
+ Returns:
139
+ An :class:`EmbeddingResponse` with one vector per input text.
140
+
141
+ Raises:
142
+ AIClientError: If the HTTP request fails or returns a non-2xx status.
143
+ """
144
+ payload = {"model": self._model, "input": texts}
145
+
146
+ log.debug("POST %s/v1/embeddings model=%s n=%d", self._base_url, self._model, len(texts))
147
+
148
+ data = await self._post("/v1/embeddings", payload)
149
+ embeddings = [item["embedding"] for item in data["data"]]
150
+ usage = data.get("usage", {})
151
+
152
+ return EmbeddingResponse(
153
+ embeddings=embeddings,
154
+ model=data.get("model", self._model),
155
+ total_tokens=usage.get("total_tokens", 0),
156
+ )
157
+
158
+ # ------------------------------------------------------------------
159
+ # Internal helpers
160
+ # ------------------------------------------------------------------
161
+
162
+ def _headers(self) -> dict[str, str]:
163
+ headers: dict[str, str] = {"Content-Type": "application/json"}
164
+ if self._api_key:
165
+ headers["Authorization"] = f"Bearer {self._api_key}"
166
+ return headers
167
+
168
+ async def _post(self, path: str, payload: dict) -> dict:
169
+ """Execute a POST request and return the parsed JSON body.
170
+
171
+ Args:
172
+ path: URL path relative to ``base_url`` (must start with ``/``).
173
+ payload: JSON-serialisable request body.
174
+
175
+ Returns:
176
+ Parsed JSON response as a dict.
177
+
178
+ Raises:
179
+ AIClientError: On network errors or non-2xx HTTP responses.
180
+ """
181
+ url = f"{self._base_url}{path}"
182
+ try:
183
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
184
+ response = await client.post(url, json=payload, headers=self._headers())
185
+ response.raise_for_status()
186
+ return response.json()
187
+ except httpx.HTTPStatusError as exc:
188
+ raise AIClientError(
189
+ f"API request to {url} failed with status {exc.response.status_code}: "
190
+ f"{exc.response.text[:200]}"
191
+ ) from exc
192
+ except httpx.RequestError as exc:
193
+ raise AIClientError(f"Network error reaching {url}: {exc}") from exc
@@ -0,0 +1,5 @@
1
+ """Click-based command-line interface."""
2
+
3
+ from .main import cli
4
+
5
+ __all__ = ["cli"]