needlepath 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. needlepath-0.2.0/CHANGELOG.md +45 -0
  2. {needlepath-0.1.1 → needlepath-0.2.0}/PKG-INFO +1 -1
  3. {needlepath-0.1.1 → needlepath-0.2.0}/pyproject.toml +1 -1
  4. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/client.py +109 -30
  5. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/models.py +45 -11
  6. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/version.py +1 -1
  7. needlepath-0.2.0/tests/test_simple_shape.py +251 -0
  8. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_wire_compatibility.py +70 -24
  9. {needlepath-0.1.1 → needlepath-0.2.0}/.gitignore +0 -0
  10. {needlepath-0.1.1 → needlepath-0.2.0}/LICENSE +0 -0
  11. {needlepath-0.1.1 → needlepath-0.2.0}/README.md +0 -0
  12. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/__init__.py +0 -0
  13. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/errors.py +0 -0
  14. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/features.py +0 -0
  15. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/py.typed +0 -0
  16. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/result.py +0 -0
  17. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/testing.py +0 -0
  18. {needlepath-0.1.1 → needlepath-0.2.0}/src/needlepath/transport.py +0 -0
  19. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_async_parity.py +0 -0
  20. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_config_and_guards.py +0 -0
  21. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_fail_open.py +0 -0
  22. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_live_endpoint.py +0 -0
  23. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_no_secret_leak.py +0 -0
  24. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_retries.py +0 -0
  25. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_shadow_mode.py +0 -0
  26. {needlepath-0.1.1 → needlepath-0.2.0}/tests/test_urllib_transport.py +0 -0
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `needlepath` Python package are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-08-07
9
+
10
+ ### Added
11
+
12
+ - **Simple text shape** (NEXPE-461): `select(text=...)` accepts a raw string as
13
+ the alternative to `records=`. Exactly one of the two may be passed; passing
14
+ both fails open client-side with a `NeedlepathConfigError` whose message
15
+ mirrors the server's `400 text_and_records_both_set`. The text goes on the
16
+ wire verbatim; the server chunks it, and each chunk comes back in
17
+ `selected[].record_id` as a content-hash id of the form `sha256:<16 hex>`.
18
+ `ContextRequest` gains an optional `text` field, omitted from the wire when
19
+ unset.
20
+
21
+ ### Changed
22
+
23
+ - **`request_id` is now optional** (NEXPE-461): it is a sent-side correlation
24
+ id. `ContextRequest.request_id` may be `None`, in which case the key is
25
+ omitted from the payload and the server assigns its own id (reported on
26
+ `SelectionResult.request_id`). In `select()` / `build_request()`, leaving the
27
+ argument off still auto-generates an id as before; pass `request_id=None`
28
+ explicitly to omit it. The response echo check is skipped when no id was
29
+ sent.
30
+
31
+ ### Note
32
+
33
+ - No breaking changes for existing callers: records requests serialize
34
+ byte-identically to 0.1.x, omitting `request_id` still auto-generates one,
35
+ and `operating_point` remains the required, immutable-label constructor
36
+ argument.
37
+
38
+ ## [0.1.1] - 2026-08-06
39
+
40
+ - Default base URL moved to `https://api.nextmoca.com`; no wire-shape changes.
41
+
42
+ ## [0.1.0] - 2026-08-01
43
+
44
+ - Initial release: fail-open `select()`, pinned operating point, client-side
45
+ 6 MB payload guard, shadow mode, sync and async clients.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: needlepath
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Framework-free client for the Needlepath hosted context-selection service.
5
5
  Project-URL: Homepage, https://nextmoca.com
6
6
  Author: Next Moca Global, Inc.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Framework-free client for the Needlepath hosted context-selection service."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -160,13 +160,23 @@ def new_request_id() -> str:
160
160
  # --------------------------------------------------------------------------- #
161
161
 
162
162
 
163
+ #: Sentinel default for ``request_id``: "the caller did not pass one, generate
164
+ #: one" (NEXPE-461). It exists so that an **explicit** ``request_id=None`` can
165
+ #: mean something different — omit the field from the payload entirely and let
166
+ #: the server assign its own id — without changing behaviour for existing
167
+ #: callers, for whom leaving the argument off still auto-generates.
168
+ _AUTO_REQUEST_ID: Any = object()
169
+
170
+
163
171
  def build_request(
164
172
  *,
165
- records: Sequence[ContextRecord],
173
+ records: Optional[Sequence[ContextRecord]] = None,
166
174
  task: TaskSpec,
167
175
  max_context_tokens: int,
168
176
  operating_point: str,
169
- request_id: Optional[str] = None,
177
+ text: Optional[str] = None,
178
+ capacity_cap_tokens: Optional[int] = None,
179
+ request_id: Any = _AUTO_REQUEST_ID,
170
180
  max_records: Optional[int] = None,
171
181
  max_excerpt_tokens_per_record: Optional[int] = None,
172
182
  mode: str = "fixed",
@@ -176,13 +186,33 @@ def build_request(
176
186
  render_format: str = "plain",
177
187
  return_per_record: bool = True,
178
188
  ) -> ContextRequest:
179
- """Assemble a :class:`ContextRequest` with the operating point already pinned."""
189
+ """Assemble a :class:`ContextRequest` with the operating point already pinned.
190
+
191
+ Exactly one of ``records`` / ``text`` must be provided (NEXPE-461). ``text``
192
+ is the simple shape: the string is sent verbatim, the server chunks it, and
193
+ each chunk comes back in ``selected[].record_id`` as a content-hash id of
194
+ the form ``sha256:<16 hex>``.
195
+
196
+ ``request_id`` is a sent-side correlation id. Leave it off to auto-generate
197
+ one; pass ``request_id=None`` explicitly to omit it from the payload, in
198
+ which case ``result.request_id`` comes back empty (echoed in the response).
199
+ """
200
+ if text is not None and records is not None:
201
+ raise NeedlepathConfigError(
202
+ "text_and_records_both_set: pass exactly one of records= or text= "
203
+ "(the server rejects a request carrying both with "
204
+ "400 text_and_records_both_set)"
205
+ )
206
+ if text is None and records is None:
207
+ raise NeedlepathConfigError("one of records= or text= is required")
180
208
  return ContextRequest(
181
- request_id=request_id or new_request_id(),
182
- records=list(records),
209
+ request_id=new_request_id() if request_id is _AUTO_REQUEST_ID else request_id,
210
+ records=list(records) if records is not None else [],
211
+ text=text,
183
212
  task=task,
184
213
  budget=BudgetSpec(
185
214
  max_context_tokens=max_context_tokens,
215
+ capacity_cap_tokens=capacity_cap_tokens,
186
216
  operating_point=operating_point,
187
217
  max_records=max_records,
188
218
  max_excerpt_tokens_per_record=max_excerpt_tokens_per_record,
@@ -203,28 +233,37 @@ def encode_body(request: ContextRequest, *, shadow: bool) -> bytes:
203
233
  guard; the JSON is otherwise identical to the published client's.
204
234
  """
205
235
  wire = request.to_wire()
236
+ # None means "not sent" (review finding on #296): to_wire maps it to a
237
+ # JSON null, which is a value, not an omission.
238
+ if wire.get("request_id") is None:
239
+ wire.pop("request_id", None)
206
240
  if shadow and features.wire_shadow_mode_enabled():
207
241
  wire["budget"]["mode"] = features.WIRE_SHADOW_MODE_VALUE
208
242
  return json.dumps(wire, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
209
243
 
210
244
 
211
- def validate_response_payload(payload: Any, request_id: str) -> ContextResponse:
245
+ def validate_response_payload(payload: Any, request_id: Optional[str]) -> ContextResponse:
212
246
  """Parse and contract-check a 2xx body.
213
247
 
214
248
  Raises :class:`NeedlepathContractError` on anything a caller cannot trust to
215
249
  describe *this* request. Unknown fields are kept, never rejected — the
216
250
  contract is additive under ``/v1``.
251
+
252
+ ``request_id=None`` means the request omitted the correlation id
253
+ (NEXPE-461): the echo check is skipped, because the id in the response is
254
+ the server's own, but ``request_id`` must still be present.
217
255
  """
218
256
  if not isinstance(payload, dict):
219
257
  raise NeedlepathContractError(f"response is not a JSON object: {type(payload).__name__}")
220
258
  missing = [f for f in REQUIRED_RESPONSE_FIELDS if f not in payload]
221
259
  if missing:
222
260
  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
- )
261
+ if request_id is not None:
262
+ echoed = payload.get("request_id")
263
+ if echoed != request_id:
264
+ raise NeedlepathContractError(
265
+ f"response request_id {echoed!r} does not echo the request's {request_id!r}"
266
+ )
228
267
  try:
229
268
  return ContextResponse.from_wire(payload)
230
269
  except (KeyError, TypeError, ValueError) as exc:
@@ -447,30 +486,48 @@ class NeedlepathClient(_ClientBase):
447
486
  def select(
448
487
  self,
449
488
  *,
450
- records: Sequence[ContextRecord],
489
+ records: Optional[Sequence[ContextRecord]] = None,
451
490
  task: TaskSpec,
452
491
  max_context_tokens: int,
453
- request_id: Optional[str] = None,
492
+ text: Optional[str] = None,
493
+ request_id: Any = _AUTO_REQUEST_ID,
454
494
  shadow: Optional[bool] = None,
455
495
  require_per_record: bool = True,
456
496
  **budget_kwargs: Any,
457
497
  ) -> SelectionResult:
458
498
  """Select context. **Never raises.**
459
499
 
500
+ Exactly one of ``records`` / ``text`` must be provided (NEXPE-461).
501
+ ``text`` is the simple shape: the string goes on the wire verbatim, the
502
+ server chunks it, and each chunk comes back in ``selected[].record_id``
503
+ as a content-hash id of the form ``sha256:<16 hex>``. Passing both is
504
+ the client-side mirror of the server's ``400 text_and_records_both_set``
505
+ and fails open with a :class:`NeedlepathConfigError` on ``.error``.
506
+
507
+ ``request_id`` is a sent-side correlation id. Leave it off to
508
+ auto-generate one; pass ``request_id=None`` explicitly to omit it from
509
+ the payload, in which case ``result.request_id`` comes back empty and
510
+ ``result.request_id`` carries the server's.
511
+
460
512
  Any keyword accepted by :func:`build_request` other than ``operating_point``
461
513
  may be passed through ``budget_kwargs`` (``max_records``, ``mode``,
462
514
  ``adaptive``, ``render_format``, …).
463
515
  """
464
- if _is_empty(records):
516
+ is_shadow = self.shadow if shadow is None else bool(shadow)
517
+ rid_for_report = request_id if isinstance(request_id, str) else ""
518
+ if (text is None and records is not None and _is_empty(records)) or (
519
+ text is not None and records is None and len(text) == 0
520
+ ):
465
521
  return SelectionResult.passthrough_result(
466
522
  Reason.NO_RECORDS,
467
- request_id=request_id or "",
523
+ request_id=rid_for_report,
468
524
  operating_point=self.operating_point,
469
- shadow=self.shadow if shadow is None else bool(shadow),
525
+ shadow=is_shadow,
470
526
  )
471
527
  try:
472
528
  request = build_request(
473
529
  records=records,
530
+ text=text,
474
531
  task=task,
475
532
  max_context_tokens=max_context_tokens,
476
533
  operating_point=self.operating_point,
@@ -480,8 +537,9 @@ class NeedlepathClient(_ClientBase):
480
537
  except Exception as exc: # noqa: BLE001 - assembling must not be a crash path
481
538
  return SelectionResult.passthrough_result(
482
539
  Reason.UNEXPECTED_ERROR,
483
- request_id=request_id or "",
540
+ request_id=rid_for_report,
484
541
  operating_point=self.operating_point,
542
+ shadow=is_shadow,
485
543
  error=exc,
486
544
  )
487
545
  return self.select_request(request, shadow=shadow, require_per_record=require_per_record)
@@ -516,7 +574,7 @@ class NeedlepathClient(_ClientBase):
516
574
  except NeedlepathError as exc:
517
575
  return SelectionResult.passthrough_result(
518
576
  _reason_for(exc),
519
- request_id=request.request_id,
577
+ request_id=request.request_id or "",
520
578
  operating_point=self.operating_point,
521
579
  shadow=is_shadow,
522
580
  error=exc,
@@ -527,7 +585,7 @@ class NeedlepathClient(_ClientBase):
527
585
  except Exception as exc: # noqa: BLE001 - a defect here must still fail open
528
586
  return SelectionResult.passthrough_result(
529
587
  Reason.UNEXPECTED_ERROR,
530
- request_id=request.request_id,
588
+ request_id=request.request_id or "",
531
589
  operating_point=self.operating_point,
532
590
  shadow=is_shadow,
533
591
  error=exc,
@@ -678,25 +736,36 @@ class AsyncNeedlepathClient(_ClientBase):
678
736
  async def select(
679
737
  self,
680
738
  *,
681
- records: Sequence[ContextRecord],
739
+ records: Optional[Sequence[ContextRecord]] = None,
682
740
  task: TaskSpec,
683
741
  max_context_tokens: int,
684
- request_id: Optional[str] = None,
742
+ text: Optional[str] = None,
743
+ request_id: Any = _AUTO_REQUEST_ID,
685
744
  shadow: Optional[bool] = None,
686
745
  require_per_record: bool = True,
687
746
  **budget_kwargs: Any,
688
747
  ) -> SelectionResult:
689
- """Select context. **Never raises.**"""
690
- if _is_empty(records):
748
+ """Select context. **Never raises.**
749
+
750
+ Behaviour is identical to :meth:`NeedlepathClient.select`, including the
751
+ simple ``text`` shape and the optional sent-side ``request_id``
752
+ (NEXPE-461) — see that docstring.
753
+ """
754
+ is_shadow = self.shadow if shadow is None else bool(shadow)
755
+ rid_for_report = request_id if isinstance(request_id, str) else ""
756
+ if (text is None and records is not None and _is_empty(records)) or (
757
+ text is not None and records is None and len(text) == 0
758
+ ):
691
759
  return SelectionResult.passthrough_result(
692
760
  Reason.NO_RECORDS,
693
- request_id=request_id or "",
761
+ request_id=rid_for_report,
694
762
  operating_point=self.operating_point,
695
- shadow=self.shadow if shadow is None else bool(shadow),
763
+ shadow=is_shadow,
696
764
  )
697
765
  try:
698
766
  request = build_request(
699
767
  records=records,
768
+ text=text,
700
769
  task=task,
701
770
  max_context_tokens=max_context_tokens,
702
771
  operating_point=self.operating_point,
@@ -706,8 +775,9 @@ class AsyncNeedlepathClient(_ClientBase):
706
775
  except Exception as exc: # noqa: BLE001
707
776
  return SelectionResult.passthrough_result(
708
777
  Reason.UNEXPECTED_ERROR,
709
- request_id=request_id or "",
778
+ request_id=rid_for_report,
710
779
  operating_point=self.operating_point,
780
+ shadow=is_shadow,
711
781
  error=exc,
712
782
  )
713
783
  return await self.select_request(
@@ -741,7 +811,7 @@ class AsyncNeedlepathClient(_ClientBase):
741
811
  except NeedlepathError as exc:
742
812
  return SelectionResult.passthrough_result(
743
813
  _reason_for(exc),
744
- request_id=request.request_id,
814
+ request_id=request.request_id or "",
745
815
  operating_point=self.operating_point,
746
816
  shadow=is_shadow,
747
817
  error=exc,
@@ -752,7 +822,7 @@ class AsyncNeedlepathClient(_ClientBase):
752
822
  except Exception as exc: # noqa: BLE001
753
823
  return SelectionResult.passthrough_result(
754
824
  Reason.UNEXPECTED_ERROR,
755
- request_id=request.request_id,
825
+ request_id=request.request_id or "",
756
826
  operating_point=self.operating_point,
757
827
  shadow=is_shadow,
758
828
  error=exc,
@@ -903,11 +973,16 @@ def _finish(
903
973
  attempts: int,
904
974
  request_bytes: int,
905
975
  ) -> SelectionResult:
976
+ # NEXPE-461: the simple text shape has no caller-supplied record ids — the
977
+ # server mints content-hash ids (sha256:<16 hex>) for its own chunks, so
978
+ # every selected id is unknown to us *by construction* and the
979
+ # unknown-selection guard must stand down rather than veto every response.
980
+ known_ids = None if request.text is not None else {r.id for r in request.records if r.id}
906
981
  reason = classify(
907
982
  response,
908
983
  render_requested=request.render,
909
984
  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},
985
+ known_record_ids=known_ids,
911
986
  )
912
987
  # Shadow wins over OK and *loses* to every decline: a shadow run must report
913
988
  # the same reason a live run would have, or the shadow report predicts an
@@ -917,7 +992,11 @@ def _finish(
917
992
  return SelectionResult(
918
993
  applied=(reason == Reason.OK),
919
994
  reason=reason,
920
- request_id=request.request_id,
995
+ # When the request omitted its sent-side id (NEXPE-461), report the
996
+ # server-assigned one so the caller can still correlate with logs.
997
+ request_id=(
998
+ request.request_id if request.request_id is not None else response.request_id
999
+ ),
921
1000
  operating_point=operating_point,
922
1001
  shadow=shadow,
923
1002
  response=response,
@@ -213,6 +213,7 @@ class BudgetSpec:
213
213
  """
214
214
 
215
215
  max_context_tokens: int
216
+ capacity_cap_tokens: Optional[int] = None
216
217
  operating_point: Optional[str] = None
217
218
  max_records: Optional[int] = None
218
219
  max_excerpt_tokens_per_record: Optional[int] = None
@@ -223,6 +224,7 @@ class BudgetSpec:
223
224
  def to_wire(self) -> Dict[str, Any]:
224
225
  return {
225
226
  "max_context_tokens": self.max_context_tokens,
227
+ "capacity_cap_tokens": self.capacity_cap_tokens,
226
228
  "operating_point": self.operating_point,
227
229
  "max_records": self.max_records,
228
230
  "max_excerpt_tokens_per_record": self.max_excerpt_tokens_per_record,
@@ -236,6 +238,9 @@ class BudgetSpec:
236
238
  adaptive = d.get("adaptive")
237
239
  return cls(
238
240
  max_context_tokens=int(d["max_context_tokens"]),
241
+ capacity_cap_tokens=(
242
+ int(d["capacity_cap_tokens"]) if d.get("capacity_cap_tokens") is not None else None
243
+ ),
239
244
  operating_point=d.get("operating_point"),
240
245
  max_records=d.get("max_records"),
241
246
  max_excerpt_tokens_per_record=d.get("max_excerpt_tokens_per_record"),
@@ -247,35 +252,64 @@ class BudgetSpec:
247
252
 
248
253
  @dataclass
249
254
  class ContextRequest:
250
- request_id: str
255
+ #: NEXPE-461 (0.2.0): a **sent-side correlation id**. ``None`` means the key
256
+ #: is OMITTED from the wire entirely — nothing is auto-generated here — and
257
+ #: the server assigns its own id, which comes back in the response.
258
+ request_id: Optional[str]
251
259
  records: List[ContextRecord]
252
260
  task: TaskSpec
253
261
  budget: BudgetSpec
254
262
  render: bool = True
255
263
  render_format: str = "plain" # "plain" | "hybrid"
256
264
  return_per_record: bool = True
265
+ #: NEXPE-461 (0.2.0): the **simple shape** — raw text instead of records.
266
+ #: Exactly one of ``text`` / ``records`` may be set; the server rejects a
267
+ #: request carrying both with ``400 text_and_records_both_set``, and this
268
+ #: dataclass mirrors that check client-side. The text is sent verbatim; the
269
+ #: server chunks it, and each chunk comes back in ``selected[].record_id``
270
+ #: as a content-hash id of the form ``sha256:<16 hex>``.
271
+ text: Optional[str] = None
272
+
273
+ def __post_init__(self) -> None:
274
+ if self.text is not None and self.records:
275
+ raise ValueError(
276
+ "text_and_records_both_set: pass exactly one of records or text "
277
+ "(the server rejects a request carrying both with "
278
+ "400 text_and_records_both_set)"
279
+ )
257
280
 
258
281
  def to_wire(self) -> Dict[str, Any]:
259
- return {
260
- "request_id": self.request_id,
261
- "records": [r.to_wire() for r in self.records],
262
- "task": self.task.to_wire(),
263
- "budget": self.budget.to_wire(),
264
- "render": self.render,
265
- "render_format": self.render_format,
266
- "return_per_record": self.return_per_record,
267
- }
282
+ wire: Dict[str, Any] = {}
283
+ # Omitted, not null, when unset: the id is sent-side-only correlation,
284
+ # and the simple shape omits `records` rather than sending both keys.
285
+ if self.request_id is not None:
286
+ wire["request_id"] = self.request_id
287
+ if self.text is not None:
288
+ wire["text"] = self.text
289
+ else:
290
+ wire["records"] = [r.to_wire() for r in self.records]
291
+ wire.update(
292
+ {
293
+ "task": self.task.to_wire(),
294
+ "budget": self.budget.to_wire(),
295
+ "render": self.render,
296
+ "render_format": self.render_format,
297
+ "return_per_record": self.return_per_record,
298
+ }
299
+ )
300
+ return wire
268
301
 
269
302
  @classmethod
270
303
  def from_wire(cls, d: Dict[str, Any]) -> ContextRequest:
271
304
  return cls(
272
- request_id=d["request_id"],
305
+ request_id=d.get("request_id"),
273
306
  records=[ContextRecord.from_wire(r) for r in d.get("records", [])],
274
307
  task=TaskSpec.from_wire(d["task"]),
275
308
  budget=BudgetSpec.from_wire(d["budget"]),
276
309
  render=bool(d.get("render", True)),
277
310
  render_format=d.get("render_format", "plain"),
278
311
  return_per_record=bool(d.get("return_per_record", True)),
312
+ text=d.get("text"),
279
313
  )
280
314
 
281
315
 
@@ -5,6 +5,6 @@ is correct when the package is run from a source checkout, which is how every
5
5
  one of our own integration tests runs it.
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  __all__ = ["__version__"]
@@ -0,0 +1,251 @@
1
+ """NEXPE-461: the simple text shape, and the optional sent-side request_id.
2
+
3
+ Two additive 0.2.0 surfaces:
4
+
5
+ 1. ``select(text=...)`` — raw text instead of records. The string goes on the
6
+ wire verbatim under ``text`` (no ``records`` key), the server chunks it, and
7
+ each chunk comes back in ``selected[].record_id`` as a content-hash id of
8
+ the form ``sha256:<16 hex>``.
9
+ 2. ``request_id=None`` — the sent-side correlation id is omitted from the
10
+ payload entirely; the server assigns its own. Leaving the argument off still
11
+ auto-generates one, so existing callers see no change.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+
19
+ import pytest
20
+
21
+ from needlepath import (
22
+ ContextRecord,
23
+ NeedlepathClient,
24
+ NeedlepathConfigError,
25
+ Reason,
26
+ TaskSpec,
27
+ build_request,
28
+ models,
29
+ )
30
+ from needlepath.client import AsyncNeedlepathClient
31
+ from needlepath.testing import FakeAsyncTransport, FakeTransport, ok, response_payload
32
+
33
+ OP = "np-2026-07-r2"
34
+ TASK = TaskSpec(prompt="what failed?")
35
+ TEXT = "step 1 passed\nstep 2 failed: TimeoutError\nstep 3 skipped"
36
+
37
+ #: What the server actually returns for a text request: its own chunk ids.
38
+ HASH_SELECTED = [
39
+ {
40
+ "record_id": "sha256:0123456789abcdef",
41
+ "kind": "external_data",
42
+ "score": 0.9,
43
+ "reason": "engage:needle",
44
+ "excerpt": "step 2 failed: TimeoutError",
45
+ "excerpt_format": "plain",
46
+ "selected_tokens": 12,
47
+ }
48
+ ]
49
+
50
+
51
+ def _echo_with_hash_ids():
52
+ """Echo the sent request_id when there is one, mint one when there is not,
53
+ and select a content-hash chunk id either way — the live text-shape reply."""
54
+
55
+ def _respond(method, url, *, headers, body, timeout):
56
+ sent = json.loads(body.decode("utf-8"))
57
+ rid = sent.get("request_id", "srv-assigned-0001")
58
+ return ok(response_payload(rid, selected=HASH_SELECTED))
59
+
60
+ return _respond
61
+
62
+
63
+ def _client(script=None):
64
+ transport = FakeTransport(script if script is not None else [_echo_with_hash_ids()])
65
+ return (
66
+ NeedlepathClient(
67
+ operating_point=OP, base_url="https://x.test", transport=transport, max_retries=0
68
+ ),
69
+ transport,
70
+ )
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # The text shape on the wire
75
+ # --------------------------------------------------------------------------- #
76
+
77
+
78
+ def test_text_is_sent_verbatim_and_records_is_absent():
79
+ client, transport = _client()
80
+ result = client.select(text=TEXT, task=TASK, max_context_tokens=1000)
81
+ assert result.applied is True
82
+ body = transport.bodies[0]
83
+ assert body["text"] == TEXT
84
+ assert "records" not in body
85
+
86
+
87
+ def test_content_hash_chunk_ids_are_applied_not_vetoed_as_unknown():
88
+ """The unknown-selection guard exists for records requests. A text request
89
+ has no caller-supplied ids at all, so server-minted sha256:<16 hex> chunk
90
+ ids must not read as 'a selection of ids we never sent'."""
91
+ client, _ = _client()
92
+ result = client.select(text=TEXT, task=TASK, max_context_tokens=1000)
93
+ assert result.applied is True
94
+ assert result.selected_ids == ["sha256:0123456789abcdef"]
95
+
96
+
97
+ def test_records_requests_still_serialize_the_old_shape():
98
+ client, transport = _client(
99
+ [
100
+ lambda m, u, *, headers, body, timeout: ok(
101
+ response_payload(json.loads(body.decode())["request_id"])
102
+ )
103
+ ]
104
+ )
105
+ result = client.select(
106
+ records=[ContextRecord(text="a", id="r1")], task=TASK, max_context_tokens=10
107
+ )
108
+ assert result.applied is True
109
+ body = transport.bodies[0]
110
+ assert "text" not in body
111
+ assert body["records"][0]["text"] == "a"
112
+
113
+
114
+ def test_empty_text_short_circuits_without_a_call():
115
+ client, transport = _client()
116
+ result = client.select(text="", task=TASK, max_context_tokens=10)
117
+ assert result.reason == Reason.NO_RECORDS
118
+ assert transport.calls == []
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # Exactly one of records / text
123
+ # --------------------------------------------------------------------------- #
124
+
125
+
126
+ def test_both_set_fails_open_with_the_server_error_name_in_the_message():
127
+ client, transport = _client()
128
+ result = client.select(
129
+ records=[ContextRecord(text="a", id="r1")],
130
+ text=TEXT,
131
+ task=TASK,
132
+ max_context_tokens=10,
133
+ )
134
+ assert result.applied is False
135
+ assert result.reason == Reason.UNEXPECTED_ERROR
136
+ assert isinstance(result.error, NeedlepathConfigError)
137
+ assert "text_and_records_both_set" in str(result.error)
138
+ assert transport.calls == [], "the mistake is caught before any bytes leave"
139
+
140
+
141
+ def test_neither_set_fails_open_with_a_clear_message():
142
+ client, transport = _client()
143
+ result = client.select(task=TASK, max_context_tokens=10)
144
+ assert result.applied is False
145
+ assert isinstance(result.error, NeedlepathConfigError)
146
+ assert transport.calls == []
147
+
148
+
149
+ def test_build_request_raises_on_both_and_on_neither():
150
+ with pytest.raises(NeedlepathConfigError, match="text_and_records_both_set"):
151
+ build_request(
152
+ records=[ContextRecord(text="a")],
153
+ text=TEXT,
154
+ task=TASK,
155
+ max_context_tokens=10,
156
+ operating_point=OP,
157
+ )
158
+ with pytest.raises(NeedlepathConfigError):
159
+ build_request(task=TASK, max_context_tokens=10, operating_point=OP)
160
+
161
+
162
+ def test_the_model_itself_refuses_both():
163
+ with pytest.raises(ValueError, match="text_and_records_both_set"):
164
+ models.ContextRequest(
165
+ request_id="np-1",
166
+ records=[models.ContextRecord(text="a")],
167
+ task=TASK,
168
+ budget=models.BudgetSpec(max_context_tokens=10),
169
+ text=TEXT,
170
+ )
171
+
172
+
173
+ # --------------------------------------------------------------------------- #
174
+ # Optional request_id
175
+ # --------------------------------------------------------------------------- #
176
+
177
+
178
+ def test_omitting_the_argument_still_autogenerates_a_request_id():
179
+ client, transport = _client()
180
+ result = client.select(text=TEXT, task=TASK, max_context_tokens=1000)
181
+ assert result.applied is True
182
+ assert transport.bodies[0]["request_id"].startswith("np-")
183
+
184
+
185
+ def test_request_id_none_is_absent_from_the_payload():
186
+ client, transport = _client()
187
+ result = client.select(text=TEXT, task=TASK, max_context_tokens=1000, request_id=None)
188
+ assert result.applied is True
189
+ assert "request_id" not in transport.bodies[0]
190
+
191
+
192
+ def test_without_a_sent_id_the_result_reports_the_server_assigned_one():
193
+ # A records request this time, so the server selects the caller's r1.
194
+ client, _ = _client(
195
+ [lambda m, u, *, headers, body, timeout: ok(response_payload("srv-assigned-0001"))]
196
+ )
197
+ result = client.select(
198
+ records=[ContextRecord(text="a", id="r1")],
199
+ task=TASK,
200
+ max_context_tokens=10,
201
+ request_id=None,
202
+ )
203
+ # The echo check is skipped — there is nothing to echo — and the server's
204
+ # own id is what the caller gets for log correlation.
205
+ assert result.applied is True
206
+ assert result.request_id == "srv-assigned-0001"
207
+
208
+
209
+ def test_a_wire_request_without_request_id_omits_the_key():
210
+ request = models.ContextRequest(
211
+ request_id=None,
212
+ records=[],
213
+ task=TASK,
214
+ budget=models.BudgetSpec(max_context_tokens=10, operating_point=OP),
215
+ text=TEXT,
216
+ )
217
+ wire = request.to_wire()
218
+ assert "request_id" not in wire
219
+ assert wire["text"] == TEXT
220
+ assert "records" not in wire
221
+ # And the round trip is stable.
222
+ assert models.ContextRequest.from_wire(wire).to_wire() == wire
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # The async twin cannot drift
227
+ # --------------------------------------------------------------------------- #
228
+
229
+
230
+ def test_async_text_and_optional_request_id_behave_identically():
231
+ async def _run():
232
+ transport = FakeAsyncTransport([_echo_with_hash_ids()])
233
+ client = AsyncNeedlepathClient(
234
+ operating_point=OP, base_url="https://x.test", transport=transport, max_retries=0
235
+ )
236
+ applied = await client.select(
237
+ text=TEXT, task=TASK, max_context_tokens=1000, request_id=None
238
+ )
239
+ both = await client.select(
240
+ records=[ContextRecord(text="a")], text=TEXT, task=TASK, max_context_tokens=10
241
+ )
242
+ return transport.bodies[0], applied, both
243
+
244
+ body, applied, both = asyncio.run(_run())
245
+ assert body["text"] == TEXT
246
+ assert "records" not in body
247
+ assert "request_id" not in body
248
+ assert applied.applied is True
249
+ assert applied.request_id == "srv-assigned-0001"
250
+ assert both.applied is False
251
+ assert "text_and_records_both_set" in str(both.error)
@@ -5,11 +5,11 @@
5
5
  customer's dependency tree. A copy is only safe if something notices when it
6
6
  drifts, so this file notices in two ways:
7
7
 
8
- 1. **Against literals**, always. The field sets below are transcribed from
9
- ``INTERFACE.md`` as published. This runs in CI with no extra checkout.
10
- 2. **Against ``csbench.contracts``**, when it is importable. Set ``CSBENCH_PATH``
11
- to a checkout of ``context-selection-bench`` to turn it on. This is the check
12
- that catches drift on the *other* side.
8
+ 1. **Against hosted v1 literals**, always. This includes additive request fields
9
+ already accepted by the deployed parser and OpenAPI.
10
+ 2. **Against ``csbench.contracts``**, when it is importable. The comparison is
11
+ exact except for explicitly listed hosted additions, so the independently
12
+ released public benchmark remains a compatibility baseline.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -22,12 +22,13 @@ import sys
22
22
  import pytest
23
23
 
24
24
  from needlepath import models
25
+ from needlepath.client import build_request
25
26
 
26
27
  # --------------------------------------------------------------------------- #
27
- # 1. Against the published field lists
28
+ # 1. Against the hosted v1 field lists
28
29
  # --------------------------------------------------------------------------- #
29
30
 
30
- PUBLISHED = {
31
+ HOSTED_WIRE_FIELDS = {
31
32
  "ContextRecord": {
32
33
  "text",
33
34
  "kind",
@@ -55,6 +56,7 @@ PUBLISHED = {
55
56
  "AdaptiveBudget": {"initial_tokens", "escalation_tokens", "allow_full_context_fallback"},
56
57
  "BudgetSpec": {
57
58
  "max_context_tokens",
59
+ "capacity_cap_tokens",
58
60
  "operating_point",
59
61
  "max_records",
60
62
  "max_excerpt_tokens_per_record",
@@ -70,6 +72,9 @@ PUBLISHED = {
70
72
  "render",
71
73
  "render_format",
72
74
  "return_per_record",
75
+ # NEXPE-461: the simple shape. Hosted v1 accepts `text` as the
76
+ # alternative to `records`; unset means omitted from the wire.
77
+ "text",
73
78
  },
74
79
  "SelectedRecord": {
75
80
  "record_id",
@@ -123,13 +128,23 @@ LOCAL_ONLY = {
123
128
  "ContextResponse": {"extra"},
124
129
  }
125
130
 
131
+ # The hosted v1 request parser owns this additive field before the separately
132
+ # released public benchmark contract does. Cross-checks below require exact
133
+ # parity except for this explicit, backwards-compatible extension.
134
+ HOSTED_ADDITIONS = {
135
+ "BudgetSpec": {"capacity_cap_tokens"},
136
+ # NEXPE-461: hosted v1 owns the simple text shape before the public
137
+ # benchmark contract does.
138
+ "ContextRequest": {"text"},
139
+ }
140
+
126
141
 
127
- @pytest.mark.parametrize("name", sorted(PUBLISHED))
128
- def test_field_names_match_the_published_contract(name):
142
+ @pytest.mark.parametrize("name", sorted(HOSTED_WIRE_FIELDS))
143
+ def test_field_names_match_the_hosted_contract(name):
129
144
  ours = {f.name for f in dataclasses.fields(getattr(models, name))}
130
- expected = PUBLISHED[name] | LOCAL_ONLY.get(name, set())
145
+ expected = HOSTED_WIRE_FIELDS[name] | LOCAL_ONLY.get(name, set())
131
146
  assert ours == expected, (
132
- f"{name} drifted from INTERFACE.md: "
147
+ f"{name} drifted from the hosted v1 contract: "
133
148
  f"extra={sorted(ours - expected)} missing={sorted(expected - ours)}"
134
149
  )
135
150
 
@@ -181,14 +196,16 @@ def test_to_wire_emits_the_full_shape_including_nones():
181
196
  budget=models.BudgetSpec(max_context_tokens=100),
182
197
  )
183
198
  wire = request.to_wire()
184
- assert set(wire) == PUBLISHED["ContextRequest"]
199
+ # NEXPE-461: `text` follows the same omit-when-unset rule as `importance`
200
+ # (below) — a records request must stay byte-identical to the old shape.
201
+ assert set(wire) == HOSTED_WIRE_FIELDS["ContextRequest"] - {"text"}
185
202
  # NEXPE-437: `importance` is the one deliberate exception to the
186
203
  # full-shape rule. Unset means OMITTED from the wire, so the server can
187
204
  # tell "never annotated" (neutral) apart from an explicit 0.0
188
205
  # (stand-down). Every other optional stays present-as-null.
189
- assert set(wire["records"][0]) == PUBLISHED["ContextRecord"] - {"importance"}
190
- assert set(wire["task"]) == PUBLISHED["TaskSpec"]
191
- assert set(wire["budget"]) == PUBLISHED["BudgetSpec"]
206
+ assert set(wire["records"][0]) == HOSTED_WIRE_FIELDS["ContextRecord"] - {"importance"}
207
+ assert set(wire["task"]) == HOSTED_WIRE_FIELDS["TaskSpec"]
208
+ assert set(wire["budget"]) == HOSTED_WIRE_FIELDS["BudgetSpec"]
192
209
  assert wire["records"][0]["source"] is None # present, not omitted
193
210
  assert wire["budget"]["adaptive"] is None
194
211
  json.dumps(wire) # and it is serializable
@@ -247,6 +264,28 @@ def test_round_trip_through_from_wire_is_stable():
247
264
  assert models.ContextRequest.from_wire(request.to_wire()).to_wire() == request.to_wire()
248
265
 
249
266
 
267
+ def test_capacity_cap_defaults_none_and_round_trips_through_the_sdk():
268
+ omitted = models.BudgetSpec.from_wire({"max_context_tokens": 2})
269
+ assert omitted.capacity_cap_tokens is None
270
+ assert omitted.to_wire()["capacity_cap_tokens"] is None
271
+
272
+ explicit = models.BudgetSpec.from_wire({"max_context_tokens": 2, "capacity_cap_tokens": 100})
273
+ assert explicit.capacity_cap_tokens == 100
274
+ assert explicit.to_wire()["capacity_cap_tokens"] == 100
275
+
276
+
277
+ def test_sdk_request_builder_emits_capacity_cap() -> None:
278
+ request = build_request(
279
+ records=[models.ContextRecord(text="context")],
280
+ task=models.TaskSpec(prompt="question"),
281
+ max_context_tokens=2,
282
+ capacity_cap_tokens=100,
283
+ operating_point="np-2026-07-r2",
284
+ )
285
+
286
+ assert request.to_wire()["budget"]["capacity_cap_tokens"] == 100
287
+
288
+
250
289
  # --------------------------------------------------------------------------- #
251
290
  # 2. Against csbench itself, when it is available
252
291
  # --------------------------------------------------------------------------- #
@@ -272,12 +311,12 @@ requires_csbench = pytest.mark.skipif(
272
311
 
273
312
 
274
313
  @requires_csbench
275
- @pytest.mark.parametrize("name", sorted(PUBLISHED))
314
+ @pytest.mark.parametrize("name", sorted(HOSTED_WIRE_FIELDS))
276
315
  def test_matches_csbench_field_for_field(name):
277
316
  theirs = {f.name for f in dataclasses.fields(getattr(csbench_contracts, name))}
278
317
  ours = {f.name for f in dataclasses.fields(getattr(models, name))}
279
318
  ours -= LOCAL_ONLY.get(name, set())
280
- assert ours == theirs
319
+ assert ours == theirs | HOSTED_ADDITIONS.get(name, set())
281
320
 
282
321
 
283
322
  @requires_csbench
@@ -286,22 +325,29 @@ def test_matches_csbench_required_response_fields():
286
325
 
287
326
 
288
327
  @requires_csbench
289
- def test_our_wire_body_is_byte_identical_to_the_published_clients():
290
- """The strongest form of the claim: same inputs, same JSON."""
328
+ def test_our_old_request_shape_is_byte_identical_to_the_published_clients():
329
+ """Removing the one additive field leaves the old public JSON unchanged."""
291
330
  ours = models.ContextRequest(
292
331
  request_id="np-1",
293
- records=[models.ContextRecord(text="t", kind="tool_result", id="r1")],
332
+ records=[models.ContextRecord(text="t", kind="tool_result", id="r1", importance=0.0)],
294
333
  task=models.TaskSpec(prompt="p"),
295
334
  budget=models.BudgetSpec(max_context_tokens=100, operating_point="np-2026-07-r2"),
296
335
  )
297
336
  theirs = csbench_contracts.ContextRequest(
298
337
  request_id="np-1",
299
- records=[csbench_contracts.ContextRecord(text="t", kind="tool_result", id="r1")],
338
+ records=[
339
+ csbench_contracts.ContextRecord(text="t", kind="tool_result", id="r1", importance=0.0)
340
+ ],
300
341
  task=csbench_contracts.TaskSpec(prompt="p"),
301
342
  budget=csbench_contracts.BudgetSpec(
302
343
  max_context_tokens=100, operating_point="np-2026-07-r2"
303
344
  ),
304
345
  )
305
- assert json.dumps(ours.to_wire(), sort_keys=True) == json.dumps(
306
- theirs.to_wire(), sort_keys=True
307
- )
346
+ ours_wire = ours.to_wire()
347
+ assert ours_wire["budget"].pop("capacity_cap_tokens") is None
348
+ assert json.dumps(ours_wire, sort_keys=True) == json.dumps(theirs.to_wire(), sort_keys=True)
349
+
350
+ # An old public parser also ignores the unknown additive key instead of
351
+ # rejecting the request. It cannot enforce the cap; the hosted parser can.
352
+ public_parse = csbench_contracts.ContextRequest.from_wire(ours.to_wire())
353
+ assert public_parse.to_wire() == theirs.to_wire()
File without changes
File without changes
File without changes