needlepath 0.1.0__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.
- needlepath-0.2.0/CHANGELOG.md +45 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/PKG-INFO +2 -2
- {needlepath-0.1.0 → needlepath-0.2.0}/README.md +1 -1
- {needlepath-0.1.0 → needlepath-0.2.0}/pyproject.toml +1 -1
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/client.py +110 -31
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/models.py +59 -15
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/version.py +1 -1
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_config_and_guards.py +1 -1
- needlepath-0.2.0/tests/test_simple_shape.py +251 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_wire_compatibility.py +90 -24
- {needlepath-0.1.0 → needlepath-0.2.0}/.gitignore +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/LICENSE +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/__init__.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/errors.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/features.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/py.typed +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/result.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/testing.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/src/needlepath/transport.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_async_parity.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_fail_open.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_live_endpoint.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_no_secret_leak.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_retries.py +0 -0
- {needlepath-0.1.0 → needlepath-0.2.0}/tests/test_shadow_mode.py +0 -0
- {needlepath-0.1.0 → 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.
|
|
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.
|
|
@@ -94,7 +94,7 @@ never blocks the event loop either way.
|
|
|
94
94
|
|---|---|---|
|
|
95
95
|
| `api_key` | `NEEDLEPATH_API_KEY` | none (no `Authorization` header) |
|
|
96
96
|
| `operating_point` | `NEEDLEPATH_OPERATING_POINT` | **required** |
|
|
97
|
-
| `base_url` | `NEEDLEPATH_BASE_URL` | `https://
|
|
97
|
+
| `base_url` | `NEEDLEPATH_BASE_URL` | `https://api.nextmoca.com` |
|
|
98
98
|
| `timeout` | — | `10.0` s, total, retries included |
|
|
99
99
|
| `max_retries` | — | `2` |
|
|
100
100
|
| `max_request_bytes` | — | `6 * 1024 * 1024` |
|
|
@@ -65,7 +65,7 @@ never blocks the event loop either way.
|
|
|
65
65
|
|---|---|---|
|
|
66
66
|
| `api_key` | `NEEDLEPATH_API_KEY` | none (no `Authorization` header) |
|
|
67
67
|
| `operating_point` | `NEEDLEPATH_OPERATING_POINT` | **required** |
|
|
68
|
-
| `base_url` | `NEEDLEPATH_BASE_URL` | `https://
|
|
68
|
+
| `base_url` | `NEEDLEPATH_BASE_URL` | `https://api.nextmoca.com` |
|
|
69
69
|
| `timeout` | — | `10.0` s, total, retries included |
|
|
70
70
|
| `max_retries` | — | `2` |
|
|
71
71
|
| `max_request_bytes` | — | `6 * 1024 * 1024` |
|
|
@@ -62,7 +62,7 @@ from .version import __version__
|
|
|
62
62
|
#: follow-through (§5, S-17a); until it exists this is the address published runs
|
|
63
63
|
#: and design partners use, and it stays overridable by argument or by
|
|
64
64
|
#: ``NEEDLEPATH_BASE_URL`` so that swapping it is a config change, not a release.
|
|
65
|
-
DEFAULT_BASE_URL = "https://
|
|
65
|
+
DEFAULT_BASE_URL = "https://api.nextmoca.com"
|
|
66
66
|
|
|
67
67
|
SELECT_PATH = "/v1/context/select"
|
|
68
68
|
HEALTH_PATH = "/v1/health"
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
523
|
+
request_id=rid_for_report,
|
|
468
524
|
operating_point=self.operating_point,
|
|
469
|
-
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=
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
761
|
+
request_id=rid_for_report,
|
|
694
762
|
operating_point=self.operating_point,
|
|
695
|
-
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=
|
|
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=
|
|
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
|
-
|
|
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,
|
|
@@ -90,24 +90,34 @@ class ContextRecord:
|
|
|
90
90
|
source: Optional[str] = None
|
|
91
91
|
title: Optional[str] = None
|
|
92
92
|
step_id: Optional[str] = None
|
|
93
|
-
|
|
93
|
+
# NEXPE-437: None means "the caller never annotated importance", and the
|
|
94
|
+
# field is then OMITTED from the wire so the server applies its neutral
|
|
95
|
+
# default. The old `0.0` default was sent explicitly, which the server
|
|
96
|
+
# cannot tell apart from a deliberate 0.0 -- and 0.0 stands the record
|
|
97
|
+
# down. An explicit `importance=0.0` still goes on the wire as 0.0.
|
|
98
|
+
importance: Optional[float] = None
|
|
94
99
|
keywords: List[str] = field(default_factory=list)
|
|
95
100
|
tags: List[str] = field(default_factory=list)
|
|
96
101
|
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
97
102
|
|
|
98
103
|
def to_wire(self) -> Dict[str, Any]:
|
|
99
|
-
|
|
104
|
+
wire = {
|
|
100
105
|
"text": self.text,
|
|
101
106
|
"kind": self.kind,
|
|
102
107
|
"id": self.id,
|
|
103
108
|
"source": self.source,
|
|
104
109
|
"title": self.title,
|
|
105
110
|
"step_id": self.step_id,
|
|
106
|
-
"importance": self.importance,
|
|
107
111
|
"keywords": list(self.keywords),
|
|
108
112
|
"tags": list(self.tags),
|
|
109
113
|
"attributes": dict(self.attributes),
|
|
110
114
|
}
|
|
115
|
+
# NEXPE-437: omitted, not null. The server reads importance with
|
|
116
|
+
# `float(d.get("importance", ...))`, so `null` would be a crash and
|
|
117
|
+
# an always-present key would erase the unset/explicit distinction.
|
|
118
|
+
if self.importance is not None:
|
|
119
|
+
wire["importance"] = self.importance
|
|
120
|
+
return wire
|
|
111
121
|
|
|
112
122
|
@classmethod
|
|
113
123
|
def from_wire(cls, d: Dict[str, Any]) -> ContextRecord:
|
|
@@ -118,7 +128,7 @@ class ContextRecord:
|
|
|
118
128
|
source=d.get("source"),
|
|
119
129
|
title=d.get("title"),
|
|
120
130
|
step_id=d.get("step_id"),
|
|
121
|
-
importance=float(d.get("importance"
|
|
131
|
+
importance=float(d["importance"]) if d.get("importance") is not None else None,
|
|
122
132
|
keywords=list(d.get("keywords", [])),
|
|
123
133
|
tags=list(d.get("tags", [])),
|
|
124
134
|
attributes=dict(d.get("attributes", {})),
|
|
@@ -203,6 +213,7 @@ class BudgetSpec:
|
|
|
203
213
|
"""
|
|
204
214
|
|
|
205
215
|
max_context_tokens: int
|
|
216
|
+
capacity_cap_tokens: Optional[int] = None
|
|
206
217
|
operating_point: Optional[str] = None
|
|
207
218
|
max_records: Optional[int] = None
|
|
208
219
|
max_excerpt_tokens_per_record: Optional[int] = None
|
|
@@ -213,6 +224,7 @@ class BudgetSpec:
|
|
|
213
224
|
def to_wire(self) -> Dict[str, Any]:
|
|
214
225
|
return {
|
|
215
226
|
"max_context_tokens": self.max_context_tokens,
|
|
227
|
+
"capacity_cap_tokens": self.capacity_cap_tokens,
|
|
216
228
|
"operating_point": self.operating_point,
|
|
217
229
|
"max_records": self.max_records,
|
|
218
230
|
"max_excerpt_tokens_per_record": self.max_excerpt_tokens_per_record,
|
|
@@ -226,6 +238,9 @@ class BudgetSpec:
|
|
|
226
238
|
adaptive = d.get("adaptive")
|
|
227
239
|
return cls(
|
|
228
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
|
+
),
|
|
229
244
|
operating_point=d.get("operating_point"),
|
|
230
245
|
max_records=d.get("max_records"),
|
|
231
246
|
max_excerpt_tokens_per_record=d.get("max_excerpt_tokens_per_record"),
|
|
@@ -237,35 +252,64 @@ class BudgetSpec:
|
|
|
237
252
|
|
|
238
253
|
@dataclass
|
|
239
254
|
class ContextRequest:
|
|
240
|
-
|
|
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]
|
|
241
259
|
records: List[ContextRecord]
|
|
242
260
|
task: TaskSpec
|
|
243
261
|
budget: BudgetSpec
|
|
244
262
|
render: bool = True
|
|
245
263
|
render_format: str = "plain" # "plain" | "hybrid"
|
|
246
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
|
+
)
|
|
247
280
|
|
|
248
281
|
def to_wire(self) -> Dict[str, Any]:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"
|
|
254
|
-
|
|
255
|
-
"
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
258
301
|
|
|
259
302
|
@classmethod
|
|
260
303
|
def from_wire(cls, d: Dict[str, Any]) -> ContextRequest:
|
|
261
304
|
return cls(
|
|
262
|
-
request_id=d
|
|
305
|
+
request_id=d.get("request_id"),
|
|
263
306
|
records=[ContextRecord.from_wire(r) for r in d.get("records", [])],
|
|
264
307
|
task=TaskSpec.from_wire(d["task"]),
|
|
265
308
|
budget=BudgetSpec.from_wire(d["budget"]),
|
|
266
309
|
render=bool(d.get("render", True)),
|
|
267
310
|
render_format=d.get("render_format", "plain"),
|
|
268
311
|
return_per_record=bool(d.get("return_per_record", True)),
|
|
312
|
+
text=d.get("text"),
|
|
269
313
|
)
|
|
270
314
|
|
|
271
315
|
|
|
@@ -185,7 +185,7 @@ def test_base_url_comes_from_the_environment(monkeypatch):
|
|
|
185
185
|
def test_the_documented_default_is_what_ships():
|
|
186
186
|
from needlepath import DEFAULT_BASE_URL
|
|
187
187
|
|
|
188
|
-
assert DEFAULT_BASE_URL == "https://
|
|
188
|
+
assert DEFAULT_BASE_URL == "https://api.nextmoca.com"
|
|
189
189
|
|
|
190
190
|
|
|
191
191
|
# --------------------------------------------------------------------------- #
|
|
@@ -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.
|
|
9
|
-
|
|
10
|
-
2. **Against ``csbench.contracts``**, when it is importable.
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
28
|
+
# 1. Against the hosted v1 field lists
|
|
28
29
|
# --------------------------------------------------------------------------- #
|
|
29
30
|
|
|
30
|
-
|
|
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(
|
|
128
|
-
def
|
|
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 =
|
|
145
|
+
expected = HOSTED_WIRE_FIELDS[name] | LOCAL_ONLY.get(name, set())
|
|
131
146
|
assert ours == expected, (
|
|
132
|
-
f"{name} drifted from
|
|
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,15 +196,37 @@ 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
|
-
|
|
185
|
-
|
|
186
|
-
assert set(wire["
|
|
187
|
-
|
|
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"}
|
|
202
|
+
# NEXPE-437: `importance` is the one deliberate exception to the
|
|
203
|
+
# full-shape rule. Unset means OMITTED from the wire, so the server can
|
|
204
|
+
# tell "never annotated" (neutral) apart from an explicit 0.0
|
|
205
|
+
# (stand-down). Every other optional stays present-as-null.
|
|
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"]
|
|
188
209
|
assert wire["records"][0]["source"] is None # present, not omitted
|
|
189
210
|
assert wire["budget"]["adaptive"] is None
|
|
190
211
|
json.dumps(wire) # and it is serializable
|
|
191
212
|
|
|
192
213
|
|
|
214
|
+
def test_unset_importance_is_omitted_and_explicit_zero_is_sent():
|
|
215
|
+
"""NEXPE-437: the wire distinguishes unannotated from deliberately 0.0."""
|
|
216
|
+
unset = models.ContextRecord(text="t").to_wire()
|
|
217
|
+
assert "importance" not in unset
|
|
218
|
+
|
|
219
|
+
explicit_zero = models.ContextRecord(text="t", importance=0.0).to_wire()
|
|
220
|
+
assert explicit_zero["importance"] == 0.0
|
|
221
|
+
|
|
222
|
+
explicit = models.ContextRecord(text="t", importance=7.5).to_wire()
|
|
223
|
+
assert explicit["importance"] == 7.5
|
|
224
|
+
|
|
225
|
+
# And from_wire preserves the same distinction on the way back in.
|
|
226
|
+
assert models.ContextRecord.from_wire({"text": "t"}).importance is None
|
|
227
|
+
assert models.ContextRecord.from_wire({"text": "t", "importance": 0.0}).importance == 0.0
|
|
228
|
+
|
|
229
|
+
|
|
193
230
|
def test_unknown_response_fields_are_kept_not_rejected():
|
|
194
231
|
"""`/v1` is additive (API_VERSIONING.md). `usage` is already scheduled to
|
|
195
232
|
arrive this way (E2), and a client that 'validates' by rejecting unknown
|
|
@@ -227,6 +264,28 @@ def test_round_trip_through_from_wire_is_stable():
|
|
|
227
264
|
assert models.ContextRequest.from_wire(request.to_wire()).to_wire() == request.to_wire()
|
|
228
265
|
|
|
229
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
|
+
|
|
230
289
|
# --------------------------------------------------------------------------- #
|
|
231
290
|
# 2. Against csbench itself, when it is available
|
|
232
291
|
# --------------------------------------------------------------------------- #
|
|
@@ -252,12 +311,12 @@ requires_csbench = pytest.mark.skipif(
|
|
|
252
311
|
|
|
253
312
|
|
|
254
313
|
@requires_csbench
|
|
255
|
-
@pytest.mark.parametrize("name", sorted(
|
|
314
|
+
@pytest.mark.parametrize("name", sorted(HOSTED_WIRE_FIELDS))
|
|
256
315
|
def test_matches_csbench_field_for_field(name):
|
|
257
316
|
theirs = {f.name for f in dataclasses.fields(getattr(csbench_contracts, name))}
|
|
258
317
|
ours = {f.name for f in dataclasses.fields(getattr(models, name))}
|
|
259
318
|
ours -= LOCAL_ONLY.get(name, set())
|
|
260
|
-
assert ours == theirs
|
|
319
|
+
assert ours == theirs | HOSTED_ADDITIONS.get(name, set())
|
|
261
320
|
|
|
262
321
|
|
|
263
322
|
@requires_csbench
|
|
@@ -266,22 +325,29 @@ def test_matches_csbench_required_response_fields():
|
|
|
266
325
|
|
|
267
326
|
|
|
268
327
|
@requires_csbench
|
|
269
|
-
def
|
|
270
|
-
"""
|
|
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."""
|
|
271
330
|
ours = models.ContextRequest(
|
|
272
331
|
request_id="np-1",
|
|
273
|
-
records=[models.ContextRecord(text="t", kind="tool_result", id="r1")],
|
|
332
|
+
records=[models.ContextRecord(text="t", kind="tool_result", id="r1", importance=0.0)],
|
|
274
333
|
task=models.TaskSpec(prompt="p"),
|
|
275
334
|
budget=models.BudgetSpec(max_context_tokens=100, operating_point="np-2026-07-r2"),
|
|
276
335
|
)
|
|
277
336
|
theirs = csbench_contracts.ContextRequest(
|
|
278
337
|
request_id="np-1",
|
|
279
|
-
records=[
|
|
338
|
+
records=[
|
|
339
|
+
csbench_contracts.ContextRecord(text="t", kind="tool_result", id="r1", importance=0.0)
|
|
340
|
+
],
|
|
280
341
|
task=csbench_contracts.TaskSpec(prompt="p"),
|
|
281
342
|
budget=csbench_contracts.BudgetSpec(
|
|
282
343
|
max_context_tokens=100, operating_point="np-2026-07-r2"
|
|
283
344
|
),
|
|
284
345
|
)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|