vera-embed-openai 0.3.1__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.
@@ -0,0 +1,16 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.pyc
5
+ *.vera
6
+ dist/
7
+ dist-electron/
8
+ release/
9
+ build/
10
+ node_modules/
11
+ *.egg-info/
12
+ examples/*.pdf
13
+ site/
14
+ .env
15
+ .env.local
16
+ .env.*.local
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: vera-embed-openai
3
+ Version: 0.3.1
4
+ Summary: Official OpenAI embeddings provider for VERA
5
+ Project-URL: Homepage, https://github.com/dkylewillis/vera
6
+ Project-URL: Repository, https://github.com/dkylewillis/vera
7
+ Project-URL: Documentation, https://dkylewillis.github.io/vera/packages/vera-embed-openai/
8
+ Author: Kyle Willis
9
+ License: Apache-2.0
10
+ Keywords: embeddings,openai,semantic-search,vera
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Text Processing :: Indexing
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: vera-doc>=0.3.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # vera-embed-openai
21
+
22
+ Official OpenAI embeddings plugin for VERA. Registers the `openai` provider
23
+ under the `vera.embedders` entry-point group.
24
+
25
+ `vera-cli` and `vera-app` depend on this package so hosted OpenAI conversion
26
+ works out of the box. The client uses stdlib `urllib` — there is no `openai`
27
+ SDK dependency.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ python -m pip install "vera-embed-openai>=0.3.0"
33
+ ```
34
+
35
+ From a repository checkout with uv, the workspace installs it by default
36
+ (via `vera-cli` / `vera-app`):
37
+
38
+ ```bash
39
+ uv sync
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ set OPENAI_API_KEY=...
46
+ vera convert "manual.pdf" --model openai:text-embedding-3-small
47
+ ```
48
+
49
+ ```python
50
+ import os
51
+
52
+ from vera_ingest import convert
53
+
54
+ os.environ["OPENAI_API_KEY"] = "..."
55
+ convert("manual.pdf", "manual.vera", model="openai:text-embedding-3-small")
56
+ ```
57
+
58
+ Keep the API key in `OPENAI_API_KEY`. Optional `OPENAI_BASE_URL` (default
59
+ `https://api.openai.com/v1`) points at Azure, OpenRouter, or a local
60
+ OpenAI-compatible server. Archives still record `openai:<model-id>`, so two
61
+ endpoints that embed different models under the same id are indistinguishable
62
+ at search time.
63
+
64
+ ## Notes
65
+
66
+ - Known dimensions (no network in the constructor): `text-embedding-3-small`
67
+ 1536, `text-embedding-3-large` 3072, `text-embedding-ada-002` 1536.
68
+ Unrecognized model ids probe once on first use.
69
+ - Convert-time options: `batch_size` (1–2048) and `timeout` seconds.
70
+ Search resolves `get_embedder(stored_model_name)` with defaults.
71
+ - Semantic search of a hosted archive needs the same provider and credentials
72
+ on the searching machine. Keyword search still works without a key.
73
+ - Desktop Convert Cancel does not interrupt an in-flight embeddings HTTP
74
+ batch; conversion checks cancellation after `embed()` returns.
75
+
76
+ See the [vera-embed-openai documentation](https://dkylewillis.github.io/vera/packages/vera-embed-openai/)
77
+ and [conversion guide](https://github.com/dkylewillis/vera/blob/main/docs/conversion.md).
@@ -0,0 +1,58 @@
1
+ # vera-embed-openai
2
+
3
+ Official OpenAI embeddings plugin for VERA. Registers the `openai` provider
4
+ under the `vera.embedders` entry-point group.
5
+
6
+ `vera-cli` and `vera-app` depend on this package so hosted OpenAI conversion
7
+ works out of the box. The client uses stdlib `urllib` — there is no `openai`
8
+ SDK dependency.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ python -m pip install "vera-embed-openai>=0.3.0"
14
+ ```
15
+
16
+ From a repository checkout with uv, the workspace installs it by default
17
+ (via `vera-cli` / `vera-app`):
18
+
19
+ ```bash
20
+ uv sync
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ set OPENAI_API_KEY=...
27
+ vera convert "manual.pdf" --model openai:text-embedding-3-small
28
+ ```
29
+
30
+ ```python
31
+ import os
32
+
33
+ from vera_ingest import convert
34
+
35
+ os.environ["OPENAI_API_KEY"] = "..."
36
+ convert("manual.pdf", "manual.vera", model="openai:text-embedding-3-small")
37
+ ```
38
+
39
+ Keep the API key in `OPENAI_API_KEY`. Optional `OPENAI_BASE_URL` (default
40
+ `https://api.openai.com/v1`) points at Azure, OpenRouter, or a local
41
+ OpenAI-compatible server. Archives still record `openai:<model-id>`, so two
42
+ endpoints that embed different models under the same id are indistinguishable
43
+ at search time.
44
+
45
+ ## Notes
46
+
47
+ - Known dimensions (no network in the constructor): `text-embedding-3-small`
48
+ 1536, `text-embedding-3-large` 3072, `text-embedding-ada-002` 1536.
49
+ Unrecognized model ids probe once on first use.
50
+ - Convert-time options: `batch_size` (1–2048) and `timeout` seconds.
51
+ Search resolves `get_embedder(stored_model_name)` with defaults.
52
+ - Semantic search of a hosted archive needs the same provider and credentials
53
+ on the searching machine. Keyword search still works without a key.
54
+ - Desktop Convert Cancel does not interrupt an in-flight embeddings HTTP
55
+ batch; conversion checks cancellation after `embed()` returns.
56
+
57
+ See the [vera-embed-openai documentation](https://dkylewillis.github.io/vera/packages/vera-embed-openai/)
58
+ and [conversion guide](https://github.com/dkylewillis/vera/blob/main/docs/conversion.md).
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "vera-embed-openai"
3
+ version = "0.3.1"
4
+ description = "Official OpenAI embeddings provider for VERA"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "vera-doc>=0.3.1",
9
+ "numpy>=1.24",
10
+ ]
11
+ authors = [{name = "Kyle Willis"}]
12
+ license = {text = "Apache-2.0"}
13
+ keywords = ["openai", "embeddings", "semantic-search", "vera"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Text Processing :: Indexing",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/dkylewillis/vera"
23
+ Repository = "https://github.com/dkylewillis/vera"
24
+ Documentation = "https://dkylewillis.github.io/vera/packages/vera-embed-openai/"
25
+
26
+ [project.entry-points."vera.embedders"]
27
+ openai = "vera_embed_openai:create_embedder"
28
+
29
+ [project.entry-points."vera.embedder_descriptors"]
30
+ openai = "vera_embed_openai:create_descriptor"
31
+
32
+ [project.entry-points."vera.embedder_models"]
33
+ openai = "vera_embed_openai:list_models"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/vera_embed_openai"]
@@ -0,0 +1,81 @@
1
+ """Official OpenAI embeddings provider for VERA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from vera_doc import (
6
+ EmbedderDescriptor,
7
+ EmbeddingModelInfo,
8
+ register_embedder,
9
+ register_embedder_descriptor,
10
+ register_embedder_models,
11
+ )
12
+
13
+ from .options import (
14
+ BASE_URL_ENV,
15
+ CREDENTIAL_ENV,
16
+ DEFAULT_BASE_URL,
17
+ DEFAULT_MODEL_ID,
18
+ MODEL_DIMENSIONS,
19
+ PROVIDER,
20
+ OpenAIOptions,
21
+ describe_provider,
22
+ )
23
+ from .options import list_models as _list_models
24
+ from .provider import (
25
+ MAX_INPUT_TOKENS,
26
+ MAX_REQUEST_TOKENS,
27
+ OpenAIEmbedder,
28
+ OpenAIEmbedderError,
29
+ create_embedder,
30
+ embeddings_url,
31
+ estimate_tokens,
32
+ iter_embed_batches,
33
+ )
34
+
35
+ __all__ = [
36
+ "BASE_URL_ENV",
37
+ "CREDENTIAL_ENV",
38
+ "DEFAULT_BASE_URL",
39
+ "DEFAULT_MODEL_ID",
40
+ "MAX_INPUT_TOKENS",
41
+ "MAX_REQUEST_TOKENS",
42
+ "MODEL_DIMENSIONS",
43
+ "OpenAIEmbedder",
44
+ "OpenAIEmbedderError",
45
+ "OpenAIOptions",
46
+ "PROVIDER",
47
+ "create_descriptor",
48
+ "create_embedder",
49
+ "describe_provider",
50
+ "embeddings_url",
51
+ "ensure_registered",
52
+ "estimate_tokens",
53
+ "iter_embed_batches",
54
+ "list_models",
55
+ ]
56
+
57
+
58
+ def create_descriptor() -> EmbedderDescriptor:
59
+ """Entry-point factory for ``vera.embedder_descriptors``."""
60
+ return describe_provider()
61
+
62
+
63
+ def list_models() -> tuple[EmbeddingModelInfo, ...]:
64
+ """Entry-point factory for ``vera.embedder_models``."""
65
+ return _list_models()
66
+
67
+
68
+ def ensure_registered(*, replace: bool = True) -> None:
69
+ """Register the ``openai`` embedder without relying on package metadata.
70
+
71
+ Entry-point discovery fails in PyInstaller freezes and PYTHONPATH-only
72
+ source runs that never install ``vera-embed-openai`` dist-info. Callers
73
+ that already import this package (CLI, the desktop sidecar) should invoke
74
+ this so Convert and search still resolve the provider.
75
+ """
76
+ register_embedder(PROVIDER, create_embedder, replace=replace)
77
+ register_embedder_descriptor(PROVIDER, create_descriptor, replace=replace)
78
+ register_embedder_models(PROVIDER, list_models, replace=replace)
79
+
80
+
81
+ ensure_registered()
@@ -0,0 +1,124 @@
1
+ """Typed options, descriptor, and model list for the OpenAI embedder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from vera_doc import (
8
+ EmbedderCapabilities,
9
+ EmbedderDescriptor,
10
+ EmbedderOptions,
11
+ EmbeddingModelInfo,
12
+ )
13
+ from vera_doc.embedder_descriptors import fields_from_dataclass
14
+
15
+ PROVIDER = "openai"
16
+ DEFAULT_MODEL_ID = "text-embedding-3-small"
17
+ CREDENTIAL_ENV = "OPENAI_API_KEY"
18
+ BASE_URL_ENV = "OPENAI_BASE_URL"
19
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
20
+
21
+ MODEL_DIMENSIONS: dict[str, int] = {
22
+ "text-embedding-3-small": 1536,
23
+ "text-embedding-3-large": 3072,
24
+ "text-embedding-ada-002": 1536,
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class OpenAIOptions(EmbedderOptions):
30
+ """OpenAI-owned convert-time embedding settings.
31
+
32
+ Credentials and the API root stay in the environment
33
+ (``OPENAI_API_KEY``, optional ``OPENAI_BASE_URL``), not in this dataclass.
34
+ """
35
+
36
+ batch_size: int = field(
37
+ default=128,
38
+ metadata={
39
+ "label": "Batch size",
40
+ "description": (
41
+ "Maximum texts per OpenAI request. Requests also split when "
42
+ "the estimated token budget would be exceeded. Convert-time "
43
+ "only — search uses the default."
44
+ ),
45
+ "minimum": 1,
46
+ "maximum": 2048,
47
+ "step": 1,
48
+ "scope": "convert",
49
+ },
50
+ )
51
+ timeout: int = field(
52
+ default=60,
53
+ metadata={
54
+ "label": "Timeout",
55
+ "description": (
56
+ "Seconds to wait for each embeddings HTTP response. "
57
+ "Convert-time only — search uses the default."
58
+ ),
59
+ "unit": "seconds",
60
+ "minimum": 1,
61
+ "maximum": 600,
62
+ "step": 1,
63
+ "scope": "convert",
64
+ },
65
+ )
66
+
67
+
68
+ def describe_provider() -> EmbedderDescriptor:
69
+ return EmbedderDescriptor(
70
+ provider=PROVIDER,
71
+ label="openai — hosted embeddings",
72
+ description=(
73
+ "OpenAI embeddings API (text-embedding-3-* and similar). "
74
+ "Requires OPENAI_API_KEY. Archives converted with this provider "
75
+ "need the same key for later semantic or hybrid search; keyword "
76
+ "search still works offline. Conversion bills per request."
77
+ ),
78
+ default_model_id=DEFAULT_MODEL_ID,
79
+ example_specs=(
80
+ "openai:text-embedding-3-small",
81
+ "openai:text-embedding-3-large",
82
+ ),
83
+ capabilities=EmbedderCapabilities(
84
+ requires_network=True,
85
+ requires_api_key=True,
86
+ credential_env=CREDENTIAL_ENV,
87
+ local_model=False,
88
+ configurable_dimension=False,
89
+ supports_model_listing=True,
90
+ ),
91
+ fields=fields_from_dataclass(OpenAIOptions),
92
+ notes=(
93
+ "Set OPENAI_API_KEY (desktop: File > Settings → Embeddings). "
94
+ "Optional OPENAI_BASE_URL overrides the API root "
95
+ f"(default {DEFAULT_BASE_URL}); archives still record "
96
+ "openai:<model-id>, so a custom endpoint that serves a different "
97
+ "model is not detectable at search time. "
98
+ "batch_size and timeout are convert-time options; search uses defaults. "
99
+ "Cancel does not interrupt an in-flight embeddings request.",
100
+ ),
101
+ )
102
+
103
+
104
+ def list_models() -> tuple[EmbeddingModelInfo, ...]:
105
+ return (
106
+ EmbeddingModelInfo(
107
+ model_id="text-embedding-3-small",
108
+ label="text-embedding-3-small",
109
+ spec="openai:text-embedding-3-small",
110
+ description="OpenAI text-embedding-3-small (1536-d).",
111
+ ),
112
+ EmbeddingModelInfo(
113
+ model_id="text-embedding-3-large",
114
+ label="text-embedding-3-large",
115
+ spec="openai:text-embedding-3-large",
116
+ description="OpenAI text-embedding-3-large (3072-d).",
117
+ ),
118
+ EmbeddingModelInfo(
119
+ model_id="text-embedding-ada-002",
120
+ label="text-embedding-ada-002",
121
+ spec="openai:text-embedding-ada-002",
122
+ description="Legacy OpenAI ada embedding model (1536-d).",
123
+ ),
124
+ )
@@ -0,0 +1,290 @@
1
+ """Stdlib HTTP OpenAI embeddings client.
2
+
3
+ Batch splitting, retries, and URL construction stay in this module. The
4
+ ``EmbeddingFunction`` contract is ``embed(texts)``, so convert and search
5
+ never see request boundaries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import threading
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from collections.abc import Iterator, Sequence
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+
22
+ from .options import (
23
+ BASE_URL_ENV,
24
+ CREDENTIAL_ENV,
25
+ DEFAULT_BASE_URL,
26
+ DEFAULT_MODEL_ID,
27
+ MODEL_DIMENSIONS,
28
+ PROVIDER,
29
+ OpenAIOptions,
30
+ )
31
+
32
+ # OpenAI embeddings caps (re-check against current docs if these start failing).
33
+ # Per-input limit for text-embedding-3-* and ada-002.
34
+ MAX_INPUT_TOKENS = 8192
35
+ # Published per-request cap is ~300k tokens; stay under it to absorb the
36
+ # character heuristic's estimation error.
37
+ MAX_REQUEST_TOKENS = 250_000
38
+ MAX_BATCH_ITEMS = 2048
39
+ MAX_RETRIES = 4
40
+ _RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
41
+ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
42
+
43
+
44
+ class OpenAIEmbedderError(RuntimeError):
45
+ """Raised when the OpenAI embeddings API rejects or cannot complete a request."""
46
+
47
+
48
+ def estimate_tokens(text: str) -> int:
49
+ """Conservative character heuristic (~3 chars/token). Over-estimates on purpose."""
50
+ if not text:
51
+ return 1
52
+ return max(1, (len(text) + 2) // 3)
53
+
54
+
55
+ def iter_embed_batches(
56
+ texts: Sequence[str],
57
+ *,
58
+ batch_size: int,
59
+ max_request_tokens: int = MAX_REQUEST_TOKENS,
60
+ max_input_tokens: int = MAX_INPUT_TOKENS,
61
+ ) -> Iterator[list[str]]:
62
+ """Yield text batches that fit both the item cap and the token budget.
63
+
64
+ A single text over ``max_input_tokens`` raises rather than truncating.
65
+ """
66
+ max_items = max(1, min(int(batch_size), MAX_BATCH_ITEMS))
67
+ budget = max(1, int(max_request_tokens))
68
+ batch: list[str] = []
69
+ batch_tokens = 0
70
+ for index, text in enumerate(texts):
71
+ value = text if isinstance(text, str) else ("" if text is None else str(text))
72
+ tokens = estimate_tokens(value)
73
+ if tokens > max_input_tokens:
74
+ raise OpenAIEmbedderError(
75
+ f"chunk {index} is ~{tokens} tokens, over the {max_input_tokens} "
76
+ "per-input OpenAI embeddings limit; lower the pipeline chunk_size"
77
+ )
78
+ if batch and (len(batch) >= max_items or batch_tokens + tokens > budget):
79
+ yield batch
80
+ batch = []
81
+ batch_tokens = 0
82
+ batch.append(value)
83
+ batch_tokens += tokens
84
+ if batch:
85
+ yield batch
86
+
87
+
88
+ def embeddings_url(base_url: str) -> str:
89
+ """Join ``base_url`` with ``/embeddings``, inserting ``/v1`` when missing."""
90
+ root = (base_url or DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL
91
+ root = root.rstrip("/")
92
+ if root.endswith("/embeddings"):
93
+ return root
94
+ if not root.endswith("/v1") and "/v1/" not in root + "/":
95
+ root = f"{root}/v1"
96
+ return f"{root}/embeddings"
97
+
98
+
99
+ def _sanitize_unicode(value: Any) -> Any:
100
+ if isinstance(value, str):
101
+ return _SURROGATE_RE.sub("\ufffd", value)
102
+ if isinstance(value, list):
103
+ return [_sanitize_unicode(item) for item in value]
104
+ if isinstance(value, dict):
105
+ return {
106
+ _sanitize_unicode(key) if isinstance(key, str) else key: _sanitize_unicode(item)
107
+ for key, item in value.items()
108
+ }
109
+ return value
110
+
111
+
112
+ def _l2_normalize(vector: np.ndarray) -> np.ndarray:
113
+ array = np.asarray(vector, dtype=np.float32)
114
+ norm = float(np.linalg.norm(array))
115
+ if norm:
116
+ array = array / norm
117
+ return array.astype(np.float32)
118
+
119
+
120
+ def _retry_delay_seconds(headers: Any, attempt: int) -> float:
121
+ raw = ""
122
+ if headers is not None:
123
+ raw = str(headers.get("Retry-After") or headers.get("retry-after") or "")
124
+ if raw:
125
+ try:
126
+ return min(60.0, max(0.5, float(raw.strip())))
127
+ except ValueError:
128
+ pass
129
+ return min(32.0, 0.5 * (2**attempt))
130
+
131
+
132
+ def _error_detail(exc: urllib.error.HTTPError) -> str:
133
+ try:
134
+ body = exc.read().decode("utf-8", errors="replace")
135
+ except Exception: # noqa: BLE001 - error path must not hide the status
136
+ body = ""
137
+ snippet = " ".join(body.split())
138
+ if len(snippet) > 500:
139
+ snippet = snippet[:497] + "..."
140
+ return snippet
141
+
142
+
143
+ def _post_embeddings(
144
+ *,
145
+ url: str,
146
+ api_key: str,
147
+ model: str,
148
+ inputs: list[str],
149
+ timeout: float,
150
+ ) -> list[np.ndarray]:
151
+ payload = json.dumps(
152
+ _sanitize_unicode({"model": model, "input": inputs}),
153
+ ensure_ascii=False,
154
+ ).encode("utf-8")
155
+ headers = {
156
+ "Content-Type": "application/json",
157
+ "Authorization": f"Bearer {api_key}",
158
+ }
159
+ last_error: Exception | None = None
160
+ for attempt in range(MAX_RETRIES):
161
+ request = urllib.request.Request(url, data=payload, headers=headers, method="POST")
162
+ try:
163
+ with urllib.request.urlopen(request, timeout=timeout) as response:
164
+ raw = json.loads(response.read().decode("utf-8"))
165
+ return _vectors_from_payload(raw, expected=len(inputs))
166
+ except urllib.error.HTTPError as exc:
167
+ detail = _error_detail(exc)
168
+ if exc.code in _RETRY_STATUS and attempt + 1 < MAX_RETRIES:
169
+ time.sleep(_retry_delay_seconds(exc.headers, attempt))
170
+ last_error = OpenAIEmbedderError(
171
+ f"OpenAI embeddings HTTP {exc.code}: {detail or exc.reason}"
172
+ )
173
+ continue
174
+ raise OpenAIEmbedderError(
175
+ f"OpenAI embeddings HTTP {exc.code}: {detail or exc.reason}"
176
+ ) from exc
177
+ except urllib.error.URLError as exc:
178
+ reason = getattr(exc, "reason", exc)
179
+ last_error = OpenAIEmbedderError(f"Unable to reach OpenAI embeddings API: {reason}")
180
+ if attempt + 1 < MAX_RETRIES:
181
+ time.sleep(_retry_delay_seconds(None, attempt))
182
+ continue
183
+ raise last_error from exc
184
+ except (json.JSONDecodeError, TypeError, ValueError, KeyError) as exc:
185
+ raise OpenAIEmbedderError(
186
+ f"OpenAI embeddings returned an invalid payload: {exc}"
187
+ ) from exc
188
+ raise last_error or OpenAIEmbedderError("OpenAI embeddings request failed")
189
+
190
+
191
+ def _vectors_from_payload(payload: Any, *, expected: int) -> list[np.ndarray]:
192
+ if not isinstance(payload, dict):
193
+ raise OpenAIEmbedderError("OpenAI embeddings response is not a JSON object")
194
+ rows = payload.get("data")
195
+ if not isinstance(rows, list) or len(rows) != expected:
196
+ raise OpenAIEmbedderError(
197
+ f"OpenAI embeddings returned {0 if not isinstance(rows, list) else len(rows)} "
198
+ f"vectors; expected {expected}"
199
+ )
200
+ indexed: list[tuple[int, np.ndarray]] = []
201
+ for position, row in enumerate(rows):
202
+ if not isinstance(row, dict):
203
+ raise OpenAIEmbedderError("OpenAI embeddings data entry is not an object")
204
+ embedding = row.get("embedding")
205
+ if not isinstance(embedding, list) or not embedding:
206
+ raise OpenAIEmbedderError("OpenAI embeddings data entry is missing embedding")
207
+ index = row.get("index", position)
208
+ try:
209
+ order = int(index)
210
+ except (TypeError, ValueError) as exc:
211
+ raise OpenAIEmbedderError("OpenAI embeddings data entry has an invalid index") from exc
212
+ indexed.append((order, _l2_normalize(np.asarray(embedding, dtype=np.float32))))
213
+ indexed.sort(key=lambda item: item[0])
214
+ return [vector for _index, vector in indexed]
215
+
216
+
217
+ class OpenAIEmbedder:
218
+ """Hosted OpenAI embeddings. Constructor does not touch the network."""
219
+
220
+ normalization = "l2"
221
+
222
+ def __init__(
223
+ self,
224
+ model_id: str,
225
+ *,
226
+ api_key: str,
227
+ base_url: str,
228
+ batch_size: int,
229
+ timeout: int,
230
+ ) -> None:
231
+ model = (model_id or "").strip() or DEFAULT_MODEL_ID
232
+ self.model_name = f"{PROVIDER}:{model}"
233
+ self._model = model
234
+ self._api_key = api_key
235
+ self._url = embeddings_url(base_url)
236
+ self._batch_size = max(1, min(int(batch_size), MAX_BATCH_ITEMS))
237
+ self._timeout = float(timeout)
238
+ self._lock = threading.Lock()
239
+ self._dimension = MODEL_DIMENSIONS.get(model)
240
+
241
+ @property
242
+ def dimension(self) -> int:
243
+ if self._dimension is None:
244
+ self._dimension = int(len(self._embed_one_batch(["ping"])[0]))
245
+ return self._dimension
246
+
247
+ def embed(self, texts: list[str]) -> list[np.ndarray]:
248
+ if not texts:
249
+ return []
250
+ vectors: list[np.ndarray] = []
251
+ for batch in iter_embed_batches(texts, batch_size=self._batch_size):
252
+ vectors.extend(self._embed_one_batch(batch))
253
+ if self._dimension is None and vectors:
254
+ self._dimension = int(vectors[0].shape[0])
255
+ return vectors
256
+
257
+ def _embed_one_batch(self, batch: list[str]) -> list[np.ndarray]:
258
+ with self._lock:
259
+ return _post_embeddings(
260
+ url=self._url,
261
+ api_key=self._api_key,
262
+ model=self._model,
263
+ inputs=batch,
264
+ timeout=self._timeout,
265
+ )
266
+
267
+
268
+ def _require_api_key() -> str:
269
+ api_key = os.environ.get(CREDENTIAL_ENV, "").strip()
270
+ if not api_key:
271
+ raise OpenAIEmbedderError(
272
+ f"Set the {CREDENTIAL_ENV} environment variable before converting or searching "
273
+ "with an OpenAI embedding model. In the desktop app, save it under "
274
+ "File > Settings → Embeddings."
275
+ )
276
+ return api_key
277
+
278
+
279
+ def create_embedder(model_id: str, **config: Any) -> OpenAIEmbedder:
280
+ """Entry-point factory for ``vera.embedders`` provider ``openai``."""
281
+ options = OpenAIOptions.from_mapping(config)
282
+ model = (model_id or "").strip() or DEFAULT_MODEL_ID
283
+ base_url = os.environ.get(BASE_URL_ENV, "").strip() or DEFAULT_BASE_URL
284
+ return OpenAIEmbedder(
285
+ model,
286
+ api_key=_require_api_key(),
287
+ base_url=base_url,
288
+ batch_size=options.batch_size,
289
+ timeout=options.timeout,
290
+ )
@@ -0,0 +1,275 @@
1
+ """Unit tests for the official OpenAI embeddings plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ from unittest.mock import patch
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from vera_doc import (
13
+ clear_embedder_cache,
14
+ describe_embedder,
15
+ get_embedder,
16
+ list_embedding_models,
17
+ preflight_embedder,
18
+ )
19
+ from vera_embed_openai import (
20
+ CREDENTIAL_ENV,
21
+ DEFAULT_MODEL_ID,
22
+ MAX_INPUT_TOKENS,
23
+ OpenAIEmbedder,
24
+ OpenAIEmbedderError,
25
+ OpenAIOptions,
26
+ create_embedder,
27
+ embeddings_url,
28
+ ensure_registered,
29
+ estimate_tokens,
30
+ iter_embed_batches,
31
+ )
32
+
33
+
34
+ def _response(vectors: list[list[float]], *, indexes: list[int] | None = None):
35
+ payload = {
36
+ "data": [
37
+ {"index": (indexes[i] if indexes else i), "embedding": vector}
38
+ for i, vector in enumerate(vectors)
39
+ ]
40
+ }
41
+ raw = json.dumps(payload).encode("utf-8")
42
+
43
+ class _Handle:
44
+ def __enter__(self):
45
+ return self
46
+
47
+ def __exit__(self, *exc):
48
+ return False
49
+
50
+ def read(self) -> bytes:
51
+ return raw
52
+
53
+ return _Handle()
54
+
55
+
56
+ @pytest.fixture
57
+ def openai_key(monkeypatch):
58
+ monkeypatch.setenv(CREDENTIAL_ENV, "sk-test")
59
+ return "sk-test"
60
+
61
+
62
+ def test_estimate_tokens_is_conservative():
63
+ assert estimate_tokens("") == 1
64
+ assert estimate_tokens("abcd") == 2
65
+ assert estimate_tokens("a" * 30) == 10
66
+
67
+
68
+ def test_iter_embed_batches_splits_on_item_cap_and_token_budget():
69
+ texts = ["aa", "bb", "cc", "dd"]
70
+ batches = list(iter_embed_batches(texts, batch_size=2, max_request_tokens=1000))
71
+ assert batches == [["aa", "bb"], ["cc", "dd"]]
72
+
73
+ # Each "aaaaaa" is 2 tokens with the heuristic; budget 3 forces size 1.
74
+ longish = ["aaaaaa", "aaaaaa", "aaaaaa"]
75
+ token_batches = list(iter_embed_batches(longish, batch_size=10, max_request_tokens=3))
76
+ assert token_batches == [["aaaaaa"], ["aaaaaa"], ["aaaaaa"]]
77
+
78
+
79
+ def test_iter_embed_batches_rejects_oversized_chunk():
80
+ huge = "a" * (MAX_INPUT_TOKENS * 3 + 3)
81
+ with pytest.raises(OpenAIEmbedderError, match="chunk 0"):
82
+ list(iter_embed_batches([huge], batch_size=8))
83
+
84
+
85
+ def test_embeddings_url_joins_v1_and_embeddings():
86
+ assert embeddings_url("https://api.openai.com/v1") == "https://api.openai.com/v1/embeddings"
87
+ assert embeddings_url("https://api.openai.com") == "https://api.openai.com/v1/embeddings"
88
+ assert (
89
+ embeddings_url("https://example.test/v1/embeddings") == "https://example.test/v1/embeddings"
90
+ )
91
+
92
+
93
+ def test_constructor_does_not_touch_the_network(openai_key):
94
+ calls: list[object] = []
95
+
96
+ def boom(*args, **kwargs):
97
+ calls.append((args, kwargs))
98
+ raise AssertionError("constructor must not call urlopen")
99
+
100
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", boom):
101
+ embedder = create_embedder("text-embedding-3-small")
102
+ assert embedder.model_name == "openai:text-embedding-3-small"
103
+ assert embedder.dimension == 1536
104
+ assert embedder.normalization == "l2"
105
+ assert calls == []
106
+
107
+
108
+ def test_known_model_dimensions_need_no_probe(openai_key):
109
+ small = OpenAIEmbedder(
110
+ "text-embedding-3-small",
111
+ api_key=openai_key,
112
+ base_url="https://api.openai.com/v1",
113
+ batch_size=8,
114
+ timeout=5,
115
+ )
116
+ large = OpenAIEmbedder(
117
+ "text-embedding-3-large",
118
+ api_key=openai_key,
119
+ base_url="https://api.openai.com/v1",
120
+ batch_size=8,
121
+ timeout=5,
122
+ )
123
+ ada = OpenAIEmbedder(
124
+ "text-embedding-ada-002",
125
+ api_key=openai_key,
126
+ base_url="https://api.openai.com/v1",
127
+ batch_size=8,
128
+ timeout=5,
129
+ )
130
+ assert small.dimension == 1536
131
+ assert large.dimension == 3072
132
+ assert ada.dimension == 1536
133
+
134
+
135
+ def test_embed_normalizes_and_preserves_order(openai_key):
136
+ embedder = create_embedder("text-embedding-3-small", batch_size=8)
137
+ captured: dict[str, object] = {}
138
+
139
+ def fake_urlopen(request, timeout=None):
140
+ captured["url"] = request.full_url
141
+ captured["timeout"] = timeout
142
+ body = json.loads(request.data.decode("utf-8"))
143
+ captured["payload"] = body
144
+ auth = request.headers.get("Authorization") or request.get_header("Authorization")
145
+ captured["authorization"] = auth
146
+ # Unnormalized, reversed indexes — client must L2-normalize and reorder.
147
+ return _response(
148
+ [[0.0, 3.0], [4.0, 0.0]],
149
+ indexes=[1, 0],
150
+ )
151
+
152
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", fake_urlopen):
153
+ vectors = embedder.embed(["one", "two"])
154
+
155
+ assert captured["url"] == "https://api.openai.com/v1/embeddings"
156
+ assert captured["payload"] == {"model": "text-embedding-3-small", "input": ["one", "two"]}
157
+ assert str(captured["authorization"]).endswith("sk-test")
158
+ assert pytest.approx(float(np.linalg.norm(vectors[0])), abs=1e-5) == 1.0
159
+ assert pytest.approx(float(np.linalg.norm(vectors[1])), abs=1e-5) == 1.0
160
+ # After reorder, index 0 is [4,0] -> [1,0]; index 1 is [0,3] -> [0,1].
161
+ assert pytest.approx(float(vectors[0][0]), abs=1e-5) == 1.0
162
+ assert pytest.approx(float(vectors[1][1]), abs=1e-5) == 1.0
163
+
164
+
165
+ def test_embed_splits_http_requests_on_batch_size(openai_key):
166
+ embedder = create_embedder("text-embedding-3-small", batch_size=2)
167
+ payloads: list[list[str]] = []
168
+
169
+ def fake_urlopen(request, timeout=None):
170
+ body = json.loads(request.data.decode("utf-8"))
171
+ payloads.append(list(body["input"]))
172
+ unit = [[1.0, 0.0] for _ in body["input"]]
173
+ return _response(unit)
174
+
175
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", fake_urlopen):
176
+ vectors = embedder.embed(["a", "b", "c"])
177
+ assert payloads == [["a", "b"], ["c"]]
178
+ assert len(vectors) == 3
179
+
180
+
181
+ def test_retries_on_429_then_succeeds(openai_key, monkeypatch):
182
+ embedder = create_embedder("text-embedding-3-small")
183
+ attempts = {"n": 0}
184
+
185
+ def fake_urlopen(request, timeout=None):
186
+ attempts["n"] += 1
187
+ if attempts["n"] == 1:
188
+ import vera_embed_openai.provider as provider_mod
189
+
190
+ raise provider_mod.urllib.error.HTTPError(
191
+ request.full_url,
192
+ 429,
193
+ "rate limited",
194
+ hdrs={"Retry-After": "0"},
195
+ fp=io.BytesIO(b'{"error":"slow down"}'),
196
+ )
197
+ return _response([[1.0, 0.0]])
198
+
199
+ monkeypatch.setattr("vera_embed_openai.provider.time.sleep", lambda _seconds: None)
200
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", fake_urlopen):
201
+ vectors = embedder.embed(["hello"])
202
+ assert attempts["n"] == 2
203
+ assert vectors[0].shape == (2,)
204
+
205
+
206
+ def test_http_error_includes_status_and_body(openai_key):
207
+ embedder = create_embedder("text-embedding-3-small")
208
+
209
+ def fake_urlopen(request, timeout=None):
210
+ import vera_embed_openai.provider as provider_mod
211
+
212
+ raise provider_mod.urllib.error.HTTPError(
213
+ request.full_url,
214
+ 400,
215
+ "bad request",
216
+ hdrs={},
217
+ fp=io.BytesIO(b'{"error":{"message":"max tokens"}}'),
218
+ )
219
+
220
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", fake_urlopen):
221
+ with pytest.raises(OpenAIEmbedderError, match="HTTP 400") as exc:
222
+ embedder.embed(["hello"])
223
+ assert "max tokens" in str(exc.value)
224
+
225
+
226
+ def test_missing_api_key_fails_in_factory(monkeypatch):
227
+ monkeypatch.delenv(CREDENTIAL_ENV, raising=False)
228
+ with pytest.raises(OpenAIEmbedderError, match="OPENAI_API_KEY"):
229
+ create_embedder("text-embedding-3-small")
230
+
231
+
232
+ def test_options_validate_bounds():
233
+ options = OpenAIOptions.from_mapping({"batch_size": 16, "timeout": 30})
234
+ assert options.batch_size == 16
235
+ assert options.timeout == 30
236
+ with pytest.raises(ValueError, match="batch_size"):
237
+ OpenAIOptions.from_mapping({"batch_size": 0})
238
+ with pytest.raises(ValueError, match="timeout"):
239
+ OpenAIOptions.from_mapping({"timeout": 0})
240
+
241
+
242
+ def test_ensure_registered_descriptor_and_preflight(monkeypatch, openai_key):
243
+ ensure_registered()
244
+ try:
245
+ descriptor = describe_embedder("openai")
246
+ assert descriptor.provider == "openai"
247
+ assert descriptor.capabilities.credential_env == CREDENTIAL_ENV
248
+ assert descriptor.capabilities.requires_api_key is True
249
+ models = list_embedding_models("openai")
250
+ assert any(item.model_id == DEFAULT_MODEL_ID for item in models)
251
+ assert preflight_embedder("openai:text-embedding-3-small").ok is True
252
+ monkeypatch.delenv(CREDENTIAL_ENV, raising=False)
253
+ failed = preflight_embedder("openai:text-embedding-3-large")
254
+ assert failed.ok is False
255
+ assert failed.missing_credential_env == CREDENTIAL_ENV
256
+ monkeypatch.setenv(CREDENTIAL_ENV, openai_key)
257
+ embedder = get_embedder("openai:text-embedding-3-small")
258
+ assert embedder.model_name == "openai:text-embedding-3-small"
259
+ assert embedder.dimension == 1536
260
+ finally:
261
+ clear_embedder_cache()
262
+
263
+
264
+ def test_unknown_model_probes_dimension_lazily(openai_key):
265
+ calls = {"n": 0}
266
+
267
+ def fake_urlopen(request, timeout=None):
268
+ calls["n"] += 1
269
+ return _response([[0.6, 0.8]])
270
+
271
+ embedder = create_embedder("text-embedding-custom")
272
+ assert calls["n"] == 0
273
+ with patch("vera_embed_openai.provider.urllib.request.urlopen", fake_urlopen):
274
+ assert embedder.dimension == 2
275
+ assert calls["n"] == 1