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.
- passageway_embedder_openai-0.3.0/.gitignore +11 -0
- passageway_embedder_openai-0.3.0/PKG-INFO +73 -0
- passageway_embedder_openai-0.3.0/README.md +63 -0
- passageway_embedder_openai-0.3.0/passageway_embedder_openai/__init__.py +1 -0
- passageway_embedder_openai-0.3.0/passageway_embedder_openai/__main__.py +3 -0
- passageway_embedder_openai-0.3.0/passageway_embedder_openai/embedder.py +227 -0
- passageway_embedder_openai-0.3.0/pyproject.toml +21 -0
- passageway_embedder_openai-0.3.0/tests/conftest.py +12 -0
- passageway_embedder_openai-0.3.0/tests/fake_openai.py +72 -0
- passageway_embedder_openai-0.3.0/tests/test_openai_embedder.py +300 -0
|
@@ -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 @@
|
|
|
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,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,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)
|