@pentatonic-ai/ai-agent-sdk 0.9.5 → 0.10.0

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.
Files changed (131) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +39 -72
  5. package/dist/index.js +36 -69
  6. package/package.json +10 -3
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/package-lock.json +49 -33
  12. package/packages/memory/package.json +4 -1
  13. package/packages/memory/src/__tests__/engine.test.js +40 -5
  14. package/packages/memory/src/engine.js +38 -3
  15. package/packages/memory/src/server.js +2 -2
  16. package/packages/memory-engine-v2/.env.example +30 -0
  17. package/packages/memory-engine-v2/README.md +125 -0
  18. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  19. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  20. package/packages/memory-engine-v2/compat/server.py +1047 -0
  21. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  22. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  23. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  24. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  25. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  26. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  27. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  28. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  29. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  30. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  31. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  32. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  33. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  34. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  35. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  36. package/packages/memory-engine/.env.example +0 -13
  37. package/packages/memory-engine/MIGRATION.md +0 -219
  38. package/packages/memory-engine/README.md +0 -145
  39. package/packages/memory-engine/bench/README.md +0 -99
  40. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  41. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  42. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  43. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  44. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  45. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  48. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  49. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  50. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  51. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  60. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  61. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  62. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  63. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  66. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  67. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  68. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  69. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  78. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  79. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  80. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  81. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  82. package/packages/memory-engine/compat/Dockerfile +0 -22
  83. package/packages/memory-engine/compat/server.py +0 -1255
  84. package/packages/memory-engine/docker-compose.test.yml +0 -59
  85. package/packages/memory-engine/docker-compose.yml +0 -240
  86. package/packages/memory-engine/engine/README.md +0 -52
  87. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  88. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  89. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  90. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  91. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -468
  92. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  93. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  94. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  95. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  96. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  97. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  98. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  99. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  100. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  101. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  102. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  103. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  104. package/packages/memory-engine/pme_memory/embed.py +0 -74
  105. package/packages/memory-engine/pme_memory/health.py +0 -36
  106. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  107. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  108. package/packages/memory-engine/pme_memory/needs.py +0 -55
  109. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  110. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  111. package/packages/memory-engine/pme_memory/search.py +0 -52
  112. package/packages/memory-engine/pme_memory/store.py +0 -86
  113. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  114. package/packages/memory-engine/pyproject.toml +0 -65
  115. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  116. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  117. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  118. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  119. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  120. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  121. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  122. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  123. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  124. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  125. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  126. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  127. package/packages/memory-engine/tests/test_embed_provider.py +0 -492
  128. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  129. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  130. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  131. package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
@@ -1,492 +0,0 @@
1
- """Unit tests for engine/services/_shared/embed_provider.py.
2
-
3
- Run with:
4
- cd packages/memory-engine
5
- python -m pytest tests/test_embed_provider.py -v
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import sys
11
- from pathlib import Path
12
-
13
- # Make the engine/services tree importable for tests without packaging it.
14
- ROOT = Path(__file__).parent.parent / "engine" / "services"
15
- sys.path.insert(0, str(ROOT))
16
-
17
- import json # noqa: E402
18
-
19
- import httpx # noqa: E402
20
- import pytest # noqa: E402
21
-
22
- from _shared.embed_provider import ( # noqa: E402
23
- PROVIDERS,
24
- EmbedAuthError,
25
- EmbedClient,
26
- EmbedHTTPError,
27
- EmbedProvider,
28
- resolve_provider,
29
- )
30
-
31
-
32
- # ----------------------------------------------------------------------
33
- # Helpers — stub httpx so we can assert the request shape.
34
- # ----------------------------------------------------------------------
35
-
36
- class _FakeResponse:
37
- def __init__(self, status_code: int, payload: dict | str = ""):
38
- self.status_code = status_code
39
- if isinstance(payload, dict):
40
- self._json = payload
41
- self.text = json.dumps(payload)
42
- else:
43
- self._json = None
44
- self.text = payload
45
-
46
- @property
47
- def is_success(self) -> bool:
48
- return 200 <= self.status_code < 300
49
-
50
- def json(self) -> dict:
51
- if self._json is None:
52
- raise ValueError("not json")
53
- return self._json
54
-
55
-
56
- class _Recorder:
57
- """Records every httpx.post call and returns canned responses keyed by URL."""
58
-
59
- def __init__(self):
60
- self.calls: list[dict] = []
61
- self.responses: dict[str, _FakeResponse] = {}
62
-
63
- def respond(self, url: str, response: _FakeResponse) -> None:
64
- self.responses[url] = response
65
-
66
- def __call__(self, url, *, json, headers, timeout):
67
- self.calls.append({"url": url, "json": json, "headers": headers, "timeout": timeout})
68
- if url in self.responses:
69
- return self.responses[url]
70
- # default: 401 to flush out unmatched URLs
71
- return _FakeResponse(401, "no stub for this url")
72
-
73
-
74
- @pytest.fixture
75
- def recorder(monkeypatch):
76
- rec = _Recorder()
77
- monkeypatch.setattr(httpx, "post", rec)
78
- return rec
79
-
80
-
81
- # ----------------------------------------------------------------------
82
- # Provider resolution
83
- # ----------------------------------------------------------------------
84
-
85
- def test_resolve_built_in_providers():
86
- for name in ("openai", "pentatonic-gateway", "cohere"):
87
- p = resolve_provider(name)
88
- assert p.name == name
89
-
90
-
91
- def test_resolve_unknown_provider_raises():
92
- with pytest.raises(ValueError):
93
- resolve_provider("not-a-provider")
94
-
95
-
96
- def test_resolve_custom_provider_from_env(monkeypatch):
97
- monkeypatch.setenv("L4_EMBED_AUTH_HEADER", "X-Custom-Auth")
98
- monkeypatch.setenv("L4_EMBED_AUTH_FORMAT", "Token {key}")
99
- monkeypatch.setenv("L4_EMBED_PATH_DEFAULT", "/embed")
100
- monkeypatch.setenv("L4_EMBED_BODY_SHAPE", "cohere")
101
- monkeypatch.setenv("L4_EMBED_RESPONSE_SHAPE", "cohere")
102
- p = resolve_provider("custom", env_prefix="L4_")
103
- assert p.auth_header == "X-Custom-Auth"
104
- assert p.auth_format == "Token {key}"
105
- assert p.path_default == "/embed"
106
- # body shape produces Cohere-style "texts" field
107
- body = p.body_builder(["hi"], "model-x")
108
- assert body == {"texts": ["hi"], "model": "model-x", "input_type": "search_document"}
109
-
110
-
111
- # ----------------------------------------------------------------------
112
- # Request shape
113
- # ----------------------------------------------------------------------
114
-
115
- def test_openai_provider_request_shape(recorder):
116
- recorder.respond(
117
- "https://gw/v1/embeddings",
118
- _FakeResponse(200, {"data": [{"embedding": [0.1, 0.2]}]}),
119
- )
120
- client = EmbedClient(
121
- url="https://gw/v1/embeddings",
122
- api_key="k",
123
- model="m",
124
- provider=PROVIDERS["openai"],
125
- )
126
- out = client.embed_batch(["hello"])
127
- assert out == [[0.1, 0.2]]
128
- call = recorder.calls[0]
129
- assert call["url"] == "https://gw/v1/embeddings"
130
- assert call["json"] == {"input": ["hello"], "model": "m"}
131
- assert call["headers"] == {"Authorization": "Bearer k"}
132
-
133
-
134
- def test_pentatonic_provider_request_shape(recorder):
135
- recorder.respond(
136
- "https://lambda-gateway.pentatonic.com/v1/embed",
137
- _FakeResponse(200, {"data": [{"embedding": [1.0, 2.0]}]}),
138
- )
139
- client = EmbedClient(
140
- url="https://lambda-gateway.pentatonic.com/v1/embed",
141
- api_key="secret",
142
- model="nv-embed-v2",
143
- provider=PROVIDERS["pentatonic-gateway"],
144
- )
145
- out = client.embed_batch(["t1"])
146
- assert out == [[1.0, 2.0]]
147
- call = recorder.calls[0]
148
- assert call["url"] == "https://lambda-gateway.pentatonic.com/v1/embed"
149
- assert call["json"] == {"input": ["t1"], "model": "nv-embed-v2"}
150
- assert call["headers"] == {"X-API-Key": "secret"}
151
-
152
-
153
- def test_pentatonic_response_parser_handles_both_shapes(recorder):
154
- """Pentatonic Gateway has historically returned both {"data":[...]} and
155
- {"embeddings":[...]} on different endpoints. Parser accepts either."""
156
- p = PROVIDERS["pentatonic-gateway"]
157
- assert p.response_parser({"data": [{"embedding": [1.0]}]}) == [[1.0]]
158
- assert p.response_parser({"embeddings": [[1.0]]}) == [[1.0]]
159
-
160
-
161
- def test_cohere_provider_request_shape(recorder):
162
- recorder.respond(
163
- "https://api.cohere.ai/v1/embed",
164
- _FakeResponse(200, {"embeddings": [[3.0, 4.0]]}),
165
- )
166
- client = EmbedClient(
167
- url="https://api.cohere.ai/v1/embed",
168
- api_key="cohere-key",
169
- model="embed-english-v3.0",
170
- provider=PROVIDERS["cohere"],
171
- )
172
- out = client.embed_batch(["hi"])
173
- assert out == [[3.0, 4.0]]
174
- call = recorder.calls[0]
175
- assert call["json"] == {
176
- "texts": ["hi"],
177
- "model": "embed-english-v3.0",
178
- "input_type": "search_document",
179
- }
180
- assert call["headers"] == {"Authorization": "Bearer cohere-key"}
181
-
182
-
183
- # ----------------------------------------------------------------------
184
- # Auto-detect
185
- # ----------------------------------------------------------------------
186
-
187
- def test_autodetect_on_401_falls_back_to_pentatonic(recorder):
188
- """Operator configured openai but the URL+key actually belong to
189
- Pentatonic Gateway. First call 401s, auto-detect probes pentatonic
190
- and succeeds."""
191
- recorder.respond(
192
- "https://lambda-gateway.pentatonic.com/v1/embeddings",
193
- _FakeResponse(401, '{"error":"Invalid or missing API key"}'),
194
- )
195
- recorder.respond(
196
- "https://lambda-gateway.pentatonic.com/v1/embed",
197
- _FakeResponse(200, {"data": [{"embedding": [9.0]}]}),
198
- )
199
- client = EmbedClient(
200
- url="https://lambda-gateway.pentatonic.com/v1/embeddings",
201
- api_key="k",
202
- model="nv-embed-v2",
203
- provider=PROVIDERS["openai"],
204
- )
205
- out = client.embed_batch(["x"])
206
- assert out == [[9.0]]
207
- assert client.active_provider == "pentatonic-gateway"
208
- # First call uses configured (openai) shape, second uses pentatonic
209
- assert recorder.calls[0]["headers"] == {"Authorization": "Bearer k"}
210
- assert recorder.calls[1]["headers"] == {"X-API-Key": "k"}
211
-
212
-
213
- def test_autodetect_caches_after_first_success(recorder):
214
- """Once auto-detect picks a winner, subsequent calls go straight to it
215
- without retrying the original 401."""
216
- recorder.respond(
217
- "https://gw/v1/embeddings",
218
- _FakeResponse(401, "wrong scheme"),
219
- )
220
- recorder.respond(
221
- "https://gw/v1/embed",
222
- _FakeResponse(200, {"data": [{"embedding": [1.0]}]}),
223
- )
224
- client = EmbedClient(
225
- url="https://gw/v1/embeddings",
226
- api_key="k",
227
- model="m",
228
- provider=PROVIDERS["openai"],
229
- )
230
- client.embed_batch(["a"]) # triggers detect
231
- n_after_first = len(recorder.calls)
232
- client.embed_batch(["b"]) # should go straight to /v1/embed
233
- assert len(recorder.calls) == n_after_first + 1
234
- assert recorder.calls[-1]["url"] == "https://gw/v1/embed"
235
-
236
-
237
- def test_autodetect_disabled_raises(recorder):
238
- recorder.respond("https://gw/v1/embeddings", _FakeResponse(401, "no auth"))
239
- client = EmbedClient(
240
- url="https://gw/v1/embeddings",
241
- api_key="k",
242
- model="m",
243
- provider=PROVIDERS["openai"],
244
- autodetect=False,
245
- )
246
- with pytest.raises(EmbedAuthError):
247
- client.embed_batch(["x"])
248
- # Only one call: no probing happened.
249
- assert len(recorder.calls) == 1
250
-
251
-
252
- def test_autodetect_all_fail_raises(recorder):
253
- """Every candidate also 401s — raise EmbedAuthError."""
254
- recorder.respond("https://gw/v1/embeddings", _FakeResponse(401, "x"))
255
- recorder.respond("https://gw/v1/embed", _FakeResponse(401, "x"))
256
- client = EmbedClient(
257
- url="https://gw/v1/embeddings",
258
- api_key="k",
259
- model="m",
260
- provider=PROVIDERS["openai"],
261
- )
262
- with pytest.raises(EmbedAuthError):
263
- client.embed_batch(["x"])
264
-
265
-
266
- # ----------------------------------------------------------------------
267
- # Error handling
268
- # ----------------------------------------------------------------------
269
-
270
- def test_non_401_http_error_does_not_trigger_autodetect(recorder):
271
- recorder.respond(
272
- "https://gw/v1/embeddings",
273
- _FakeResponse(503, "upstream down"),
274
- )
275
- client = EmbedClient(
276
- url="https://gw/v1/embeddings",
277
- api_key="k",
278
- model="m",
279
- provider=PROVIDERS["openai"],
280
- )
281
- with pytest.raises(EmbedHTTPError) as exc:
282
- client.embed_batch(["x"])
283
- assert exc.value.status == 503
284
- assert len(recorder.calls) == 1
285
-
286
-
287
- def test_empty_input_returns_empty(recorder):
288
- client = EmbedClient(
289
- url="https://gw/v1/embeddings",
290
- api_key="k",
291
- model="m",
292
- provider=PROVIDERS["openai"],
293
- )
294
- assert client.embed_batch([]) == []
295
- assert recorder.calls == []
296
-
297
-
298
- # ----------------------------------------------------------------------
299
- # from_env construction
300
- # ----------------------------------------------------------------------
301
-
302
- def test_from_env_reads_layer_prefix(monkeypatch, recorder):
303
- monkeypatch.setenv("L4_NV_EMBED_URL", "https://lambda-gateway.pentatonic.com/v1/embed")
304
- monkeypatch.setenv("L4_EMBED_API_KEY", "real-key")
305
- monkeypatch.setenv("L4_EMBED_MODEL", "nv-embed-v2")
306
- monkeypatch.setenv("L4_EMBED_PROVIDER", "pentatonic-gateway")
307
- recorder.respond(
308
- "https://lambda-gateway.pentatonic.com/v1/embed",
309
- _FakeResponse(200, {"data": [{"embedding": [42.0]}]}),
310
- )
311
- client = EmbedClient.from_env(prefix="L4_")
312
- out = client.embed_batch(["t"])
313
- assert out == [[42.0]]
314
- assert client.active_provider == "pentatonic-gateway"
315
- assert recorder.calls[0]["headers"] == {"X-API-Key": "real-key"}
316
-
317
-
318
- def test_from_env_default_provider_is_openai(monkeypatch):
319
- monkeypatch.setenv("L5_NV_EMBED_URL", "https://gw/v1/embeddings")
320
- monkeypatch.setenv("L5_EMBED_API_KEY", "k")
321
- client = EmbedClient.from_env(prefix="L5_")
322
- assert client.active_provider == "openai"
323
-
324
-
325
- def test_from_env_autodetect_opt_out(monkeypatch, recorder):
326
- monkeypatch.setenv("L4_NV_EMBED_URL", "https://gw/v1/embeddings")
327
- monkeypatch.setenv("L4_EMBED_API_KEY", "k")
328
- monkeypatch.setenv("L4_EMBED_AUTODETECT", "false")
329
- recorder.respond("https://gw/v1/embeddings", _FakeResponse(401, "x"))
330
- client = EmbedClient.from_env(prefix="L4_")
331
- with pytest.raises(EmbedAuthError):
332
- client.embed_batch(["x"])
333
- assert len(recorder.calls) == 1
334
-
335
-
336
- # ----------------------------------------------------------------------
337
- # URL handling
338
- # ----------------------------------------------------------------------
339
-
340
- def test_url_without_path_gets_provider_default(recorder):
341
- """If operator provides only a base URL, the provider's path_default
342
- is appended."""
343
- recorder.respond(
344
- "https://lambda-gateway.pentatonic.com/v1/embed",
345
- _FakeResponse(200, {"data": [{"embedding": [0.0]}]}),
346
- )
347
- client = EmbedClient(
348
- url="https://lambda-gateway.pentatonic.com",
349
- api_key="k",
350
- model="m",
351
- provider=PROVIDERS["pentatonic-gateway"],
352
- )
353
- client.embed_batch(["x"])
354
- assert recorder.calls[0]["url"] == "https://lambda-gateway.pentatonic.com/v1/embed"
355
-
356
-
357
- # ----------------------------------------------------------------------
358
- # Chunking — work around the Pentatonic AI Gateway's per-call cap of 5
359
- # texts. Above the cap the gateway 502s; without chunking the layer's
360
- # /index-batch handler raises, the compat shim swallows it, and vector
361
- # writes silently drop. Chunking splits the request into chunks of
362
- # `max_batch` so each call stays within the gateway's limit.
363
- # ----------------------------------------------------------------------
364
-
365
-
366
- class _PentatonicEchoStub:
367
- """httpx.post replacement that returns one embedding per input text,
368
- matching real gateway behaviour. Each response embedding encodes the
369
- input index so tests can assert order preservation across chunks."""
370
-
371
- def __init__(self):
372
- self.calls: list[dict] = []
373
- self._offset = 0 # running input-index counter across calls
374
-
375
- def __call__(self, url, *, json, headers, timeout):
376
- self.calls.append({"url": url, "json": json, "headers": headers, "timeout": timeout})
377
- n = len(json.get("input") or [])
378
- embs = [[float(self._offset + i)] for i in range(n)]
379
- self._offset += n
380
- return _FakeResponse(200, {"embeddings": embs})
381
-
382
-
383
- def test_chunking_below_max_batch_makes_one_call(monkeypatch):
384
- """N <= max_batch sends one request, no chunking overhead."""
385
- stub = _PentatonicEchoStub()
386
- monkeypatch.setattr(httpx, "post", stub)
387
- client = EmbedClient(
388
- url="https://lambda-gateway.pentatonic.com/v1/embed",
389
- api_key="k", model="m",
390
- provider=PROVIDERS["pentatonic-gateway"],
391
- max_batch=5,
392
- )
393
- out = client.embed_batch([f"t{i}" for i in range(5)])
394
- assert len(out) == 5
395
- assert len(stub.calls) == 1
396
- assert len(stub.calls[0]["json"]["input"]) == 5
397
-
398
-
399
- def test_chunking_above_max_batch_splits_into_calls(monkeypatch):
400
- """N > max_batch is split into len(N)/max_batch posts; results are
401
- concatenated in input order so the caller can't tell."""
402
- stub = _PentatonicEchoStub()
403
- monkeypatch.setattr(httpx, "post", stub)
404
- client = EmbedClient(
405
- url="https://lambda-gateway.pentatonic.com/v1/embed",
406
- api_key="k", model="m",
407
- provider=PROVIDERS["pentatonic-gateway"],
408
- max_batch=5,
409
- )
410
- out = client.embed_batch([f"t{i}" for i in range(12)])
411
- # 12 texts → chunks of [5, 5, 2] → 3 calls
412
- assert len(stub.calls) == 3
413
- assert [len(c["json"]["input"]) for c in stub.calls] == [5, 5, 2]
414
- # Stub returns one vector per input. Each vector encodes its
415
- # cross-chunk input index → assert order preserved.
416
- assert len(out) == 12
417
- assert out == [[float(i)] for i in range(12)]
418
-
419
-
420
- def test_chunking_disabled_with_max_batch_zero(monkeypatch):
421
- """max_batch=0 means no chunking — old behaviour (one big call)."""
422
- stub = _PentatonicEchoStub()
423
- monkeypatch.setattr(httpx, "post", stub)
424
- client = EmbedClient(
425
- url="https://lambda-gateway.pentatonic.com/v1/embed",
426
- api_key="k", model="m",
427
- provider=PROVIDERS["pentatonic-gateway"],
428
- max_batch=0,
429
- )
430
- client.embed_batch([f"t{i}" for i in range(20)])
431
- assert len(stub.calls) == 1
432
- assert len(stub.calls[0]["json"]["input"]) == 20
433
-
434
-
435
- def test_chunking_propagates_first_error(recorder):
436
- """If a chunk fails (e.g., gateway 502), the whole call raises with
437
- the first error — matching the un-chunked semantics. We don't return
438
- a partial vector list because the caller's downstream `for r, emb, txt
439
- in zip(...)` loop would silently drop the failed records."""
440
- # Pentatonic gateway 502 on every call (simulates the real bug)
441
- recorder.respond(
442
- "https://lambda-gateway.pentatonic.com/v1/embed",
443
- _FakeResponse(502, "<html>...bad gateway...</html>"),
444
- )
445
- client = EmbedClient(
446
- url="https://lambda-gateway.pentatonic.com/v1/embed",
447
- api_key="k", model="m",
448
- provider=PROVIDERS["pentatonic-gateway"],
449
- max_batch=5,
450
- )
451
- with pytest.raises(EmbedHTTPError) as exc:
452
- client.embed_batch([f"t{i}" for i in range(8)])
453
- assert exc.value.status == 502
454
-
455
-
456
- class _OpenAIEchoStub:
457
- """OpenAI-shaped stub: returns one embedding per input as
458
- {data: [{embedding: [...]}]}."""
459
-
460
- def __init__(self):
461
- self.calls: list[dict] = []
462
-
463
- def __call__(self, url, *, json, headers, timeout):
464
- self.calls.append({"url": url, "json": json, "headers": headers, "timeout": timeout})
465
- n = len(json.get("input") or [])
466
- return _FakeResponse(200, {"data": [{"embedding": [0.0]} for _ in range(n)]})
467
-
468
-
469
- def test_from_env_reads_max_batch(monkeypatch):
470
- """{prefix}EMBED_MAX_BATCH overrides the default of 5."""
471
- monkeypatch.setenv("L4_NV_EMBED_URL", "https://gw/v1/embeddings")
472
- monkeypatch.setenv("L4_EMBED_API_KEY", "k")
473
- monkeypatch.setenv("L4_EMBED_MAX_BATCH", "3")
474
- stub = _OpenAIEchoStub()
475
- monkeypatch.setattr(httpx, "post", stub)
476
- client = EmbedClient.from_env(prefix="L4_")
477
- client.embed_batch([f"t{i}" for i in range(7)])
478
- # 7 with chunk=3 → [3, 3, 1] → 3 calls
479
- assert len(stub.calls) == 3
480
- assert [len(c["json"]["input"]) for c in stub.calls] == [3, 3, 1]
481
-
482
-
483
- def test_from_env_default_max_batch_is_five(monkeypatch):
484
- """Default max_batch=5 matches the observed Pentatonic Gateway cap."""
485
- monkeypatch.setenv("L4_NV_EMBED_URL", "https://gw/v1/embeddings")
486
- monkeypatch.setenv("L4_EMBED_API_KEY", "k")
487
- stub = _OpenAIEchoStub()
488
- monkeypatch.setattr(httpx, "post", stub)
489
- client = EmbedClient.from_env(prefix="L4_")
490
- client.embed_batch([f"t{i}" for i in range(10)])
491
- # 10 with default chunk=5 → [5, 5] → 2 calls
492
- assert len(stub.calls) == 2