needlepath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
needlepath/errors.py ADDED
@@ -0,0 +1,123 @@
1
+ """The exception hierarchy.
2
+
3
+ Every exception here is *recorded* rather than raised on the framework path
4
+ (§8.2: fail open, always). They exist so that the recorded error carries a type
5
+ a caller can branch on, and so that ``select_or_raise`` — the explicitly
6
+ opt-in, explicitly-not-for-adapters variant — has something to raise.
7
+
8
+ **Nothing in this module ever carries the API key.** Error messages are built
9
+ from status codes, byte counts and the server's own bounded error bodies; the
10
+ request headers are never interpolated into a message. That is asserted in
11
+ ``tests/test_no_secret_leak.py`` rather than left to care.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Optional
17
+
18
+
19
+ class NeedlepathError(Exception):
20
+ """Base class for everything this package raises or records."""
21
+
22
+
23
+ class NeedlepathConfigError(NeedlepathError):
24
+ """A misconfiguration, detected at construction time.
25
+
26
+ This is the one class of failure that is *not* handled by failing open, and
27
+ the distinction is deliberate. Failing open on a bad request is degradation
28
+ a customer can absorb; failing open on "no operating point was pinned" would
29
+ silently accept exactly the behaviour drift §8.2 exists to prevent. A
30
+ configuration error is raised where it is made — at wire-up — never under
31
+ load.
32
+ """
33
+
34
+
35
+ class NeedlepathTransportError(NeedlepathError):
36
+ """The request never produced an HTTP response (DNS, connect, reset, TLS)."""
37
+
38
+
39
+ class NeedlepathTimeoutError(NeedlepathTransportError):
40
+ """The deadline elapsed before a response arrived."""
41
+
42
+
43
+ class NeedlepathHTTPError(NeedlepathError):
44
+ """A non-2xx response.
45
+
46
+ ``body`` is the parsed JSON error body when the server sent one. Our own
47
+ error bodies are bounded and metadata-only by construction (they carry an
48
+ ``error`` enum plus counts); an intermediary's error page is truncated
49
+ before it is stored.
50
+ """
51
+
52
+ #: How much of a non-JSON error body is kept. An intermediary (a WAF, a
53
+ #: proxy) can return an arbitrarily large HTML page, and this error object
54
+ #: routinely ends up inside a caller's log line.
55
+ BODY_EXCERPT_MAX_CHARS = 512
56
+
57
+ def __init__(
58
+ self,
59
+ status: int,
60
+ *,
61
+ body: Any = None,
62
+ text: Optional[str] = None,
63
+ retry_after: Optional[float] = None,
64
+ ) -> None:
65
+ self.status = status
66
+ self.body = body
67
+ self.text = (text or "")[: self.BODY_EXCERPT_MAX_CHARS]
68
+ self.retry_after = retry_after
69
+ code = ""
70
+ if isinstance(body, dict):
71
+ raw = body.get("error")
72
+ if isinstance(raw, str):
73
+ code = f" ({raw})"
74
+ super().__init__(f"HTTP {status}{code}")
75
+
76
+ @property
77
+ def error_code(self) -> Optional[str]:
78
+ """The server's own ``error`` enum, when it sent one.
79
+
80
+ Open enum: ``bad_request``, ``request_too_large``, ``engine_error``,
81
+ ``not_found`` today. Callers must tolerate values they do not know.
82
+ """
83
+ if isinstance(self.body, dict):
84
+ raw = self.body.get("error")
85
+ if isinstance(raw, str):
86
+ return raw
87
+ return None
88
+
89
+
90
+ class NeedlepathContractError(NeedlepathError):
91
+ """A 2xx body that violates the published response contract.
92
+
93
+ Missing a required field, or an echoed ``request_id`` that is not the one
94
+ that was sent. Both mean the response cannot be trusted to describe *this*
95
+ request, so it is not applied.
96
+ """
97
+
98
+
99
+ class NeedlepathPayloadTooLargeError(NeedlepathError):
100
+ """The serialized request exceeded the client-side size guard.
101
+
102
+ Raised (and recorded) *before* any network call: §8.2 requires the 6 MB cap
103
+ to be guarded client-side rather than surfaced as a platform error from the
104
+ far side of the wire.
105
+ """
106
+
107
+ def __init__(self, size_bytes: int, limit_bytes: int) -> None:
108
+ self.size_bytes = size_bytes
109
+ self.limit_bytes = limit_bytes
110
+ super().__init__(
111
+ f"request body is {size_bytes} bytes, over the client guard of {limit_bytes} bytes"
112
+ )
113
+
114
+
115
+ __all__ = [
116
+ "NeedlepathError",
117
+ "NeedlepathConfigError",
118
+ "NeedlepathTransportError",
119
+ "NeedlepathTimeoutError",
120
+ "NeedlepathHTTPError",
121
+ "NeedlepathContractError",
122
+ "NeedlepathPayloadTooLargeError",
123
+ ]
needlepath/features.py ADDED
@@ -0,0 +1,71 @@
1
+ """Feature flags for wire fields that are designed but not yet published.
2
+
3
+ There is exactly one today, and it exists because the SDK has to ship a
4
+ capability the *contract* has not shipped yet.
5
+
6
+ Shadow mode
7
+ -----------
8
+
9
+ ``NEEDLEPATH_SAAS_DESIGN.md`` §0.1 records the discrepancy plainly: the
10
+ companion design calls ``mode: "shadow"`` a first-class wire field, but
11
+ ``BudgetSpec.mode`` on the published contract accepts only ``"fixed"`` and
12
+ ``"adaptive"``, and the string ``shadow`` appears nowhere in ``INTERFACE.md``.
13
+ Adding it is a **public-repo contract change**, sequenced in E7 and required by
14
+ E2's billing rule and E5's shadow report.
15
+
16
+ So this SDK does what §8.2 asks for — "shadow mode is a one-line switch" — with
17
+ the wire half feature-flagged off:
18
+
19
+ * ``shadow=True`` **today**: the call is made exactly as a normal select call
20
+ (same body, same billing, same operating point), the full response comes back
21
+ and is reported, and **the selection is never applied**. The caller's original
22
+ context passes through untouched. That is the whole day-one integration path:
23
+ measure what Needlepath *would* have saved you, change nothing.
24
+ * ``shadow=True`` **once the field ships**: flip ``WIRE_SHADOW_MODE`` and
25
+ ``budget.mode`` carries ``"shadow"`` on the wire. Client-side behaviour does
26
+ not change — it is already correct — so the flip is additive and reversible.
27
+
28
+ Sending ``mode: "shadow"`` before the server knows it would be read as an
29
+ unknown mode and fall through to the fixed-budget path (``select()`` branches on
30
+ ``== "adaptive"``), which is *silently the right answer for the wrong reason* —
31
+ a fragile thing to depend on, and precisely why it is behind a flag instead of
32
+ just being sent.
33
+
34
+ The flag can be turned on for forward-compatibility testing without a release::
35
+
36
+ NEEDLEPATH_WIRE_SHADOW=1
37
+
38
+ That override exists for our own integration tests against a server build that
39
+ has the field. It is not a documented customer knob.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+
46
+ #: Whether ``budget.mode = "shadow"`` is emitted on the wire.
47
+ #:
48
+ #: **Flip this to ``True`` in the same change that lands the field in
49
+ #: ``INTERFACE.md``.** Nothing else in this SDK needs to change.
50
+ WIRE_SHADOW_MODE = False
51
+
52
+ #: The value ``budget.mode`` takes when shadow is both requested and on the wire.
53
+ WIRE_SHADOW_MODE_VALUE = "shadow"
54
+
55
+ _ENV_OVERRIDE = "NEEDLEPATH_WIRE_SHADOW"
56
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
57
+
58
+
59
+ def wire_shadow_mode_enabled() -> bool:
60
+ """Whether to put ``shadow`` on the wire, env override included.
61
+
62
+ Read at call time rather than import time so a test can set the variable
63
+ without reimporting the package.
64
+ """
65
+ raw = os.environ.get(_ENV_OVERRIDE)
66
+ if raw is not None:
67
+ return raw.strip().lower() in _TRUTHY
68
+ return WIRE_SHADOW_MODE
69
+
70
+
71
+ __all__ = ["WIRE_SHADOW_MODE", "WIRE_SHADOW_MODE_VALUE", "wire_shadow_mode_enabled"]
needlepath/models.py ADDED
@@ -0,0 +1,476 @@
1
+ """The wire contract, as plain dataclasses.
2
+
3
+ This is an independent implementation of the published contract (``INTERFACE.md``
4
+ in ``context-selection-bench``, mirrored by ``csbench.contracts``). It is copied
5
+ rather than imported on purpose: the benchmark harness is a separate, public
6
+ distribution with its own release cadence, and a client SDK that imports it
7
+ would drag a research harness into every customer's dependency tree.
8
+
9
+ Two properties are load-bearing and are asserted by
10
+ ``tests/test_wire_compatibility.py``:
11
+
12
+ 1. **Field names and defaults match the published contract exactly.** A field
13
+ that drifts here is a request the endpoint 400s or, worse, silently reads
14
+ differently.
15
+ 2. **``to_wire()`` emits the full shape**, ``None``\\ s and empty collections
16
+ included, exactly as the published client's ``dataclasses.asdict()`` does.
17
+ Emitting a compact body would be accepted by today's server — every optional
18
+ is read with ``.get(..., default)`` — but byte-compatibility with the client
19
+ that published runs cite is worth more than the few percent of payload it
20
+ costs, because it means "the harness works" and "the SDK works" are the same
21
+ statement.
22
+
23
+ Unknown response fields are **ignored, never rejected**: the contract is
24
+ additive under ``/v1`` (``API_VERSIONING.md``), and ``usage`` (E2) and
25
+ ``mode: "shadow"`` (§0.1) are both already scheduled to arrive that way.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Dict, List, Optional
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # Record kinds
35
+ # --------------------------------------------------------------------------- #
36
+
37
+
38
+ class RecordKind:
39
+ """The published record-role enum, as string constants.
40
+
41
+ Deliberately **not** an ``enum.Enum`` and deliberately not validated
42
+ client-side. The set is server-owned and additive; a client that rejects a
43
+ kind it has not heard of turns a server-side addition into a client-side
44
+ outage. An unknown kind is the server's 4xx/5xx to make, and the fail-open
45
+ path already covers it.
46
+ """
47
+
48
+ USER_INPUT = "user_input"
49
+ LLM_RESPONSE = "llm_response"
50
+ TOOL_CALL = "tool_call"
51
+ TOOL_RESULT = "tool_result"
52
+ EXTERNAL_DATA = "external_data"
53
+ ERROR = "error"
54
+ ARTIFACT = "artifact"
55
+ TOOL_SCHEMA = "tool_schema"
56
+
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # Request
60
+ # --------------------------------------------------------------------------- #
61
+
62
+
63
+ def _text(value: Any, default: str = "") -> str:
64
+ """A wire field that must be text, coerced rather than trusted.
65
+
66
+ The response comes off a socket. `rendered_context: 123` is a
67
+ contract-valid-looking 200 that turns into an `AttributeError` the first
68
+ time anything calls `.strip()` on it — and the place that happens is outside
69
+ the client's fail-open boundary, so a malformed response from a proxy or a
70
+ future server bug would reach the caller as a crash.
71
+
72
+ Coercing here rather than raising keeps a merely-sloppy field from
73
+ invalidating an otherwise usable response; the fields that must be *right*
74
+ (`request_id`, the required set) are still checked at the HTTP boundary.
75
+ """
76
+ if isinstance(value, str):
77
+ return value
78
+ if value is None:
79
+ return default
80
+ return str(value)
81
+
82
+
83
+ @dataclass
84
+ class ContextRecord:
85
+ """One unit of state the service may keep, drop, or excerpt."""
86
+
87
+ text: str
88
+ kind: str = RecordKind.EXTERNAL_DATA
89
+ id: Optional[str] = None
90
+ source: Optional[str] = None
91
+ title: Optional[str] = None
92
+ step_id: Optional[str] = None
93
+ importance: float = 0.0
94
+ keywords: List[str] = field(default_factory=list)
95
+ tags: List[str] = field(default_factory=list)
96
+ attributes: Dict[str, Any] = field(default_factory=dict)
97
+
98
+ def to_wire(self) -> Dict[str, Any]:
99
+ return {
100
+ "text": self.text,
101
+ "kind": self.kind,
102
+ "id": self.id,
103
+ "source": self.source,
104
+ "title": self.title,
105
+ "step_id": self.step_id,
106
+ "importance": self.importance,
107
+ "keywords": list(self.keywords),
108
+ "tags": list(self.tags),
109
+ "attributes": dict(self.attributes),
110
+ }
111
+
112
+ @classmethod
113
+ def from_wire(cls, d: Dict[str, Any]) -> ContextRecord:
114
+ return cls(
115
+ text=d["text"],
116
+ kind=d.get("kind", RecordKind.EXTERNAL_DATA),
117
+ id=d.get("id"),
118
+ source=d.get("source"),
119
+ title=d.get("title"),
120
+ step_id=d.get("step_id"),
121
+ importance=float(d.get("importance", 0.0)),
122
+ keywords=list(d.get("keywords", [])),
123
+ tags=list(d.get("tags", [])),
124
+ attributes=dict(d.get("attributes", {})),
125
+ )
126
+
127
+
128
+ @dataclass
129
+ class TaskSpec:
130
+ """The query the selected context has to serve."""
131
+
132
+ prompt: str
133
+ tool_name: Optional[str] = None
134
+ required_record_ids: List[str] = field(default_factory=list)
135
+ parent_record_ids: List[str] = field(default_factory=list)
136
+ keywords: List[str] = field(default_factory=list)
137
+ tags: List[str] = field(default_factory=list)
138
+ step_id: Optional[str] = None
139
+ recent_prompts: List[str] = field(default_factory=list)
140
+ output_mode: Optional[str] = None
141
+ output_token_budget: Optional[int] = None
142
+
143
+ def to_wire(self) -> Dict[str, Any]:
144
+ return {
145
+ "prompt": self.prompt,
146
+ "tool_name": self.tool_name,
147
+ "required_record_ids": list(self.required_record_ids),
148
+ "parent_record_ids": list(self.parent_record_ids),
149
+ "keywords": list(self.keywords),
150
+ "tags": list(self.tags),
151
+ "step_id": self.step_id,
152
+ "recent_prompts": list(self.recent_prompts),
153
+ "output_mode": self.output_mode,
154
+ "output_token_budget": self.output_token_budget,
155
+ }
156
+
157
+ @classmethod
158
+ def from_wire(cls, d: Dict[str, Any]) -> TaskSpec:
159
+ return cls(
160
+ prompt=d["prompt"],
161
+ tool_name=d.get("tool_name"),
162
+ required_record_ids=list(d.get("required_record_ids", [])),
163
+ parent_record_ids=list(d.get("parent_record_ids", [])),
164
+ keywords=list(d.get("keywords", [])),
165
+ tags=list(d.get("tags", [])),
166
+ step_id=d.get("step_id"),
167
+ recent_prompts=list(d.get("recent_prompts", [])),
168
+ output_mode=d.get("output_mode"),
169
+ output_token_budget=d.get("output_token_budget"),
170
+ )
171
+
172
+
173
+ @dataclass
174
+ class AdaptiveBudget:
175
+ """Budget-escalation ladder for ``mode="adaptive"``."""
176
+
177
+ initial_tokens: int
178
+ escalation_tokens: List[int] = field(default_factory=list)
179
+ allow_full_context_fallback: bool = True
180
+
181
+ def to_wire(self) -> Dict[str, Any]:
182
+ return {
183
+ "initial_tokens": self.initial_tokens,
184
+ "escalation_tokens": list(self.escalation_tokens),
185
+ "allow_full_context_fallback": self.allow_full_context_fallback,
186
+ }
187
+
188
+ @classmethod
189
+ def from_wire(cls, d: Dict[str, Any]) -> AdaptiveBudget:
190
+ return cls(
191
+ initial_tokens=int(d["initial_tokens"]),
192
+ escalation_tokens=list(d.get("escalation_tokens", [])),
193
+ allow_full_context_fallback=bool(d.get("allow_full_context_fallback", True)),
194
+ )
195
+
196
+
197
+ @dataclass
198
+ class BudgetSpec:
199
+ """The operating point plus the arm-neutral budget overrides.
200
+
201
+ No method-tuning knob appears here, by contract: thresholds, weightings and
202
+ gate parameters are resolved server-side from ``operating_point``.
203
+ """
204
+
205
+ max_context_tokens: int
206
+ operating_point: Optional[str] = None
207
+ max_records: Optional[int] = None
208
+ max_excerpt_tokens_per_record: Optional[int] = None
209
+ mode: str = "fixed" # "fixed" | "adaptive" (see features.WIRE_SHADOW_MODE)
210
+ adaptive: Optional[AdaptiveBudget] = None
211
+ require_evidence_coverage: Optional[bool] = None
212
+
213
+ def to_wire(self) -> Dict[str, Any]:
214
+ return {
215
+ "max_context_tokens": self.max_context_tokens,
216
+ "operating_point": self.operating_point,
217
+ "max_records": self.max_records,
218
+ "max_excerpt_tokens_per_record": self.max_excerpt_tokens_per_record,
219
+ "mode": self.mode,
220
+ "adaptive": self.adaptive.to_wire() if self.adaptive is not None else None,
221
+ "require_evidence_coverage": self.require_evidence_coverage,
222
+ }
223
+
224
+ @classmethod
225
+ def from_wire(cls, d: Dict[str, Any]) -> BudgetSpec:
226
+ adaptive = d.get("adaptive")
227
+ return cls(
228
+ max_context_tokens=int(d["max_context_tokens"]),
229
+ operating_point=d.get("operating_point"),
230
+ max_records=d.get("max_records"),
231
+ max_excerpt_tokens_per_record=d.get("max_excerpt_tokens_per_record"),
232
+ mode=d.get("mode", "fixed"),
233
+ adaptive=AdaptiveBudget.from_wire(adaptive) if adaptive else None,
234
+ require_evidence_coverage=d.get("require_evidence_coverage"),
235
+ )
236
+
237
+
238
+ @dataclass
239
+ class ContextRequest:
240
+ request_id: str
241
+ records: List[ContextRecord]
242
+ task: TaskSpec
243
+ budget: BudgetSpec
244
+ render: bool = True
245
+ render_format: str = "plain" # "plain" | "hybrid"
246
+ return_per_record: bool = True
247
+
248
+ def to_wire(self) -> Dict[str, Any]:
249
+ return {
250
+ "request_id": self.request_id,
251
+ "records": [r.to_wire() for r in self.records],
252
+ "task": self.task.to_wire(),
253
+ "budget": self.budget.to_wire(),
254
+ "render": self.render,
255
+ "render_format": self.render_format,
256
+ "return_per_record": self.return_per_record,
257
+ }
258
+
259
+ @classmethod
260
+ def from_wire(cls, d: Dict[str, Any]) -> ContextRequest:
261
+ return cls(
262
+ request_id=d["request_id"],
263
+ records=[ContextRecord.from_wire(r) for r in d.get("records", [])],
264
+ task=TaskSpec.from_wire(d["task"]),
265
+ budget=BudgetSpec.from_wire(d["budget"]),
266
+ render=bool(d.get("render", True)),
267
+ render_format=d.get("render_format", "plain"),
268
+ return_per_record=bool(d.get("return_per_record", True)),
269
+ )
270
+
271
+
272
+ # --------------------------------------------------------------------------- #
273
+ # Response
274
+ # --------------------------------------------------------------------------- #
275
+
276
+
277
+ @dataclass
278
+ class SelectedRecord:
279
+ record_id: str
280
+ kind: str = RecordKind.EXTERNAL_DATA
281
+ title: Optional[str] = None
282
+ source: Optional[str] = None
283
+ score: float = 0.0
284
+ reason: str = ""
285
+ excerpt: str = ""
286
+ excerpt_format: str = "plain"
287
+ selected_tokens: int = 0
288
+
289
+ @classmethod
290
+ def from_wire(cls, d: Dict[str, Any]) -> SelectedRecord:
291
+ return cls(
292
+ record_id=str(d["record_id"]),
293
+ kind=d.get("kind", RecordKind.EXTERNAL_DATA),
294
+ title=d.get("title"),
295
+ source=d.get("source"),
296
+ score=float(d.get("score", 0.0) or 0.0),
297
+ reason=_text(d.get("reason")),
298
+ # Coerced, not trusted: an adapter does `len(excerpt)` and
299
+ # `excerpt.strip()` on this, and an int here would raise inside the
300
+ # customer's agent rather than inside our fail-open boundary.
301
+ excerpt=_text(d.get("excerpt")),
302
+ excerpt_format=_text(d.get("excerpt_format"), "plain"),
303
+ selected_tokens=int(d.get("selected_tokens", 0)),
304
+ )
305
+
306
+
307
+ @dataclass
308
+ class SafetySummary:
309
+ selection_safe: bool = True
310
+ fallback_required: bool = False
311
+ fallback_reason: str = ""
312
+ coverage_score: float = 0.0
313
+ evidence_shape: str = "unknown"
314
+ evidence_terms: Optional[Dict[str, List[str]]] = None
315
+ repair_reasons: List[str] = field(default_factory=list)
316
+
317
+ @classmethod
318
+ def from_wire(cls, d: Dict[str, Any]) -> SafetySummary:
319
+ return cls(
320
+ selection_safe=bool(d.get("selection_safe", True)),
321
+ fallback_required=bool(d.get("fallback_required", False)),
322
+ fallback_reason=_text(d.get("fallback_reason")),
323
+ coverage_score=float(d.get("coverage_score", 0.0) or 0.0),
324
+ evidence_shape=_text(d.get("evidence_shape"), "unknown"),
325
+ evidence_terms=d.get("evidence_terms"),
326
+ repair_reasons=list(d.get("repair_reasons", [])),
327
+ )
328
+
329
+
330
+ @dataclass
331
+ class GateSummary:
332
+ """Neutral engage/stand-down summary.
333
+
334
+ ``reason`` is an **open, extensible enum** (``INTERFACE.md``). Nothing in
335
+ this SDK branches on its value, and nothing you build on this SDK should
336
+ either: a third outcome (*escalate*) is a planned additive change, and a
337
+ client that exhaustively matches today's two prefixes is a client that
338
+ breaks when it lands.
339
+ """
340
+
341
+ engaged: bool = False
342
+ reason: str = ""
343
+ signals: Dict[str, Any] = field(default_factory=dict)
344
+
345
+ @classmethod
346
+ def from_wire(cls, d: Dict[str, Any]) -> GateSummary:
347
+ return cls(
348
+ engaged=bool(d.get("engaged", False)),
349
+ reason=_text(d.get("reason")),
350
+ signals=dict(d.get("signals", {}) or {}),
351
+ )
352
+
353
+
354
+ @dataclass
355
+ class ContextResponse:
356
+ request_id: str
357
+ rendered_context: str
358
+ policy_version: Optional[str] = None
359
+ selected: List[SelectedRecord] = field(default_factory=list)
360
+ tokens_before: int = 0
361
+ tokens_after: int = 0
362
+ tokens_saved: int = 0
363
+ records_available: int = 0
364
+ records_selected: int = 0
365
+ fallback_used: bool = False
366
+ selection_error: Optional[str] = None
367
+ engine_latency_ms: float = 0.0
368
+ budget_tokens: int = 0
369
+ attempted_budget_tokens: List[int] = field(default_factory=list)
370
+ reduction_ratio: float = 0.0
371
+ safety: Optional[SafetySummary] = None
372
+ gate: Optional[GateSummary] = None
373
+ format_metrics: Dict[str, Any] = field(default_factory=dict)
374
+
375
+ #: Every response field this client does not model, kept verbatim. The
376
+ #: contract is additive under ``/v1``: ``usage`` (E2) and any later block
377
+ #: land here without a client release, and a caller can read them today.
378
+ extra: Dict[str, Any] = field(default_factory=dict)
379
+
380
+ _KNOWN = frozenset(
381
+ {
382
+ "request_id",
383
+ "rendered_context",
384
+ "policy_version",
385
+ "selected",
386
+ "tokens_before",
387
+ "tokens_after",
388
+ "tokens_saved",
389
+ "records_available",
390
+ "records_selected",
391
+ "fallback_used",
392
+ "selection_error",
393
+ "engine_latency_ms",
394
+ "budget_tokens",
395
+ "attempted_budget_tokens",
396
+ "reduction_ratio",
397
+ "safety",
398
+ "gate",
399
+ "format_metrics",
400
+ }
401
+ )
402
+
403
+ @classmethod
404
+ def from_wire(cls, d: Dict[str, Any]) -> ContextResponse:
405
+ safety = d.get("safety")
406
+ gate = d.get("gate")
407
+ return cls(
408
+ request_id=d["request_id"],
409
+ rendered_context=_text(d.get("rendered_context")),
410
+ policy_version=(
411
+ _text(d["policy_version"]) if d.get("policy_version") is not None else None
412
+ ),
413
+ selected=[SelectedRecord.from_wire(s) for s in d.get("selected", []) or []],
414
+ tokens_before=int(d.get("tokens_before", 0)),
415
+ tokens_after=int(d.get("tokens_after", 0)),
416
+ tokens_saved=int(d.get("tokens_saved", 0)),
417
+ records_available=int(d.get("records_available", 0)),
418
+ records_selected=int(d.get("records_selected", 0)),
419
+ fallback_used=bool(d.get("fallback_used", False)),
420
+ selection_error=(
421
+ _text(d["selection_error"]) if d.get("selection_error") is not None else None
422
+ ),
423
+ engine_latency_ms=float(d.get("engine_latency_ms", 0.0)),
424
+ budget_tokens=int(d.get("budget_tokens", 0)),
425
+ attempted_budget_tokens=list(d.get("attempted_budget_tokens", []) or []),
426
+ reduction_ratio=float(d.get("reduction_ratio", 0.0)),
427
+ safety=SafetySummary.from_wire(safety) if safety else None,
428
+ gate=GateSummary.from_wire(gate) if gate else None,
429
+ format_metrics=dict(d.get("format_metrics", {}) or {}),
430
+ extra={k: v for k, v in d.items() if k not in cls._KNOWN},
431
+ )
432
+
433
+ # ----------------------------------------------------------------- #
434
+
435
+ @property
436
+ def selected_ids(self) -> set[str]:
437
+ """Every ``record_id`` the response claims to have selected."""
438
+ return {s.record_id for s in self.selected}
439
+
440
+ @property
441
+ def excerpts(self) -> Dict[str, str]:
442
+ """``record_id`` → excerpt, for the records the service kept.
443
+
444
+ Empty when the request set ``return_per_record=False``.
445
+ """
446
+ return {s.record_id: s.excerpt for s in self.selected}
447
+
448
+
449
+ #: Fields every conformant response must carry. Checked at the HTTP boundary,
450
+ #: exactly as the published client checks them.
451
+ REQUIRED_RESPONSE_FIELDS = (
452
+ "request_id",
453
+ "rendered_context",
454
+ "tokens_before",
455
+ "tokens_after",
456
+ "tokens_saved",
457
+ "records_available",
458
+ "records_selected",
459
+ "fallback_used",
460
+ "engine_latency_ms",
461
+ )
462
+
463
+
464
+ __all__ = [
465
+ "RecordKind",
466
+ "ContextRecord",
467
+ "TaskSpec",
468
+ "AdaptiveBudget",
469
+ "BudgetSpec",
470
+ "ContextRequest",
471
+ "SelectedRecord",
472
+ "SafetySummary",
473
+ "GateSummary",
474
+ "ContextResponse",
475
+ "REQUIRED_RESPONSE_FIELDS",
476
+ ]
needlepath/py.typed ADDED
File without changes