passageway-embedder-openai 0.3.0__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,11 @@
1
+ docs/superpowers/
2
+ .superpowers/
3
+ .env
4
+ .DS_Store
5
+ .venv/
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .models/
9
+ dist/
10
+ site/
11
+ .playwright-mcp/
@@ -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,63 @@
1
+ # passageway-embedder-openai
2
+
3
+ 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.
4
+
5
+ **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.
6
+
7
+ ## Configure
8
+
9
+ ```toml
10
+ [embedder]
11
+ kind = "command"
12
+ command = ["/path/to/python", "-m", "passageway_embedder_openai"]
13
+ # Ollama: nomic-embed-text wants these prefixes; DIMENSIONS lets passageway start before Ollama does
14
+ env = { BASE_URL = "http://localhost:11434/v1", MODEL = "nomic-embed-text", DIMENSIONS = "768",
15
+ QUERY_PREFIX = "search_query: ", PASSAGE_PREFIX = "search_document: " }
16
+ # min_similarity = ... # measure it for this model: passageway eval --sweep
17
+ timeout = 60 # the core's limit per request; keep it above TIMEOUT below
18
+
19
+ # OpenAI
20
+ # env = { MODEL = "text-embedding-3-small" }
21
+ # secrets = { API_KEY = "env:OPENAI_API_KEY" }
22
+
23
+ # Voyage
24
+ # env = { PROVIDER = "voyage", MODEL = "voyage-3" }
25
+ # secrets = { API_KEY = "env:VOYAGE_API_KEY" }
26
+ ```
27
+
28
+ | Setting | Default | Meaning |
29
+ |---|---|---|
30
+ | `MODEL` | required | The embedding model the service serves |
31
+ | `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 |
32
+ | `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` |
33
+ | `PROVIDER` | `openai` | `openai` for every OpenAI-compatible service, `voyage` for Voyage, which wants `input_type` (`query` or `document`) with each request |
34
+ | `QUERY_PREFIX`, `PASSAGE_PREFIX` | | Text put in front of every question or passage; e5-style models served locally want `query: ` and `passage: ` |
35
+ | `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 |
36
+ | `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 |
37
+ | `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 |
38
+
39
+ `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.
40
+
41
+ ## Behaviour
42
+
43
+ - **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.
44
+ - **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.
45
+ - **The key** is never printed: it is masked in every error, also when a gateway quotes the header back.
46
+ - **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.
47
+ - **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.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ uv tool install \
53
+ --with 'passageway-embedder-openai @ git+https://github.com/getpassageway/passageway#subdirectory=embedders/openai' \
54
+ git+https://github.com/getpassageway/passageway
55
+ ```
56
+
57
+ and use that environment's interpreter in `command` (`$(uv tool dir)/passageway/bin/python`), as for a connector.
58
+
59
+ ## Test
60
+
61
+ ```bash
62
+ uv run pytest embedders/openai # offline, against an in-test embeddings API
63
+ ```
@@ -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,21 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "passageway-embedder-openai"
7
+ version = "0.3.0"
8
+ description = "passageway embedder for OpenAI-compatible embedding services: Ollama, LM Studio, vLLM, OpenAI, Voyage"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "PolyForm-Noncommercial-1.0.0"
12
+ dependencies = ["httpx>=0.28.1", "truststore>=0.10"]
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["passageway_embedder_openai"]
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8.3", "passageway"]
19
+
20
+ [tool.uv.sources]
21
+ passageway = { workspace = true }
@@ -0,0 +1,12 @@
1
+ import pytest
2
+
3
+ from fake_openai import FakeApi, serve
4
+
5
+
6
+ @pytest.fixture
7
+ def api_server():
8
+ api = FakeApi()
9
+ server = serve(api)
10
+ yield f"http://127.0.0.1:{server.server_port}/v1", api
11
+ server.shutdown()
12
+ server.server_close()
@@ -0,0 +1,72 @@
1
+ """An in-test embeddings API: the JSON shapes of POST /v1/embeddings as OpenAI, Ollama and Voyage answer them."""
2
+
3
+ import hashlib
4
+ import json
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+
8
+
9
+ def vector(text: str, dim: int) -> list[float]:
10
+ """Deterministic, and different for different texts: hashed words into buckets (not normalised on purpose)."""
11
+ out = [0.0] * dim
12
+ for word in text.lower().split():
13
+ out[int.from_bytes(hashlib.blake2b(word.encode(), digest_size=4).digest(), "big") % dim] += 1.0
14
+ return out
15
+
16
+
17
+ class FakeApi:
18
+ def __init__(self):
19
+ self.dim = 16
20
+ self.requests: list[dict] = [] # {"path", "headers", "body"} per request
21
+ self.status_errors: list[int] = [] # answer the next requests with these statuses
22
+ self.retry_after = "0"
23
+ self.models = {"nomic-embed-text", "text-embedding-3-small", "voyage-3"}
24
+ self.ignore_dimensions = False # like a service that does not know the field
25
+ self.echo_auth = False # answer 401 with the Authorization header quoted, like some gateways do
26
+ self.redirect_to = None # answer 301 to this address
27
+ self.bad_body = None # answer 200 with this raw body
28
+
29
+ def respond(self, body: dict, headers: dict) -> tuple[int, dict]:
30
+ if self.echo_auth:
31
+ return 401, {"error": {"message": f"bad key {headers.get('Authorization', '')}"}}
32
+ if body.get("model") not in self.models:
33
+ return 404, {"error": {"message": f"model '{body.get('model')}' not found", "type": "invalid_request_error"}}
34
+ texts = body["input"] if isinstance(body["input"], list) else [body["input"]]
35
+ dim = int(body.get("dimensions") or self.dim) if not self.ignore_dimensions else self.dim
36
+ data = [{"object": "embedding", "index": i, "embedding": vector(t, dim)} for i, t in enumerate(texts)]
37
+ data.reverse() # out of order, as the API may answer: the client must sort by index
38
+ return 200, {"object": "list", "data": data, "model": body["model"], "usage": {"prompt_tokens": 1, "total_tokens": 1}}
39
+
40
+
41
+ def serve(api: FakeApi):
42
+ class Handler(BaseHTTPRequestHandler):
43
+ def do_POST(self):
44
+ raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
45
+ body = json.loads(raw) if raw else {}
46
+ api.requests.append({"path": self.path, "headers": dict(self.headers), "body": body})
47
+ if api.status_errors:
48
+ self.send_response(api.status_errors.pop(0))
49
+ self.send_header("Retry-After", api.retry_after)
50
+ self.end_headers()
51
+ return
52
+ if api.redirect_to:
53
+ self.send_response(301)
54
+ self.send_header("Location", api.redirect_to)
55
+ self.end_headers()
56
+ return
57
+ if api.bad_body is not None:
58
+ status, payload = 200, api.bad_body
59
+ else:
60
+ status, reply = api.respond(body, dict(self.headers))
61
+ payload = json.dumps(reply).encode()
62
+ self.send_response(status)
63
+ self.send_header("Content-Type", "application/json")
64
+ self.end_headers()
65
+ self.wfile.write(payload)
66
+
67
+ def log_message(self, *args):
68
+ pass
69
+
70
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
71
+ threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True).start()
72
+ return server
@@ -0,0 +1,300 @@
1
+ import io
2
+ import json
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from passageway_embedder_openai.embedder import Embedder, SettingsError
10
+
11
+ README = Path(__file__).resolve().parents[1] / "README.md"
12
+
13
+
14
+ def run(env, requests):
15
+ stdin = io.StringIO("".join(json.dumps(r) + "\n" for r in requests))
16
+ out, err = io.StringIO(), io.StringIO()
17
+ code = Embedder.run(env, stdin, out, err, sleep=lambda s: None)
18
+ return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
19
+
20
+
21
+ @pytest.fixture
22
+ def env(api_server):
23
+ url, _ = api_server
24
+ return {"BASE_URL": url, "MODEL": "nomic-embed-text"}
25
+
26
+
27
+ def test_info_learns_the_dimension_by_embedding_a_probe_and_names_model_and_host(env, api_server):
28
+ _, api = api_server
29
+ code, replies, _ = run(env, [{"op": "info"}])
30
+ assert code == 0 and replies[0]["dim"] == 16 and replies[0]["protocol"] == "1"
31
+ assert replies[0]["name"].startswith("openai:nomic-embed-text@127.0.0.1:")
32
+ assert len(api.requests) == 1 and api.requests[0]["body"]["input"] == ["passageway"]
33
+
34
+
35
+ def test_embed_posts_the_texts_and_returns_the_vectors_in_order(env, api_server):
36
+ _, api = api_server
37
+ code, replies, _ = run(env, [{"op": "embed", "kind": "passage", "texts": ["alpha beta", "gamma"]}])
38
+ assert code == 0 and len(replies[0]["vectors"]) == 2
39
+ assert api.requests[-1]["path"] == "/v1/embeddings" and api.requests[-1]["body"] == {"model": "nomic-embed-text", "input": ["alpha beta", "gamma"]}
40
+ from fake_openai import vector
41
+ assert replies[0]["vectors"] == [vector("alpha beta", 16), vector("gamma", 16)] # sorted by index, as sent
42
+
43
+
44
+ def test_the_api_key_goes_in_the_authorization_header_and_nowhere_else(env, api_server):
45
+ _, api = api_server
46
+ run({**env, "API_KEY": "sk-secret"}, [{"op": "embed", "kind": "query", "texts": ["x"]}])
47
+ assert api.requests[-1]["headers"]["Authorization"] == "Bearer sk-secret"
48
+ run(env, [{"op": "embed", "kind": "query", "texts": ["x"]}])
49
+ assert "Authorization" not in api.requests[-1]["headers"] # a local server needs none
50
+
51
+
52
+ def test_prefixes_are_added_per_kind(env, api_server):
53
+ _, api = api_server
54
+ run({**env, "QUERY_PREFIX": "query: ", "PASSAGE_PREFIX": "passage: "},
55
+ [{"op": "embed", "kind": "query", "texts": ["q"]}, {"op": "embed", "kind": "passage", "texts": ["p"]}])
56
+ assert [r["body"]["input"] for r in api.requests[-2:]] == [["query: q"], ["passage: p"]]
57
+
58
+
59
+ def test_dimensions_is_sent_and_answers_info_without_a_probe(env, api_server):
60
+ _, api = api_server
61
+ code, replies, _ = run({**env, "DIMENSIONS": "8"}, [{"op": "info"}, {"op": "embed", "kind": "query", "texts": ["x"]}])
62
+ assert code == 0 and replies[0]["dim"] == 8 and len(replies[1]["vectors"][0]) == 8
63
+ assert len(api.requests) == 1 and api.requests[0]["body"]["dimensions"] == 8
64
+
65
+
66
+ def test_voyage_sends_input_type_and_defaults_to_its_own_url(env, api_server):
67
+ _, api = api_server
68
+ run({**env, "PROVIDER": "voyage", "MODEL": "voyage-3"},
69
+ [{"op": "embed", "kind": "query", "texts": ["q"]}, {"op": "embed", "kind": "passage", "texts": ["p"]}])
70
+ assert [r["body"]["input_type"] for r in api.requests[-2:]] == ["query", "document"]
71
+ _, replies, _ = run({**env, "PROVIDER": "voyage", "MODEL": "voyage-3"}, [{"op": "info"}])
72
+ assert replies[0]["name"].startswith("voyage:voyage-3@")
73
+ from passageway_embedder_openai.embedder import parse_settings
74
+ assert parse_settings({"PROVIDER": "voyage", "MODEL": "voyage-3", "API_KEY": "k"}).base_url == "https://api.voyageai.com/v1"
75
+ assert parse_settings({"MODEL": "text-embedding-3-small", "API_KEY": "k"}).base_url == "https://api.openai.com/v1"
76
+
77
+
78
+ def test_a_name_override(env):
79
+ _, replies, _ = run({**env, "NAME": "my-ollama-nomic"}, [{"op": "info"}])
80
+ assert replies[0]["name"] == "my-ollama-nomic"
81
+
82
+
83
+ def test_a_failing_request_is_an_error_reply_and_the_process_goes_on(env, api_server):
84
+ _, api = api_server
85
+ code, replies, _ = run({**env, "MODEL": "no-such-model"},
86
+ [{"op": "embed", "kind": "query", "texts": ["x"]}, {"op": "info"}])
87
+ assert code == 0 and "error" in replies[0] and "no-such-model" in replies[0]["error"] and "404" in replies[0]["error"]
88
+ assert "error" in replies[1]
89
+
90
+
91
+ def test_the_key_never_appears_in_an_error(env, api_server):
92
+ _, api = api_server
93
+ api.status_errors = [401]
94
+ _, replies, err = run({**env, "API_KEY": "sk-secret"}, [{"op": "embed", "kind": "query", "texts": ["x"]}])
95
+ assert "sk-secret" not in json.dumps(replies) + err and "401" in replies[0]["error"]
96
+
97
+
98
+ def test_429_and_5xx_are_retried_with_backoff_and_retry_after(env, api_server):
99
+ _, api = api_server
100
+ api.status_errors = [429, 503, 502]
101
+ api.retry_after = "0"
102
+ code, replies, _ = run(env, [{"op": "embed", "kind": "query", "texts": ["x"]}])
103
+ assert code == 0 and "vectors" in replies[0] and len(api.requests) == 4
104
+
105
+
106
+ def test_giving_up_at_the_deadline_is_an_error_reply(env, api_server):
107
+ _, api = api_server
108
+ api.status_errors = [503] * 1000
109
+ _, replies, _ = run({**env, "TIMEOUT": "300"}, [{"op": "embed", "kind": "query", "texts": ["x"]}])
110
+ assert "error" in replies[0] and "503" in replies[0]["error"] and "300 s" in replies[0]["error"]
111
+
112
+
113
+ def test_more_than_five_429s_are_waited_out_within_a_long_timeout(env, api_server):
114
+ _, api = api_server
115
+ api.status_errors = [429] * 8
116
+ waited = []
117
+ stdin = io.StringIO('{"op": "embed", "kind": "query", "texts": ["x"]}\n')
118
+ out = io.StringIO()
119
+ Embedder.run({**env, "TIMEOUT": "1000"}, stdin, out, io.StringIO(), sleep=waited.append)
120
+ assert "vectors" in json.loads(out.getvalue()) and len(api.requests) == 9 and sum(waited) > 120
121
+
122
+
123
+ def test_a_retry_after_beyond_30_seconds_is_honoured_within_timeout(env, api_server):
124
+ _, api = api_server
125
+ api.status_errors, api.retry_after = [429], "200"
126
+ waited = []
127
+ stdin = io.StringIO('{"op": "embed", "kind": "query", "texts": ["x"]}\n')
128
+ out = io.StringIO()
129
+ Embedder.run({**env, "TIMEOUT": "1000"}, stdin, out, io.StringIO(), sleep=waited.append)
130
+ assert "vectors" in json.loads(out.getvalue()) and waited == [200]
131
+
132
+
133
+ def test_a_server_that_is_not_there_is_an_error_reply_not_a_crash():
134
+ _, replies, _ = run({"BASE_URL": "http://127.0.0.1:9/v1", "MODEL": "m"}, [{"op": "info"}])
135
+ assert "error" in replies[0] and "127.0.0.1:9" in replies[0]["error"]
136
+
137
+
138
+ @pytest.mark.parametrize("env,message", [
139
+ ({}, "MODEL"),
140
+ ({"MODEL": "m", "BASE_URL": "ftp://x"}, "BASE_URL"),
141
+ ({"MODEL": "m", "PROVIDER": "cohere"}, "PROVIDER"),
142
+ ({"MODEL": "m", "BASE_URL": "http://localhost:11434/v1", "DIMENSIONS": "big"}, "DIMENSIONS"),
143
+ ({"MODEL": "m", "BASE_URL": "http://localhost:11434/v1", "DIMENSIONS": "0"}, "DIMENSIONS"),
144
+ ({"MODEL": "m", "BASE_URL": "http://localhost:11434/v1", "TIMEOUT": "soon"}, "TIMEOUT"),
145
+ ({"MODEL": "m", "PROVIDER": "voyage"}, "API_KEY"), # a cloud provider needs a key
146
+ ({"MODEL": "m"}, "API_KEY"), # api.openai.com too
147
+ ])
148
+ def test_bad_settings_exit_2_with_a_message_and_no_output(env, message):
149
+ stdin, out, err = io.StringIO('{"op": "info"}\n'), io.StringIO(), io.StringIO()
150
+ assert Embedder.run(env, stdin, out, err) == 2
151
+ assert out.getvalue() == "" and message in err.getvalue()
152
+
153
+
154
+ def test_an_empty_text_list_is_an_empty_reply_without_a_request(env, api_server):
155
+ _, api = api_server
156
+ _, replies, _ = run(env, [{"op": "embed", "kind": "query", "texts": []}])
157
+ assert replies == [{"vectors": []}] and api.requests == []
158
+
159
+
160
+ def test_every_setting_is_in_the_readme():
161
+ code = (Path(__file__).resolve().parents[1] / "passageway_embedder_openai" / "embedder.py").read_text()
162
+ names = sorted(set(re.findall(r'env\.get\(\s*"([A-Z_]+)"', code)))
163
+ assert len(names) >= 8, names
164
+ readme = README.read_text()
165
+ for name in names:
166
+ assert f"`{name}`" in readme, f"{name} is read by the embedder but missing from its README"
167
+ assert "third party" in readme or "third-party" in readme # the privacy sentence
168
+
169
+
170
+ def test_the_contract_check_passes_as_a_real_subprocess(api_server):
171
+ url, _ = api_server
172
+ from passageway.embed_command import CommandEmbedder
173
+ from passageway.testing.embedder_contract import check_embedder
174
+ embedder = CommandEmbedder("openai", [sys.executable, "-m", "passageway_embedder_openai"],
175
+ env={"BASE_URL": url, "MODEL": "nomic-embed-text"})
176
+ try:
177
+ check_embedder(embedder)
178
+ assert embedder.dim == 16
179
+ finally:
180
+ embedder.close()
181
+
182
+
183
+ # ---- review findings ------------------------------------------------------------------------------
184
+
185
+ def test_dimensions_is_checked_against_what_the_service_really_returns(env, api_server):
186
+ _, api = api_server
187
+ api.ignore_dimensions = True
188
+ _, replies, _ = run({**env, "DIMENSIONS": "8"}, [{"op": "info"}, {"op": "embed", "kind": "query", "texts": ["x"]}])
189
+ assert replies[0]["dim"] == 8 and "error" in replies[1] and "8" in replies[1]["error"] and "16" in replies[1]["error"]
190
+
191
+
192
+ def test_voyage_sends_output_dimension_not_dimensions(env, api_server):
193
+ _, api = api_server
194
+ run({**env, "PROVIDER": "voyage", "MODEL": "voyage-3", "DIMENSIONS": "8"}, [{"op": "embed", "kind": "query", "texts": ["x"]}])
195
+ body = api.requests[-1]["body"]
196
+ assert body.get("output_dimension") == 8 and "dimensions" not in body
197
+
198
+
199
+ def test_the_whole_request_including_retries_stays_within_timeout(env, api_server):
200
+ _, api = api_server
201
+ api.status_errors = [503] * 5
202
+ api.retry_after = "100"
203
+ waited = []
204
+ stdin = io.StringIO('{"op": "embed", "kind": "query", "texts": ["x"]}\n')
205
+ out, err = io.StringIO(), io.StringIO()
206
+ Embedder.run({**env, "TIMEOUT": "2"}, stdin, out, err, sleep=lambda s: waited.append(s))
207
+ reply = json.loads(out.getvalue())
208
+ assert "error" in reply and "503" in reply["error"]
209
+ assert sum(waited) <= 2 and len(api.requests) <= 3 # it gave up within the budget, not after five long waits
210
+
211
+
212
+ def test_retry_after_is_honoured_and_clamped(env, api_server):
213
+ _, api = api_server
214
+ api.status_errors = [429]
215
+ api.retry_after = "3"
216
+ waited = []
217
+ stdin = io.StringIO('{"op": "embed", "kind": "query", "texts": ["x"]}\n')
218
+ Embedder.run({**env, "TIMEOUT": "30"}, stdin, io.StringIO(), io.StringIO(), sleep=lambda s: waited.append(s))
219
+ assert waited == [3.0]
220
+ for value in ("-5", "nan", "Wed, 21 Oct 2015 07:28:00 GMT"):
221
+ api.status_errors = [429]
222
+ api.retry_after = value
223
+ waited.clear()
224
+ stdin = io.StringIO('{"op": "embed", "kind": "query", "texts": ["x"]}\n')
225
+ out = io.StringIO()
226
+ Embedder.run({**env, "TIMEOUT": "30"}, stdin, out, io.StringIO(), sleep=lambda s: waited.append(s))
227
+ assert "vectors" in json.loads(out.getvalue()) and len(waited) == 1 and 0 <= waited[0] <= 30
228
+
229
+
230
+ def test_a_connection_error_is_retried_but_a_read_timeout_is_not(env, api_server, monkeypatch):
231
+ import httpx
232
+ calls = []
233
+
234
+ class Client:
235
+ def __init__(self, *a, **k): pass
236
+ def post(self, url, json, timeout=None):
237
+ calls.append(json)
238
+ raise (httpx.ConnectError("refused") if len(calls) < 3 else httpx.ReadTimeout("slow"))
239
+ monkeypatch.setattr(httpx, "Client", Client)
240
+ _, replies, _ = run(env, [{"op": "embed", "kind": "query", "texts": ["x"]}])
241
+ assert "error" in replies[0] and "ReadTimeout" in replies[0]["error"] and len(calls) == 3 # 2 retries, then no retry
242
+
243
+
244
+ def test_a_key_quoted_by_the_service_is_masked(env, api_server):
245
+ _, api = api_server
246
+ api.echo_auth = True
247
+ _, replies, _ = run({**env, "API_KEY": "sk-secret"}, [{"op": "embed", "kind": "query", "texts": ["x"]}])
248
+ assert "sk-secret" not in json.dumps(replies) and "***" in replies[0]["error"]
249
+
250
+
251
+ def test_credentials_in_the_base_url_are_refused():
252
+ out, err = io.StringIO(), io.StringIO()
253
+ assert Embedder.run({"MODEL": "m", "BASE_URL": "http://user:pw@localhost:11434/v1"}, io.StringIO('{"op":"info"}\n'), out, err) == 2
254
+ assert "API_KEY" in err.getvalue() and "pw" not in err.getvalue().replace("API_KEY", "")
255
+
256
+
257
+ @pytest.mark.parametrize("env_extra,message", [
258
+ ({"DIMENSIONS": "²"}, "DIMENSIONS"), ({"TIMEOUT": "nan"}, "TIMEOUT"), ({"TIMEOUT": "inf"}, "TIMEOUT"),
259
+ ({"API_KEY": "sk-’"}, "API_KEY"),
260
+ ])
261
+ def test_more_settings_that_must_be_refused_cleanly(env_extra, message):
262
+ out, err = io.StringIO(), io.StringIO()
263
+ env = {"MODEL": "m", "BASE_URL": "http://localhost:11434/v1", **env_extra}
264
+ assert Embedder.run(env, io.StringIO('{"op":"info"}\n'), out, err) == 2 and message in err.getvalue()
265
+
266
+
267
+ def test_a_redirect_and_a_base_url_ending_in_embeddings_give_clear_answers(env, api_server):
268
+ url, api = api_server
269
+ api.redirect_to = "https://elsewhere.example/v1/embeddings"
270
+ _, replies, _ = run(env, [{"op": "info"}])
271
+ assert "redirect" in replies[0]["error"] and "elsewhere.example" in replies[0]["error"]
272
+ api.redirect_to = None
273
+ from passageway_embedder_openai.embedder import parse_settings
274
+ assert parse_settings({**env, "BASE_URL": url + "/embeddings"}).base_url == url
275
+
276
+
277
+ @pytest.mark.parametrize("body", [b'{"data": [{"index": 0, "embedding": "abc"}]}', b'{"data": [{"index": 0, "embedding": null}]}',
278
+ b'{"data": [{"index": 0, "embedding": [NaN, 1.0]}]}', b'{"data": [{"index": 0, "embedding": []}]}',
279
+ b"[" * 100000])
280
+ def test_a_service_answer_that_is_not_vectors_is_an_error_reply_not_a_crash(env, api_server, body):
281
+ _, api = api_server
282
+ api.bad_body = body
283
+ code, replies, _ = run(env, [{"op": "embed", "kind": "query", "texts": ["x"]}, {"op": "info"}])
284
+ assert code == 0 and len(replies) == 2 and all("error" in r for r in replies)
285
+
286
+
287
+ @pytest.mark.parametrize("request_", ['{"op": "embed", "kind": "query", "texts": "abc"}', '{"op": "embed", "kind": "other", "texts": ["a"]}',
288
+ "[" * 100000])
289
+ def test_a_bad_request_line_is_an_error_reply_and_the_process_goes_on(env, request_):
290
+ stdin = io.StringIO(request_ + '\n{"op": "info"}\n')
291
+ out = io.StringIO()
292
+ assert Embedder.run(env, stdin, out, io.StringIO(), sleep=lambda s: None) == 0
293
+ replies = [json.loads(line) for line in out.getvalue().splitlines()]
294
+ assert "error" in replies[0] and "dim" in replies[1]
295
+
296
+
297
+ def test_the_name_changes_with_the_prefixes_and_carries_the_port(env):
298
+ plain = run(env, [{"op": "info"}])[1][0]["name"]
299
+ prefixed = run({**env, "QUERY_PREFIX": "search_query: "}, [{"op": "info"}])[1][0]["name"]
300
+ assert plain != prefixed and plain.startswith("openai:nomic-embed-text@127.0.0.1:") and prefixed.startswith(plain)