needlepath 0.2.0__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.0 → needlepath-0.2.1}/CHANGELOG.md +6 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/PKG-INFO +43 -7
- {needlepath-0.2.0 → needlepath-0.2.1}/README.md +41 -5
- {needlepath-0.2.0 → needlepath-0.2.1}/pyproject.toml +1 -1
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/__init__.py +15 -1
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/client.py +197 -9
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/models.py +186 -4
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/result.py +17 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/version.py +1 -1
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_fail_open.py +90 -0
- {needlepath-0.2.0 → 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.0 → needlepath-0.2.1}/tests/test_wire_compatibility.py +79 -3
- {needlepath-0.2.0 → needlepath-0.2.1}/.gitignore +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/LICENSE +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/errors.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/features.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/py.typed +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/testing.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/transport.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_async_parity.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_config_and_guards.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_no_secret_leak.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_retries.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_shadow_mode.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_simple_shape.py +0 -0
- {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_urllib_transport.py +0 -0
|
@@ -5,6 +5,12 @@ All notable changes to the `needlepath` Python package are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
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
|
+
|
|
8
14
|
## [0.2.0] - 2026-08-07
|
|
9
15
|
|
|
10
16
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: needlepath
|
|
3
|
-
Version: 0.2.
|
|
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",
|
|
@@ -42,6 +42,8 @@ from .models import (
|
|
|
42
42
|
ContextRecord,
|
|
43
43
|
ContextRequest,
|
|
44
44
|
ContextResponse,
|
|
45
|
+
OutcomeReport,
|
|
46
|
+
OutcomeResponse,
|
|
45
47
|
TaskSpec,
|
|
46
48
|
)
|
|
47
49
|
from .result import Reason, SelectionResult
|
|
@@ -65,6 +67,7 @@ from .version import __version__
|
|
|
65
67
|
DEFAULT_BASE_URL = "https://api.nextmoca.com"
|
|
66
68
|
|
|
67
69
|
SELECT_PATH = "/v1/context/select"
|
|
70
|
+
OUTCOMES_PATH = "/v1/outcomes"
|
|
68
71
|
HEALTH_PATH = "/v1/health"
|
|
69
72
|
OPERATING_POINTS_PATH = "/v1/operating-points"
|
|
70
73
|
|
|
@@ -86,6 +89,7 @@ DEFAULT_MAX_RETRIES = 2
|
|
|
86
89
|
#: Retried. Every one of these is a transient condition where a second attempt
|
|
87
90
|
#: has a real chance of a different answer.
|
|
88
91
|
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
92
|
+
OUTCOME_RETRY_STATUSES = RETRY_STATUSES | {500}
|
|
89
93
|
|
|
90
94
|
#: **Not** retried, and worth saying why: a 500 from this endpoint is
|
|
91
95
|
#: ``engine_error`` — a deterministic failure on this exact input. Retrying it
|
|
@@ -129,7 +133,7 @@ def _resolve_operating_point(operating_point: Optional[str]) -> str:
|
|
|
129
133
|
if not value or not value.strip():
|
|
130
134
|
raise NeedlepathConfigError(
|
|
131
135
|
"operating_point is required. Pin an immutable label explicitly "
|
|
132
|
-
"(e.g. 'np-2026-
|
|
136
|
+
"(e.g. 'np-2026-08-r4') so behaviour never shifts under you when the "
|
|
133
137
|
"service default moves; see docs/needlepath/API_VERSIONING.md. "
|
|
134
138
|
f"Pass operating_point=... or set {ENV_OPERATING_POINT}."
|
|
135
139
|
)
|
|
@@ -185,6 +189,9 @@ def build_request(
|
|
|
185
189
|
render: bool = True,
|
|
186
190
|
render_format: str = "plain",
|
|
187
191
|
return_per_record: bool = True,
|
|
192
|
+
return_trace: bool = False,
|
|
193
|
+
selection_strategy: Optional[str] = None,
|
|
194
|
+
support_contract: Optional[str] = None,
|
|
188
195
|
) -> ContextRequest:
|
|
189
196
|
"""Assemble a :class:`ContextRequest` with the operating point already pinned.
|
|
190
197
|
|
|
@@ -223,6 +230,9 @@ def build_request(
|
|
|
223
230
|
render=render,
|
|
224
231
|
render_format=render_format,
|
|
225
232
|
return_per_record=return_per_record,
|
|
233
|
+
return_trace=return_trace,
|
|
234
|
+
selection_strategy=selection_strategy,
|
|
235
|
+
support_contract=support_contract,
|
|
226
236
|
)
|
|
227
237
|
|
|
228
238
|
|
|
@@ -270,6 +280,52 @@ def validate_response_payload(payload: Any, request_id: Optional[str]) -> Contex
|
|
|
270
280
|
raise NeedlepathContractError(f"malformed response body: {exc}") from exc
|
|
271
281
|
|
|
272
282
|
|
|
283
|
+
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
|
|
284
|
+
wanted = name.lower()
|
|
285
|
+
for key, value in headers.items():
|
|
286
|
+
if key.lower() == wanted and value:
|
|
287
|
+
return value
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_outcome_report(
|
|
292
|
+
selection_id: str,
|
|
293
|
+
*,
|
|
294
|
+
task_success: Optional[bool] = None,
|
|
295
|
+
context_sufficient: Optional[bool] = None,
|
|
296
|
+
evaluation_source: str = "application",
|
|
297
|
+
task_latency_ms: Optional[int] = None,
|
|
298
|
+
baseline_task_latency_ms: Optional[int] = None,
|
|
299
|
+
) -> OutcomeReport:
|
|
300
|
+
try:
|
|
301
|
+
return OutcomeReport(
|
|
302
|
+
selection_id=selection_id,
|
|
303
|
+
task_success=task_success,
|
|
304
|
+
context_sufficient=context_sufficient,
|
|
305
|
+
evaluation_source=evaluation_source,
|
|
306
|
+
task_latency_ms=task_latency_ms,
|
|
307
|
+
baseline_task_latency_ms=baseline_task_latency_ms,
|
|
308
|
+
)
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
raise NeedlepathConfigError(str(exc)) from exc
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def encode_outcome_body(report: OutcomeReport) -> bytes:
|
|
314
|
+
return json.dumps(report.to_wire(), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_outcome_payload(payload: Any, selection_id: str) -> OutcomeResponse:
|
|
318
|
+
if not isinstance(payload, dict):
|
|
319
|
+
raise NeedlepathContractError("outcome response is not a JSON object")
|
|
320
|
+
try:
|
|
321
|
+
response = OutcomeResponse.from_wire(payload, expected_selection_id=selection_id)
|
|
322
|
+
except (TypeError, ValueError) as exc:
|
|
323
|
+
raise NeedlepathContractError(f"malformed outcome response: {exc}") from exc
|
|
324
|
+
if not response.accepted:
|
|
325
|
+
raise NeedlepathContractError("outcome response did not acknowledge acceptance")
|
|
326
|
+
return response
|
|
327
|
+
|
|
328
|
+
|
|
273
329
|
def classify(
|
|
274
330
|
response: ContextResponse,
|
|
275
331
|
*,
|
|
@@ -285,9 +341,10 @@ def classify(
|
|
|
285
341
|
the service can say "I did not select anything useful".
|
|
286
342
|
|
|
287
343
|
Note what this function does **not** do: it never reads ``gate.reason``.
|
|
288
|
-
That value is an open enum
|
|
289
|
-
|
|
290
|
-
|
|
344
|
+
That value is an open enum, and a client that branches on today's prefixes
|
|
345
|
+
is a client that breaks additively. Every decision here comes from a
|
|
346
|
+
structural field, plus one recognised value of the top-level ``outcome``
|
|
347
|
+
(see the note at that branch for why that is not the same mistake).
|
|
291
348
|
"""
|
|
292
349
|
if response.selection_error:
|
|
293
350
|
return Reason.SELECTION_ERROR
|
|
@@ -295,6 +352,15 @@ def classify(
|
|
|
295
352
|
# The engine stood down and is asking for the full context. Applying a
|
|
296
353
|
# fallback render would at best be a no-op and at worst a lossy one.
|
|
297
354
|
return Reason.ENGINE_FALLBACK
|
|
355
|
+
if response.outcome == "escalated":
|
|
356
|
+
# The one service-supplied enum this function reads, and it reads
|
|
357
|
+
# exactly one value from it. This is not the exhaustive matching the
|
|
358
|
+
# docstring forbids: any value we do not recognise falls through to the
|
|
359
|
+
# structural checks and gets the same answer it got before the field
|
|
360
|
+
# existed. The branch exists because escalation and a stand-down are
|
|
361
|
+
# identical on the wire apart from this field, and they are billed
|
|
362
|
+
# differently: escalation at the full rate, a stand-down free.
|
|
363
|
+
return Reason.ESCALATED
|
|
298
364
|
safety = response.safety
|
|
299
365
|
if safety is not None and (safety.fallback_required or not safety.selection_safe):
|
|
300
366
|
# The coverage verdict is a structural field, not the open enum: it says
|
|
@@ -449,7 +515,7 @@ class NeedlepathClient(_ClientBase):
|
|
|
449
515
|
|
|
450
516
|
client = NeedlepathClient(
|
|
451
517
|
api_key="np_live_...", # or NEEDLEPATH_API_KEY
|
|
452
|
-
operating_point="np-2026-
|
|
518
|
+
operating_point="np-2026-08-r4", # required, and immutable
|
|
453
519
|
)
|
|
454
520
|
result = client.select(
|
|
455
521
|
records=[ContextRecord(text=..., kind="tool_result", id="r1")],
|
|
@@ -464,7 +530,7 @@ class NeedlepathClient(_ClientBase):
|
|
|
464
530
|
:param api_key: ``np_live_…``. Falls back to ``NEEDLEPATH_API_KEY``. Pass
|
|
465
531
|
``""`` to send no ``Authorization`` header (the localhost adapter).
|
|
466
532
|
:param operating_point: **Required.** An immutable label such as
|
|
467
|
-
``np-2026-
|
|
533
|
+
``np-2026-08-r4``. Falls back to ``NEEDLEPATH_OPERATING_POINT``.
|
|
468
534
|
:param base_url: Defaults to :data:`DEFAULT_BASE_URL` or ``NEEDLEPATH_BASE_URL``.
|
|
469
535
|
:param timeout: Total wall-clock deadline for one :meth:`select`, retries
|
|
470
536
|
and backoff included. Not a per-attempt timeout.
|
|
@@ -644,6 +710,7 @@ class NeedlepathClient(_ClientBase):
|
|
|
644
710
|
"a response arrived after the deadline had elapsed"
|
|
645
711
|
)
|
|
646
712
|
response = validate_response_payload(http.json(), request.request_id)
|
|
713
|
+
response.selection_id = _header(http.headers, "x-needlepath-selection-id")
|
|
647
714
|
return response, attempts, len(body)
|
|
648
715
|
last = err
|
|
649
716
|
if http.status not in RETRY_STATUSES:
|
|
@@ -670,6 +737,67 @@ class NeedlepathClient(_ClientBase):
|
|
|
670
737
|
last._np_bytes = len(body) # type: ignore[attr-defined]
|
|
671
738
|
raise last
|
|
672
739
|
|
|
740
|
+
def report_outcome(
|
|
741
|
+
self,
|
|
742
|
+
selection_id: str,
|
|
743
|
+
*,
|
|
744
|
+
task_success: Optional[bool] = None,
|
|
745
|
+
context_sufficient: Optional[bool] = None,
|
|
746
|
+
evaluation_source: str = "application",
|
|
747
|
+
task_latency_ms: Optional[int] = None,
|
|
748
|
+
baseline_task_latency_ms: Optional[int] = None,
|
|
749
|
+
timeout: Optional[float] = None,
|
|
750
|
+
) -> OutcomeResponse:
|
|
751
|
+
"""Report downstream quality for a served selection.
|
|
752
|
+
|
|
753
|
+
This call is intentionally separate from :meth:`select`: it is
|
|
754
|
+
idempotent and retryable, and any exception here cannot alter the
|
|
755
|
+
selection result already returned to the caller.
|
|
756
|
+
"""
|
|
757
|
+
report = build_outcome_report(
|
|
758
|
+
selection_id,
|
|
759
|
+
task_success=task_success,
|
|
760
|
+
context_sufficient=context_sufficient,
|
|
761
|
+
evaluation_source=evaluation_source,
|
|
762
|
+
task_latency_ms=task_latency_ms,
|
|
763
|
+
baseline_task_latency_ms=baseline_task_latency_ms,
|
|
764
|
+
)
|
|
765
|
+
body = encode_outcome_body(report)
|
|
766
|
+
if len(body) > self.max_request_bytes:
|
|
767
|
+
raise NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
|
|
768
|
+
deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
|
|
769
|
+
last: Optional[NeedlepathError] = None
|
|
770
|
+
for attempt in range(self.max_retries + 1):
|
|
771
|
+
remaining = deadline - time.monotonic()
|
|
772
|
+
if remaining <= 0:
|
|
773
|
+
break
|
|
774
|
+
try:
|
|
775
|
+
http = self._transport.request(
|
|
776
|
+
"POST",
|
|
777
|
+
self._url(OUTCOMES_PATH),
|
|
778
|
+
headers=self._headers(),
|
|
779
|
+
body=body,
|
|
780
|
+
timeout=remaining,
|
|
781
|
+
)
|
|
782
|
+
err = self._to_error(http)
|
|
783
|
+
if err is None:
|
|
784
|
+
return validate_outcome_payload(http.json(), selection_id)
|
|
785
|
+
last = err
|
|
786
|
+
if http.status not in OUTCOME_RETRY_STATUSES:
|
|
787
|
+
break
|
|
788
|
+
retry_after = err.retry_after
|
|
789
|
+
except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
|
|
790
|
+
last = exc
|
|
791
|
+
retry_after = None
|
|
792
|
+
if attempt >= self.max_retries:
|
|
793
|
+
break
|
|
794
|
+
delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
|
|
795
|
+
if delay:
|
|
796
|
+
time.sleep(delay)
|
|
797
|
+
if last is None:
|
|
798
|
+
last = NeedlepathTimeoutError("deadline elapsed before outcome report was sent")
|
|
799
|
+
raise last
|
|
800
|
+
|
|
673
801
|
# ---------------------------------------------------------------- #
|
|
674
802
|
# Diagnostics. These raise: they are not on any agent's hot path.
|
|
675
803
|
# ---------------------------------------------------------------- #
|
|
@@ -873,6 +1001,7 @@ class AsyncNeedlepathClient(_ClientBase):
|
|
|
873
1001
|
"a response arrived after the deadline had elapsed"
|
|
874
1002
|
)
|
|
875
1003
|
response = validate_response_payload(http.json(), request.request_id)
|
|
1004
|
+
response.selection_id = _header(http.headers, "x-needlepath-selection-id")
|
|
876
1005
|
return response, attempts, len(body)
|
|
877
1006
|
last = err
|
|
878
1007
|
if http.status not in RETRY_STATUSES:
|
|
@@ -897,6 +1026,62 @@ class AsyncNeedlepathClient(_ClientBase):
|
|
|
897
1026
|
last._np_bytes = len(body) # type: ignore[attr-defined]
|
|
898
1027
|
raise last
|
|
899
1028
|
|
|
1029
|
+
async def report_outcome(
|
|
1030
|
+
self,
|
|
1031
|
+
selection_id: str,
|
|
1032
|
+
*,
|
|
1033
|
+
task_success: Optional[bool] = None,
|
|
1034
|
+
context_sufficient: Optional[bool] = None,
|
|
1035
|
+
evaluation_source: str = "application",
|
|
1036
|
+
task_latency_ms: Optional[int] = None,
|
|
1037
|
+
baseline_task_latency_ms: Optional[int] = None,
|
|
1038
|
+
timeout: Optional[float] = None,
|
|
1039
|
+
) -> OutcomeResponse:
|
|
1040
|
+
"""Asynchronous twin of :meth:`NeedlepathClient.report_outcome`."""
|
|
1041
|
+
report = build_outcome_report(
|
|
1042
|
+
selection_id,
|
|
1043
|
+
task_success=task_success,
|
|
1044
|
+
context_sufficient=context_sufficient,
|
|
1045
|
+
evaluation_source=evaluation_source,
|
|
1046
|
+
task_latency_ms=task_latency_ms,
|
|
1047
|
+
baseline_task_latency_ms=baseline_task_latency_ms,
|
|
1048
|
+
)
|
|
1049
|
+
body = encode_outcome_body(report)
|
|
1050
|
+
if len(body) > self.max_request_bytes:
|
|
1051
|
+
raise NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
|
|
1052
|
+
deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
|
|
1053
|
+
last: Optional[NeedlepathError] = None
|
|
1054
|
+
for attempt in range(self.max_retries + 1):
|
|
1055
|
+
remaining = deadline - time.monotonic()
|
|
1056
|
+
if remaining <= 0:
|
|
1057
|
+
break
|
|
1058
|
+
try:
|
|
1059
|
+
http = await self._transport.request(
|
|
1060
|
+
"POST",
|
|
1061
|
+
self._url(OUTCOMES_PATH),
|
|
1062
|
+
headers=self._headers(),
|
|
1063
|
+
body=body,
|
|
1064
|
+
timeout=remaining,
|
|
1065
|
+
)
|
|
1066
|
+
err = self._to_error(http)
|
|
1067
|
+
if err is None:
|
|
1068
|
+
return validate_outcome_payload(http.json(), selection_id)
|
|
1069
|
+
last = err
|
|
1070
|
+
if http.status not in OUTCOME_RETRY_STATUSES:
|
|
1071
|
+
break
|
|
1072
|
+
retry_after = err.retry_after
|
|
1073
|
+
except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
|
|
1074
|
+
last = exc
|
|
1075
|
+
retry_after = None
|
|
1076
|
+
if attempt >= self.max_retries:
|
|
1077
|
+
break
|
|
1078
|
+
delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
|
|
1079
|
+
if delay:
|
|
1080
|
+
await asyncio.sleep(delay)
|
|
1081
|
+
if last is None:
|
|
1082
|
+
last = NeedlepathTimeoutError("deadline elapsed before outcome report was sent")
|
|
1083
|
+
raise last
|
|
1084
|
+
|
|
900
1085
|
async def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
901
1086
|
return await self._aget(HEALTH_PATH, timeout)
|
|
902
1087
|
|
|
@@ -994,9 +1179,7 @@ def _finish(
|
|
|
994
1179
|
reason=reason,
|
|
995
1180
|
# When the request omitted its sent-side id (NEXPE-461), report the
|
|
996
1181
|
# 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
|
-
),
|
|
1182
|
+
request_id=(request.request_id if request.request_id is not None else response.request_id),
|
|
1000
1183
|
operating_point=operating_point,
|
|
1001
1184
|
shadow=shadow,
|
|
1002
1185
|
response=response,
|
|
@@ -1010,12 +1193,14 @@ def _finish(
|
|
|
1010
1193
|
__all__ = [
|
|
1011
1194
|
"DEFAULT_BASE_URL",
|
|
1012
1195
|
"SELECT_PATH",
|
|
1196
|
+
"OUTCOMES_PATH",
|
|
1013
1197
|
"HEALTH_PATH",
|
|
1014
1198
|
"OPERATING_POINTS_PATH",
|
|
1015
1199
|
"MAX_REQUEST_BYTES",
|
|
1016
1200
|
"DEFAULT_TIMEOUT_S",
|
|
1017
1201
|
"DEFAULT_MAX_RETRIES",
|
|
1018
1202
|
"RETRY_STATUSES",
|
|
1203
|
+
"OUTCOME_RETRY_STATUSES",
|
|
1019
1204
|
"NeedlepathClient",
|
|
1020
1205
|
"AsyncNeedlepathClient",
|
|
1021
1206
|
"build_request",
|
|
@@ -1023,4 +1208,7 @@ __all__ = [
|
|
|
1023
1208
|
"classify",
|
|
1024
1209
|
"validate_response_payload",
|
|
1025
1210
|
"new_request_id",
|
|
1211
|
+
"build_outcome_report",
|
|
1212
|
+
"encode_outcome_body",
|
|
1213
|
+
"validate_outcome_payload",
|
|
1026
1214
|
]
|