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 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
+ ]
@@ -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]}