python-yama 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.
yama/cli.py ADDED
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ from io import StringIO
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rich import box
11
+ from rich.console import Console, Group
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+ from rich.tree import Tree
15
+
16
+ from .workspace import (
17
+ ConfigurationError,
18
+ collect_case_paths,
19
+ load_workspace_config,
20
+ resolve_plugin_root,
21
+ )
22
+ from .llm import LLMError, LiteLLMClient, ScriptedLLMClient
23
+ from .loaders import CollectionError, resolve_case
24
+ from .report import write_html_report
25
+ from .runner import YamaRunner
26
+
27
+ DEFAULT_REPORT = Path("__yama_default_report__")
28
+
29
+
30
+ def _status(passed: bool, *, compact: bool = False) -> Text:
31
+ if passed:
32
+ return Text("✓" if compact else "PASS", style="bold green")
33
+ return Text("✗" if compact else "FAIL", style="bold red")
34
+
35
+
36
+ def _summary_case_key(case_key: str) -> str:
37
+ prefix = "__evals__/cases/"
38
+ return case_key.removeprefix(prefix)
39
+
40
+
41
+ def build_results_renderable(results: list[Any]) -> Group:
42
+ table = Table(
43
+ title="Yama Eval Results",
44
+ box=box.SIMPLE_HEAVY,
45
+ header_style="bold cyan",
46
+ title_style="bold",
47
+ expand=True,
48
+ )
49
+ table.add_column("Result", no_wrap=True)
50
+ table.add_column("Case", ratio=1, overflow="fold")
51
+ table.add_column("Runs", justify="right", no_wrap=True)
52
+ table.add_column("Pass rate", justify="right", no_wrap=True)
53
+
54
+ passed_runs = 0
55
+ total_runs = 0
56
+ for result in results:
57
+ case_passed_runs = sum(run.passed for run in result.runs)
58
+ passed_runs += case_passed_runs
59
+ total_runs += len(result.runs)
60
+ table.add_row(
61
+ _status(result.passed),
62
+ Text(_summary_case_key(result.case_key)),
63
+ f"{case_passed_runs}/{len(result.runs)}",
64
+ f"{result.pass_rate:.0%}",
65
+ )
66
+
67
+ passed_cases = sum(result.passed for result in results)
68
+ table.caption = (
69
+ f"{passed_cases}/{len(results)} cases passed • "
70
+ f"{passed_runs}/{total_runs} runs passed"
71
+ )
72
+ table.caption_style = "green" if passed_cases == len(results) else "red"
73
+
74
+ details = Tree(Text("Details", style="bold"), guide_style="dim")
75
+ for result in results:
76
+ case_label = Text.assemble(
77
+ _status(result.passed, compact=True),
78
+ " ",
79
+ (result.case_key, "bold"),
80
+ (f" {result.pass_rate:.0%}", "dim"),
81
+ )
82
+ case_node = details.add(case_label)
83
+ for run in result.runs:
84
+ run_label = Text.assemble(
85
+ _status(run.passed, compact=True),
86
+ " ",
87
+ (f"Run {run.run_index}", "bold"),
88
+ )
89
+ run_node = case_node.add(run_label)
90
+ if run.error:
91
+ run_node.add(Text.assemble(("Error: ", "bold red"), (run.error, "red")))
92
+ for step in run.steps:
93
+ failed = [
94
+ check
95
+ for check in step.hard_checks
96
+ if not check.get("passed", False)
97
+ ]
98
+ judge = step.judge_result
99
+ judge_passed = judge is None or bool(judge.get("passed", False))
100
+ step_passed = not failed and step.hard_pass and judge_passed
101
+ hard_passed = len(step.hard_checks) - len(failed)
102
+ step_label = Text.assemble(
103
+ _status(step_passed, compact=True),
104
+ " ",
105
+ (step.step_id, "bold"),
106
+ (
107
+ f" hard checks {hard_passed}/{len(step.hard_checks)}",
108
+ "dim",
109
+ ),
110
+ )
111
+ if judge is not None:
112
+ step_label.append(" judge ", style="dim")
113
+ step_label.append(
114
+ "passed" if judge_passed else "failed",
115
+ style="green" if judge_passed else "bold red",
116
+ )
117
+ if isinstance(judge.get("overall"), (int, float)):
118
+ step_label.append(f" {judge['overall']:.0%}", style="dim")
119
+ step_node = run_node.add(step_label)
120
+ if judge is not None and not judge_passed:
121
+ judge_node = step_node.add(Text("Judge dimensions", style="bold red"))
122
+ for dimension in judge.get("dimensions", []):
123
+ dimension_label = Text.assemble(
124
+ (str(dimension.get("name", "unnamed")), "bold"),
125
+ f": {dimension.get('score', '?')}/5",
126
+ )
127
+ if dimension.get("reason"):
128
+ dimension_label.append(
129
+ f" {dimension['reason']}", style="dim"
130
+ )
131
+ judge_node.add(dimension_label)
132
+ for index, check in enumerate(step.hard_checks, 1):
133
+ label = check.get("label") or check.get("type", "hard check")
134
+ if check.get("passed", False):
135
+ step_node.add(
136
+ Text.assemble(
137
+ _status(True, compact=True),
138
+ " ",
139
+ (f"{index:02d} {label}", "green"),
140
+ )
141
+ )
142
+ continue
143
+ expected = check.get("expected", "assertion passes")
144
+ actual = check.get("actual", check.get("detail", "unknown"))
145
+ reason = check.get("reason", check.get("detail", "assertion failed"))
146
+ check_node = step_node.add(
147
+ Text.assemble(
148
+ _status(False, compact=True),
149
+ " ",
150
+ (f"{index:02d} {label}", "bold red"),
151
+ )
152
+ )
153
+ check_node.add(
154
+ Text.assemble(("Expected ", "bold cyan"), str(expected))
155
+ )
156
+ check_node.add(
157
+ Text.assemble(("Actual ", "bold yellow"), str(actual))
158
+ )
159
+ check_node.add(
160
+ Text.assemble(("Reason ", "bold red"), str(reason))
161
+ )
162
+
163
+ return Group(table, Text(), details)
164
+
165
+
166
+ def format_text_results(results: list[Any]) -> str:
167
+ """Render deterministic, color-free output for tests and non-console callers."""
168
+ output = StringIO()
169
+ console = Console(
170
+ file=output,
171
+ color_system=None,
172
+ force_terminal=False,
173
+ width=120,
174
+ )
175
+ console.print(build_results_renderable(results))
176
+ return output.getvalue().rstrip()
177
+
178
+
179
+ def _parser() -> argparse.ArgumentParser:
180
+ parser = argparse.ArgumentParser(
181
+ prog="yama", description="Run Agent Skill eval cases"
182
+ )
183
+ parser.add_argument("paths", nargs="*", type=Path, help="Explicit YAML case paths")
184
+ parser.add_argument(
185
+ "--plugin-root", type=Path, help="Run a single plugin by root path"
186
+ )
187
+ parser.add_argument(
188
+ "--plugin", action="append", help="Plugin name from yama.toml"
189
+ )
190
+ parser.add_argument(
191
+ "--all-plugins", action="store_true", help="Run all configured plugins"
192
+ )
193
+ parser.add_argument(
194
+ "--response-script", type=Path, help="Use deterministic scripted LLM responses"
195
+ )
196
+ parser.add_argument("--result-dir", type=Path, help="Override artifact directory")
197
+ parser.add_argument(
198
+ "--report",
199
+ type=Path,
200
+ nargs="?",
201
+ const=DEFAULT_REPORT,
202
+ metavar="PATH",
203
+ help=(
204
+ "Write a standalone HTML execution report"
205
+ " (default: reports/latest.html beside the artifact runs directory)"
206
+ ),
207
+ )
208
+ parser.add_argument(
209
+ "--list", action="store_true", help="Collect and list cases without running"
210
+ )
211
+ parser.add_argument(
212
+ "--no-artifacts", action="store_true", help="Do not write run artifacts"
213
+ )
214
+ parser.add_argument("--json", action="store_true", help="Print JSON results")
215
+ return parser
216
+
217
+
218
+ async def _run(args: argparse.Namespace) -> int:
219
+ workspace = load_workspace_config(Path.cwd())
220
+ targets: list[tuple[str | None, Path, dict[str, Any]]] = []
221
+ if args.plugin_root:
222
+ root, plugin_config = resolve_plugin_root(
223
+ workspace, explicit_root=args.plugin_root
224
+ )
225
+ targets.append((None, root, plugin_config))
226
+ elif args.all_plugins:
227
+ if not workspace.plugins:
228
+ raise ConfigurationError("yama.toml has no configured plugins")
229
+ for name in workspace.plugins:
230
+ root, plugin_config = resolve_plugin_root(workspace, plugin_name=name)
231
+ targets.append((name, root, plugin_config))
232
+ elif args.plugin:
233
+ for name in args.plugin:
234
+ root, plugin_config = resolve_plugin_root(workspace, plugin_name=name)
235
+ targets.append((name, root, plugin_config))
236
+ else:
237
+ root, plugin_config = resolve_plugin_root(workspace)
238
+ targets.append((None, root, plugin_config))
239
+
240
+ collected = []
241
+ for _, plugin_root, plugin_config in targets:
242
+ paths = collect_case_paths(
243
+ plugin_root,
244
+ workspace,
245
+ plugin_config,
246
+ explicit_paths=args.paths or None,
247
+ )
248
+ for path in paths:
249
+ collected.append(resolve_case(path, plugin_root, workspace, plugin_config))
250
+ if args.list:
251
+ for case in collected:
252
+ print(case.case_key)
253
+ return 0
254
+ if not collected:
255
+ Console(stderr=True).print("[bold red]No eval cases collected.[/bold red]")
256
+ return 2
257
+
258
+ llm = (
259
+ ScriptedLLMClient.from_file(args.response_script.resolve())
260
+ if args.response_script
261
+ else LiteLLMClient()
262
+ )
263
+ all_results = []
264
+ result_roots = []
265
+ for case in collected:
266
+ result_root = (
267
+ args.result_dir.resolve()
268
+ if args.result_dir
269
+ else case.plugin_root / workspace.result_dir
270
+ )
271
+ result_roots.append(result_root)
272
+ runner = YamaRunner(
273
+ llm,
274
+ result_root=result_root,
275
+ write_artifacts=not args.no_artifacts,
276
+ )
277
+ all_results.append(await runner.run_case(case))
278
+ if isinstance(llm, ScriptedLLMClient):
279
+ llm.assert_consumed()
280
+ report_target = args.report
281
+ if report_target == DEFAULT_REPORT:
282
+ report_target = result_roots[0].parent / "reports" / "latest.html"
283
+ report_path = (
284
+ write_html_report(collected, all_results, report_target)
285
+ if report_target
286
+ else None
287
+ )
288
+ if args.json:
289
+ print(
290
+ json.dumps(
291
+ [result.to_dict() for result in all_results],
292
+ ensure_ascii=False,
293
+ indent=2,
294
+ )
295
+ )
296
+ else:
297
+ Console().print(build_results_renderable(all_results))
298
+ if report_path:
299
+ Console(stderr=args.json).print(
300
+ Text.assemble(
301
+ ("HTML report: ", "bold"),
302
+ (str(report_path), f"cyan link {report_path.as_uri()}"),
303
+ )
304
+ )
305
+ return 0 if all(result.passed for result in all_results) else 1
306
+
307
+
308
+ def main(argv: list[str] | None = None) -> int:
309
+ args = _parser().parse_args(argv)
310
+ try:
311
+ return asyncio.run(_run(args))
312
+ except (ConfigurationError, CollectionError, LLMError, ValueError) as exc:
313
+ Console(stderr=True).print(
314
+ Text.assemble(("Collection error: ", "bold red"), (str(exc), "red"))
315
+ )
316
+ return 2
317
+ except KeyboardInterrupt:
318
+ return 130
yama/llm.py ADDED
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from copy import deepcopy
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping
10
+
11
+ import yaml
12
+
13
+ from .models import LLMResponse, ToolCall, ToolSchema
14
+
15
+
16
+ class LLMError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def _looks_like_openai_model(model: str) -> bool:
21
+ return model.startswith(("gpt-", "chatgpt-", "o1", "o3", "o4"))
22
+
23
+
24
+ class LLMClient(ABC):
25
+ @abstractmethod
26
+ async def complete(
27
+ self,
28
+ *,
29
+ messages: list[dict[str, Any]],
30
+ tools: list[ToolSchema],
31
+ model: dict[str, Any],
32
+ ) -> LLMResponse:
33
+ raise NotImplementedError
34
+
35
+
36
+ def _data_url(path: Path, media_type: str) -> str:
37
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
38
+ return f"data:{media_type};base64,{encoded}"
39
+
40
+
41
+ def _file_delivery(part: dict[str, Any]) -> str:
42
+ delivery = part.get("delivery", "file_path_inline")
43
+ return {
44
+ "metadata": "file_path_inline",
45
+ "content": "file_content",
46
+ }.get(delivery, delivery)
47
+
48
+
49
+ def _litellm_content(content: Any) -> str | list[dict[str, Any]]:
50
+ if isinstance(content, str):
51
+ return content
52
+ inline_file_paths = [
53
+ part["fixture"]
54
+ for part in content
55
+ if part["type"] in {"input_image", "input_file"}
56
+ and _file_delivery(part) == "file_path_inline"
57
+ ]
58
+ inline_files_text = "\n".join(
59
+ ["Files:", *(f"- {path}" for path in inline_file_paths)]
60
+ )
61
+ has_text_part = any(part["type"] == "text" for part in content)
62
+ inline_files_added = False
63
+ parts: list[dict[str, Any]] = []
64
+ for part in content:
65
+ if part["type"] == "text":
66
+ text = part["text"]
67
+ if inline_file_paths and not inline_files_added:
68
+ text = (
69
+ f"{text.rstrip()}\n{inline_files_text}"
70
+ if text
71
+ else inline_files_text
72
+ )
73
+ inline_files_added = True
74
+ parts.append({"type": "text", "text": text})
75
+ elif part["type"] == "input_image" and _file_delivery(part) == "file_content":
76
+ parts.append(
77
+ {
78
+ "type": "image_url",
79
+ "image_url": {
80
+ "url": _data_url(
81
+ Path(part["fixture"]), part["media_type"]
82
+ )
83
+ },
84
+ }
85
+ )
86
+ elif (
87
+ part["type"] in {"input_image", "input_file"}
88
+ and _file_delivery(part) == "file_path_inline"
89
+ ):
90
+ if not has_text_part and not inline_files_added:
91
+ parts.append({"type": "text", "text": inline_files_text})
92
+ inline_files_added = True
93
+ elif part["type"] == "input_file" and _file_delivery(part) == "file_content":
94
+ encoded = base64.b64encode(Path(part["fixture"]).read_bytes()).decode(
95
+ "ascii"
96
+ )
97
+ parts.append(
98
+ {
99
+ "type": "file",
100
+ "file": {
101
+ "file_data": (
102
+ f'data:{part["media_type"]};base64,{encoded}'
103
+ ),
104
+ },
105
+ }
106
+ )
107
+ if len(parts) == 1 and parts[0]["type"] == "text":
108
+ return parts[0]["text"]
109
+ return parts
110
+
111
+
112
+ def _litellm_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
113
+ converted: list[dict[str, Any]] = []
114
+ for message in messages:
115
+ converted_message = deepcopy(message)
116
+ converted_message["content"] = _litellm_content(
117
+ converted_message.get("content", "")
118
+ )
119
+ if converted_message["role"] == "assistant" and converted_message.get(
120
+ "tool_calls"
121
+ ):
122
+ for call in converted_message["tool_calls"]:
123
+ arguments = call["function"].get("arguments", {})
124
+ if not isinstance(arguments, str):
125
+ call["function"]["arguments"] = json.dumps(
126
+ arguments, ensure_ascii=False
127
+ )
128
+ converted.append(converted_message)
129
+ return converted
130
+
131
+
132
+ def _resolve_litellm_model(
133
+ model: Mapping[str, Any], environment: Mapping[str, str]
134
+ ) -> tuple[str, str]:
135
+ raw_name = model.get("name")
136
+ if not isinstance(raw_name, str) or not raw_name:
137
+ raise LLMError("model.name is required")
138
+ explicit_provider = model.get("provider") or environment.get(
139
+ "YAMA_MODEL_PROVIDER"
140
+ )
141
+ if explicit_provider is not None and not isinstance(explicit_provider, str):
142
+ raise LLMError("model.provider must be a string")
143
+
144
+ if explicit_provider:
145
+ prefix = f"{explicit_provider}/"
146
+ resolved = raw_name if raw_name.startswith(prefix) else f"{prefix}{raw_name}"
147
+ return resolved, explicit_provider
148
+ if "/" in raw_name:
149
+ return raw_name, raw_name.split("/", 1)[0]
150
+ if (
151
+ environment.get("OPENROUTER_API_KEY") or environment.get("OR_API_KEY")
152
+ ) and not environment.get("OPENAI_API_KEY"):
153
+ routed = f"openai/{raw_name}" if _looks_like_openai_model(raw_name) else raw_name
154
+ return f"openrouter/{routed}", "openrouter"
155
+ return f"openai/{raw_name}", "openai"
156
+
157
+
158
+ def _litellm_call_arguments(
159
+ *,
160
+ messages: list[dict[str, Any]],
161
+ tools: list[ToolSchema],
162
+ model: Mapping[str, Any],
163
+ environment: Mapping[str, str],
164
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
165
+ resolved_model, provider = _resolve_litellm_model(model, environment)
166
+ options = deepcopy(dict(model))
167
+ options.pop("name", None)
168
+ options.pop("provider", None)
169
+ api_base = options.pop("api_base", None) or options.pop("base_url", None)
170
+ arguments: dict[str, Any] = {
171
+ "model": resolved_model,
172
+ "messages": _litellm_messages(messages),
173
+ }
174
+ if tools:
175
+ arguments["tools"] = [tool.for_provider() for tool in tools]
176
+ if api_base:
177
+ arguments["api_base"] = api_base
178
+ arguments.update({key: value for key, value in options.items() if value is not None})
179
+ return arguments, {"name": provider, "model": resolved_model}
180
+
181
+
182
+ def _value(item: Any, key: str, default: Any = None) -> Any:
183
+ if isinstance(item, dict):
184
+ return item.get(key, default)
185
+ return getattr(item, key, default)
186
+
187
+
188
+ class LiteLLMClient(LLMClient):
189
+ def __init__(
190
+ self,
191
+ completion_callable: Callable[..., Any] | None = None,
192
+ *,
193
+ environment: Mapping[str, str] | None = None,
194
+ ) -> None:
195
+ self.completion_callable = completion_callable
196
+ self.environment = environment if environment is not None else os.environ
197
+ self.last_provider: dict[str, Any] | None = None
198
+ self.last_request: dict[str, Any] | None = None
199
+
200
+ async def complete(
201
+ self,
202
+ *,
203
+ messages: list[dict[str, Any]],
204
+ tools: list[ToolSchema],
205
+ model: dict[str, Any],
206
+ ) -> LLMResponse:
207
+ self.last_request = None
208
+ try:
209
+ arguments, provider = _litellm_call_arguments(
210
+ messages=messages,
211
+ tools=tools,
212
+ model=model,
213
+ environment=self.environment,
214
+ )
215
+ self.last_provider = provider
216
+ self.last_request = {
217
+ "method": "litellm.acompletion",
218
+ "arguments": deepcopy(arguments),
219
+ }
220
+ if self.completion_callable is None:
221
+ from litellm import acompletion
222
+
223
+ response = await acompletion(**arguments)
224
+ else:
225
+ response = await self.completion_callable(**arguments)
226
+ except Exception as exc:
227
+ target = (
228
+ self.last_provider["model"]
229
+ if self.last_provider is not None
230
+ else "unresolved model"
231
+ )
232
+ raise LLMError(f"LiteLLM request failed for {target!r}: {exc}") from exc
233
+
234
+ choices = _value(response, "choices", [])
235
+ if not choices:
236
+ raise LLMError("LiteLLM response has no choices")
237
+ message = _value(choices[0], "message")
238
+ if message is None:
239
+ raise LLMError("LiteLLM response choice has no message")
240
+ calls: list[ToolCall] = []
241
+ for call in _value(message, "tool_calls", []) or []:
242
+ function = _value(call, "function", {})
243
+ raw_arguments = _value(function, "arguments", "{}")
244
+ try:
245
+ arguments = (
246
+ raw_arguments
247
+ if isinstance(raw_arguments, dict)
248
+ else json.loads(raw_arguments or "{}")
249
+ )
250
+ except json.JSONDecodeError as exc:
251
+ raise LLMError(
252
+ "model returned invalid JSON arguments for "
253
+ f"{_value(function, 'name')}: {raw_arguments}"
254
+ ) from exc
255
+ calls.append(
256
+ ToolCall(
257
+ str(_value(call, "id", "")),
258
+ str(_value(function, "name", "")),
259
+ arguments,
260
+ )
261
+ )
262
+ content = _value(message, "content", "") or ""
263
+ if not isinstance(content, str):
264
+ content = "\n".join(
265
+ str(_value(part, "text", ""))
266
+ for part in content
267
+ if _value(part, "text") is not None
268
+ )
269
+ raw = (
270
+ response.model_dump() if hasattr(response, "model_dump") else str(response)
271
+ )
272
+ return LLMResponse(content=content, tool_calls=calls, raw=raw)
273
+
274
+
275
+ class ScriptedLLMClient(LLMClient):
276
+ """Deterministic LLM adapter for framework tests and replay fixtures."""
277
+
278
+ def __init__(self, responses: list[dict[str, Any]]) -> None:
279
+ self.responses = deepcopy(responses)
280
+ self.index = 0
281
+ self.requests: list[dict[str, Any]] = []
282
+ self.last_request: dict[str, Any] | None = None
283
+ self.last_provider: dict[str, Any] | None = None
284
+
285
+ @classmethod
286
+ def from_file(cls, path: Path) -> "ScriptedLLMClient":
287
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
288
+ responses = raw.get("responses") if isinstance(raw, dict) else raw
289
+ if not isinstance(responses, list):
290
+ raise LLMError(f"script must contain a response list: {path}")
291
+ return cls(responses)
292
+
293
+ async def complete(
294
+ self,
295
+ *,
296
+ messages: list[dict[str, Any]],
297
+ tools: list[ToolSchema],
298
+ model: dict[str, Any],
299
+ ) -> LLMResponse:
300
+ if self.index >= len(self.responses):
301
+ raise LLMError("scripted LLM response list is exhausted")
302
+ request, provider = _litellm_call_arguments(
303
+ messages=messages,
304
+ tools=tools,
305
+ model=model,
306
+ environment=os.environ,
307
+ )
308
+ self.last_provider = provider
309
+ self.last_request = {
310
+ "method": "litellm.acompletion",
311
+ "arguments": deepcopy(request),
312
+ }
313
+ self.requests.append(request)
314
+ scripted = self.responses[self.index]
315
+ self.index += 1
316
+ if not isinstance(scripted, dict):
317
+ raise LLMError(f"scripted response {self.index} must be an object")
318
+ self._check_expectation(scripted.get("expect"), request)
319
+ calls: list[ToolCall] = []
320
+ for index, call in enumerate(scripted.get("tool_calls", []), 1):
321
+ calls.append(
322
+ ToolCall(
323
+ id=str(call.get("id", f"script-call-{self.index}-{index}")),
324
+ name=call["name"],
325
+ arguments=deepcopy(call.get("arguments", {})),
326
+ )
327
+ )
328
+ return LLMResponse(
329
+ content=str(scripted.get("content", "")),
330
+ tool_calls=calls,
331
+ raw=deepcopy(scripted),
332
+ )
333
+
334
+ def _check_expectation(self, expectation: Any, request: dict[str, Any]) -> None:
335
+ if expectation is None:
336
+ return
337
+ if not isinstance(expectation, dict):
338
+ raise LLMError("script expect must be an object")
339
+ if "tools" in expectation:
340
+ actual = [tool["function"]["name"] for tool in request["tools"]]
341
+ if actual != expectation["tools"]:
342
+ raise LLMError(
343
+ f"script expected tools {expectation['tools']}, got {actual}"
344
+ )
345
+ if "last_role" in expectation:
346
+ actual_role = request["messages"][-1]["role"]
347
+ if actual_role != expectation["last_role"]:
348
+ raise LLMError(
349
+ f"script expected last role {expectation['last_role']!r}, got {actual_role!r}"
350
+ )
351
+ if "last_user_contains" in expectation:
352
+ user_messages = [
353
+ item for item in request["messages"] if item["role"] == "user"
354
+ ]
355
+ text = json.dumps(user_messages[-1]["content"], ensure_ascii=False)
356
+ if expectation["last_user_contains"] not in text:
357
+ raise LLMError(
358
+ f"last user message does not contain {expectation['last_user_contains']!r}"
359
+ )
360
+
361
+ def assert_consumed(self) -> None:
362
+ if self.index != len(self.responses):
363
+ raise LLMError(
364
+ f"scripted LLM has {len(self.responses) - self.index} unconsumed responses"
365
+ )
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .case import resolve_case
4
+ from .common import CollectionError, load_yaml
5
+
6
+ __all__ = ["CollectionError", "load_yaml", "resolve_case"]