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 +101 -0
- diffrag/__main__.py +3 -0
- diffrag/ai/__init__.py +15 -0
- diffrag/ai/base.py +106 -0
- diffrag/ai/ollama.py +144 -0
- diffrag/ai/openai_compat.py +193 -0
- diffrag/cli/__init__.py +5 -0
- diffrag/cli/main.py +440 -0
- diffrag/config/__init__.py +21 -0
- diffrag/config/settings.py +190 -0
- diffrag/data/config.example.toml +53 -0
- diffrag/diff/__init__.py +11 -0
- diffrag/diff/git.py +227 -0
- diffrag/diff/models.py +76 -0
- diffrag/exceptions.py +21 -0
- diffrag/indexing/__init__.py +5 -0
- diffrag/indexing/indexer.py +387 -0
- diffrag/indexing/splitter.py +296 -0
- diffrag/py.typed +0 -0
- diffrag/review/__init__.py +5 -0
- diffrag/review/prompts.py +75 -0
- diffrag/review/reviewer.py +322 -0
- diffrag-0.2.3.dist-info/METADATA +425 -0
- diffrag-0.2.3.dist-info/RECORD +26 -0
- diffrag-0.2.3.dist-info/WHEEL +4 -0
- diffrag-0.2.3.dist-info/entry_points.txt +3 -0
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
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
|