llmentary 0.2.0__py3-none-win_amd64.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.
- llmentary/__init__.py +28 -0
- llmentary/assertions.py +407 -0
- llmentary/bin/win_amd64/AGPL-3.0.txt +235 -0
- llmentary/bin/win_amd64/VERSION +1 -0
- llmentary/bin/win_amd64/llmentary.exe +0 -0
- llmentary/binary.py +102 -0
- llmentary/client.py +473 -0
- llmentary/py.typed +1 -0
- llmentary/pytest_plugin.py +125 -0
- llmentary/types.py +885 -0
- llmentary-0.2.0.dist-info/METADATA +179 -0
- llmentary-0.2.0.dist-info/RECORD +16 -0
- llmentary-0.2.0.dist-info/WHEEL +5 -0
- llmentary-0.2.0.dist-info/entry_points.txt +2 -0
- llmentary-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmentary-0.2.0.dist-info/licenses/licenses/AGPL-3.0.txt +235 -0
llmentary/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Python client for llmentary deterministic LLM integration tests."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .assertions import Expectation, SoftExpectation
|
|
6
|
+
from .binary import BinaryResolutionError, find_binary
|
|
7
|
+
from .client import ControlClient, ProxyHandle, Query, Session, start_proxy
|
|
8
|
+
from .types import APIType, SessionTrace
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("llmentary")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0.2.0" # x-release-please-version
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"APIType",
|
|
17
|
+
"BinaryResolutionError",
|
|
18
|
+
"ControlClient",
|
|
19
|
+
"Expectation",
|
|
20
|
+
"ProxyHandle",
|
|
21
|
+
"Query",
|
|
22
|
+
"Session",
|
|
23
|
+
"SessionTrace",
|
|
24
|
+
"SoftExpectation",
|
|
25
|
+
"__version__",
|
|
26
|
+
"find_binary",
|
|
27
|
+
"start_proxy",
|
|
28
|
+
]
|
llmentary/assertions.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from re import Pattern
|
|
7
|
+
from typing import Any, Callable, Optional, Union
|
|
8
|
+
|
|
9
|
+
from jsonschema import ValidationError
|
|
10
|
+
from jsonschema.validators import Draft202012Validator
|
|
11
|
+
|
|
12
|
+
from .types import (
|
|
13
|
+
CapturedCall,
|
|
14
|
+
SessionTrace,
|
|
15
|
+
ToolCall,
|
|
16
|
+
as_mapping,
|
|
17
|
+
model_matches,
|
|
18
|
+
sequence_at,
|
|
19
|
+
stringify_value,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ArgsMatcher = Union[Mapping[str, Any], Callable[[Mapping[str, Any]], bool]]
|
|
23
|
+
Regex = Union[str, Pattern[str]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Expectation:
|
|
27
|
+
"""Fluent deterministic assertions over an llmentary session trace."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, trace: SessionTrace) -> None:
|
|
30
|
+
self.trace = trace
|
|
31
|
+
|
|
32
|
+
def tool_called(self, name: str) -> Expectation:
|
|
33
|
+
self._assert(
|
|
34
|
+
any(call.name == name for call in self._tool_calls()),
|
|
35
|
+
f"expected tool {name!r} to be called",
|
|
36
|
+
)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def not_called(self, name: str) -> Expectation:
|
|
40
|
+
self._assert(
|
|
41
|
+
all(call.name != name for call in self._tool_calls()),
|
|
42
|
+
f"expected tool {name!r} not to be called",
|
|
43
|
+
)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def called_times(self, name: str, count: int) -> Expectation:
|
|
47
|
+
actual = sum(1 for call in self._tool_calls() if call.name == name)
|
|
48
|
+
self._assert(
|
|
49
|
+
actual == count,
|
|
50
|
+
f"expected tool {name!r} to be called {count} time(s), got {actual}",
|
|
51
|
+
)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def args_subset(self, name: str, subset: Mapping[str, Any]) -> Expectation:
|
|
55
|
+
matching = [tool for tool in self._tool_calls() if tool.name == name]
|
|
56
|
+
self._assert(bool(matching), f"expected tool {name!r} to be called")
|
|
57
|
+
self._assert(
|
|
58
|
+
any(mapping_contains(tool.arguments, subset) for tool in matching),
|
|
59
|
+
f"expected a {name!r} call with argument subset {subset!r}",
|
|
60
|
+
)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def args_matcher(self, name: str, matcher: ArgsMatcher) -> Expectation:
|
|
64
|
+
matching = [tool for tool in self._tool_calls() if tool.name == name]
|
|
65
|
+
self._assert(bool(matching), f"expected tool {name!r} to be called")
|
|
66
|
+
if isinstance(matcher, Mapping):
|
|
67
|
+
return self.args_subset(name, matcher)
|
|
68
|
+
self._assert(
|
|
69
|
+
any(matcher(tool.arguments) for tool in matching),
|
|
70
|
+
f"expected a {name!r} call to satisfy matcher",
|
|
71
|
+
)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def order(self, *names: str, strict: bool = False) -> Expectation:
|
|
75
|
+
actual = [tool.name for tool in self._tool_calls()]
|
|
76
|
+
if strict:
|
|
77
|
+
self._assert(
|
|
78
|
+
actual == list(names),
|
|
79
|
+
f"expected strict tool order {list(names)!r}, got {actual!r}",
|
|
80
|
+
)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
position = 0
|
|
84
|
+
for expected in names:
|
|
85
|
+
try:
|
|
86
|
+
index = actual.index(expected, position)
|
|
87
|
+
except ValueError:
|
|
88
|
+
self._assert(False, f"expected tool order {list(names)!r}, got {actual!r}")
|
|
89
|
+
return self
|
|
90
|
+
position = index + 1
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def parallel_tool_calls(self, expected: bool = True) -> Expectation:
|
|
94
|
+
actual = any(len(call.tool_calls) > 1 for call in self.trace.calls)
|
|
95
|
+
self._assert(
|
|
96
|
+
actual is expected,
|
|
97
|
+
f"expected parallel tool calls to be {expected}, got {actual}",
|
|
98
|
+
)
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def tool_result_fed_back(self, name: str) -> Expectation:
|
|
102
|
+
tool_ids = {
|
|
103
|
+
tool.call_id
|
|
104
|
+
for call in self.trace.calls
|
|
105
|
+
for tool in call.tool_calls
|
|
106
|
+
if tool.name == name and tool.call_id
|
|
107
|
+
}
|
|
108
|
+
any_named_tool = any(tool.name == name for tool in self._tool_calls())
|
|
109
|
+
result_matches_id = any(
|
|
110
|
+
result.get("tool_call_id") in tool_ids
|
|
111
|
+
or result.get("call_id") in tool_ids
|
|
112
|
+
or result.get("callId") in tool_ids
|
|
113
|
+
for call in self.trace.calls
|
|
114
|
+
for result in call.tool_results
|
|
115
|
+
)
|
|
116
|
+
result_mentions_name = not tool_ids and any(
|
|
117
|
+
result.get("name") == name
|
|
118
|
+
for call in self.trace.calls
|
|
119
|
+
for result in call.tool_results
|
|
120
|
+
)
|
|
121
|
+
self._assert(
|
|
122
|
+
any_named_tool and (result_matches_id or result_mentions_name),
|
|
123
|
+
f"expected tool result for {name!r} to be fed back in a later request",
|
|
124
|
+
)
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def model(self, expected: str) -> Expectation:
|
|
128
|
+
models = {call.model for call in self.trace.calls if call.model}
|
|
129
|
+
self._assert(
|
|
130
|
+
any(model_matches(model, expected) for model in models),
|
|
131
|
+
f"expected model {expected!r}, got {sorted(models)!r}",
|
|
132
|
+
)
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def request_param(self, name: str, expected: Any) -> Expectation:
|
|
136
|
+
matches = [call for call in self.trace.calls if nested_get(call.request, name) == expected]
|
|
137
|
+
self._assert(
|
|
138
|
+
bool(matches),
|
|
139
|
+
f"expected request parameter {name!r} to equal {expected!r}",
|
|
140
|
+
)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def finish_reason(self, expected: str) -> Expectation:
|
|
144
|
+
reasons = [call.finish_reason for call in self.trace.calls if call.finish_reason]
|
|
145
|
+
self._assert(
|
|
146
|
+
expected in reasons,
|
|
147
|
+
f"expected finish reason {expected!r}, got {reasons!r}",
|
|
148
|
+
)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def tokens(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
min_total: Optional[int] = None,
|
|
155
|
+
max_total: Optional[int] = None,
|
|
156
|
+
min_prompt: Optional[int] = None,
|
|
157
|
+
max_prompt: Optional[int] = None,
|
|
158
|
+
min_completion: Optional[int] = None,
|
|
159
|
+
max_completion: Optional[int] = None,
|
|
160
|
+
) -> Expectation:
|
|
161
|
+
usage = aggregate_usage(self.trace.calls)
|
|
162
|
+
checks = [
|
|
163
|
+
("total", min_total, max_total),
|
|
164
|
+
("prompt", min_prompt, max_prompt),
|
|
165
|
+
("completion", min_completion, max_completion),
|
|
166
|
+
]
|
|
167
|
+
for key, minimum, maximum in checks:
|
|
168
|
+
if minimum is None and maximum is None:
|
|
169
|
+
continue
|
|
170
|
+
value = usage.get(key)
|
|
171
|
+
if value is None:
|
|
172
|
+
self._assert(False, f"expected usage token field {key!r} to be present")
|
|
173
|
+
continue
|
|
174
|
+
if minimum is not None:
|
|
175
|
+
self._assert(value >= minimum, f"expected {key} tokens >= {minimum}, got {value}")
|
|
176
|
+
if maximum is not None:
|
|
177
|
+
self._assert(value <= maximum, f"expected {key} tokens <= {maximum}, got {value}")
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def latency(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
min_ms: Optional[float] = None,
|
|
184
|
+
max_ms: Optional[float] = None,
|
|
185
|
+
) -> Expectation:
|
|
186
|
+
return self._bounds(
|
|
187
|
+
"latency",
|
|
188
|
+
[call.latency_ms for call in self.trace.calls],
|
|
189
|
+
min_ms,
|
|
190
|
+
max_ms,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def ttft(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
min_ms: Optional[float] = None,
|
|
197
|
+
max_ms: Optional[float] = None,
|
|
198
|
+
) -> Expectation:
|
|
199
|
+
return self._bounds(
|
|
200
|
+
"ttft",
|
|
201
|
+
[call.ttft_ms for call in self.trace.calls],
|
|
202
|
+
min_ms,
|
|
203
|
+
max_ms,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def retries(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
min_count: Optional[int] = None,
|
|
210
|
+
max_count: Optional[int] = None,
|
|
211
|
+
) -> Expectation:
|
|
212
|
+
total = sum(call.retries for call in self.trace.calls)
|
|
213
|
+
if min_count is not None:
|
|
214
|
+
self._assert(total >= min_count, f"expected retries >= {min_count}, got {total}")
|
|
215
|
+
if max_count is not None:
|
|
216
|
+
self._assert(total <= max_count, f"expected retries <= {max_count}, got {total}")
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def no_retries(self) -> Expectation:
|
|
220
|
+
return self.retries(max_count=0)
|
|
221
|
+
|
|
222
|
+
def no_errors(self) -> Expectation:
|
|
223
|
+
errors = [call for call in self.trace.calls if call.error or call.mid_stream_error]
|
|
224
|
+
self._assert(not errors, f"expected no errors, got {len(errors)} errored call(s)")
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
def has_error(self, pattern: Optional[Regex] = None) -> Expectation:
|
|
228
|
+
errors = [call.error for call in self.trace.calls if call.error]
|
|
229
|
+
self._assert(bool(errors), "expected at least one error")
|
|
230
|
+
if pattern is not None:
|
|
231
|
+
self._assert(
|
|
232
|
+
any(regex_search(pattern, str(error)) for error in errors),
|
|
233
|
+
f"expected an error matching {pattern!r}",
|
|
234
|
+
)
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def mid_stream_error(self, pattern: Optional[Regex] = None) -> Expectation:
|
|
238
|
+
errors = [call.mid_stream_error for call in self.trace.calls if call.mid_stream_error]
|
|
239
|
+
self._assert(bool(errors), "expected at least one mid-stream error")
|
|
240
|
+
if pattern is not None:
|
|
241
|
+
self._assert(
|
|
242
|
+
any(regex_search(pattern, str(error)) for error in errors),
|
|
243
|
+
f"expected a mid-stream error matching {pattern!r}",
|
|
244
|
+
)
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def prompt_contains(self, text: str) -> Expectation:
|
|
248
|
+
prompt = "\n".join(call.prompt_text for call in self.trace.calls)
|
|
249
|
+
self._assert(text in prompt, f"expected prompt to contain {text!r}")
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def prompt_regex(self, pattern: Regex) -> Expectation:
|
|
253
|
+
prompt = "\n".join(call.prompt_text for call in self.trace.calls)
|
|
254
|
+
self._assert(regex_search(pattern, prompt), f"expected prompt to match {pattern!r}")
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def response_contains(self, text: str) -> Expectation:
|
|
258
|
+
response = "\n".join(call.response_text for call in self.trace.calls)
|
|
259
|
+
self._assert(text in response, f"expected response to contain {text!r}")
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def response_regex(self, pattern: Regex) -> Expectation:
|
|
263
|
+
response = "\n".join(call.response_text for call in self.trace.calls)
|
|
264
|
+
self._assert(regex_search(pattern, response), f"expected response to match {pattern!r}")
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
def response_json_valid(self) -> Expectation:
|
|
268
|
+
for text in self._response_json_candidates():
|
|
269
|
+
try:
|
|
270
|
+
json.loads(text)
|
|
271
|
+
return self
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
continue
|
|
274
|
+
self._assert(False, "expected at least one response body/content to be valid JSON")
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def response_json_schema(self, schema: Mapping[str, Any]) -> Expectation:
|
|
278
|
+
validator = Draft202012Validator(schema)
|
|
279
|
+
for text in self._response_json_candidates():
|
|
280
|
+
try:
|
|
281
|
+
payload = json.loads(text)
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
validator.validate(payload)
|
|
286
|
+
return self
|
|
287
|
+
except ValidationError:
|
|
288
|
+
continue
|
|
289
|
+
self._assert(False, "expected at least one JSON response to satisfy schema")
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def _tool_calls(self) -> Sequence[ToolCall]:
|
|
293
|
+
return [tool for call in self.trace.calls for tool in call.tool_calls]
|
|
294
|
+
|
|
295
|
+
def _response_json_candidates(self) -> Sequence[str]:
|
|
296
|
+
candidates: list[str] = []
|
|
297
|
+
for call in self.trace.calls:
|
|
298
|
+
candidates.extend(model_output_text_candidates(call))
|
|
299
|
+
return candidates
|
|
300
|
+
|
|
301
|
+
def _bounds(
|
|
302
|
+
self,
|
|
303
|
+
name: str,
|
|
304
|
+
values: Sequence[Optional[float]],
|
|
305
|
+
minimum: Optional[float],
|
|
306
|
+
maximum: Optional[float],
|
|
307
|
+
) -> Expectation:
|
|
308
|
+
present = [value for value in values if value is not None]
|
|
309
|
+
self._assert(bool(present), f"expected {name} measurements to be present")
|
|
310
|
+
for value in present:
|
|
311
|
+
if minimum is not None:
|
|
312
|
+
self._assert(value >= minimum, f"expected {name} >= {minimum}ms, got {value}ms")
|
|
313
|
+
if maximum is not None:
|
|
314
|
+
self._assert(value <= maximum, f"expected {name} <= {maximum}ms, got {value}ms")
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def _assert(self, condition: bool, message: str) -> None:
|
|
318
|
+
if not condition:
|
|
319
|
+
raise AssertionError(f"{message}\n\n{self.trace.trace_summary()}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class SoftExpectation(Expectation):
|
|
323
|
+
"""Non-fatal variant that accumulates failures until ``assert_all``."""
|
|
324
|
+
|
|
325
|
+
def __init__(self, trace: SessionTrace) -> None:
|
|
326
|
+
super().__init__(trace)
|
|
327
|
+
self.failures: list[str] = []
|
|
328
|
+
|
|
329
|
+
def assert_all(self) -> None:
|
|
330
|
+
if self.failures:
|
|
331
|
+
joined = "\n\n".join(f"- {failure}" for failure in self.failures)
|
|
332
|
+
raise AssertionError(f"{len(self.failures)} soft assertion(s) failed:\n{joined}")
|
|
333
|
+
|
|
334
|
+
def _assert(self, condition: bool, message: str) -> None:
|
|
335
|
+
if not condition:
|
|
336
|
+
self.failures.append(f"{message}\n\n{self.trace.trace_summary()}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def mapping_contains(actual: Mapping[str, Any], expected: Mapping[str, Any]) -> bool:
|
|
340
|
+
for key, expected_value in expected.items():
|
|
341
|
+
if key not in actual:
|
|
342
|
+
return False
|
|
343
|
+
actual_value = actual[key]
|
|
344
|
+
if isinstance(expected_value, Mapping):
|
|
345
|
+
if not isinstance(actual_value, Mapping):
|
|
346
|
+
return False
|
|
347
|
+
if not mapping_contains(actual_value, expected_value):
|
|
348
|
+
return False
|
|
349
|
+
elif actual_value != expected_value:
|
|
350
|
+
return False
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def regex_search(pattern: Regex, value: str) -> bool:
|
|
355
|
+
if isinstance(pattern, str):
|
|
356
|
+
return re.search(pattern, value, flags=re.DOTALL) is not None
|
|
357
|
+
return pattern.search(value) is not None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def nested_get(mapping: Mapping[str, Any], dotted_key: str) -> Any:
|
|
361
|
+
current: Any = mapping
|
|
362
|
+
for part in dotted_key.split("."):
|
|
363
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
364
|
+
return None
|
|
365
|
+
current = current[part]
|
|
366
|
+
return current
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def model_output_text_candidates(call: CapturedCall) -> list[str]:
|
|
370
|
+
response = call.response
|
|
371
|
+
candidates: list[str] = []
|
|
372
|
+
output_text = response.get("output_text")
|
|
373
|
+
if isinstance(output_text, str):
|
|
374
|
+
candidates.append(output_text)
|
|
375
|
+
|
|
376
|
+
for choice in sequence_at(response, "choices"):
|
|
377
|
+
message = as_mapping(as_mapping(choice).get("message"))
|
|
378
|
+
content = message.get("content")
|
|
379
|
+
if content is not None:
|
|
380
|
+
candidates.append(stringify_value(content))
|
|
381
|
+
|
|
382
|
+
for item in sequence_at(response, "output"):
|
|
383
|
+
item_map = as_mapping(item)
|
|
384
|
+
if item_map.get("type") == "message" or "content" in item_map:
|
|
385
|
+
candidates.append(stringify_value(item_map.get("content")))
|
|
386
|
+
|
|
387
|
+
return [candidate for candidate in candidates if candidate]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def aggregate_usage(calls: Sequence[CapturedCall]) -> dict[str, int]:
|
|
391
|
+
totals = {"total": 0, "prompt": 0, "completion": 0}
|
|
392
|
+
seen_any = {"total": False, "prompt": False, "completion": False}
|
|
393
|
+
aliases = {
|
|
394
|
+
"total": ("total_tokens", "totalTokens"),
|
|
395
|
+
"prompt": ("prompt_tokens", "promptTokens", "input_tokens", "inputTokens"),
|
|
396
|
+
"completion": ("completion_tokens", "completionTokens", "output_tokens", "outputTokens"),
|
|
397
|
+
}
|
|
398
|
+
for call in calls:
|
|
399
|
+
usage = call.usage
|
|
400
|
+
for canonical, keys in aliases.items():
|
|
401
|
+
for key in keys:
|
|
402
|
+
value = usage.get(key)
|
|
403
|
+
if isinstance(value, int):
|
|
404
|
+
totals[canonical] += value
|
|
405
|
+
seen_any[canonical] = True
|
|
406
|
+
break
|
|
407
|
+
return {key: value for key, value in totals.items() if seen_any[key]}
|