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.
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,3 @@
1
+ from passageway_embedder_openai.embedder import main
2
+
3
+ main()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any