passageway-embedder-openai 0.3.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.
- passageway_embedder_openai/__init__.py +1 -0
- passageway_embedder_openai/__main__.py +3 -0
- passageway_embedder_openai/embedder.py +227 -0
- passageway_embedder_openai-0.3.0.dist-info/METADATA +73 -0
- passageway_embedder_openai-0.3.0.dist-info/RECORD +6 -0
- passageway_embedder_openai-0.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""passageway embedder for OpenAI-compatible embedding services (Ollama, LM Studio, vLLM, OpenAI) and Voyage.
|
|
2
|
+
|
|
3
|
+
Speaks the embedder protocol (https://passageway.marceltov.de/reference/embedder-protocol/) on stdin/stdout; every setting
|
|
4
|
+
comes from the environment and is documented in README.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import ssl
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from email.utils import parsedate_to_datetime
|
|
17
|
+
from typing import TextIO
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import truststore
|
|
22
|
+
|
|
23
|
+
PROVIDERS = {"openai": "https://api.openai.com/v1", "voyage": "https://api.voyageai.com/v1"}
|
|
24
|
+
CLOUD_HOSTS = {"api.openai.com", "api.voyageai.com"}
|
|
25
|
+
PROBE = "passageway" # the word embedded once at `info` to learn the dimension
|
|
26
|
+
MAX_BACKOFF = 60 # the longest wait the embedder picks itself; a Retry-After may ask for more, up to TIMEOUT
|
|
27
|
+
RETRY_STATUS = (429, 500, 502, 503, 504)
|
|
28
|
+
RETRY_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError, httpx.PoolTimeout) # never a read timeout:
|
|
29
|
+
# a request that timed out while being answered may already have been charged for
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SettingsError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Settings:
|
|
38
|
+
provider: str
|
|
39
|
+
base_url: str
|
|
40
|
+
model: str
|
|
41
|
+
api_key: str
|
|
42
|
+
query_prefix: str
|
|
43
|
+
passage_prefix: str
|
|
44
|
+
dimensions: int | None
|
|
45
|
+
timeout: float
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_settings(env: Mapping[str, str]) -> Settings:
|
|
50
|
+
provider = env.get("PROVIDER", "").strip() or "openai"
|
|
51
|
+
if provider not in PROVIDERS:
|
|
52
|
+
raise SettingsError(f"PROVIDER must be one of {', '.join(PROVIDERS)}, got {provider!r}")
|
|
53
|
+
model = env.get("MODEL", "").strip()
|
|
54
|
+
if not model:
|
|
55
|
+
raise SettingsError("MODEL is required: the embedding model the service serves, e.g. nomic-embed-text")
|
|
56
|
+
base_url = (env.get("BASE_URL", "").strip() or PROVIDERS[provider]).rstrip("/")
|
|
57
|
+
if base_url.endswith("/embeddings"):
|
|
58
|
+
base_url = base_url[: -len("/embeddings")] # the endpoint is added here
|
|
59
|
+
parsed = urlparse(base_url)
|
|
60
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
61
|
+
raise SettingsError(f"BASE_URL must be an http(s) URL such as http://localhost:11434/v1, got {base_url!r}")
|
|
62
|
+
if parsed.username or parsed.password:
|
|
63
|
+
raise SettingsError("BASE_URL must not carry credentials: put the key in API_KEY (as a secret reference)")
|
|
64
|
+
api_key = env.get("API_KEY", "").strip()
|
|
65
|
+
if parsed.hostname in CLOUD_HOSTS and not api_key:
|
|
66
|
+
raise SettingsError(f"API_KEY is required for {parsed.hostname} (as a secret reference, never a value)")
|
|
67
|
+
if not api_key.isascii():
|
|
68
|
+
raise SettingsError("API_KEY holds a character that cannot go into an HTTP header; check for a pasted quote or space")
|
|
69
|
+
raw = env.get("DIMENSIONS", "").strip()
|
|
70
|
+
dimensions = None
|
|
71
|
+
if raw:
|
|
72
|
+
if not raw.isdecimal() or int(raw) < 1:
|
|
73
|
+
raise SettingsError(f"DIMENSIONS must be a whole number from 1, got {raw!r}")
|
|
74
|
+
dimensions = int(raw)
|
|
75
|
+
try:
|
|
76
|
+
timeout = float(env.get("TIMEOUT", "").strip() or 50)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise SettingsError(f"TIMEOUT must be a number of seconds, got {env.get('TIMEOUT')!r}") from None
|
|
79
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
80
|
+
raise SettingsError("TIMEOUT must be a number of seconds greater than 0")
|
|
81
|
+
query_prefix, passage_prefix = env.get("QUERY_PREFIX", ""), env.get("PASSAGE_PREFIX", "")
|
|
82
|
+
name = env.get("NAME", "").strip()
|
|
83
|
+
if not name:
|
|
84
|
+
host = parsed.hostname + (f":{parsed.port}" if parsed.port else "")
|
|
85
|
+
name = f"{provider}:{model}@{host}"
|
|
86
|
+
if query_prefix or passage_prefix: # a prefix changes every vector, so it is part of the identity
|
|
87
|
+
name += "#" + hashlib.sha256(f"{query_prefix}\0{passage_prefix}".encode()).hexdigest()[:6]
|
|
88
|
+
return Settings(provider, base_url, model, api_key, query_prefix, passage_prefix, dimensions, timeout, name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ApiError(Exception):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _retry_after(header: str | None, default: float) -> float:
|
|
96
|
+
if header:
|
|
97
|
+
try:
|
|
98
|
+
value = float(header)
|
|
99
|
+
except ValueError:
|
|
100
|
+
try:
|
|
101
|
+
value = (parsedate_to_datetime(header) - parsedate_to_datetime(time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()))).total_seconds()
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
value = default
|
|
104
|
+
if math.isfinite(value):
|
|
105
|
+
return max(value, 0.0) # how long is up to the deadline
|
|
106
|
+
return max(default, 0.0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Embedder:
|
|
110
|
+
def __init__(self, settings: Settings, *, sleep=time.sleep):
|
|
111
|
+
self.s = settings
|
|
112
|
+
self._sleep = sleep
|
|
113
|
+
headers = {"Authorization": f"Bearer {settings.api_key}"} if settings.api_key else {}
|
|
114
|
+
self._http = httpx.Client(headers=headers, timeout=settings.timeout, follow_redirects=False,
|
|
115
|
+
verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT))
|
|
116
|
+
self._dim = settings.dimensions
|
|
117
|
+
|
|
118
|
+
def _mask(self, text: str) -> str:
|
|
119
|
+
return text.replace(self.s.api_key, "***") if self.s.api_key else text
|
|
120
|
+
|
|
121
|
+
def _post(self, texts: list[str], kind: str) -> list[list[float]]:
|
|
122
|
+
body: dict = {"model": self.s.model, "input": texts}
|
|
123
|
+
if self.s.dimensions:
|
|
124
|
+
body["output_dimension" if self.s.provider == "voyage" else "dimensions"] = self.s.dimensions
|
|
125
|
+
if self.s.provider == "voyage":
|
|
126
|
+
body["input_type"] = "query" if kind == "query" else "document"
|
|
127
|
+
start, waited, attempt = time.monotonic(), 0.0, 0 # the whole request, retries included, stays within TIMEOUT
|
|
128
|
+
while True:
|
|
129
|
+
# the slept seconds count even when the sleep does not pass them on the clock (an injected one)
|
|
130
|
+
remaining = self.s.timeout - max(time.monotonic() - start, waited)
|
|
131
|
+
if remaining <= 0:
|
|
132
|
+
break
|
|
133
|
+
wait = min(2 ** attempt, MAX_BACKOFF)
|
|
134
|
+
attempt += 1
|
|
135
|
+
try:
|
|
136
|
+
response = self._http.post(f"{self.s.base_url}/embeddings", json=body, timeout=remaining)
|
|
137
|
+
except RETRY_ERRORS as e:
|
|
138
|
+
response, reason = None, f"{self.s.base_url}: {type(e).__name__}: {e}"
|
|
139
|
+
except httpx.HTTPError as e: # a read timeout or another error: not repeated
|
|
140
|
+
raise ApiError(f"{self.s.base_url}: {type(e).__name__}: {e}") from None
|
|
141
|
+
if response is not None:
|
|
142
|
+
if response.status_code in RETRY_STATUS:
|
|
143
|
+
reason = f"HTTP {response.status_code} from {self.s.base_url}"
|
|
144
|
+
wait = max(wait, _retry_after(response.headers.get("Retry-After"), wait))
|
|
145
|
+
elif 300 <= response.status_code < 400:
|
|
146
|
+
raise ApiError(f"{self.s.base_url} answered with a redirect to {response.headers.get('Location', '?')}: "
|
|
147
|
+
"set BASE_URL to the final address")
|
|
148
|
+
elif response.status_code >= 400:
|
|
149
|
+
detail = ""
|
|
150
|
+
try:
|
|
151
|
+
detail = response.json().get("error", {}).get("message", "")
|
|
152
|
+
except (ValueError, AttributeError):
|
|
153
|
+
pass
|
|
154
|
+
raise ApiError(f"HTTP {response.status_code} from {self.s.base_url}: {detail or response.text[:200]}")
|
|
155
|
+
else:
|
|
156
|
+
return self._vectors(response, len(texts))
|
|
157
|
+
if wait >= self.s.timeout - max(time.monotonic() - start, waited):
|
|
158
|
+
break
|
|
159
|
+
self._sleep(wait)
|
|
160
|
+
waited += wait
|
|
161
|
+
raise ApiError(f"giving up after {attempt} attempt(s) within {self.s.timeout:g} s: {reason}")
|
|
162
|
+
|
|
163
|
+
def _vectors(self, response: httpx.Response, expected: int) -> list[list[float]]:
|
|
164
|
+
try:
|
|
165
|
+
data = response.json()["data"]
|
|
166
|
+
rows = sorted(data, key=lambda d: d["index"])
|
|
167
|
+
vectors = [row["embedding"] for row in rows]
|
|
168
|
+
except (ValueError, KeyError, TypeError, RecursionError):
|
|
169
|
+
raise ApiError(f"the service did not answer with {{data: [{{index, embedding}}]}}: {response.text[:200]}") from None
|
|
170
|
+
if len(vectors) != expected:
|
|
171
|
+
raise ApiError(f"the service returned {len(vectors)} vectors for {expected} texts")
|
|
172
|
+
for v in vectors:
|
|
173
|
+
if not (isinstance(v, list) and v and all(isinstance(x, (int, float)) and not isinstance(x, bool)
|
|
174
|
+
and math.isfinite(x) for x in v)):
|
|
175
|
+
raise ApiError("the service returned an embedding that is not a non-empty list of finite numbers")
|
|
176
|
+
if self._dim is not None and len(vectors[0]) != self._dim:
|
|
177
|
+
raise ApiError(f"the service returned vectors with {len(vectors[0])} dimensions, but DIMENSIONS (or the probe) "
|
|
178
|
+
f"says {self._dim}: the model does not honour the setting")
|
|
179
|
+
return vectors
|
|
180
|
+
|
|
181
|
+
def embed(self, kind: str, texts: list[str]) -> list[list[float]]:
|
|
182
|
+
if not texts:
|
|
183
|
+
return []
|
|
184
|
+
prefix = self.s.query_prefix if kind == "query" else self.s.passage_prefix
|
|
185
|
+
return self._post([prefix + t for t in texts], kind)
|
|
186
|
+
|
|
187
|
+
def info(self) -> dict:
|
|
188
|
+
if self._dim is None:
|
|
189
|
+
self._dim = len(self._post([PROBE], "query")[0])
|
|
190
|
+
return {"name": self.s.name, "dim": self._dim, "protocol": "1"}
|
|
191
|
+
|
|
192
|
+
# -- the protocol loop -------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def run(cls, env: Mapping[str, str], stdin: TextIO, stdout: TextIO, stderr: TextIO, *, sleep=time.sleep) -> int:
|
|
196
|
+
try:
|
|
197
|
+
settings = parse_settings(env)
|
|
198
|
+
except SettingsError as e:
|
|
199
|
+
print(f"openai embedder: {e}", file=stderr)
|
|
200
|
+
return 2
|
|
201
|
+
embedder = cls(settings, sleep=sleep)
|
|
202
|
+
for line in stdin:
|
|
203
|
+
if not line.strip():
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
request = json.loads(line)
|
|
207
|
+
if not isinstance(request, dict):
|
|
208
|
+
raise ValueError("a request is a JSON object")
|
|
209
|
+
if request.get("op") == "info":
|
|
210
|
+
reply = embedder.info()
|
|
211
|
+
elif request.get("op") == "embed":
|
|
212
|
+
kind, texts = request.get("kind"), request.get("texts")
|
|
213
|
+
if kind not in ("query", "passage") or not isinstance(texts, list) or not all(isinstance(t, str) for t in texts):
|
|
214
|
+
raise ValueError("embed needs kind query|passage and texts as a list of strings")
|
|
215
|
+
reply = {"vectors": embedder.embed(kind, texts)}
|
|
216
|
+
else:
|
|
217
|
+
reply = {"error": f"unknown op {request.get('op')!r}"}
|
|
218
|
+
except (ApiError, httpx.HTTPError) as e:
|
|
219
|
+
reply = {"error": embedder._mask(str(e))}
|
|
220
|
+
except (ValueError, KeyError, TypeError, RecursionError) as e:
|
|
221
|
+
reply = {"error": f"bad request: {e}"}
|
|
222
|
+
print(json.dumps(reply), file=stdout, flush=True)
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> None:
|
|
227
|
+
sys.exit(Embedder.run(os.environ, sys.stdin, sys.stdout, sys.stderr))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-embedder-openai
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: passageway embedder for OpenAI-compatible embedding services: Ollama, LM Studio, vLLM, OpenAI, Voyage
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: truststore>=0.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# passageway-embedder-openai
|
|
12
|
+
|
|
13
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) embedder for services that speak the OpenAI embeddings API: **Ollama**, **LM Studio**, **vLLM**, **OpenAI** itself, and **Voyage** (whose API differs in one field). It speaks the [embedder protocol](https://passageway.marceltov.de/reference/embedder-protocol/), so passageway runs it as `kind = "command"` and the core does not change.
|
|
14
|
+
|
|
15
|
+
**Where your text goes:** every indexed passage and every question is sent to the service at `BASE_URL`. With a local Ollama or LM Studio that is your own machine; with OpenAI or Voyage it is a third party, under their terms. passageway's default stays local; this embedder is your explicit choice.
|
|
16
|
+
|
|
17
|
+
## Configure
|
|
18
|
+
|
|
19
|
+
```toml
|
|
20
|
+
[embedder]
|
|
21
|
+
kind = "command"
|
|
22
|
+
command = ["/path/to/python", "-m", "passageway_embedder_openai"]
|
|
23
|
+
# Ollama: nomic-embed-text wants these prefixes; DIMENSIONS lets passageway start before Ollama does
|
|
24
|
+
env = { BASE_URL = "http://localhost:11434/v1", MODEL = "nomic-embed-text", DIMENSIONS = "768",
|
|
25
|
+
QUERY_PREFIX = "search_query: ", PASSAGE_PREFIX = "search_document: " }
|
|
26
|
+
# min_similarity = ... # measure it for this model: passageway eval --sweep
|
|
27
|
+
timeout = 60 # the core's limit per request; keep it above TIMEOUT below
|
|
28
|
+
|
|
29
|
+
# OpenAI
|
|
30
|
+
# env = { MODEL = "text-embedding-3-small" }
|
|
31
|
+
# secrets = { API_KEY = "env:OPENAI_API_KEY" }
|
|
32
|
+
|
|
33
|
+
# Voyage
|
|
34
|
+
# env = { PROVIDER = "voyage", MODEL = "voyage-3" }
|
|
35
|
+
# secrets = { API_KEY = "env:VOYAGE_API_KEY" }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Setting | Default | Meaning |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `MODEL` | required | The embedding model the service serves |
|
|
41
|
+
| `BASE_URL` | `https://api.openai.com/v1`, or Voyage's for `PROVIDER = "voyage"` | The service; `POST {BASE_URL}/embeddings` is called (a trailing `/embeddings` is dropped). No credentials in the URL |
|
|
42
|
+
| `API_KEY` | | Sent as `Authorization: Bearer`. Required for api.openai.com and api.voyageai.com, usually not for a local server. Put it in `secrets`, never in `env` |
|
|
43
|
+
| `PROVIDER` | `openai` | `openai` for every OpenAI-compatible service, `voyage` for Voyage, which wants `input_type` (`query` or `document`) with each request |
|
|
44
|
+
| `QUERY_PREFIX`, `PASSAGE_PREFIX` | | Text put in front of every question or passage; e5-style models served locally want `query: ` and `passage: ` |
|
|
45
|
+
| `DIMENSIONS` | | The vector length. Set, it answers `info` without a probe, so passageway can start while the service is still down; it is also sent as `dimensions` (`output_dimension` for Voyage) for models that can shorten their vectors. The first answer is checked against it |
|
|
46
|
+
| `TIMEOUT` | `50` | Seconds for one request including its retries. Keep the core's `timeout` for this embedder above it, or the core kills the process before this program can report what went wrong |
|
|
47
|
+
| `NAME` | `<provider>:<model>@<host>[:port][#prefixes]` | The embedder's name as the index records it. A prefix changes every vector, so a short hash of the prefixes is part of it; set your own when the model behind a name changes |
|
|
48
|
+
|
|
49
|
+
`min_similarity` belongs to the model: similarity scales differ, so measure it with [`passageway eval --sweep`](https://passageway.marceltov.de/guides/evaluate/) before relying on it.
|
|
50
|
+
|
|
51
|
+
## Behaviour
|
|
52
|
+
|
|
53
|
+
- **The dimension** is learned at start by embedding one word (`passageway`), unless `DIMENSIONS` is set. Without `DIMENSIONS`, a service that is down when passageway starts keeps passageway in its error state until passageway is restarted; with it, passageway starts and the first `recall` succeeds once the service is up.
|
|
54
|
+
- **Retries:** 429 and 5xx answers and connection errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honoured) for as long as `TIMEOUT` allows: `TIMEOUT` alone bounds a request, retries included, so raise it to wait out a longer rate limit. A request that timed out while being answered is not repeated, because a paid service may already have charged for it. Any other HTTP error, and giving up, is an error reply to the core for that request; the process stays up.
|
|
55
|
+
- **The key** is never printed: it is masked in every error, also when a gateway quotes the header back.
|
|
56
|
+
- **Tested against a fake** of the API in this repository, not against any live service; Voyage is written from its public API documentation. Recent Ollama versions serve the OpenAI-compatible `/v1/embeddings`; older ones only `/api/embed`, which this embedder does not speak.
|
|
57
|
+
- **Batches:** the core sends at most `batch_size` texts per request (32 by default); the services accept far more, so raise it for a cloud model if you like.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv tool install \
|
|
63
|
+
--with 'passageway-embedder-openai @ git+https://github.com/getpassageway/passageway#subdirectory=embedders/openai' \
|
|
64
|
+
git+https://github.com/getpassageway/passageway
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
and use that environment's interpreter in `command` (`$(uv tool dir)/passageway/bin/python`), as for a connector.
|
|
68
|
+
|
|
69
|
+
## Test
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv run pytest embedders/openai # offline, against an in-test embeddings API
|
|
73
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
passageway_embedder_openai/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
2
|
+
passageway_embedder_openai/__main__.py,sha256=4YwPODulgYxt62Z2gys7787mTmoYRDA5fHCmq4SinD4,61
|
|
3
|
+
passageway_embedder_openai/embedder.py,sha256=mbO9FYAcWjpBNyrFmF8T1FyDcd5WzjaxwRhIkAgfrHY,11096
|
|
4
|
+
passageway_embedder_openai-0.3.0.dist-info/METADATA,sha256=vYCnO0ud9KgQeOg6ILCgnlk4vpRY4WrLcXQS81TzfZg,5419
|
|
5
|
+
passageway_embedder_openai-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
passageway_embedder_openai-0.3.0.dist-info/RECORD,,
|