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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any