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/client.py ADDED
@@ -0,0 +1,947 @@
1
+ """The framework-free core client.
2
+
3
+ Two clients, one behaviour: :class:`NeedlepathClient` (sync) and
4
+ :class:`AsyncNeedlepathClient`. Everything that decides *what happens* lives in
5
+ module-level helpers shared by both, so the async client cannot drift from the
6
+ sync one — the failure mode that makes most dual-API SDKs quietly wrong.
7
+
8
+ The three binding rules of §8.2 are implemented here and nowhere else:
9
+
10
+ * **Fail open, always.** :meth:`NeedlepathClient.select` has no raising path.
11
+ Every error becomes a :class:`~needlepath.result.SelectionResult` with
12
+ ``applied=False`` and the exception recorded on ``.error``.
13
+ * **Pin the operating point explicitly.** The constructor requires one.
14
+ * **Guard the 6 MB cap client-side.** The body is measured before it is sent.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import os
22
+ import random
23
+ import time
24
+ import uuid
25
+ from typing import AbstractSet, Any, Dict, Optional, Sequence, Tuple
26
+ from urllib.parse import urlsplit
27
+
28
+ from . import features
29
+ from .errors import (
30
+ NeedlepathConfigError,
31
+ NeedlepathContractError,
32
+ NeedlepathError,
33
+ NeedlepathHTTPError,
34
+ NeedlepathPayloadTooLargeError,
35
+ NeedlepathTimeoutError,
36
+ NeedlepathTransportError,
37
+ )
38
+ from .models import (
39
+ REQUIRED_RESPONSE_FIELDS,
40
+ AdaptiveBudget,
41
+ BudgetSpec,
42
+ ContextRecord,
43
+ ContextRequest,
44
+ ContextResponse,
45
+ TaskSpec,
46
+ )
47
+ from .result import Reason, SelectionResult
48
+ from .transport import (
49
+ AsyncTransport,
50
+ HttpResult,
51
+ Transport,
52
+ UrllibTransport,
53
+ default_async_transport,
54
+ )
55
+ from .version import __version__
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Documented defaults
59
+ # --------------------------------------------------------------------------- #
60
+
61
+ #: The documented default endpoint. A durable custom domain replaces this in E1's
62
+ #: follow-through (§5, S-17a); until it exists this is the address published runs
63
+ #: and design partners use, and it stays overridable by argument or by
64
+ #: ``NEEDLEPATH_BASE_URL`` so that swapping it is a config change, not a release.
65
+ DEFAULT_BASE_URL = "https://ayf02egtaf.execute-api.us-east-2.amazonaws.com/prod"
66
+
67
+ SELECT_PATH = "/v1/context/select"
68
+ HEALTH_PATH = "/v1/health"
69
+ OPERATING_POINTS_PATH = "/v1/operating-points"
70
+
71
+ #: API Gateway REST caps a request at 10 MB; Lambda caps a synchronous payload
72
+ #: at 6 MB. 6 MB is therefore the real contract limit for one select call, and
73
+ #: §8.2 requires the client to guard it rather than surface a platform error.
74
+ #:
75
+ #: Measured against the **JSON body**. The Lambda limit applies to the whole
76
+ #: invocation payload — the API Gateway event envelope (headers, request
77
+ #: context, base64 expansion when a body is not UTF-8-clean) sits on top of the
78
+ #: body — so a request that passes this guard at 5.99 MB can still be rejected
79
+ #: upstream. Lower ``max_request_bytes`` if you routinely run near the cap; the
80
+ #: honest guard is the one that says what it does not cover.
81
+ MAX_REQUEST_BYTES = 6 * 1024 * 1024
82
+
83
+ DEFAULT_TIMEOUT_S = 10.0
84
+ DEFAULT_MAX_RETRIES = 2
85
+
86
+ #: Retried. Every one of these is a transient condition where a second attempt
87
+ #: has a real chance of a different answer.
88
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
89
+
90
+ #: **Not** retried, and worth saying why: a 500 from this endpoint is
91
+ #: ``engine_error`` — a deterministic failure on this exact input. Retrying it
92
+ #: buys nothing, doubles the latency the caller waits before failing open, and
93
+ #: spends a second billable invocation on a request that will fail identically.
94
+
95
+ ENV_BASE_URL = "NEEDLEPATH_BASE_URL"
96
+ ENV_API_KEY = "NEEDLEPATH_API_KEY"
97
+ ENV_OPERATING_POINT = "NEEDLEPATH_OPERATING_POINT"
98
+
99
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"})
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Configuration resolution — shared, so both clients resolve identically
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ def _resolve_base_url(base_url: Optional[str]) -> str:
108
+ raw = base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL
109
+ raw = raw.strip().rstrip("/")
110
+ parts = urlsplit(raw)
111
+ if parts.scheme not in ("http", "https"):
112
+ raise NeedlepathConfigError(f"base_url must be http or https, got {parts.scheme!r}")
113
+ host = (parts.hostname or "").lower()
114
+ if parts.scheme == "http" and host not in _LOOPBACK_HOSTS:
115
+ # "TLS only" is an inherited binding constraint, not a preference
116
+ # (NEEDLEPATH_SAAS_DESIGN §0). Loopback is exempted because the
117
+ # localhost benchmark adapter is plain HTTP by design and is the seam's
118
+ # whole reason for existing.
119
+ raise NeedlepathConfigError(
120
+ f"base_url must use https for any non-loopback host (TLS only); got http://{host}"
121
+ )
122
+ if not host:
123
+ raise NeedlepathConfigError(f"base_url has no host: {raw!r}")
124
+ return raw
125
+
126
+
127
+ def _resolve_operating_point(operating_point: Optional[str]) -> str:
128
+ value = operating_point or os.environ.get(ENV_OPERATING_POINT)
129
+ if not value or not value.strip():
130
+ raise NeedlepathConfigError(
131
+ "operating_point is required. Pin an immutable label explicitly "
132
+ "(e.g. 'np-2026-07-r2') so behaviour never shifts under you when the "
133
+ "service default moves; see docs/needlepath/API_VERSIONING.md. "
134
+ f"Pass operating_point=... or set {ENV_OPERATING_POINT}."
135
+ )
136
+ return value.strip()
137
+
138
+
139
+ def _resolve_api_key(api_key: Optional[str]) -> Optional[str]:
140
+ # An explicitly-passed empty string means "no auth" (the localhost adapter);
141
+ # None means "look in the environment".
142
+ if api_key is not None:
143
+ return api_key.strip() or None
144
+ env = os.environ.get(ENV_API_KEY)
145
+ return env.strip() if env and env.strip() else None
146
+
147
+
148
+ def new_request_id() -> str:
149
+ """A request id inside the published identifier charset.
150
+
151
+ ``[A-Za-z0-9._:-]``, at most 128 characters (§1.3). Staying inside it means
152
+ the endpoint never has to sanitize ours, so ``format_metrics`` comes back
153
+ byte-identical to a conformant caller's.
154
+ """
155
+ return "np-" + uuid.uuid4().hex
156
+
157
+
158
+ # --------------------------------------------------------------------------- #
159
+ # Request assembly and response classification — shared by both clients
160
+ # --------------------------------------------------------------------------- #
161
+
162
+
163
+ def build_request(
164
+ *,
165
+ records: Sequence[ContextRecord],
166
+ task: TaskSpec,
167
+ max_context_tokens: int,
168
+ operating_point: str,
169
+ request_id: Optional[str] = None,
170
+ max_records: Optional[int] = None,
171
+ max_excerpt_tokens_per_record: Optional[int] = None,
172
+ mode: str = "fixed",
173
+ adaptive: Optional[AdaptiveBudget] = None,
174
+ require_evidence_coverage: Optional[bool] = None,
175
+ render: bool = True,
176
+ render_format: str = "plain",
177
+ return_per_record: bool = True,
178
+ ) -> ContextRequest:
179
+ """Assemble a :class:`ContextRequest` with the operating point already pinned."""
180
+ return ContextRequest(
181
+ request_id=request_id or new_request_id(),
182
+ records=list(records),
183
+ task=task,
184
+ budget=BudgetSpec(
185
+ max_context_tokens=max_context_tokens,
186
+ operating_point=operating_point,
187
+ max_records=max_records,
188
+ max_excerpt_tokens_per_record=max_excerpt_tokens_per_record,
189
+ mode=mode,
190
+ adaptive=adaptive,
191
+ require_evidence_coverage=require_evidence_coverage,
192
+ ),
193
+ render=render,
194
+ render_format=render_format,
195
+ return_per_record=return_per_record,
196
+ )
197
+
198
+
199
+ def encode_body(request: ContextRequest, *, shadow: bool) -> bytes:
200
+ """Serialize the request, applying the shadow feature flag.
201
+
202
+ Compact separators are used because every byte counts against the 6 MB
203
+ guard; the JSON is otherwise identical to the published client's.
204
+ """
205
+ wire = request.to_wire()
206
+ if shadow and features.wire_shadow_mode_enabled():
207
+ wire["budget"]["mode"] = features.WIRE_SHADOW_MODE_VALUE
208
+ return json.dumps(wire, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
209
+
210
+
211
+ def validate_response_payload(payload: Any, request_id: str) -> ContextResponse:
212
+ """Parse and contract-check a 2xx body.
213
+
214
+ Raises :class:`NeedlepathContractError` on anything a caller cannot trust to
215
+ describe *this* request. Unknown fields are kept, never rejected — the
216
+ contract is additive under ``/v1``.
217
+ """
218
+ if not isinstance(payload, dict):
219
+ raise NeedlepathContractError(f"response is not a JSON object: {type(payload).__name__}")
220
+ missing = [f for f in REQUIRED_RESPONSE_FIELDS if f not in payload]
221
+ if missing:
222
+ raise NeedlepathContractError(f"response missing required fields: {missing}")
223
+ echoed = payload.get("request_id")
224
+ if echoed != request_id:
225
+ raise NeedlepathContractError(
226
+ f"response request_id {echoed!r} does not echo the request's {request_id!r}"
227
+ )
228
+ try:
229
+ return ContextResponse.from_wire(payload)
230
+ except (KeyError, TypeError, ValueError) as exc:
231
+ raise NeedlepathContractError(f"malformed response body: {exc}") from exc
232
+
233
+
234
+ def classify(
235
+ response: ContextResponse,
236
+ *,
237
+ render_requested: bool = True,
238
+ require_per_record: bool = True,
239
+ known_record_ids: Optional[AbstractSet[str]] = None,
240
+ ) -> str:
241
+ """Decide whether a contract-valid response is safe to *apply*.
242
+
243
+ This is the NEXPE-413 rule, generalized. An empty selection returned as a
244
+ success is a defect at the engine boundary; at a framework boundary it must
245
+ degrade to passthrough, never to an empty context. So must every other way
246
+ the service can say "I did not select anything useful".
247
+
248
+ Note what this function does **not** do: it never reads ``gate.reason``.
249
+ That value is an open enum with a planned third outcome, and a client that
250
+ branches on today's prefixes is a client that breaks additively. Every
251
+ decision here comes from a structural field.
252
+ """
253
+ if response.selection_error:
254
+ return Reason.SELECTION_ERROR
255
+ if response.fallback_used:
256
+ # The engine stood down and is asking for the full context. Applying a
257
+ # fallback render would at best be a no-op and at worst a lossy one.
258
+ return Reason.ENGINE_FALLBACK
259
+ safety = response.safety
260
+ if safety is not None and (safety.fallback_required or not safety.selection_safe):
261
+ # The coverage verdict is a structural field, not the open enum: it says
262
+ # in as many words that this selection should not be used.
263
+ return Reason.SELECTION_UNSAFE
264
+ if response.records_selected <= 0:
265
+ return Reason.EMPTY_SELECTION
266
+ if render_requested and not response.rendered_context.strip():
267
+ return Reason.EMPTY_SELECTION
268
+ if require_per_record and not response.selected:
269
+ return Reason.NO_PER_RECORD_DETAIL
270
+ # A response whose selected ids are all unknown to us is the most destructive
271
+ # shape there is: an adapter that maps "not in the selection" to a
272
+ # placeholder would collapse EVERY record the caller sent. Refuse it here,
273
+ # once, rather than in each adapter.
274
+ if (
275
+ known_record_ids is not None
276
+ and response.selected
277
+ and not (response.selected_ids & set(known_record_ids))
278
+ ):
279
+ return Reason.UNKNOWN_SELECTION
280
+ return Reason.OK
281
+
282
+
283
+ def _retry_after_seconds(headers: Dict[str, str]) -> Optional[float]:
284
+ """Parse ``Retry-After`` when it is a delta-seconds value.
285
+
286
+ HTTP-date form is deliberately not honoured: it needs a clock-skew argument
287
+ to be correct, and this client's backoff is already bounded by the caller's
288
+ deadline, so ignoring it degrades to the default backoff rather than to
289
+ anything wrong.
290
+ """
291
+ raw = headers.get("retry-after")
292
+ if not raw:
293
+ return None
294
+ try:
295
+ value = float(raw.strip())
296
+ except (TypeError, ValueError):
297
+ return None
298
+ return value if value >= 0 else None
299
+
300
+
301
+ class _Backoff:
302
+ """Full-jitter exponential backoff, bounded by the caller's deadline."""
303
+
304
+ def __init__(self, base: float = 0.1, cap: float = 2.0, rng: Optional[random.Random] = None):
305
+ self._base = base
306
+ self._cap = cap
307
+ self._rng = rng or random.Random()
308
+
309
+ def delay(self, attempt: int, retry_after: Optional[float], remaining: float) -> float:
310
+ window = min(self._cap, self._base * (2**attempt))
311
+ wait = retry_after if retry_after is not None else self._rng.uniform(0.0, window)
312
+ # Leave room to actually make the next attempt.
313
+ return max(0.0, min(wait, max(0.0, remaining - 0.05)))
314
+
315
+
316
+ # --------------------------------------------------------------------------- #
317
+ # Base
318
+ # --------------------------------------------------------------------------- #
319
+
320
+
321
+ class _ClientBase:
322
+ def __init__(
323
+ self,
324
+ *,
325
+ api_key: Optional[str] = None,
326
+ operating_point: Optional[str] = None,
327
+ base_url: Optional[str] = None,
328
+ timeout: float = DEFAULT_TIMEOUT_S,
329
+ max_retries: int = DEFAULT_MAX_RETRIES,
330
+ max_request_bytes: int = MAX_REQUEST_BYTES,
331
+ shadow: bool = False,
332
+ user_agent_suffix: Optional[str] = None,
333
+ ) -> None:
334
+ self.base_url = _resolve_base_url(base_url)
335
+ self.operating_point = _resolve_operating_point(operating_point)
336
+ self._api_key = _resolve_api_key(api_key)
337
+ if timeout <= 0:
338
+ raise NeedlepathConfigError(f"timeout must be positive, got {timeout!r}")
339
+ if max_retries < 0:
340
+ raise NeedlepathConfigError(f"max_retries must be >= 0, got {max_retries!r}")
341
+ if max_request_bytes <= 0:
342
+ raise NeedlepathConfigError(
343
+ f"max_request_bytes must be positive, got {max_request_bytes!r}"
344
+ )
345
+ self.timeout = float(timeout)
346
+ self.max_retries = int(max_retries)
347
+ self.max_request_bytes = int(max_request_bytes)
348
+ self.shadow = bool(shadow)
349
+ self._user_agent = f"needlepath-python/{__version__}"
350
+ if user_agent_suffix:
351
+ self._user_agent += f" {user_agent_suffix}"
352
+ self._backoff = _Backoff()
353
+
354
+ # ---------------------------------------------------------------- #
355
+
356
+ def __repr__(self) -> str:
357
+ # The API key is never rendered. Whether one is configured is useful;
358
+ # its value is not, and this object ends up in tracebacks and log lines.
359
+ return (
360
+ f"{type(self).__name__}(base_url={self.base_url!r}, "
361
+ f"operating_point={self.operating_point!r}, "
362
+ f"api_key={'set' if self._api_key else 'unset'}, "
363
+ f"shadow={self.shadow!r})"
364
+ )
365
+
366
+ def _headers(self) -> Dict[str, str]:
367
+ headers = {
368
+ "content-type": "application/json",
369
+ "accept": "application/json",
370
+ "user-agent": self._user_agent,
371
+ }
372
+ if self._api_key:
373
+ headers["authorization"] = f"Bearer {self._api_key}"
374
+ return headers
375
+
376
+ def _url(self, path: str) -> str:
377
+ return f"{self.base_url}{path}"
378
+
379
+ def _prepare(
380
+ self, request: ContextRequest, shadow: bool
381
+ ) -> Tuple[bytes, Optional[NeedlepathPayloadTooLargeError]]:
382
+ body = encode_body(request, shadow=shadow)
383
+ if len(body) > self.max_request_bytes:
384
+ return body, NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
385
+ return body, None
386
+
387
+ @staticmethod
388
+ def _to_error(result: HttpResult) -> Optional[NeedlepathHTTPError]:
389
+ if 200 <= result.status < 300:
390
+ return None
391
+ return NeedlepathHTTPError(
392
+ result.status,
393
+ body=result.json(),
394
+ text=result.text(),
395
+ retry_after=_retry_after_seconds(result.headers),
396
+ )
397
+
398
+
399
+ # --------------------------------------------------------------------------- #
400
+ # Sync
401
+ # --------------------------------------------------------------------------- #
402
+
403
+
404
+ class NeedlepathClient(_ClientBase):
405
+ """Synchronous client for the Needlepath hosted context-selection service.
406
+
407
+ ::
408
+
409
+ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
410
+
411
+ client = NeedlepathClient(
412
+ api_key="np_live_...", # or NEEDLEPATH_API_KEY
413
+ operating_point="np-2026-07-r2", # required, and immutable
414
+ )
415
+ result = client.select(
416
+ records=[ContextRecord(text=..., kind="tool_result", id="r1")],
417
+ task=TaskSpec(prompt="what is the invoice total?"),
418
+ max_context_tokens=4000,
419
+ )
420
+ if result.applied:
421
+ use(result.rendered_context)
422
+ else:
423
+ use(my_original_context) # always safe, always available
424
+
425
+ :param api_key: ``np_live_…``. Falls back to ``NEEDLEPATH_API_KEY``. Pass
426
+ ``""`` to send no ``Authorization`` header (the localhost adapter).
427
+ :param operating_point: **Required.** An immutable label such as
428
+ ``np-2026-07-r2``. Falls back to ``NEEDLEPATH_OPERATING_POINT``.
429
+ :param base_url: Defaults to :data:`DEFAULT_BASE_URL` or ``NEEDLEPATH_BASE_URL``.
430
+ :param timeout: Total wall-clock deadline for one :meth:`select`, retries
431
+ and backoff included. Not a per-attempt timeout.
432
+ :param max_retries: Extra attempts after the first, for transient statuses
433
+ and transport failures only.
434
+ :param shadow: Default shadow mode for this client. See
435
+ :mod:`needlepath.features`.
436
+ """
437
+
438
+ def __init__(self, *, transport: Optional[Transport] = None, **kwargs: Any) -> None:
439
+ super().__init__(**kwargs)
440
+ self._transport: Transport = transport or UrllibTransport()
441
+ self._owns_transport = transport is None
442
+
443
+ # ---------------------------------------------------------------- #
444
+ # The fail-open surface
445
+ # ---------------------------------------------------------------- #
446
+
447
+ def select(
448
+ self,
449
+ *,
450
+ records: Sequence[ContextRecord],
451
+ task: TaskSpec,
452
+ max_context_tokens: int,
453
+ request_id: Optional[str] = None,
454
+ shadow: Optional[bool] = None,
455
+ require_per_record: bool = True,
456
+ **budget_kwargs: Any,
457
+ ) -> SelectionResult:
458
+ """Select context. **Never raises.**
459
+
460
+ Any keyword accepted by :func:`build_request` other than ``operating_point``
461
+ may be passed through ``budget_kwargs`` (``max_records``, ``mode``,
462
+ ``adaptive``, ``render_format``, …).
463
+ """
464
+ if _is_empty(records):
465
+ return SelectionResult.passthrough_result(
466
+ Reason.NO_RECORDS,
467
+ request_id=request_id or "",
468
+ operating_point=self.operating_point,
469
+ shadow=self.shadow if shadow is None else bool(shadow),
470
+ )
471
+ try:
472
+ request = build_request(
473
+ records=records,
474
+ task=task,
475
+ max_context_tokens=max_context_tokens,
476
+ operating_point=self.operating_point,
477
+ request_id=request_id,
478
+ **budget_kwargs,
479
+ )
480
+ except Exception as exc: # noqa: BLE001 - assembling must not be a crash path
481
+ return SelectionResult.passthrough_result(
482
+ Reason.UNEXPECTED_ERROR,
483
+ request_id=request_id or "",
484
+ operating_point=self.operating_point,
485
+ error=exc,
486
+ )
487
+ return self.select_request(request, shadow=shadow, require_per_record=require_per_record)
488
+
489
+ def select_request(
490
+ self,
491
+ request: ContextRequest,
492
+ *,
493
+ shadow: Optional[bool] = None,
494
+ require_per_record: bool = True,
495
+ timeout: Optional[float] = None,
496
+ ) -> SelectionResult:
497
+ """Select context from an assembled request. **Never raises.**"""
498
+ is_shadow = self.shadow if shadow is None else bool(shadow)
499
+ started = time.perf_counter()
500
+ try:
501
+ response, attempts, size = self._call(request, is_shadow, timeout)
502
+ # `_finish` is INSIDE the boundary on purpose. It reads fields off a
503
+ # body that came from a socket; a malformed one (`rendered_context`
504
+ # as a number, say) would otherwise raise here, outside every
505
+ # except-clause, and reach the caller as a crash.
506
+ return _finish(
507
+ response,
508
+ request=request,
509
+ operating_point=self.operating_point,
510
+ shadow=is_shadow,
511
+ require_per_record=require_per_record,
512
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
513
+ attempts=attempts,
514
+ request_bytes=size,
515
+ )
516
+ except NeedlepathError as exc:
517
+ return SelectionResult.passthrough_result(
518
+ _reason_for(exc),
519
+ request_id=request.request_id,
520
+ operating_point=self.operating_point,
521
+ shadow=is_shadow,
522
+ error=exc,
523
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
524
+ attempts=getattr(exc, "_np_attempts", 0),
525
+ request_bytes=getattr(exc, "_np_bytes", 0),
526
+ )
527
+ except Exception as exc: # noqa: BLE001 - a defect here must still fail open
528
+ return SelectionResult.passthrough_result(
529
+ Reason.UNEXPECTED_ERROR,
530
+ request_id=request.request_id,
531
+ operating_point=self.operating_point,
532
+ shadow=is_shadow,
533
+ error=exc,
534
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
535
+ )
536
+
537
+ def select_or_raise(
538
+ self,
539
+ request: ContextRequest,
540
+ *,
541
+ shadow: Optional[bool] = None,
542
+ timeout: Optional[float] = None,
543
+ ) -> ContextResponse:
544
+ """Select context, raising on any error path.
545
+
546
+ **Not for framework adapters.** §8.2's fail-open rule is binding at a
547
+ framework boundary, and this method exists for the two places it does
548
+ not apply: diagnostics, and callers who are themselves a benchmark and
549
+ must not silently substitute a passthrough for a measurement.
550
+ """
551
+ response, _attempts, _size = self._call(
552
+ request, self.shadow if shadow is None else bool(shadow), timeout
553
+ )
554
+ return response
555
+
556
+ # ---------------------------------------------------------------- #
557
+
558
+ def _call(
559
+ self, request: ContextRequest, shadow: bool, timeout: Optional[float]
560
+ ) -> Tuple[ContextResponse, int, int]:
561
+ body, oversize = self._prepare(request, shadow)
562
+ if oversize is not None:
563
+ oversize._np_bytes = len(body) # type: ignore[attr-defined]
564
+ raise oversize
565
+ deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
566
+ url = self._url(SELECT_PATH)
567
+ headers = self._headers()
568
+ last: Optional[NeedlepathError] = None
569
+ attempts = 0
570
+ for attempt in range(self.max_retries + 1):
571
+ remaining = deadline - time.monotonic()
572
+ if remaining <= 0:
573
+ break
574
+ attempts += 1
575
+ try:
576
+ http = self._transport.request(
577
+ "POST", url, headers=headers, body=body, timeout=remaining
578
+ )
579
+ err = self._to_error(http)
580
+ if err is None:
581
+ if time.monotonic() > deadline:
582
+ # Defence in depth behind the transport's own read
583
+ # deadline: a response that arrives after the caller's
584
+ # budget is not a response the caller asked for.
585
+ raise NeedlepathTimeoutError(
586
+ "a response arrived after the deadline had elapsed"
587
+ )
588
+ response = validate_response_payload(http.json(), request.request_id)
589
+ return response, attempts, len(body)
590
+ last = err
591
+ if http.status not in RETRY_STATUSES:
592
+ break
593
+ retry_after = err.retry_after
594
+ except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
595
+ last = exc
596
+ retry_after = None
597
+ except NeedlepathContractError as exc:
598
+ # A conformant server does not do this; a second attempt at the
599
+ # same build will not do it differently.
600
+ last = exc
601
+ break
602
+ if attempt >= self.max_retries:
603
+ break
604
+ delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
605
+ if delay <= 0 and deadline - time.monotonic() <= 0:
606
+ break
607
+ if delay:
608
+ time.sleep(delay)
609
+ if last is None: # pragma: no cover - only when the deadline was already past
610
+ last = NeedlepathTimeoutError("deadline elapsed before any attempt was made")
611
+ last._np_attempts = attempts # type: ignore[attr-defined]
612
+ last._np_bytes = len(body) # type: ignore[attr-defined]
613
+ raise last
614
+
615
+ # ---------------------------------------------------------------- #
616
+ # Diagnostics. These raise: they are not on any agent's hot path.
617
+ # ---------------------------------------------------------------- #
618
+
619
+ def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
620
+ """``GET /v1/health``. Raises on failure."""
621
+ return self._get(HEALTH_PATH, timeout)
622
+
623
+ def operating_points(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
624
+ """``GET /v1/operating-points``. Raises on failure.
625
+
626
+ Useful once, at wire-up, to assert that the label you pinned is one the
627
+ endpoint actually serves. Do not call it per request.
628
+ """
629
+ return self._get(OPERATING_POINTS_PATH, timeout)
630
+
631
+ def _get(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
632
+ http = self._transport.request(
633
+ "GET",
634
+ self._url(path),
635
+ headers=self._headers(),
636
+ body=None,
637
+ timeout=float(timeout if timeout is not None else self.timeout),
638
+ )
639
+ err = self._to_error(http)
640
+ if err is not None:
641
+ raise err
642
+ payload = http.json()
643
+ if not isinstance(payload, dict):
644
+ raise NeedlepathContractError(f"{path} did not return a JSON object")
645
+ return payload
646
+
647
+ # ---------------------------------------------------------------- #
648
+
649
+ def close(self) -> None:
650
+ if self._owns_transport:
651
+ self._transport.close()
652
+
653
+ def __enter__(self) -> NeedlepathClient:
654
+ return self
655
+
656
+ def __exit__(self, *exc: Any) -> None:
657
+ self.close()
658
+
659
+
660
+ # --------------------------------------------------------------------------- #
661
+ # Async
662
+ # --------------------------------------------------------------------------- #
663
+
664
+
665
+ class AsyncNeedlepathClient(_ClientBase):
666
+ """Asynchronous twin of :class:`NeedlepathClient`.
667
+
668
+ Identical behaviour, identical results — the decision logic is the same
669
+ module-level functions. Uses ``httpx.AsyncClient`` when httpx is importable
670
+ and a worker thread otherwise, so it never blocks the event loop either way.
671
+ """
672
+
673
+ def __init__(self, *, transport: Optional[AsyncTransport] = None, **kwargs: Any) -> None:
674
+ super().__init__(**kwargs)
675
+ self._transport: AsyncTransport = transport or default_async_transport()
676
+ self._owns_transport = transport is None
677
+
678
+ async def select(
679
+ self,
680
+ *,
681
+ records: Sequence[ContextRecord],
682
+ task: TaskSpec,
683
+ max_context_tokens: int,
684
+ request_id: Optional[str] = None,
685
+ shadow: Optional[bool] = None,
686
+ require_per_record: bool = True,
687
+ **budget_kwargs: Any,
688
+ ) -> SelectionResult:
689
+ """Select context. **Never raises.**"""
690
+ if _is_empty(records):
691
+ return SelectionResult.passthrough_result(
692
+ Reason.NO_RECORDS,
693
+ request_id=request_id or "",
694
+ operating_point=self.operating_point,
695
+ shadow=self.shadow if shadow is None else bool(shadow),
696
+ )
697
+ try:
698
+ request = build_request(
699
+ records=records,
700
+ task=task,
701
+ max_context_tokens=max_context_tokens,
702
+ operating_point=self.operating_point,
703
+ request_id=request_id,
704
+ **budget_kwargs,
705
+ )
706
+ except Exception as exc: # noqa: BLE001
707
+ return SelectionResult.passthrough_result(
708
+ Reason.UNEXPECTED_ERROR,
709
+ request_id=request_id or "",
710
+ operating_point=self.operating_point,
711
+ error=exc,
712
+ )
713
+ return await self.select_request(
714
+ request, shadow=shadow, require_per_record=require_per_record
715
+ )
716
+
717
+ async def select_request(
718
+ self,
719
+ request: ContextRequest,
720
+ *,
721
+ shadow: Optional[bool] = None,
722
+ require_per_record: bool = True,
723
+ timeout: Optional[float] = None,
724
+ ) -> SelectionResult:
725
+ """Select context from an assembled request. **Never raises.**"""
726
+ is_shadow = self.shadow if shadow is None else bool(shadow)
727
+ started = time.perf_counter()
728
+ try:
729
+ response, attempts, size = await self._call(request, is_shadow, timeout)
730
+ # Inside the boundary, for the same reason as the sync twin.
731
+ return _finish(
732
+ response,
733
+ request=request,
734
+ operating_point=self.operating_point,
735
+ shadow=is_shadow,
736
+ require_per_record=require_per_record,
737
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
738
+ attempts=attempts,
739
+ request_bytes=size,
740
+ )
741
+ except NeedlepathError as exc:
742
+ return SelectionResult.passthrough_result(
743
+ _reason_for(exc),
744
+ request_id=request.request_id,
745
+ operating_point=self.operating_point,
746
+ shadow=is_shadow,
747
+ error=exc,
748
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
749
+ attempts=getattr(exc, "_np_attempts", 0),
750
+ request_bytes=getattr(exc, "_np_bytes", 0),
751
+ )
752
+ except Exception as exc: # noqa: BLE001
753
+ return SelectionResult.passthrough_result(
754
+ Reason.UNEXPECTED_ERROR,
755
+ request_id=request.request_id,
756
+ operating_point=self.operating_point,
757
+ shadow=is_shadow,
758
+ error=exc,
759
+ client_latency_ms=(time.perf_counter() - started) * 1000.0,
760
+ )
761
+
762
+ async def select_or_raise(
763
+ self,
764
+ request: ContextRequest,
765
+ *,
766
+ shadow: Optional[bool] = None,
767
+ timeout: Optional[float] = None,
768
+ ) -> ContextResponse:
769
+ """Select context, raising on any error path. Not for adapters."""
770
+ response, _attempts, _size = await self._call(
771
+ request, self.shadow if shadow is None else bool(shadow), timeout
772
+ )
773
+ return response
774
+
775
+ async def _call(
776
+ self, request: ContextRequest, shadow: bool, timeout: Optional[float]
777
+ ) -> Tuple[ContextResponse, int, int]:
778
+ body, oversize = self._prepare(request, shadow)
779
+ if oversize is not None:
780
+ oversize._np_bytes = len(body) # type: ignore[attr-defined]
781
+ raise oversize
782
+ deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
783
+ url = self._url(SELECT_PATH)
784
+ headers = self._headers()
785
+ last: Optional[NeedlepathError] = None
786
+ attempts = 0
787
+ for attempt in range(self.max_retries + 1):
788
+ remaining = deadline - time.monotonic()
789
+ if remaining <= 0:
790
+ break
791
+ attempts += 1
792
+ try:
793
+ http = await self._transport.request(
794
+ "POST", url, headers=headers, body=body, timeout=remaining
795
+ )
796
+ err = self._to_error(http)
797
+ if err is None:
798
+ if time.monotonic() > deadline:
799
+ # Defence in depth behind the transport's own read
800
+ # deadline: a response that arrives after the caller's
801
+ # budget is not a response the caller asked for.
802
+ raise NeedlepathTimeoutError(
803
+ "a response arrived after the deadline had elapsed"
804
+ )
805
+ response = validate_response_payload(http.json(), request.request_id)
806
+ return response, attempts, len(body)
807
+ last = err
808
+ if http.status not in RETRY_STATUSES:
809
+ break
810
+ retry_after = err.retry_after
811
+ except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
812
+ last = exc
813
+ retry_after = None
814
+ except NeedlepathContractError as exc:
815
+ last = exc
816
+ break
817
+ if attempt >= self.max_retries:
818
+ break
819
+ delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
820
+ if delay <= 0 and deadline - time.monotonic() <= 0:
821
+ break
822
+ if delay:
823
+ await asyncio.sleep(delay)
824
+ if last is None: # pragma: no cover
825
+ last = NeedlepathTimeoutError("deadline elapsed before any attempt was made")
826
+ last._np_attempts = attempts # type: ignore[attr-defined]
827
+ last._np_bytes = len(body) # type: ignore[attr-defined]
828
+ raise last
829
+
830
+ async def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
831
+ return await self._aget(HEALTH_PATH, timeout)
832
+
833
+ async def operating_points(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
834
+ return await self._aget(OPERATING_POINTS_PATH, timeout)
835
+
836
+ async def _aget(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
837
+ http = await self._transport.request(
838
+ "GET",
839
+ self._url(path),
840
+ headers=self._headers(),
841
+ body=None,
842
+ timeout=float(timeout if timeout is not None else self.timeout),
843
+ )
844
+ err = self._to_error(http)
845
+ if err is not None:
846
+ raise err
847
+ payload = http.json()
848
+ if not isinstance(payload, dict):
849
+ raise NeedlepathContractError(f"{path} did not return a JSON object")
850
+ return payload
851
+
852
+ async def aclose(self) -> None:
853
+ if self._owns_transport:
854
+ await self._transport.aclose()
855
+
856
+ async def __aenter__(self) -> AsyncNeedlepathClient:
857
+ return self
858
+
859
+ async def __aexit__(self, *exc: Any) -> None:
860
+ await self.aclose()
861
+
862
+
863
+ # --------------------------------------------------------------------------- #
864
+ # Shared outcome assembly
865
+ # --------------------------------------------------------------------------- #
866
+
867
+
868
+ def _is_empty(records: Any) -> bool:
869
+ """Whether there is nothing to select over, for any sequence-ish argument.
870
+
871
+ `not records` is the obvious spelling and it raises for anything with an
872
+ ambiguous truth value (a numpy array, a pandas Series) — outside the
873
+ fail-open boundary, because it runs before the request is even built.
874
+ """
875
+ try:
876
+ return len(records) == 0
877
+ except Exception: # noqa: BLE001
878
+ return False
879
+
880
+
881
+ def _reason_for(exc: NeedlepathError) -> str:
882
+ if isinstance(exc, NeedlepathPayloadTooLargeError):
883
+ return Reason.PAYLOAD_TOO_LARGE
884
+ if isinstance(exc, NeedlepathTimeoutError):
885
+ return Reason.TIMEOUT
886
+ if isinstance(exc, NeedlepathTransportError):
887
+ return Reason.TRANSPORT_ERROR
888
+ if isinstance(exc, NeedlepathHTTPError):
889
+ return Reason.HTTP_ERROR
890
+ if isinstance(exc, NeedlepathContractError):
891
+ return Reason.CONTRACT_ERROR
892
+ return Reason.UNEXPECTED_ERROR
893
+
894
+
895
+ def _finish(
896
+ response: ContextResponse,
897
+ *,
898
+ request: ContextRequest,
899
+ operating_point: str,
900
+ shadow: bool,
901
+ require_per_record: bool,
902
+ client_latency_ms: float,
903
+ attempts: int,
904
+ request_bytes: int,
905
+ ) -> SelectionResult:
906
+ reason = classify(
907
+ response,
908
+ render_requested=request.render,
909
+ require_per_record=require_per_record and request.return_per_record,
910
+ known_record_ids={r.id for r in request.records if r.id},
911
+ )
912
+ # Shadow wins over OK and *loses* to every decline: a shadow run must report
913
+ # the same reason a live run would have, or the shadow report predicts an
914
+ # invoice for selections that would never have been applied.
915
+ if reason == Reason.OK and shadow:
916
+ reason = Reason.SHADOW
917
+ return SelectionResult(
918
+ applied=(reason == Reason.OK),
919
+ reason=reason,
920
+ request_id=request.request_id,
921
+ operating_point=operating_point,
922
+ shadow=shadow,
923
+ response=response,
924
+ error=None,
925
+ client_latency_ms=client_latency_ms,
926
+ attempts=attempts,
927
+ request_bytes=request_bytes,
928
+ )
929
+
930
+
931
+ __all__ = [
932
+ "DEFAULT_BASE_URL",
933
+ "SELECT_PATH",
934
+ "HEALTH_PATH",
935
+ "OPERATING_POINTS_PATH",
936
+ "MAX_REQUEST_BYTES",
937
+ "DEFAULT_TIMEOUT_S",
938
+ "DEFAULT_MAX_RETRIES",
939
+ "RETRY_STATUSES",
940
+ "NeedlepathClient",
941
+ "AsyncNeedlepathClient",
942
+ "build_request",
943
+ "encode_body",
944
+ "classify",
945
+ "validate_response_payload",
946
+ "new_request_id",
947
+ ]