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 +61 -0
- iris_eval/client.py +506 -0
- iris_eval/discovery.py +78 -0
- iris_eval/py.typed +0 -0
- iris_eval/pytest_plugin.py +106 -0
- iris_eval/types.py +120 -0
- iris_eval-0.1.0.dist-info/METADATA +119 -0
- iris_eval-0.1.0.dist-info/RECORD +10 -0
- iris_eval-0.1.0.dist-info/WHEEL +4 -0
- iris_eval-0.1.0.dist-info/entry_points.txt +2 -0
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,,
|