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.
- vera_embed_openai-0.3.1/.gitignore +16 -0
- vera_embed_openai-0.3.1/PKG-INFO +77 -0
- vera_embed_openai-0.3.1/README.md +58 -0
- vera_embed_openai-0.3.1/pyproject.toml +40 -0
- vera_embed_openai-0.3.1/src/vera_embed_openai/__init__.py +81 -0
- vera_embed_openai-0.3.1/src/vera_embed_openai/options.py +124 -0
- vera_embed_openai-0.3.1/src/vera_embed_openai/provider.py +290 -0
- vera_embed_openai-0.3.1/tests/test_openai_embedder.py +275 -0
|
@@ -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
|