needlepath 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- needlepath/__init__.py +137 -0
- needlepath/client.py +947 -0
- needlepath/errors.py +123 -0
- needlepath/features.py +71 -0
- needlepath/models.py +476 -0
- needlepath/py.typed +0 -0
- needlepath/result.py +302 -0
- needlepath/testing.py +224 -0
- needlepath/transport.py +316 -0
- needlepath/version.py +10 -0
- needlepath-0.1.0.dist-info/METADATA +251 -0
- needlepath-0.1.0.dist-info/RECORD +14 -0
- needlepath-0.1.0.dist-info/WHEEL +4 -0
- needlepath-0.1.0.dist-info/licenses/LICENSE +202 -0
needlepath/transport.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The one transport/auth seam.
|
|
2
|
+
|
|
3
|
+
``ADAPTER_API_DESIGN.md`` invariant 5 and §8 both ask for a single seam so that
|
|
4
|
+
localhost, the hosted endpoint, a customer VPC deployment and a future MCP
|
|
5
|
+
server are the same client. That seam is :class:`Transport` / :class:`AsyncTransport`:
|
|
6
|
+
a two-method protocol over ``(method, url, headers, body) -> HttpResult``.
|
|
7
|
+
|
|
8
|
+
The default sync implementation is **stdlib ``urllib``**. That is a deliberate
|
|
9
|
+
dependency decision, not laziness: this client is dropped into agent stacks that
|
|
10
|
+
already pin ``httpx``, ``requests``, ``pydantic`` and a framework, and a core
|
|
11
|
+
client that can force a resolver conflict does not get adopted. The cost is that
|
|
12
|
+
we write ~60 lines of HTTP by hand; the benefit is that ``pip install needlepath``
|
|
13
|
+
can never break a customer's environment.
|
|
14
|
+
|
|
15
|
+
The default async implementation prefers ``httpx.AsyncClient`` when it is
|
|
16
|
+
importable and otherwise runs the sync transport on a worker thread. Both are
|
|
17
|
+
correct; the first is better under a proxy's concurrency (LiteLLM), which is why
|
|
18
|
+
it is preferred when available rather than required.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import socket
|
|
27
|
+
import ssl
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Dict, Optional
|
|
33
|
+
|
|
34
|
+
from .errors import NeedlepathTimeoutError, NeedlepathTransportError
|
|
35
|
+
|
|
36
|
+
try: # pragma: no cover - trivial
|
|
37
|
+
from typing import Protocol
|
|
38
|
+
except ImportError: # pragma: no cover - Python < 3.8 is unsupported anyway
|
|
39
|
+
Protocol = object # type: ignore[assignment,misc]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class HttpResult:
|
|
44
|
+
"""One HTTP response, decoded no further than bytes.
|
|
45
|
+
|
|
46
|
+
``json_body`` is parsed lazily by the client so that a transport
|
|
47
|
+
implementation never has to care about content negotiation.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
status: int
|
|
51
|
+
body: bytes
|
|
52
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def json(self) -> Any:
|
|
55
|
+
"""Parse the body as JSON, or return ``None`` when it is not JSON."""
|
|
56
|
+
if not self.body:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(self.body.decode("utf-8"))
|
|
60
|
+
except (ValueError, UnicodeDecodeError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def text(self) -> str:
|
|
64
|
+
try:
|
|
65
|
+
return self.body.decode("utf-8", errors="replace")
|
|
66
|
+
except Exception: # pragma: no cover - decode with errors= cannot raise
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Transport(Protocol): # pragma: no cover - a structural type
|
|
71
|
+
"""The synchronous seam."""
|
|
72
|
+
|
|
73
|
+
def request(
|
|
74
|
+
self,
|
|
75
|
+
method: str,
|
|
76
|
+
url: str,
|
|
77
|
+
*,
|
|
78
|
+
headers: Dict[str, str],
|
|
79
|
+
body: Optional[bytes],
|
|
80
|
+
timeout: float,
|
|
81
|
+
) -> HttpResult: ...
|
|
82
|
+
|
|
83
|
+
def close(self) -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncTransport(Protocol): # pragma: no cover - a structural type
|
|
87
|
+
"""The asynchronous seam."""
|
|
88
|
+
|
|
89
|
+
async def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
url: str,
|
|
93
|
+
*,
|
|
94
|
+
headers: Dict[str, str],
|
|
95
|
+
body: Optional[bytes],
|
|
96
|
+
timeout: float,
|
|
97
|
+
) -> HttpResult: ...
|
|
98
|
+
|
|
99
|
+
async def aclose(self) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------- #
|
|
103
|
+
# Sync: stdlib
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class UrllibTransport:
|
|
108
|
+
"""Zero-dependency sync transport on ``urllib.request``.
|
|
109
|
+
|
|
110
|
+
Notes on the two things urllib gets subtly wrong for our purposes:
|
|
111
|
+
|
|
112
|
+
* ``urlopen`` raises :class:`urllib.error.HTTPError` for a non-2xx, and that
|
|
113
|
+
exception *is* a response object. We turn it back into an
|
|
114
|
+
:class:`HttpResult` — a 429 or a 413 is data this client acts on, not an
|
|
115
|
+
exceptional condition at the transport layer.
|
|
116
|
+
* ``timeout`` on ``urlopen`` is a **socket inactivity** timeout, not a
|
|
117
|
+
wall-clock deadline. A server that drips one chunk every 25 ms keeps every
|
|
118
|
+
individual read under the timeout while the whole response takes as long
|
|
119
|
+
as it likes — so an "8 s deadline" client can wait a minute. The body is
|
|
120
|
+
therefore read in chunks against a real monotonic deadline, and the
|
|
121
|
+
timeout is enforced twice: by the socket, and by the clock.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
#: Body read granularity. Small enough that the deadline is checked often,
|
|
125
|
+
#: large enough that a normal response is one or two reads.
|
|
126
|
+
READ_CHUNK_BYTES = 65536
|
|
127
|
+
|
|
128
|
+
def __init__(self, *, ssl_context: Optional[ssl.SSLContext] = None) -> None:
|
|
129
|
+
self._opener = urllib.request.build_opener(
|
|
130
|
+
urllib.request.HTTPSHandler(context=ssl_context)
|
|
131
|
+
if ssl_context
|
|
132
|
+
else urllib.request.HTTPSHandler()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def request(
|
|
136
|
+
self,
|
|
137
|
+
method: str,
|
|
138
|
+
url: str,
|
|
139
|
+
*,
|
|
140
|
+
headers: Dict[str, str],
|
|
141
|
+
body: Optional[bytes],
|
|
142
|
+
timeout: float,
|
|
143
|
+
) -> HttpResult:
|
|
144
|
+
req = urllib.request.Request(url=url, data=body, method=method)
|
|
145
|
+
for name, value in headers.items():
|
|
146
|
+
req.add_header(name, value)
|
|
147
|
+
deadline = time.monotonic() + timeout
|
|
148
|
+
try:
|
|
149
|
+
with self._opener.open(req, timeout=timeout) as resp:
|
|
150
|
+
return HttpResult(
|
|
151
|
+
status=int(resp.status),
|
|
152
|
+
body=self._read_bounded(resp, deadline, timeout),
|
|
153
|
+
headers={k.lower(): v for k, v in resp.headers.items()},
|
|
154
|
+
)
|
|
155
|
+
except urllib.error.HTTPError as exc:
|
|
156
|
+
# Not an error at this layer: it is a response with a status.
|
|
157
|
+
try:
|
|
158
|
+
payload = self._read_bounded(exc, deadline, timeout)
|
|
159
|
+
except NeedlepathTimeoutError:
|
|
160
|
+
raise
|
|
161
|
+
except Exception: # pragma: no cover - a closed error body
|
|
162
|
+
payload = b""
|
|
163
|
+
finally:
|
|
164
|
+
# `HTTPError` is itself a response object and owns a socket. If
|
|
165
|
+
# the read above blew up, nothing else closes it.
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
exc.close()
|
|
168
|
+
return HttpResult(
|
|
169
|
+
status=int(exc.code),
|
|
170
|
+
body=payload,
|
|
171
|
+
headers={k.lower(): v for k, v in (exc.headers or {}).items()},
|
|
172
|
+
)
|
|
173
|
+
except socket.timeout as exc:
|
|
174
|
+
raise NeedlepathTimeoutError(f"request timed out after {timeout:.3f}s") from exc
|
|
175
|
+
except urllib.error.URLError as exc:
|
|
176
|
+
reason = getattr(exc, "reason", exc)
|
|
177
|
+
if isinstance(reason, socket.timeout):
|
|
178
|
+
raise NeedlepathTimeoutError(f"request timed out after {timeout:.3f}s") from exc
|
|
179
|
+
raise NeedlepathTransportError(f"{type(reason).__name__}: {reason}") from exc
|
|
180
|
+
except (OSError, ValueError, ssl.SSLError) as exc:
|
|
181
|
+
raise NeedlepathTransportError(f"{type(exc).__name__}: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
def _read_bounded(self, resp: Any, deadline: float, timeout: float) -> bytes:
|
|
184
|
+
"""Read a body against a wall-clock deadline, not just a socket timeout.
|
|
185
|
+
|
|
186
|
+
``read1`` rather than ``read``, and the difference is the whole point:
|
|
187
|
+
``read(n)`` blocks until it has ``n`` bytes or hits EOF, so for a body
|
|
188
|
+
smaller than one chunk it is a single call that blocks for the entire
|
|
189
|
+
drip — the deadline would be checked once, after the wait. ``read1``
|
|
190
|
+
returns as soon as *any* data is available, so the loop gets to look at
|
|
191
|
+
the clock between chunks.
|
|
192
|
+
|
|
193
|
+
A reader without ``read1`` (an ``HTTPError`` on some versions) falls back
|
|
194
|
+
to ``read``; the deadline is then only bounded between chunks, which is
|
|
195
|
+
still better than not at all.
|
|
196
|
+
"""
|
|
197
|
+
reader = getattr(resp, "read1", None) or resp.read
|
|
198
|
+
chunks = []
|
|
199
|
+
while True:
|
|
200
|
+
if time.monotonic() > deadline:
|
|
201
|
+
raise NeedlepathTimeoutError(
|
|
202
|
+
f"response body did not complete within {timeout:.3f}s"
|
|
203
|
+
)
|
|
204
|
+
chunk = reader(self.READ_CHUNK_BYTES)
|
|
205
|
+
if not chunk:
|
|
206
|
+
return b"".join(chunks)
|
|
207
|
+
chunks.append(chunk)
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
self._opener.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
# Async
|
|
215
|
+
# --------------------------------------------------------------------------- #
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ThreadedAsyncTransport:
|
|
219
|
+
"""Runs a sync :class:`Transport` on the default executor.
|
|
220
|
+
|
|
221
|
+
The fallback when ``httpx`` is not installed. It costs a worker thread per
|
|
222
|
+
in-flight request, which is fine for an agent and merely adequate for a
|
|
223
|
+
proxy — hence :func:`default_async_transport` preferring ``httpx``.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def __init__(self, inner: Optional[Transport] = None) -> None:
|
|
227
|
+
self._inner: Transport = inner or UrllibTransport()
|
|
228
|
+
|
|
229
|
+
async def request(
|
|
230
|
+
self,
|
|
231
|
+
method: str,
|
|
232
|
+
url: str,
|
|
233
|
+
*,
|
|
234
|
+
headers: Dict[str, str],
|
|
235
|
+
body: Optional[bytes],
|
|
236
|
+
timeout: float,
|
|
237
|
+
) -> HttpResult:
|
|
238
|
+
loop = asyncio.get_running_loop()
|
|
239
|
+
|
|
240
|
+
def _call() -> HttpResult:
|
|
241
|
+
return self._inner.request(method, url, headers=headers, body=body, timeout=timeout)
|
|
242
|
+
|
|
243
|
+
# `wait_for` gives the CALLER a real deadline even though the worker
|
|
244
|
+
# thread cannot be cancelled — it will finish and be discarded. The
|
|
245
|
+
# inner transport enforces its own deadline too, so the orphaned thread
|
|
246
|
+
# is bounded rather than indefinite.
|
|
247
|
+
try:
|
|
248
|
+
return await asyncio.wait_for(loop.run_in_executor(None, _call), timeout)
|
|
249
|
+
except asyncio.TimeoutError as exc:
|
|
250
|
+
raise NeedlepathTimeoutError(f"request timed out after {timeout:.3f}s") from exc
|
|
251
|
+
|
|
252
|
+
async def aclose(self) -> None:
|
|
253
|
+
self._inner.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class HttpxAsyncTransport:
|
|
257
|
+
"""Async transport on ``httpx.AsyncClient``. Used when httpx is importable."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, client: Any = None) -> None:
|
|
260
|
+
import httpx # local import: httpx is an optional dependency
|
|
261
|
+
|
|
262
|
+
self._owns_client = client is None
|
|
263
|
+
self._client = client if client is not None else httpx.AsyncClient()
|
|
264
|
+
self._httpx = httpx
|
|
265
|
+
|
|
266
|
+
async def request(
|
|
267
|
+
self,
|
|
268
|
+
method: str,
|
|
269
|
+
url: str,
|
|
270
|
+
*,
|
|
271
|
+
headers: Dict[str, str],
|
|
272
|
+
body: Optional[bytes],
|
|
273
|
+
timeout: float,
|
|
274
|
+
) -> HttpResult:
|
|
275
|
+
try:
|
|
276
|
+
# httpx's `timeout` is per-operation (connect/read/write each get
|
|
277
|
+
# it), so a drip-fed body can outlive it many times over. `wait_for`
|
|
278
|
+
# is the wall-clock bound the caller actually asked for.
|
|
279
|
+
resp = await asyncio.wait_for(
|
|
280
|
+
self._client.request(method, url, headers=headers, content=body, timeout=timeout),
|
|
281
|
+
timeout,
|
|
282
|
+
)
|
|
283
|
+
except asyncio.TimeoutError as exc:
|
|
284
|
+
raise NeedlepathTimeoutError(f"request timed out after {timeout:.3f}s") from exc
|
|
285
|
+
except self._httpx.TimeoutException as exc:
|
|
286
|
+
raise NeedlepathTimeoutError(f"request timed out after {timeout:.3f}s") from exc
|
|
287
|
+
except self._httpx.HTTPError as exc:
|
|
288
|
+
raise NeedlepathTransportError(f"{type(exc).__name__}: {exc}") from exc
|
|
289
|
+
return HttpResult(
|
|
290
|
+
status=int(resp.status_code),
|
|
291
|
+
body=resp.content,
|
|
292
|
+
headers={k.lower(): v for k, v in resp.headers.items()},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def aclose(self) -> None:
|
|
296
|
+
if self._owns_client:
|
|
297
|
+
await self._client.aclose()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def default_async_transport() -> AsyncTransport:
|
|
301
|
+
"""``httpx`` when it is importable, a worker thread otherwise."""
|
|
302
|
+
try:
|
|
303
|
+
return HttpxAsyncTransport()
|
|
304
|
+
except ImportError:
|
|
305
|
+
return ThreadedAsyncTransport()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
__all__ = [
|
|
309
|
+
"HttpResult",
|
|
310
|
+
"Transport",
|
|
311
|
+
"AsyncTransport",
|
|
312
|
+
"UrllibTransport",
|
|
313
|
+
"ThreadedAsyncTransport",
|
|
314
|
+
"HttpxAsyncTransport",
|
|
315
|
+
"default_async_transport",
|
|
316
|
+
]
|
needlepath/version.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Single source of truth for the package version.
|
|
2
|
+
|
|
3
|
+
Kept in a module rather than read from installed metadata so that the User-Agent
|
|
4
|
+
is correct when the package is run from a source checkout, which is how every
|
|
5
|
+
one of our own integration tests runs it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: needlepath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-free client for the Needlepath hosted context-selection service.
|
|
5
|
+
Project-URL: Homepage, https://nextmoca.com
|
|
6
|
+
Author: Next Moca Global, Inc.
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,context,context-engineering,llm,needlepath
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
|
+
Provides-Extra: httpx
|
|
27
|
+
Requires-Dist: httpx>=0.24; extra == 'httpx'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# `needlepath` — Python client
|
|
31
|
+
|
|
32
|
+
The framework-free core client for the Needlepath hosted context-selection
|
|
33
|
+
service. HTTP only, zero required dependencies, one transport/auth seam.
|
|
34
|
+
|
|
35
|
+
Framework integrations are **separate distributions** and depend on this one:
|
|
36
|
+
|
|
37
|
+
| Distribution | Seam |
|
|
38
|
+
|---|---|
|
|
39
|
+
| [`needlepath-langchain`](../needlepath-langchain) | `AgentMiddleware` (`wrap_model_call`, `wrap_tool_call`) + the LangGraph recipe |
|
|
40
|
+
| [`needlepath-litellm`](../needlepath-litellm) | LiteLLM **proxy** `CustomGuardrail` |
|
|
41
|
+
| [`llama-index-postprocessor-needlepath`](../llama-index-postprocessor-needlepath) | `BaseNodePostprocessor` |
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install needlepath
|
|
47
|
+
pip install "needlepath[httpx]" # optional: httpx-backed async transport
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.9+. No required runtime dependencies — the default transport is stdlib
|
|
51
|
+
`urllib`, so installing this client can never move a version you already pinned.
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from needlepath import NeedlepathClient, ContextRecord, TaskSpec
|
|
57
|
+
|
|
58
|
+
client = NeedlepathClient(
|
|
59
|
+
api_key="np_live_...", # or NEEDLEPATH_API_KEY
|
|
60
|
+
operating_point="np-2026-07-r2", # required — see "Pin the operating point"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = client.select(
|
|
64
|
+
records=[
|
|
65
|
+
ContextRecord(text=tool_output, kind="tool_result", id="lookup-1"),
|
|
66
|
+
ContextRecord(text=doc, kind="external_data", id="doc-7", title="SOP"),
|
|
67
|
+
],
|
|
68
|
+
task=TaskSpec(prompt="what is the invoice total for A-1002?"),
|
|
69
|
+
max_context_tokens=4000,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
context = result.rendered_context if result.applied else my_original_context
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`select()` **never raises.** Read `result.applied`; when it is `False`, use your
|
|
76
|
+
original context. That is the whole integration contract.
|
|
77
|
+
|
|
78
|
+
### Async
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from needlepath import AsyncNeedlepathClient
|
|
82
|
+
|
|
83
|
+
async with AsyncNeedlepathClient(operating_point="np-2026-07-r2") as client:
|
|
84
|
+
result = await client.select(records=..., task=..., max_context_tokens=4000)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Identical semantics — the decision logic is literally the same functions. Uses
|
|
88
|
+
`httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
|
|
89
|
+
never blocks the event loop either way.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
| Argument | Environment | Default |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `api_key` | `NEEDLEPATH_API_KEY` | none (no `Authorization` header) |
|
|
96
|
+
| `operating_point` | `NEEDLEPATH_OPERATING_POINT` | **required** |
|
|
97
|
+
| `base_url` | `NEEDLEPATH_BASE_URL` | `https://ayf02egtaf.execute-api.us-east-2.amazonaws.com/prod` |
|
|
98
|
+
| `timeout` | — | `10.0` s, total, retries included |
|
|
99
|
+
| `max_retries` | — | `2` |
|
|
100
|
+
| `max_request_bytes` | — | `6 * 1024 * 1024` |
|
|
101
|
+
| `shadow` | — | `False` |
|
|
102
|
+
|
|
103
|
+
The base URL is a **documented default, not a hard-coded one**: a durable custom
|
|
104
|
+
domain replaces it, and swapping it is a config change rather than a release.
|
|
105
|
+
|
|
106
|
+
`http://` is refused for any non-loopback host. TLS-only is an inherited binding
|
|
107
|
+
constraint; loopback is exempt so the localhost benchmark adapter still works.
|
|
108
|
+
|
|
109
|
+
## The four binding rules, and where they live
|
|
110
|
+
|
|
111
|
+
### 1. Fail open, always
|
|
112
|
+
|
|
113
|
+
Timeout, 5xx, a violated contract, an empty selection, a stood-down gate — every
|
|
114
|
+
one of them returns a `SelectionResult` with `applied=False`, the exception on
|
|
115
|
+
`.error`, and a machine-readable `.reason`. Nothing is raised on the selection
|
|
116
|
+
path.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
if not result.applied:
|
|
120
|
+
log.info("needlepath passthrough", extra=result.metadata())
|
|
121
|
+
context = my_original_context
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`result.reason` is an **open enum** (`needlepath.Reason`). Today:
|
|
125
|
+
|
|
126
|
+
| reason | applied | meaning |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `ok` | yes | safe to apply |
|
|
129
|
+
| `shadow` | no | shadow mode — measured, deliberately not applied |
|
|
130
|
+
| `disabled` | no | the integration was constructed disabled |
|
|
131
|
+
| `no_records` | no | nothing to select over; no call was made |
|
|
132
|
+
| `below_trigger` | no | under the configured trigger; no call was made |
|
|
133
|
+
| `empty_selection` | no | zero records selected |
|
|
134
|
+
| `engine_fallback` | no | `fallback_used` — the engine wants the full context |
|
|
135
|
+
| `selection_error` | no | the response carried a `selection_error` |
|
|
136
|
+
| `no_per_record_detail` | no | per-record detail was required and absent |
|
|
137
|
+
| `payload_too_large` | no | over the client-side guard; **no call was made** |
|
|
138
|
+
| `timeout` / `transport_error` / `http_error` / `contract_error` | no | error paths |
|
|
139
|
+
| `unexpected_error` | no | a defect in this client — still fails open |
|
|
140
|
+
|
|
141
|
+
Treat an unrecognized reason as "do not apply". `result.failed` separates the
|
|
142
|
+
error paths from the benign declines, which is the distinction that decides
|
|
143
|
+
whether a passthrough deserves a `WARNING` or a `DEBUG`.
|
|
144
|
+
|
|
145
|
+
The empty-selection rule is not a nicety. NEXPE-413 is the precedent: the engine
|
|
146
|
+
returned an empty selection as a success, and at a framework boundary that has
|
|
147
|
+
to degrade to passthrough, never to an empty context.
|
|
148
|
+
|
|
149
|
+
**`select_or_raise()` exists** for the two cases where fail-open is wrong —
|
|
150
|
+
diagnostics, and a caller that is itself a benchmark and must not silently
|
|
151
|
+
substitute a passthrough for a measurement. Do not use it in an adapter.
|
|
152
|
+
|
|
153
|
+
### 2. Pin the operating point
|
|
154
|
+
|
|
155
|
+
`operating_point` is required at construction, and this is the one failure that
|
|
156
|
+
raises rather than fails open. Failing open on a bad request is degradation a
|
|
157
|
+
customer absorbs; failing open on "no operating point was pinned" accepts
|
|
158
|
+
exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
|
|
159
|
+
never under load.
|
|
160
|
+
|
|
161
|
+
Labels are immutable: `np-2026-07-r2` means one frozen configuration forever, and
|
|
162
|
+
a retune mints a new label. `client.operating_points()` lists what the endpoint
|
|
163
|
+
serves — call it once at wire-up if you want the assertion, never per request.
|
|
164
|
+
|
|
165
|
+
### 3. Guard the 6 MB cap client-side
|
|
166
|
+
|
|
167
|
+
The serialized body is measured before it is sent. Over the guard, you get
|
|
168
|
+
`reason="payload_too_large"` and **no network call**, rather than a platform
|
|
169
|
+
error from the far side of the wire.
|
|
170
|
+
|
|
171
|
+
The guard measures the JSON body. Lambda's 6 MB limit applies to the whole
|
|
172
|
+
invocation payload — the API Gateway event envelope sits on top — so a request
|
|
173
|
+
that passes at 5.99 MB can still be rejected upstream. Lower
|
|
174
|
+
`max_request_bytes` if you run near the cap.
|
|
175
|
+
|
|
176
|
+
### 4. Shadow mode is a one-line switch
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
client = NeedlepathClient(operating_point="np-2026-07-r2", shadow=True)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The call is made exactly as a live call would be — same body, same billing, same
|
|
183
|
+
operating point — the full response comes back and is reported, and **the
|
|
184
|
+
selection is never applied**. Measure what Needlepath would have saved you;
|
|
185
|
+
change nothing.
|
|
186
|
+
|
|
187
|
+
`budget.mode = "shadow"` is **not on the wire yet**. `BudgetSpec.mode` on the
|
|
188
|
+
published contract accepts `fixed` and `adaptive` only; adding `shadow` is a
|
|
189
|
+
public-repo contract change (§0.1, sequenced in E7). The wire half is behind
|
|
190
|
+
`needlepath.features.WIRE_SHADOW_MODE`; flip it in the same change that lands the
|
|
191
|
+
field. Client-side behaviour does not change when you do — it is already correct.
|
|
192
|
+
|
|
193
|
+
## Retries
|
|
194
|
+
|
|
195
|
+
Retried: `429`, `502`, `503`, `504`, and transport failures. `Retry-After` is
|
|
196
|
+
honoured in delta-seconds form. Backoff is full-jitter exponential, and every
|
|
197
|
+
retry is bounded by the `timeout` deadline — retries can never exceed the budget
|
|
198
|
+
the caller gave you.
|
|
199
|
+
|
|
200
|
+
**`500` is not retried.** A 500 from this endpoint is `engine_error`: a
|
|
201
|
+
deterministic failure on this exact input. Retrying buys nothing, doubles the
|
|
202
|
+
latency before you fail open, and spends a second billable invocation on a
|
|
203
|
+
request that will fail identically.
|
|
204
|
+
|
|
205
|
+
POST is safe to retry here because the endpoint is stateless and idempotent by
|
|
206
|
+
contract (`ADAPTER_API_DESIGN.md` invariant 1).
|
|
207
|
+
|
|
208
|
+
## Metadata
|
|
209
|
+
|
|
210
|
+
`result.metadata()` is a flat, **metadata-only** dict built for a framework's
|
|
211
|
+
metadata blob:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
{"needlepath.applied": False, "needlepath.reason": "engine_fallback",
|
|
215
|
+
"needlepath.tokens_before": 8400, "needlepath.tokens_saved": 0,
|
|
216
|
+
"needlepath.gate_reason": "standdown:flat_gap", ...}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Nothing derived from record text is in it. One consequence is non-obvious:
|
|
220
|
+
`selection_error` is built server-side as `f"{type(exc).__name__}: {exc}"` and can
|
|
221
|
+
carry fragments of your input in an exception message — the Lambda deliberately
|
|
222
|
+
declines to log it for that reason — so only its *type* appears here. The full
|
|
223
|
+
string stays on `result.response.selection_error`.
|
|
224
|
+
|
|
225
|
+
## Testing your integration
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from needlepath.testing import FakeTransport, response_payload, ok, error
|
|
229
|
+
|
|
230
|
+
transport = FakeTransport([error(503), ok(response_payload("np-1"))])
|
|
231
|
+
client = NeedlepathClient(operating_point="np-2026-07-r2", transport=transport)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`needlepath.testing` is public API, not a private helper. Every rule above is a
|
|
235
|
+
rule about what happens when the service misbehaves, and you cannot test that
|
|
236
|
+
against a healthy service.
|
|
237
|
+
|
|
238
|
+
## Wire contract
|
|
239
|
+
|
|
240
|
+
`INTERFACE.md` in `context-selection-bench` is the published contract; the
|
|
241
|
+
dataclasses here are an independent implementation of it, deliberately copied
|
|
242
|
+
rather than imported so a research harness never lands in a customer's
|
|
243
|
+
dependency tree. `tests/test_wire_compatibility.py` asserts they have not
|
|
244
|
+
drifted.
|
|
245
|
+
|
|
246
|
+
Unknown response fields are kept on `response.extra`, never rejected: `/v1` is
|
|
247
|
+
additive, and `usage` (E2) is already scheduled to arrive that way.
|
|
248
|
+
|
|
249
|
+
The gate outcome and `reason` are an **open enum**. Nothing in this SDK branches
|
|
250
|
+
on them and nothing you build on it should — a third outcome is a planned
|
|
251
|
+
additive change.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
needlepath/__init__.py,sha256=gPl4JgYTjwa2D7BZPX79_Vo21xJTdPxCqQXPBj9Daps,3683
|
|
2
|
+
needlepath/client.py,sha256=W_o_9XgV9scQB7DzPihJnAaYNiJQYloagTCLAw6gwMU,37658
|
|
3
|
+
needlepath/errors.py,sha256=YElGVzDL8VBi-HMgL-LC2tJZ7_PmdOBo6CH--rHGBSs,4296
|
|
4
|
+
needlepath/features.py,sha256=-W-awNrjzsTv1pHEnrdCZc5t6Y8QAwce6JYc5VzlfN4,2905
|
|
5
|
+
needlepath/models.py,sha256=yAfRLWqOjkprKyR-GEmEp8eV5V-r2p_Lb6AeM9oYl7k,17342
|
|
6
|
+
needlepath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
needlepath/result.py,sha256=O3_HPH0YwoCTIL_n-hY5HGic7LSE0HUSj3Ar30dtLgw,11811
|
|
8
|
+
needlepath/testing.py,sha256=8pfw9HcjagjTt98iVF3lG1QMxlh_79qOeMUoYZwhxI4,7089
|
|
9
|
+
needlepath/transport.py,sha256=ZjNy9c7KyRyQGORF5uY2ft7y0l1tr05fmQDoEsuXCRY,11773
|
|
10
|
+
needlepath/version.py,sha256=RMgmvDRBq67Hr7YmyQW0Fog13ihX3lePDO-sMa7jCTU,307
|
|
11
|
+
needlepath-0.1.0.dist-info/METADATA,sha256=Y_puZt7TqmKPoVZ2hvBIaGl6BAJS3fR9lwTL0_iOPYo,10436
|
|
12
|
+
needlepath-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
needlepath-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
14
|
+
needlepath-0.1.0.dist-info/RECORD,,
|