iris-eval 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.
iris_eval/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """iris-eval — the Python client for Iris, the open-source agent-evaluation MCP server.
2
+
3
+ from iris_eval import IrisClient
4
+
5
+ iris = IrisClient() # finds the server: IRIS_URL, or the runtime.json a running server wrote
6
+ logged = iris.log_trace("support-bot", input="Was the refund approved?", output="Yes, it posts within five days.")
7
+ evaluation = iris.evaluate_output("The refund was approved.", input="Was the refund approved?", agent_name="support-bot")
8
+ evaluation["verdict"]["state"] # "pass" | "fail" | "unknown"
9
+
10
+ A thin client over the HTTP API (docs/sdk-spec.md rules it so): the rules, the
11
+ composer and the storage live in the server; this package speaks to them. The
12
+ pytest plugin (``iris`` fixture, ``assert_iris``) rides on it.
13
+ """
14
+
15
+ from .client import (
16
+ DEFAULT_TIMEOUT,
17
+ AsyncIrisClient,
18
+ IrisClient,
19
+ IrisConnectionError,
20
+ IrisError,
21
+ )
22
+ from .discovery import ServerLocation, find_server
23
+ from .types import (
24
+ Capabilities,
25
+ Evaluation,
26
+ Health,
27
+ LoggedTrace,
28
+ RuleResult,
29
+ TokenUsage,
30
+ ToolCall,
31
+ Trace,
32
+ TraceDetail,
33
+ TracePage,
34
+ Verdict,
35
+ VerdictState,
36
+ )
37
+
38
+ __version__ = "0.1.0"
39
+
40
+ __all__ = [
41
+ "AsyncIrisClient",
42
+ "Capabilities",
43
+ "DEFAULT_TIMEOUT",
44
+ "Evaluation",
45
+ "Health",
46
+ "IrisClient",
47
+ "IrisConnectionError",
48
+ "IrisError",
49
+ "LoggedTrace",
50
+ "RuleResult",
51
+ "ServerLocation",
52
+ "TokenUsage",
53
+ "ToolCall",
54
+ "Trace",
55
+ "TraceDetail",
56
+ "TracePage",
57
+ "Verdict",
58
+ "VerdictState",
59
+ "__version__",
60
+ "find_server",
61
+ ]
iris_eval/client.py ADDED
@@ -0,0 +1,506 @@
1
+ """The client: one method per door, the server's own sentence on every refusal.
2
+
3
+ ``IrisClient`` and ``AsyncIrisClient`` have the same methods; the async one
4
+ awaits. Both are context managers and close their connection pool. A
5
+ non-2xx answer raises ``IrisError`` carrying the server's ``error`` sentence,
6
+ the status and, when the server sent them, the validation ``details``; a
7
+ server that cannot be reached raises ``IrisConnectionError`` naming the URL
8
+ and how to start one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Mapping
14
+
15
+ import httpx
16
+
17
+ from .discovery import find_server
18
+ from .types import Capabilities, Evaluation, Health, LoggedTrace, TraceDetail, TracePage
19
+
20
+ DEFAULT_TIMEOUT = 10.0
21
+ API = "/api/v1"
22
+ NO_SERVER = (
23
+ "No Iris server: set IRIS_URL (for example http://127.0.0.1:6920), or start one where this runs — "
24
+ "`npx -y @iris-eval/mcp-server --dashboard` — and the client finds its port in runtime.json."
25
+ )
26
+
27
+
28
+ class IrisError(Exception):
29
+ """The server refused: its own sentence, the HTTP status, and the validation details when it sent them."""
30
+
31
+ def __init__(self, message: str, *, status: int, details: Any = None, method: str = "", path: str = ""):
32
+ super().__init__(message)
33
+ self.message = message
34
+ self.status = status
35
+ self.details = details
36
+ self.method = method
37
+ self.path = path
38
+
39
+ def __str__(self) -> str: # pragma: no cover - trivial
40
+ where = f" ({self.method} {self.path} → {self.status})" if self.method else f" ({self.status})"
41
+ return f"{self.message}{where}"
42
+
43
+
44
+ class IrisConnectionError(IrisError):
45
+ """No server answered at the URL."""
46
+
47
+ def __init__(self, base_url: str, cause: Exception):
48
+ super().__init__(f"Could not reach the Iris server at {base_url}: {cause}. {NO_SERVER}", status=0)
49
+ self.base_url = base_url
50
+ self.cause = cause
51
+
52
+
53
+ def _error_from(res: httpx.Response) -> IrisError:
54
+ message = f"HTTP {res.status_code}"
55
+ details = None
56
+ try:
57
+ body = res.json()
58
+ if isinstance(body, dict):
59
+ if isinstance(body.get("error"), str):
60
+ message = body["error"]
61
+ elif isinstance(body.get("message"), str):
62
+ message = body["message"]
63
+ details = body.get("details")
64
+ except ValueError:
65
+ text = res.text.strip()
66
+ if text:
67
+ message = text[:300]
68
+ return IrisError(message, status=res.status_code, details=details, method=res.request.method, path=res.request.url.path)
69
+
70
+
71
+ def _trace_body(
72
+ agent_name: str,
73
+ *,
74
+ input: str | None,
75
+ output: str | None,
76
+ framework: str | None,
77
+ tool_calls: list[Mapping[str, Any]] | None,
78
+ latency_ms: float | None,
79
+ token_usage: Mapping[str, Any] | None,
80
+ cost_usd: float | None,
81
+ metadata: Mapping[str, Any] | None,
82
+ tools: list[Mapping[str, Any]] | None,
83
+ run: str | None,
84
+ case_key: str | None,
85
+ session_id: str | None,
86
+ spans: list[Mapping[str, Any]] | None,
87
+ timestamp: str | None,
88
+ evaluate: bool,
89
+ eval_type: str | None,
90
+ ) -> dict[str, Any]:
91
+ body: dict[str, Any] = {"agent_name": agent_name}
92
+ for key, value in (
93
+ ("input", input),
94
+ ("output", output),
95
+ ("framework", framework),
96
+ ("tool_calls", tool_calls),
97
+ ("latency_ms", latency_ms),
98
+ ("token_usage", token_usage),
99
+ ("cost_usd", cost_usd),
100
+ ("metadata", metadata),
101
+ ("tools", tools),
102
+ ("run", run),
103
+ ("case_key", case_key),
104
+ ("session_id", session_id),
105
+ ("spans", spans),
106
+ ("timestamp", timestamp),
107
+ ("eval_type", eval_type),
108
+ ):
109
+ if value is not None:
110
+ body[key] = value
111
+ if evaluate:
112
+ body["evaluate"] = True
113
+ return body
114
+
115
+
116
+ def _query(
117
+ *,
118
+ agent_name: str | None,
119
+ framework: str | None,
120
+ session: str | None,
121
+ since: str | None,
122
+ until: str | None,
123
+ min_score: float | None,
124
+ max_score: float | None,
125
+ limit: int | None,
126
+ offset: int | None,
127
+ sort_by: str | None,
128
+ sort_order: str | None,
129
+ ) -> dict[str, Any]:
130
+ params: dict[str, Any] = {}
131
+ for key, value in (
132
+ ("agent_name", agent_name),
133
+ ("framework", framework),
134
+ ("session", session),
135
+ ("since", since),
136
+ ("until", until),
137
+ ("min_score", min_score),
138
+ ("max_score", max_score),
139
+ ("limit", limit),
140
+ ("offset", offset),
141
+ ("sort_by", sort_by),
142
+ ("sort_order", sort_order),
143
+ ):
144
+ if value is not None:
145
+ params[key] = value
146
+ return params
147
+
148
+
149
+ class _Base:
150
+ def __init__(self, base_url: str | None, api_key: str | None, timeout: float, user_agent: str):
151
+ located = base_url.rstrip("/") if base_url else None
152
+ if located is None:
153
+ found = find_server()
154
+ if found is None:
155
+ raise IrisConnectionError("(no URL)", RuntimeError("nothing names a server"))
156
+ located = found.base_url
157
+ self.base_url = located
158
+ headers = {"accept": "application/json", "user-agent": user_agent}
159
+ if api_key:
160
+ headers["authorization"] = f"Bearer {api_key}"
161
+ self._headers = headers
162
+ self._timeout = timeout
163
+
164
+
165
+ class IrisClient(_Base):
166
+ """The synchronous client.
167
+
168
+ ``base_url``: ``http://host:port`` of the dashboard; omitted, the server is
169
+ found from ``IRIS_URL`` or ``runtime.json``. ``api_key``: sent as
170
+ ``Authorization: Bearer`` — required by a server bound beyond loopback.
171
+ ``transport``: an ``httpx`` transport, for tests.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ base_url: str | None = None,
177
+ *,
178
+ api_key: str | None = None,
179
+ timeout: float = DEFAULT_TIMEOUT,
180
+ transport: httpx.BaseTransport | None = None,
181
+ ):
182
+ from . import __version__
183
+
184
+ super().__init__(base_url, api_key, timeout, f"iris-eval-python/{__version__}")
185
+ self._http = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=timeout, transport=transport)
186
+
187
+ def __enter__(self) -> "IrisClient":
188
+ return self
189
+
190
+ def __exit__(self, *exc: object) -> None:
191
+ self.close()
192
+
193
+ def close(self) -> None:
194
+ self._http.close()
195
+
196
+ def _request(self, method: str, path: str, *, json: Any = None, params: Mapping[str, Any] | None = None) -> Any:
197
+ try:
198
+ res = self._http.request(method, API + path, json=json, params=params)
199
+ except httpx.HTTPError as err:
200
+ raise IrisConnectionError(self.base_url, err) from err
201
+ if res.status_code >= 400:
202
+ raise _error_from(res)
203
+ return res.json()
204
+
205
+ def log_trace(
206
+ self,
207
+ agent_name: str,
208
+ *,
209
+ input: str | None = None,
210
+ output: str | None = None,
211
+ framework: str | None = None,
212
+ tool_calls: list[Mapping[str, Any]] | None = None,
213
+ latency_ms: float | None = None,
214
+ token_usage: Mapping[str, Any] | None = None,
215
+ cost_usd: float | None = None,
216
+ metadata: Mapping[str, Any] | None = None,
217
+ tools: list[Mapping[str, Any]] | None = None,
218
+ run: str | None = None,
219
+ case_key: str | None = None,
220
+ session_id: str | None = None,
221
+ spans: list[Mapping[str, Any]] | None = None,
222
+ timestamp: str | None = None,
223
+ evaluate: bool = False,
224
+ eval_type: str | None = None,
225
+ ) -> LoggedTrace:
226
+ """Store a trace (``POST /api/v1/traces``); with ``evaluate=True`` the answer carries its evaluation too."""
227
+ body = _trace_body(
228
+ agent_name,
229
+ input=input,
230
+ output=output,
231
+ framework=framework,
232
+ tool_calls=tool_calls,
233
+ latency_ms=latency_ms,
234
+ token_usage=token_usage,
235
+ cost_usd=cost_usd,
236
+ metadata=metadata,
237
+ tools=tools,
238
+ run=run,
239
+ case_key=case_key,
240
+ session_id=session_id,
241
+ spans=spans,
242
+ timestamp=timestamp,
243
+ evaluate=evaluate,
244
+ eval_type=eval_type,
245
+ )
246
+ return self._request("POST", "/traces", json=body)
247
+
248
+ def evaluate_output(
249
+ self,
250
+ output: str,
251
+ *,
252
+ input: str | None = None,
253
+ agent_name: str = "python",
254
+ eval_type: str | None = None,
255
+ tool_calls: list[Mapping[str, Any]] | None = None,
256
+ tools: list[Mapping[str, Any]] | None = None,
257
+ cost_usd: float | None = None,
258
+ token_usage: Mapping[str, Any] | None = None,
259
+ run: str | None = None,
260
+ case_key: str | None = None,
261
+ session_id: str | None = None,
262
+ metadata: Mapping[str, Any] | None = None,
263
+ ) -> Evaluation:
264
+ """Evaluate an output under the server's rules and get the verdict.
265
+
266
+ Over HTTP the evaluate door is the ingest door: the output is stored
267
+ as a trace of ``agent_name`` (so the dashboard shows it, and a run or
268
+ case key makes it comparable) and evaluated in the same call — the
269
+ same object the ``evaluate_output`` MCP tool returns. ``eval_type``
270
+ names one bundle; omitted, every bundle runs.
271
+ """
272
+ logged = self.log_trace(
273
+ agent_name,
274
+ input=input,
275
+ output=output,
276
+ tool_calls=tool_calls,
277
+ tools=tools,
278
+ cost_usd=cost_usd,
279
+ token_usage=token_usage,
280
+ run=run,
281
+ case_key=case_key,
282
+ session_id=session_id,
283
+ metadata=metadata,
284
+ evaluate=True,
285
+ eval_type=eval_type,
286
+ )
287
+ evaluation = logged.get("evaluation")
288
+ if not isinstance(evaluation, dict):
289
+ raise IrisError("The server stored the trace but answered with no evaluation", status=500, method="POST", path=API + "/traces")
290
+ return evaluation
291
+
292
+ def get_traces(
293
+ self,
294
+ *,
295
+ agent_name: str | None = None,
296
+ framework: str | None = None,
297
+ session: str | None = None,
298
+ since: str | None = None,
299
+ until: str | None = None,
300
+ min_score: float | None = None,
301
+ max_score: float | None = None,
302
+ limit: int | None = None,
303
+ offset: int | None = None,
304
+ sort_by: str | None = None,
305
+ sort_order: str | None = None,
306
+ **extra: Any,
307
+ ) -> TracePage:
308
+ """A page of traces (``GET /api/v1/traces``) — every filter the route reads.
309
+
310
+ Any other keyword is sent as a query parameter as it is, so a filter the
311
+ server gains later needs no new client; one the server does not read is
312
+ a 400 naming it (``IrisError``), never silently ignored.
313
+ """
314
+ params = _query(
315
+ agent_name=agent_name,
316
+ framework=framework,
317
+ session=session,
318
+ since=since,
319
+ until=until,
320
+ min_score=min_score,
321
+ max_score=max_score,
322
+ limit=limit,
323
+ offset=offset,
324
+ sort_by=sort_by,
325
+ sort_order=sort_order,
326
+ )
327
+ params.update({k: v for k, v in extra.items() if v is not None})
328
+ return self._request("GET", "/traces", params=params)
329
+
330
+ def get_trace(self, trace_id: str) -> TraceDetail:
331
+ """One trace with its spans and evaluations (``GET /api/v1/traces/:id``); a 404 raises ``IrisError``."""
332
+ return self._request("GET", f"/traces/{trace_id}")
333
+
334
+ def health(self) -> Health:
335
+ """``GET /api/v1/health`` — open, unkeyed; ``status`` is ``ok`` or ``degraded`` (a 503 is still an answer)."""
336
+ try:
337
+ res = self._http.get(API + "/health")
338
+ except httpx.HTTPError as err:
339
+ raise IrisConnectionError(self.base_url, err) from err
340
+ if res.status_code not in (200, 503):
341
+ raise _error_from(res)
342
+ return res.json()
343
+
344
+ def capabilities(self) -> Capabilities:
345
+ """``GET /api/v1/capabilities`` — what this server can do."""
346
+ return self._request("GET", "/capabilities")
347
+
348
+
349
+ class AsyncIrisClient(_Base):
350
+ """The asynchronous client — the same methods, awaited."""
351
+
352
+ def __init__(
353
+ self,
354
+ base_url: str | None = None,
355
+ *,
356
+ api_key: str | None = None,
357
+ timeout: float = DEFAULT_TIMEOUT,
358
+ transport: httpx.AsyncBaseTransport | None = None,
359
+ ):
360
+ from . import __version__
361
+
362
+ super().__init__(base_url, api_key, timeout, f"iris-eval-python/{__version__}")
363
+ self._http = httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=timeout, transport=transport)
364
+
365
+ async def __aenter__(self) -> "AsyncIrisClient":
366
+ return self
367
+
368
+ async def __aexit__(self, *exc: object) -> None:
369
+ await self.aclose()
370
+
371
+ async def aclose(self) -> None:
372
+ await self._http.aclose()
373
+
374
+ async def _request(self, method: str, path: str, *, json: Any = None, params: Mapping[str, Any] | None = None) -> Any:
375
+ try:
376
+ res = await self._http.request(method, API + path, json=json, params=params)
377
+ except httpx.HTTPError as err:
378
+ raise IrisConnectionError(self.base_url, err) from err
379
+ if res.status_code >= 400:
380
+ raise _error_from(res)
381
+ return res.json()
382
+
383
+ async def log_trace(
384
+ self,
385
+ agent_name: str,
386
+ *,
387
+ input: str | None = None,
388
+ output: str | None = None,
389
+ framework: str | None = None,
390
+ tool_calls: list[Mapping[str, Any]] | None = None,
391
+ latency_ms: float | None = None,
392
+ token_usage: Mapping[str, Any] | None = None,
393
+ cost_usd: float | None = None,
394
+ metadata: Mapping[str, Any] | None = None,
395
+ tools: list[Mapping[str, Any]] | None = None,
396
+ run: str | None = None,
397
+ case_key: str | None = None,
398
+ session_id: str | None = None,
399
+ spans: list[Mapping[str, Any]] | None = None,
400
+ timestamp: str | None = None,
401
+ evaluate: bool = False,
402
+ eval_type: str | None = None,
403
+ ) -> LoggedTrace:
404
+ body = _trace_body(
405
+ agent_name,
406
+ input=input,
407
+ output=output,
408
+ framework=framework,
409
+ tool_calls=tool_calls,
410
+ latency_ms=latency_ms,
411
+ token_usage=token_usage,
412
+ cost_usd=cost_usd,
413
+ metadata=metadata,
414
+ tools=tools,
415
+ run=run,
416
+ case_key=case_key,
417
+ session_id=session_id,
418
+ spans=spans,
419
+ timestamp=timestamp,
420
+ evaluate=evaluate,
421
+ eval_type=eval_type,
422
+ )
423
+ return await self._request("POST", "/traces", json=body)
424
+
425
+ async def evaluate_output(
426
+ self,
427
+ output: str,
428
+ *,
429
+ input: str | None = None,
430
+ agent_name: str = "python",
431
+ eval_type: str | None = None,
432
+ tool_calls: list[Mapping[str, Any]] | None = None,
433
+ tools: list[Mapping[str, Any]] | None = None,
434
+ cost_usd: float | None = None,
435
+ token_usage: Mapping[str, Any] | None = None,
436
+ run: str | None = None,
437
+ case_key: str | None = None,
438
+ session_id: str | None = None,
439
+ metadata: Mapping[str, Any] | None = None,
440
+ ) -> Evaluation:
441
+ logged = await self.log_trace(
442
+ agent_name,
443
+ input=input,
444
+ output=output,
445
+ tool_calls=tool_calls,
446
+ tools=tools,
447
+ cost_usd=cost_usd,
448
+ token_usage=token_usage,
449
+ run=run,
450
+ case_key=case_key,
451
+ session_id=session_id,
452
+ metadata=metadata,
453
+ evaluate=True,
454
+ eval_type=eval_type,
455
+ )
456
+ evaluation = logged.get("evaluation")
457
+ if not isinstance(evaluation, dict):
458
+ raise IrisError("The server stored the trace but answered with no evaluation", status=500, method="POST", path=API + "/traces")
459
+ return evaluation
460
+
461
+ async def get_traces(
462
+ self,
463
+ *,
464
+ agent_name: str | None = None,
465
+ framework: str | None = None,
466
+ session: str | None = None,
467
+ since: str | None = None,
468
+ until: str | None = None,
469
+ min_score: float | None = None,
470
+ max_score: float | None = None,
471
+ limit: int | None = None,
472
+ offset: int | None = None,
473
+ sort_by: str | None = None,
474
+ sort_order: str | None = None,
475
+ **extra: Any,
476
+ ) -> TracePage:
477
+ params = _query(
478
+ agent_name=agent_name,
479
+ framework=framework,
480
+ session=session,
481
+ since=since,
482
+ until=until,
483
+ min_score=min_score,
484
+ max_score=max_score,
485
+ limit=limit,
486
+ offset=offset,
487
+ sort_by=sort_by,
488
+ sort_order=sort_order,
489
+ )
490
+ params.update({k: v for k, v in extra.items() if v is not None})
491
+ return await self._request("GET", "/traces", params=params)
492
+
493
+ async def get_trace(self, trace_id: str) -> TraceDetail:
494
+ return await self._request("GET", f"/traces/{trace_id}")
495
+
496
+ async def health(self) -> Health:
497
+ try:
498
+ res = await self._http.get(API + "/health")
499
+ except httpx.HTTPError as err:
500
+ raise IrisConnectionError(self.base_url, err) from err
501
+ if res.status_code not in (200, 503):
502
+ raise _error_from(res)
503
+ return res.json()
504
+
505
+ async def capabilities(self) -> Capabilities:
506
+ return await self._request("GET", "/capabilities")
iris_eval/discovery.py ADDED
@@ -0,0 +1,78 @@
1
+ """Where the server is.
2
+
3
+ In order: ``IRIS_URL`` (the whole base, ``http://host:port``); the
4
+ ``runtime.json`` a running dashboard writes under ``IRIS_HOME`` (or
5
+ ``~/.iris``) with the port it actually bound; nothing. The file may be
6
+ stale after an unclean exit, so a location read from it is verified with
7
+ ``GET /api/v1/health`` before it is trusted — the contract the server's own
8
+ comment states.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+
20
+ DEFAULT_HEALTH_TIMEOUT = 2.0
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServerLocation:
25
+ """A base URL and where it came from: ``env``, ``runtime.json`` or ``default``."""
26
+
27
+ base_url: str
28
+ source: str
29
+
30
+
31
+ def iris_home() -> Path:
32
+ """``IRIS_HOME``, or ``~/.iris`` — the same rule the server applies."""
33
+ home = os.environ.get("IRIS_HOME")
34
+ return Path(home).expanduser() if home else Path.home() / ".iris"
35
+
36
+
37
+ def runtime_file() -> Path:
38
+ return iris_home() / "runtime.json"
39
+
40
+
41
+ def read_runtime_port(path: Path | None = None) -> int | None:
42
+ """The dashboard port a running server recorded, or None."""
43
+ p = path or runtime_file()
44
+ try:
45
+ data = json.loads(p.read_text(encoding="utf-8"))
46
+ except (OSError, ValueError):
47
+ return None
48
+ port = data.get("dashboardPort") if isinstance(data, dict) else None
49
+ return port if isinstance(port, int) and 0 < port < 65536 else None
50
+
51
+
52
+ def is_healthy(base_url: str, *, timeout: float = DEFAULT_HEALTH_TIMEOUT, transport: httpx.BaseTransport | None = None) -> bool:
53
+ """``GET /api/v1/health`` answers — 200 (ok) or 503 (degraded) both mean a server is there; a connection error means none."""
54
+ try:
55
+ with httpx.Client(base_url=base_url, timeout=timeout, transport=transport) as client:
56
+ res = client.get("/api/v1/health")
57
+ return res.status_code in (200, 503)
58
+ except httpx.HTTPError:
59
+ return False
60
+
61
+
62
+ def find_server(*, verify: bool = True, transport: httpx.BaseTransport | None = None) -> ServerLocation | None:
63
+ """The server's base URL, or None when nothing names one.
64
+
65
+ ``IRIS_URL`` wins as written (a URL the operator set is not second-guessed).
66
+ A port read from ``runtime.json`` is only returned when the health route
67
+ answers on it, unless ``verify`` is False.
68
+ """
69
+ env = os.environ.get("IRIS_URL")
70
+ if env:
71
+ return ServerLocation(env.rstrip("/"), "env")
72
+ port = read_runtime_port()
73
+ if port is None:
74
+ return None
75
+ base = f"http://127.0.0.1:{port}"
76
+ if verify and not is_healthy(base, transport=transport):
77
+ return None
78
+ return ServerLocation(base, "runtime.json")
iris_eval/py.typed ADDED
File without changes
@@ -0,0 +1,106 @@
1
+ """The pytest plugin: an ``iris`` fixture and ``assert_iris``.
2
+
3
+ Registered by the ``pytest11`` entry point when ``iris-eval`` is installed,
4
+ so a test file needs no ``conftest``::
5
+
6
+ from iris_eval.pytest_plugin import assert_iris
7
+
8
+ def test_refund_answer(iris):
9
+ evaluation = assert_iris("The refund was approved and posts within five days.",
10
+ input="Was the refund approved?", agent_name="support-bot", client=iris)
11
+ assert evaluation["verdict"]["basis"] == "clean"
12
+
13
+ The fixture finds the server the way the client does (``IRIS_URL``, then
14
+ ``runtime.json``). With none, the test is **skipped** with a sentence that
15
+ says how to start one — unless ``IRIS_REQUIRE=1``, when it fails instead
16
+ (the setting for a CI job that must not pass green because no server ran).
17
+ ``assert_iris`` evaluates the output and asserts on ``verdict.state``, the
18
+ composed verdict — never on the score alone.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from typing import Any, Mapping
25
+
26
+ import pytest
27
+
28
+ from .client import IrisClient, IrisConnectionError, IrisError
29
+ from .discovery import find_server
30
+ from .types import Evaluation, VerdictState
31
+
32
+ SKIP_REASON = (
33
+ "no Iris server: set IRIS_URL, or start one — `npx -y @iris-eval/mcp-server --dashboard` — "
34
+ "and the client reads its port from runtime.json; set IRIS_REQUIRE=1 to fail instead of skipping"
35
+ )
36
+
37
+
38
+ def pytest_addoption(parser: pytest.Parser) -> None:
39
+ group = parser.getgroup("iris", "Iris agent evaluation")
40
+ group.addoption("--iris-url", action="store", default=None, help="The Iris server's base URL (overrides IRIS_URL and runtime.json).")
41
+ group.addoption("--iris-api-key", action="store", default=None, help="The API key (overrides IRIS_API_KEY).")
42
+
43
+
44
+ @pytest.fixture(scope="session")
45
+ def iris(request: pytest.FixtureRequest) -> IrisClient:
46
+ """A client for the Iris server this session can reach; skipped (or failed with IRIS_REQUIRE=1) when there is none."""
47
+ url = request.config.getoption("--iris-url") or None
48
+ key = request.config.getoption("--iris-api-key") or os.environ.get("IRIS_API_KEY") or None
49
+ required = os.environ.get("IRIS_REQUIRE", "").strip() in ("1", "true", "yes")
50
+ if url is None and find_server() is None:
51
+ if required:
52
+ pytest.fail(SKIP_REASON, pytrace=False)
53
+ pytest.skip(SKIP_REASON)
54
+ try:
55
+ client = IrisClient(url, api_key=key)
56
+ client.health()
57
+ except (IrisConnectionError, IrisError) as err:
58
+ if required:
59
+ pytest.fail(f"the Iris server did not answer: {err}", pytrace=False)
60
+ pytest.skip(f"the Iris server did not answer: {err}")
61
+ request.addfinalizer(client.close)
62
+ return client
63
+
64
+
65
+ def assert_iris(
66
+ output: str,
67
+ *,
68
+ input: str | None = None,
69
+ agent_name: str = "pytest",
70
+ eval_type: str | None = None,
71
+ expect: VerdictState = "pass",
72
+ client: IrisClient | None = None,
73
+ **trace: Any,
74
+ ) -> Evaluation:
75
+ """Evaluate ``output`` and assert the verdict's state is ``expect`` (``pass`` by default).
76
+
77
+ The message names the basis and the rules that decided, so a failing
78
+ test reads like the dashboard: ``verdict fail on detector_veto by no_pii``.
79
+ Extra keyword arguments (``tool_calls``, ``tools``, ``cost_usd``,
80
+ ``token_usage``, ``run``, ``case_key``, ``session_id``, ``metadata``) go on
81
+ the trace. Returns the evaluation for further assertions.
82
+ """
83
+ own = client is None
84
+ c = client or IrisClient()
85
+ try:
86
+ evaluation = c.evaluate_output(output, input=input, agent_name=agent_name, eval_type=eval_type, **_trace_fields(trace))
87
+ finally:
88
+ if own:
89
+ c.close()
90
+ verdict = evaluation.get("verdict") or {}
91
+ state = verdict.get("state")
92
+ if state != expect:
93
+ by = ", ".join(verdict.get("by") or []) or "—"
94
+ raise AssertionError(
95
+ f"Iris verdict {state} on {verdict.get('basis')} by {by} (expected {expect}); "
96
+ f"score {evaluation.get('score')}; evaluation {evaluation.get('id')} on trace {evaluation.get('trace_id')}"
97
+ )
98
+ return evaluation
99
+
100
+
101
+ def _trace_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
102
+ allowed = {"tool_calls", "tools", "cost_usd", "token_usage", "run", "case_key", "session_id", "metadata"}
103
+ unknown = sorted(set(fields) - allowed)
104
+ if unknown:
105
+ raise TypeError(f"assert_iris got unexpected keyword(s): {', '.join(unknown)} — the trace fields are {', '.join(sorted(allowed))}")
106
+ return dict(fields)
iris_eval/types.py ADDED
@@ -0,0 +1,120 @@
1
+ """The shapes the server answers with, as typed dictionaries.
2
+
3
+ They mirror the HTTP API (docs/api-reference.md) key for key, so a reader
4
+ of the API reference reads these, and a key the server adds tomorrow is
5
+ carried through untouched (``total=False`` everywhere: the server owns the
6
+ contract, the client only names what it knows).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, TypedDict
12
+
13
+ VerdictState = Literal["pass", "fail", "unknown"]
14
+
15
+
16
+ class Verdict(TypedDict, total=False):
17
+ """Which layer decided, and what it decided on."""
18
+
19
+ state: VerdictState
20
+ passed: bool
21
+ basis: str
22
+ by: list[str]
23
+ risk: dict[str, Any] | None
24
+ confidence: str
25
+
26
+
27
+ class RuleResult(TypedDict, total=False):
28
+ ruleName: str
29
+ passed: bool
30
+ score: float
31
+ message: str
32
+ skipped: bool
33
+ skipReason: str
34
+ evidence: list[dict[str, Any]]
35
+ value: dict[str, Any]
36
+
37
+
38
+ class Evaluation(TypedDict, total=False):
39
+ """One evaluation, as ``evaluate_output`` and ``POST /api/v1/traces`` answer it."""
40
+
41
+ id: str
42
+ trace_id: str
43
+ eval_type: str
44
+ score: float
45
+ passed: bool
46
+ verdict: Verdict
47
+ rule_results: list[RuleResult]
48
+ critical_failures: list[str]
49
+ critical_skipped: list[str]
50
+ interpretations: list[dict[str, Any]]
51
+ coverage: dict[str, Any]
52
+ provenance: dict[str, Any]
53
+ note: str
54
+
55
+
56
+ class TokenUsage(TypedDict, total=False):
57
+ input_tokens: int
58
+ output_tokens: int
59
+ total_tokens: int
60
+
61
+
62
+ class ToolCall(TypedDict, total=False):
63
+ name: str
64
+ arguments: dict[str, Any]
65
+ result: Any
66
+ duration_ms: float
67
+ error: str
68
+
69
+
70
+ class Trace(TypedDict, total=False):
71
+ trace_id: str
72
+ agent_name: str
73
+ framework: str
74
+ input: str
75
+ output: str
76
+ tool_calls: list[ToolCall]
77
+ latency_ms: float
78
+ token_usage: TokenUsage
79
+ cost_usd: float
80
+ metadata: dict[str, Any]
81
+ timestamp: str
82
+ tools: list[dict[str, Any]]
83
+ run_id: str
84
+ case_key: str
85
+ session_id: str
86
+ source: str
87
+
88
+
89
+ class LoggedTrace(TypedDict, total=False):
90
+ """What ``POST /api/v1/traces`` answers: the id the server minted, and the evaluation when one was asked for."""
91
+
92
+ trace_id: str
93
+ status: str
94
+ evaluation: Evaluation
95
+
96
+
97
+ class TracePage(TypedDict, total=False):
98
+ traces: list[Trace]
99
+ total: int
100
+ limit: int
101
+ offset: int
102
+
103
+
104
+ class TraceDetail(TypedDict, total=False):
105
+ trace: Trace
106
+ spans: list[dict[str, Any]]
107
+ evals: list[Evaluation]
108
+
109
+
110
+ class Health(TypedDict, total=False):
111
+ status: str
112
+ version: str
113
+ uptime_seconds: float
114
+ driver: str
115
+ checks: dict[str, Any]
116
+ judge: dict[str, Any]
117
+
118
+
119
+ Capabilities = dict[str, Any]
120
+ """``GET /api/v1/capabilities``: the version, the rules with their proof, the judge state, the limits — see the API reference."""
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: iris-eval
3
+ Version: 0.1.0
4
+ Summary: The Python client for Iris, the open-source agent-evaluation MCP server: log a trace, get its verdict, gate a test on it.
5
+ Project-URL: Homepage, https://iris-eval.com
6
+ Project-URL: Documentation, https://github.com/iris-eval/mcp-server/blob/main/packages/python/README.md
7
+ Project-URL: Source, https://github.com/iris-eval/mcp-server/tree/main/packages/python
8
+ Project-URL: Changelog, https://github.com/iris-eval/mcp-server/blob/main/CHANGELOG.md
9
+ Author: Iris
10
+ License-Expression: MIT
11
+ Keywords: agents,evaluation,iris,llm,mcp,pytest
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx<1,>=0.27
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
27
+ Requires-Dist: pytest>=8; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # iris-eval — the Python client
31
+
32
+ [Iris](https://iris-eval.dev) is an open-source agent-evaluation MCP server: it stores your agent's traces and judges each one under 25 built-in rules — PII, injection, hallucination markers, tool loops, cost outliers, a regression watcher — with a verdict that says which layer decided and why. This package is the Python door to a running server: log a trace, get its verdict, gate a test on it.
33
+
34
+ ```bash
35
+ pip install iris-eval
36
+ npx -y @iris-eval/mcp-server --dashboard # the server, in another terminal (Node 22.13+)
37
+ ```
38
+
39
+ ```python
40
+ from iris_eval import IrisClient
41
+
42
+ iris = IrisClient() # finds the server: IRIS_URL, or the runtime.json a running dashboard wrote
43
+
44
+ evaluation = iris.evaluate_output(
45
+ "The refund was approved and posts within five business days.",
46
+ input="Was the refund approved?",
47
+ agent_name="support-bot",
48
+ )
49
+ evaluation["verdict"] # {"state": "pass", "basis": "clean", "by": []}
50
+
51
+ logged = iris.log_trace("support-bot", input="…", output="…", run="nightly-42", case_key="refund-policy")
52
+ page = iris.get_traces(agent_name="support-bot", limit=20)
53
+ iris.health()["status"] # "ok" | "degraded"
54
+ iris.capabilities()["rules"] # every rule, what it needs, its published accuracy
55
+ ```
56
+
57
+ A thin client over the HTTP API: the rules, the composer and the storage live in the server, and this package speaks to them — the same verdict the MCP tools, the dashboard and the CI gate read. `AsyncIrisClient` has the same methods, awaited.
58
+
59
+ ## The methods
60
+
61
+ | Method | Route | What it answers |
62
+ |---|---|---|
63
+ | `log_trace(agent_name, *, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, tools, run, case_key, session_id, spans, timestamp, evaluate, eval_type)` | `POST /api/v1/traces` | `{"trace_id", "status"}` — with `evaluate=True`, `"evaluation"` too |
64
+ | `evaluate_output(output, *, input, agent_name, eval_type, …)` | `POST /api/v1/traces` with `evaluate: true` | The evaluation: `verdict` (`state`, `basis`, `by`), `score`, `rule_results`, `critical_failures`, `coverage`, `provenance`. Over HTTP the evaluate door is the ingest door, so the output is stored as a trace of `agent_name` and shows on the dashboard |
65
+ | `get_traces(*, agent_name, framework, session, since, until, min_score, max_score, limit, offset, sort_by, sort_order, **extra)` | `GET /api/v1/traces` | `{"traces", "total", "limit", "offset"}` — any other keyword is sent as a query parameter as it is; one the server does not read is a 400 naming it |
66
+ | `get_trace(trace_id)` | `GET /api/v1/traces/:id` | `{"trace", "spans", "evals"}` |
67
+ | `health()` | `GET /api/v1/health` | Open, unkeyed; `status`, `version`, `checks` |
68
+ | `capabilities()` | `GET /api/v1/capabilities` | What this server can do |
69
+
70
+ Every answer is the route's JSON as a typed dictionary (`iris_eval.types`): the keys the [API reference](https://github.com/iris-eval/mcp-server/blob/main/docs/api-reference.md) documents, and any key the server adds later carried through.
71
+
72
+ **Errors.** A refusal raises `IrisError` with the server's own sentence, the status and the validation details: `IrisError: Invalid query parameters (GET /api/v1/traces → 400)`. A server that cannot be reached raises `IrisConnectionError` naming the URL and how to start one.
73
+
74
+ **Auth.** `IrisClient(api_key="…")` sends `Authorization: Bearer` — a server bound beyond loopback requires it; `IRIS_API_KEY` is read by the pytest fixture.
75
+
76
+ **Finding the server.** `IrisClient(base_url=None)`: `IRIS_URL` first (`http://host:port`), then the port the running dashboard recorded in `runtime.json` under `IRIS_HOME` (or `~/.iris`), verified with the health route before it is trusted. Nothing named: `IrisConnectionError` with the recipe.
77
+
78
+ ## pytest
79
+
80
+ Installing the package registers a plugin. An `iris` fixture finds the server; `assert_iris` evaluates an output and asserts on the **verdict's state** — the composed verdict, never the score alone.
81
+
82
+ ```python
83
+ from iris_eval.pytest_plugin import assert_iris
84
+
85
+ def test_refund_answer(iris):
86
+ evaluation = assert_iris(
87
+ agent("Was the refund approved?"),
88
+ input="Was the refund approved?",
89
+ agent_name="support-bot",
90
+ client=iris,
91
+ )
92
+ assert evaluation["verdict"]["basis"] == "clean"
93
+
94
+ def test_the_leak_is_caught(iris):
95
+ assert_iris("The SSN is 123-45-6789.", input="q", expect="fail", client=iris)
96
+ ```
97
+
98
+ A failing assertion reads like the dashboard: `Iris verdict fail on detector_veto by no_pii (expected pass); score 0.4; evaluation eval_… on trace trace_…`. Without a server the tests are **skipped** with the sentence that says how to start one; set `IRIS_REQUIRE=1` in a CI job that must not pass green because no server ran. `--iris-url` and `--iris-api-key` override the environment.
99
+
100
+ In CI, start the server in the job and point the tests at it:
101
+
102
+ ```yaml
103
+ - run: npx -y @iris-eval/mcp-server --dashboard --dashboard-port 6920 --api-key "$IRIS_API_KEY" &
104
+ - run: pip install iris-eval && pytest
105
+ env:
106
+ IRIS_URL: http://127.0.0.1:6920
107
+ IRIS_API_KEY: ${{ secrets.IRIS_API_KEY }}
108
+ IRIS_REQUIRE: "1"
109
+ ```
110
+
111
+ Or gate a traces file without a server at all — the [CI gate](https://github.com/iris-eval/mcp-server/blob/main/docs/ci-gate.md) and its GitHub Action.
112
+
113
+ ## Tracing from Python
114
+
115
+ This package does not instrument your code. Iris reads the OpenTelemetry traces your framework already emits — Pydantic AI, Google ADK, LangGraph, CrewAI, Semantic Kernel and the others — through `POST /v1/traces` on the dashboard port ([docs/otel-integration.md](https://github.com/iris-eval/mcp-server/blob/main/docs/otel-integration.md)); a decorator SDK that built a second trace model is a maintenance surface Iris chose not to carry.
116
+
117
+ ## Versions
118
+
119
+ The client follows the HTTP API, which the server versions; `iris_eval.__version__` is this package's own. Python 3.10+, `httpx` the only dependency. Source: [packages/python](https://github.com/iris-eval/mcp-server/tree/main/packages/python).
@@ -0,0 +1,10 @@
1
+ iris_eval/__init__.py,sha256=hXEiAMDuh79nJtkK__q1vM_MBF5MUFDWjiwkI4CRRIU,1583
2
+ iris_eval/client.py,sha256=0znRIashuu6vH0QTdr3y15qU5Eq7865qfvbSYBQc4P0,17426
3
+ iris_eval/discovery.py,sha256=yhOp__OQjVpvFpcUud8txoclQV3McMYf3T3jwHWQKAk,2683
4
+ iris_eval/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ iris_eval/pytest_plugin.py,sha256=6J-kVp_EvD323W14zTYygZRNaH3HwG8pNPfr2tvioQU,4518
6
+ iris_eval/types.py,sha256=HoEcSAaSot5m01znx555t_pNb35Ry-NMnE5ESLh0Q7w,2775
7
+ iris_eval-0.1.0.dist-info/METADATA,sha256=6atORPs3tzNCthRI1EAJ_z5QEXWTvgjpDgQvixaUMoQ,7321
8
+ iris_eval-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ iris_eval-0.1.0.dist-info/entry_points.txt,sha256=Uamo2ZSotN1Ng2iw1nyxOJZBFptv7k-RTPXticHksLE,47
10
+ iris_eval-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ iris_eval = iris_eval.pytest_plugin