codegraph-voyage 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.
@@ -0,0 +1,275 @@
1
+ """Embedding provider strategy — abstract base and concrete implementations.
2
+
3
+ Supports:
4
+ - VoyageEmbeddingProvider: Real voyage-code-4 API via urllib.request (stdlib).
5
+ - FakeEmbeddingProvider: Deterministic fake for tests.
6
+
7
+ Each provider produces embeddings as lists of floats with a configurable
8
+ dimension.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import struct
16
+ import urllib.error
17
+ import urllib.request
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any
20
+
21
+
22
+ class EmbeddingProvider(ABC):
23
+ """Abstract base for embedding providers."""
24
+
25
+ @abstractmethod
26
+ def embed_documents(self, texts: list[str], *, input_type: str = "document") -> list[list[float]]:
27
+ """Embed a list of document texts.
28
+
29
+ Returns a list of embedding vectors (list of floats).
30
+ """
31
+ ...
32
+
33
+ @abstractmethod
34
+ def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
35
+ """Embed a single query string.
36
+
37
+ Returns an embedding vector (list of floats).
38
+ """
39
+ ...
40
+
41
+ @property
42
+ @abstractmethod
43
+ def model_name(self) -> str:
44
+ """Return the model identifier (e.g. 'voyage-code-4')."""
45
+ ...
46
+
47
+ @property
48
+ @abstractmethod
49
+ def dimensions(self) -> int:
50
+ """Return the embedding dimension."""
51
+ ...
52
+
53
+ def raw_bytes(self, vector: list[float]) -> bytes:
54
+ """Serialize a float vector to raw bytes (float32 little-endian)."""
55
+ return struct.pack(f"<{len(vector)}f", *vector)
56
+
57
+ def from_bytes(self, data: bytes) -> list[float]:
58
+ """Deserialize raw bytes back to a float vector."""
59
+ n = len(data) // 4
60
+ return list(struct.unpack(f"<{n}f", data))
61
+
62
+
63
+ class VoyageEmbeddingProvider(EmbeddingProvider):
64
+ """Real voyage-code-4 embedding provider via the Voyage API.
65
+
66
+ Uses the official Voyage AI embeddings API:
67
+ POST https://api.voyageai.com/v1/embeddings
68
+ """
69
+
70
+ API_URL = "https://api.voyageai.com/v1/embeddings"
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str | None = None,
75
+ model: str | None = None,
76
+ dimensions: int = 512,
77
+ timeout: int = 60,
78
+ batch_size: int = 128,
79
+ ):
80
+ if not api_key:
81
+ api_key = os.environ.get("VOYAGE_API_KEY", "")
82
+ if not api_key:
83
+ raise ValueError(
84
+ "VOYAGE_API_KEY is required. Set the environment variable or pass api_key."
85
+ )
86
+ self._api_key = api_key
87
+ self._model = model or os.environ.get("VOYAGE_MODEL", "voyage-code-4")
88
+ self._dimensions = dimensions
89
+ self._timeout = timeout
90
+ if not 1 <= batch_size <= 128:
91
+ raise ValueError("batch_size must be between 1 and 128")
92
+ self._batch_size = batch_size
93
+
94
+ @property
95
+ def model_name(self) -> str:
96
+ return self._model
97
+
98
+ @property
99
+ def dimensions(self) -> int:
100
+ return self._dimensions
101
+
102
+ def embed_documents(
103
+ self, texts: list[str], *, input_type: str = "document"
104
+ ) -> list[list[float]]:
105
+ """Embed documents in Voyage batches while preserving input order."""
106
+ result: list[list[float]] = []
107
+ for offset in range(0, len(texts), self._batch_size):
108
+ batch = texts[offset:offset + self._batch_size]
109
+ try:
110
+ result.extend(self._call_api(batch, input_type=input_type))
111
+ except (RuntimeError, OSError, ValueError) as exc:
112
+ batch_number = offset // self._batch_size + 1
113
+ raise RuntimeError(
114
+ f"Voyage embedding batch {batch_number} failed: {exc}"
115
+ ) from exc
116
+ return result
117
+
118
+ def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
119
+ """Embed a query using voyage-code-4 with input_type='query'."""
120
+ result = self._call_api([text], input_type=input_type)
121
+ return result[0]
122
+
123
+ def _call_api(
124
+ self, texts: list[str], *, input_type: str
125
+ ) -> list[list[float]]:
126
+ """Make the actual API call to Voyage AI via stdlib urllib.request.
127
+
128
+ Empty texts are filtered out before the API call; the returned list
129
+ is mapped back to the original text order (empty lists for skipped
130
+ texts).
131
+ """
132
+ # Track which original indices are non-empty
133
+ valid_indices: list[int] = []
134
+ valid_texts: list[str] = []
135
+ for i, t in enumerate(texts):
136
+ if t.strip():
137
+ valid_indices.append(i)
138
+ valid_texts.append(t)
139
+
140
+ if not valid_texts:
141
+ return [[] for _ in texts]
142
+
143
+ payload: dict[str, Any] = {
144
+ "model": self._model,
145
+ "input": valid_texts,
146
+ "input_type": input_type,
147
+ }
148
+ if self._dimensions:
149
+ payload["output_dimension"] = self._dimensions
150
+
151
+ body = json.dumps(payload).encode("utf-8")
152
+ req = urllib.request.Request(
153
+ self.API_URL,
154
+ data=body,
155
+ headers={
156
+ "Authorization": f"Bearer {self._api_key}",
157
+ "Content-Type": "application/json",
158
+ },
159
+ method="POST",
160
+ )
161
+
162
+ try:
163
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
164
+ data = json.loads(resp.read().decode("utf-8"))
165
+ except urllib.error.HTTPError as exc:
166
+ # Read minimal error info — never include response body (may
167
+ # echo sensitive content).
168
+ raise RuntimeError(
169
+ f"Voyage API error {exc.code}: {exc.reason}"
170
+ ) from exc
171
+ except urllib.error.URLError as exc:
172
+ raise RuntimeError(
173
+ f"Voyage API connection error: {exc.reason}"
174
+ ) from exc
175
+ except OSError as exc:
176
+ raise RuntimeError(
177
+ f"Voyage API request failed: {exc}"
178
+ ) from exc
179
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
180
+ raise RuntimeError("Voyage API returned malformed JSON") from exc
181
+
182
+ try:
183
+ items = data["data"]
184
+ if not isinstance(items, list) or len(items) != len(valid_texts):
185
+ raise ValueError("embedding count mismatch")
186
+ by_index: dict[int, list[float]] = {}
187
+ for item in items:
188
+ if not isinstance(item, dict):
189
+ raise ValueError("malformed embedding item")
190
+ index = item.get("index")
191
+ embedding = item.get("embedding")
192
+ if not isinstance(index, int) or isinstance(index, bool):
193
+ raise ValueError("invalid embedding index")
194
+ if index < 0 or index >= len(valid_texts) or index in by_index:
195
+ raise ValueError("duplicate or out-of-range embedding index")
196
+ if not isinstance(embedding, list) or len(embedding) != self._dimensions:
197
+ raise ValueError("embedding dimension mismatch")
198
+ if not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in embedding):
199
+ raise ValueError("malformed embedding vector")
200
+ by_index[index] = [float(value) for value in embedding]
201
+ valid_embeddings = [by_index[index] for index in range(len(valid_texts))]
202
+ except (KeyError, TypeError, ValueError) as exc:
203
+ raise RuntimeError(f"Voyage API response validation failed: {exc}") from exc
204
+
205
+ # Map back to original text order
206
+ result: list[list[float] | None] = [None] * len(texts)
207
+ for orig_idx, emb in zip(valid_indices, valid_embeddings):
208
+ result[orig_idx] = emb
209
+ # Fill in empty lists for skipped texts
210
+ return [r if r is not None else [] for r in result]
211
+
212
+
213
+ class FakeEmbeddingProvider(EmbeddingProvider):
214
+ """Deterministic fake embedding provider for testing.
215
+
216
+ Produces embedding vectors where each dimension is a deterministic
217
+ function of the text content and dimension index.
218
+ """
219
+
220
+ def __init__(self, dimensions: int = 512, model: str = "fake-embedding-v1"):
221
+ self._dimensions = dimensions
222
+ self._model = model
223
+
224
+ @property
225
+ def model_name(self) -> str:
226
+ return self._model
227
+
228
+ @property
229
+ def dimensions(self) -> int:
230
+ return self._dimensions
231
+
232
+ def _derive(self, text: str, seed: int = 0) -> list[float]:
233
+ """Deterministic embedding from text content."""
234
+ import hashlib
235
+
236
+ h = hashlib.sha256(f"{text}:{seed}".encode("utf-8")).hexdigest()
237
+ # Use the hash to seed a deterministic pseudo-random sequence
238
+ vals: list[float] = []
239
+ for i in range(self._dimensions):
240
+ # Mix hash bytes with position
241
+ h2 = hashlib.sha256(f"{h}:{i}:{seed}".encode("utf-8")).hexdigest()
242
+ # Convert first 8 hex chars to a float in [0, 1)
243
+ chunk = int(h2[:8], 16) / 0xFFFFFFFF
244
+ vals.append(chunk)
245
+ return vals
246
+
247
+ def embed_documents(
248
+ self, texts: list[str], *, input_type: str = "document"
249
+ ) -> list[list[float]]:
250
+ seed = 1 if input_type == "document" else 2
251
+ return [self._derive(t, seed) for t in texts]
252
+
253
+ def embed_query(self, text: str, *, input_type: str = "query") -> list[float]:
254
+ return self._derive(text, 2 if input_type == "query" else 1)
255
+
256
+
257
+ def create_provider(
258
+ provider_name: str = "fake",
259
+ *,
260
+ api_key: str | None = None,
261
+ model: str | None = None,
262
+ dimensions: int = 512,
263
+ ) -> EmbeddingProvider:
264
+ """Factory: create an embedding provider by name.
265
+
266
+ Supported names: 'voyage', 'fake'.
267
+ """
268
+ if provider_name == "voyage":
269
+ return VoyageEmbeddingProvider(
270
+ api_key=api_key, model=model, dimensions=dimensions
271
+ )
272
+ elif provider_name == "fake":
273
+ return FakeEmbeddingProvider(dimensions=dimensions)
274
+ else:
275
+ raise ValueError(f"Unknown provider: {provider_name}. Use 'voyage' or 'fake'.")