needlepath 0.1.1__tar.gz → 0.2.1__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.1/CHANGELOG.md +51 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/PKG-INFO +43 -7
- {needlepath-0.1.1 → needlepath-0.2.1}/README.md +41 -5
- {needlepath-0.1.1 → needlepath-0.2.1}/pyproject.toml +1 -1
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/__init__.py +15 -1
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/client.py +303 -36
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/models.py +231 -15
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/result.py +17 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/version.py +1 -1
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_fail_open.py +90 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_live_endpoint.py +7 -5
- needlepath-0.2.1/tests/test_r4_contract.py +175 -0
- needlepath-0.2.1/tests/test_simple_shape.py +251 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_wire_compatibility.py +147 -25
- {needlepath-0.1.1 → needlepath-0.2.1}/.gitignore +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/LICENSE +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/errors.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/features.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/py.typed +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/testing.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/transport.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_async_parity.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_config_and_guards.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_no_secret_leak.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_retries.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_shadow_mode.py +0 -0
- {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_urllib_transport.py +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
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.1] - 2026-09-03
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Docs, docstrings and examples pin np-2026-08-r4. No behaviour change.
|
|
13
|
+
|
|
14
|
+
## [0.2.0] - 2026-08-07
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Simple text shape** (NEXPE-461): `select(text=...)` accepts a raw string as
|
|
19
|
+
the alternative to `records=`. Exactly one of the two may be passed; passing
|
|
20
|
+
both fails open client-side with a `NeedlepathConfigError` whose message
|
|
21
|
+
mirrors the server's `400 text_and_records_both_set`. The text goes on the
|
|
22
|
+
wire verbatim; the server chunks it, and each chunk comes back in
|
|
23
|
+
`selected[].record_id` as a content-hash id of the form `sha256:<16 hex>`.
|
|
24
|
+
`ContextRequest` gains an optional `text` field, omitted from the wire when
|
|
25
|
+
unset.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`request_id` is now optional** (NEXPE-461): it is a sent-side correlation
|
|
30
|
+
id. `ContextRequest.request_id` may be `None`, in which case the key is
|
|
31
|
+
omitted from the payload and the server assigns its own id (reported on
|
|
32
|
+
`SelectionResult.request_id`). In `select()` / `build_request()`, leaving the
|
|
33
|
+
argument off still auto-generates an id as before; pass `request_id=None`
|
|
34
|
+
explicitly to omit it. The response echo check is skipped when no id was
|
|
35
|
+
sent.
|
|
36
|
+
|
|
37
|
+
### Note
|
|
38
|
+
|
|
39
|
+
- No breaking changes for existing callers: records requests serialize
|
|
40
|
+
byte-identically to 0.1.x, omitting `request_id` still auto-generates one,
|
|
41
|
+
and `operating_point` remains the required, immutable-label constructor
|
|
42
|
+
argument.
|
|
43
|
+
|
|
44
|
+
## [0.1.1] - 2026-08-06
|
|
45
|
+
|
|
46
|
+
- Default base URL moved to `https://api.nextmoca.com`; no wire-shape changes.
|
|
47
|
+
|
|
48
|
+
## [0.1.0] - 2026-08-01
|
|
49
|
+
|
|
50
|
+
- Initial release: fail-open `select()`, pinned operating point, client-side
|
|
51
|
+
6 MB payload guard, shadow mode, sync and async clients.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: needlepath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
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.
|
|
@@ -57,7 +57,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
|
|
|
57
57
|
|
|
58
58
|
client = NeedlepathClient(
|
|
59
59
|
api_key="np_live_...", # or NEEDLEPATH_API_KEY
|
|
60
|
-
operating_point="np-2026-
|
|
60
|
+
operating_point="np-2026-08-r4", # required — see "Pin the operating point"
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
result = client.select(
|
|
@@ -80,7 +80,7 @@ original context. That is the whole integration contract.
|
|
|
80
80
|
```python
|
|
81
81
|
from needlepath import AsyncNeedlepathClient
|
|
82
82
|
|
|
83
|
-
async with AsyncNeedlepathClient(operating_point="np-2026-
|
|
83
|
+
async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
|
|
84
84
|
result = await client.select(records=..., task=..., max_context_tokens=4000)
|
|
85
85
|
```
|
|
86
86
|
|
|
@@ -88,6 +88,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
|
|
|
88
88
|
`httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
|
|
89
89
|
never blocks the event loop either way.
|
|
90
90
|
|
|
91
|
+
### R4 trace and outcome attribution
|
|
92
|
+
|
|
93
|
+
Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
|
|
94
|
+
inspect record dispositions:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
result = client.select(
|
|
98
|
+
records=records,
|
|
99
|
+
task=TaskSpec(prompt=question),
|
|
100
|
+
max_context_tokens=4000,
|
|
101
|
+
return_trace=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if result.response and result.response.selection_trace:
|
|
105
|
+
for item in result.response.selection_trace.records:
|
|
106
|
+
print(item.record_id, item.disposition, item.reason)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The hosted response also supplies `result.selection_id`, a server-issued id
|
|
110
|
+
separate from your request id. Report downstream quality after the model call:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
if result.selection_id:
|
|
114
|
+
client.report_outcome(
|
|
115
|
+
result.selection_id,
|
|
116
|
+
task_success=True,
|
|
117
|
+
context_sufficient=True,
|
|
118
|
+
evaluation_source="application",
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`report_outcome()` is idempotent and independently retryable. It can raise on a
|
|
123
|
+
callback failure; that never changes `select()` or the context already returned.
|
|
124
|
+
Do not send prompts, context, model output, answers, or free-form notes.
|
|
125
|
+
|
|
91
126
|
## Configuration
|
|
92
127
|
|
|
93
128
|
| Argument | Environment | Default |
|
|
@@ -130,6 +165,7 @@ if not result.applied:
|
|
|
130
165
|
| `disabled` | no | the integration was constructed disabled |
|
|
131
166
|
| `no_records` | no | nothing to select over; no call was made |
|
|
132
167
|
| `below_trigger` | no | under the configured trigger; no call was made |
|
|
168
|
+
| `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
|
|
133
169
|
| `empty_selection` | no | zero records selected |
|
|
134
170
|
| `engine_fallback` | no | `fallback_used` — the engine wants the full context |
|
|
135
171
|
| `selection_error` | no | the response carried a `selection_error` |
|
|
@@ -158,7 +194,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
|
|
|
158
194
|
exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
|
|
159
195
|
never under load.
|
|
160
196
|
|
|
161
|
-
Labels are immutable: `np-2026-
|
|
197
|
+
Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
|
|
162
198
|
a retune mints a new label. `client.operating_points()` lists what the endpoint
|
|
163
199
|
serves — call it once at wire-up if you want the assertion, never per request.
|
|
164
200
|
|
|
@@ -176,7 +212,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
|
|
|
176
212
|
### 4. Shadow mode is a one-line switch
|
|
177
213
|
|
|
178
214
|
```python
|
|
179
|
-
client = NeedlepathClient(operating_point="np-2026-
|
|
215
|
+
client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
|
|
180
216
|
```
|
|
181
217
|
|
|
182
218
|
The call is made exactly as a live call would be — same body, same billing, same
|
|
@@ -228,7 +264,7 @@ string stays on `result.response.selection_error`.
|
|
|
228
264
|
from needlepath.testing import FakeTransport, response_payload, ok, error
|
|
229
265
|
|
|
230
266
|
transport = FakeTransport([error(503), ok(response_payload("np-1"))])
|
|
231
|
-
client = NeedlepathClient(operating_point="np-2026-
|
|
267
|
+
client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
|
|
232
268
|
```
|
|
233
269
|
|
|
234
270
|
`needlepath.testing` is public API, not a private helper. Every rule above is a
|
|
@@ -28,7 +28,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
|
|
|
28
28
|
|
|
29
29
|
client = NeedlepathClient(
|
|
30
30
|
api_key="np_live_...", # or NEEDLEPATH_API_KEY
|
|
31
|
-
operating_point="np-2026-
|
|
31
|
+
operating_point="np-2026-08-r4", # required — see "Pin the operating point"
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
result = client.select(
|
|
@@ -51,7 +51,7 @@ original context. That is the whole integration contract.
|
|
|
51
51
|
```python
|
|
52
52
|
from needlepath import AsyncNeedlepathClient
|
|
53
53
|
|
|
54
|
-
async with AsyncNeedlepathClient(operating_point="np-2026-
|
|
54
|
+
async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
|
|
55
55
|
result = await client.select(records=..., task=..., max_context_tokens=4000)
|
|
56
56
|
```
|
|
57
57
|
|
|
@@ -59,6 +59,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
|
|
|
59
59
|
`httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
|
|
60
60
|
never blocks the event loop either way.
|
|
61
61
|
|
|
62
|
+
### R4 trace and outcome attribution
|
|
63
|
+
|
|
64
|
+
Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
|
|
65
|
+
inspect record dispositions:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
result = client.select(
|
|
69
|
+
records=records,
|
|
70
|
+
task=TaskSpec(prompt=question),
|
|
71
|
+
max_context_tokens=4000,
|
|
72
|
+
return_trace=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if result.response and result.response.selection_trace:
|
|
76
|
+
for item in result.response.selection_trace.records:
|
|
77
|
+
print(item.record_id, item.disposition, item.reason)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The hosted response also supplies `result.selection_id`, a server-issued id
|
|
81
|
+
separate from your request id. Report downstream quality after the model call:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
if result.selection_id:
|
|
85
|
+
client.report_outcome(
|
|
86
|
+
result.selection_id,
|
|
87
|
+
task_success=True,
|
|
88
|
+
context_sufficient=True,
|
|
89
|
+
evaluation_source="application",
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`report_outcome()` is idempotent and independently retryable. It can raise on a
|
|
94
|
+
callback failure; that never changes `select()` or the context already returned.
|
|
95
|
+
Do not send prompts, context, model output, answers, or free-form notes.
|
|
96
|
+
|
|
62
97
|
## Configuration
|
|
63
98
|
|
|
64
99
|
| Argument | Environment | Default |
|
|
@@ -101,6 +136,7 @@ if not result.applied:
|
|
|
101
136
|
| `disabled` | no | the integration was constructed disabled |
|
|
102
137
|
| `no_records` | no | nothing to select over; no call was made |
|
|
103
138
|
| `below_trigger` | no | under the configured trigger; no call was made |
|
|
139
|
+
| `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
|
|
104
140
|
| `empty_selection` | no | zero records selected |
|
|
105
141
|
| `engine_fallback` | no | `fallback_used` — the engine wants the full context |
|
|
106
142
|
| `selection_error` | no | the response carried a `selection_error` |
|
|
@@ -129,7 +165,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
|
|
|
129
165
|
exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
|
|
130
166
|
never under load.
|
|
131
167
|
|
|
132
|
-
Labels are immutable: `np-2026-
|
|
168
|
+
Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
|
|
133
169
|
a retune mints a new label. `client.operating_points()` lists what the endpoint
|
|
134
170
|
serves — call it once at wire-up if you want the assertion, never per request.
|
|
135
171
|
|
|
@@ -147,7 +183,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
|
|
|
147
183
|
### 4. Shadow mode is a one-line switch
|
|
148
184
|
|
|
149
185
|
```python
|
|
150
|
-
client = NeedlepathClient(operating_point="np-2026-
|
|
186
|
+
client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
|
|
151
187
|
```
|
|
152
188
|
|
|
153
189
|
The call is made exactly as a live call would be — same body, same billing, same
|
|
@@ -199,7 +235,7 @@ string stays on `result.response.selection_error`.
|
|
|
199
235
|
from needlepath.testing import FakeTransport, response_payload, ok, error
|
|
200
236
|
|
|
201
237
|
transport = FakeTransport([error(503), ok(response_payload("np-1"))])
|
|
202
|
-
client = NeedlepathClient(operating_point="np-2026-
|
|
238
|
+
client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
|
|
203
239
|
```
|
|
204
240
|
|
|
205
241
|
`needlepath.testing` is public API, not a private helper. Every rule above is a
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from needlepath import NeedlepathClient, ContextRecord, TaskSpec
|
|
4
4
|
|
|
5
|
-
client = NeedlepathClient(operating_point="np-2026-
|
|
5
|
+
client = NeedlepathClient(operating_point="np-2026-08-r4") # key from env
|
|
6
6
|
result = client.select(
|
|
7
7
|
records=[ContextRecord(text=tool_output, kind="tool_result", id="t1")],
|
|
8
8
|
task=TaskSpec(prompt=user_question),
|
|
@@ -33,10 +33,13 @@ from .client import (
|
|
|
33
33
|
HEALTH_PATH,
|
|
34
34
|
MAX_REQUEST_BYTES,
|
|
35
35
|
OPERATING_POINTS_PATH,
|
|
36
|
+
OUTCOME_RETRY_STATUSES,
|
|
37
|
+
OUTCOMES_PATH,
|
|
36
38
|
RETRY_STATUSES,
|
|
37
39
|
SELECT_PATH,
|
|
38
40
|
AsyncNeedlepathClient,
|
|
39
41
|
NeedlepathClient,
|
|
42
|
+
build_outcome_report,
|
|
40
43
|
build_request,
|
|
41
44
|
classify,
|
|
42
45
|
encode_body,
|
|
@@ -61,9 +64,13 @@ from .models import (
|
|
|
61
64
|
ContextRequest,
|
|
62
65
|
ContextResponse,
|
|
63
66
|
GateSummary,
|
|
67
|
+
OutcomeReport,
|
|
68
|
+
OutcomeResponse,
|
|
64
69
|
RecordKind,
|
|
65
70
|
SafetySummary,
|
|
66
71
|
SelectedRecord,
|
|
72
|
+
SelectionTrace,
|
|
73
|
+
SelectionTraceRecord,
|
|
67
74
|
TaskSpec,
|
|
68
75
|
)
|
|
69
76
|
from .result import BENIGN_REASONS, Reason, SelectionResult, SelectionStats
|
|
@@ -93,6 +100,10 @@ __all__ = [
|
|
|
93
100
|
"SelectedRecord",
|
|
94
101
|
"SafetySummary",
|
|
95
102
|
"GateSummary",
|
|
103
|
+
"SelectionTrace",
|
|
104
|
+
"SelectionTraceRecord",
|
|
105
|
+
"OutcomeReport",
|
|
106
|
+
"OutcomeResponse",
|
|
96
107
|
"RecordKind",
|
|
97
108
|
"REQUIRED_RESPONSE_FIELDS",
|
|
98
109
|
# results
|
|
@@ -118,6 +129,7 @@ __all__ = [
|
|
|
118
129
|
"default_async_transport",
|
|
119
130
|
# helpers
|
|
120
131
|
"build_request",
|
|
132
|
+
"build_outcome_report",
|
|
121
133
|
"encode_body",
|
|
122
134
|
"classify",
|
|
123
135
|
"validate_response_payload",
|
|
@@ -125,12 +137,14 @@ __all__ = [
|
|
|
125
137
|
# constants
|
|
126
138
|
"DEFAULT_BASE_URL",
|
|
127
139
|
"SELECT_PATH",
|
|
140
|
+
"OUTCOMES_PATH",
|
|
128
141
|
"HEALTH_PATH",
|
|
129
142
|
"OPERATING_POINTS_PATH",
|
|
130
143
|
"MAX_REQUEST_BYTES",
|
|
131
144
|
"DEFAULT_TIMEOUT_S",
|
|
132
145
|
"DEFAULT_MAX_RETRIES",
|
|
133
146
|
"RETRY_STATUSES",
|
|
147
|
+
"OUTCOME_RETRY_STATUSES",
|
|
134
148
|
# feature flags
|
|
135
149
|
"WIRE_SHADOW_MODE",
|
|
136
150
|
"wire_shadow_mode_enabled",
|