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/__init__.py +137 -0
- needlepath/client.py +947 -0
- needlepath/errors.py +123 -0
- needlepath/features.py +71 -0
- needlepath/models.py +476 -0
- needlepath/py.typed +0 -0
- needlepath/result.py +302 -0
- needlepath/testing.py +224 -0
- needlepath/transport.py +316 -0
- needlepath/version.py +10 -0
- needlepath-0.1.0.dist-info/METADATA +251 -0
- needlepath-0.1.0.dist-info/RECORD +14 -0
- needlepath-0.1.0.dist-info/WHEEL +4 -0
- needlepath-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|