agentx-python 0.6.27__tar.gz → 0.6.28__tar.gz
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.
- {agentx_python-0.6.27/agentx_python.egg-info → agentx_python-0.6.28}/PKG-INFO +1 -1
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/client.py +167 -11
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/runner.py +8 -1
- agentx_python-0.6.28/agentx/version.py +1 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28/agentx_python.egg-info}/PKG-INFO +1 -1
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/SOURCES.txt +1 -0
- agentx_python-0.6.28/tests/test_selfhost_analysis_fallback.py +251 -0
- agentx_python-0.6.27/agentx/version.py +0 -1
- {agentx_python-0.6.27 → agentx_python-0.6.28}/LICENSE +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/README.md +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/agentx.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/cli.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/redaction.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/exceptions.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/feedback.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/_traced_call.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/anthropic.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/openai.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/client.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/models.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/outcomes.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/py.typed +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/resources/agent.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/tracing/tracer.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx/util.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/setup.cfg +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/setup.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/tests/test_integration.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/tests/test_integrations.py +0 -0
- {agentx_python-0.6.27 → agentx_python-0.6.28}/tests/test_span_tree.py +0 -0
|
@@ -32,9 +32,23 @@ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
|
32
32
|
_MAX_RETRIES = 3
|
|
33
33
|
_RETRY_BACKOFF = [1.0, 2.0, 4.0]
|
|
34
34
|
|
|
35
|
+
# The self-host analyze route judges every result before it responds, so the client has to
|
|
36
|
+
# wait out the whole job on one connection. Matches EvaluationRunContext.analyze()'s own
|
|
37
|
+
# default timeout.
|
|
38
|
+
_SELF_HOST_ANALYZE_TIMEOUT = 1800
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
class AgentXEvaluationsError(Exception):
|
|
37
|
-
|
|
42
|
+
"""An evaluations API call failed.
|
|
43
|
+
|
|
44
|
+
``status_code`` carries the HTTP status when the failure came from a response rather
|
|
45
|
+
than from the transport, so callers can branch on it instead of matching on the message
|
|
46
|
+
text. It is ``None`` for connection errors and for retry exhaustion.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.status_code = status_code
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
class AgentXAuthError(AgentXEvaluationsError):
|
|
@@ -69,6 +83,8 @@ class EvaluationsClient:
|
|
|
69
83
|
if not _api_base.endswith("/custom-agent-evaluations"):
|
|
70
84
|
_api_base = f"{_api_base}/custom-agent-evaluations"
|
|
71
85
|
self._base_url = _api_base
|
|
86
|
+
# None until an analysis call tells us which engine this is; see _api_root.
|
|
87
|
+
self._analysis_on_dashboard_router: Optional[bool] = None
|
|
72
88
|
self._session = requests.Session()
|
|
73
89
|
self._session.headers.update(
|
|
74
90
|
{
|
|
@@ -103,10 +119,29 @@ class EvaluationsClient:
|
|
|
103
119
|
"""Same as _with_workspace(), for GET requests that take workspaceId as a query param."""
|
|
104
120
|
return {"workspaceId": self._workspace_id} if self._workspace_id else None
|
|
105
121
|
|
|
106
|
-
def _request(
|
|
107
|
-
|
|
122
|
+
def _request(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
path: str,
|
|
126
|
+
timeout: int = 30,
|
|
127
|
+
base: Optional[str] = None,
|
|
128
|
+
retry: bool = True,
|
|
129
|
+
**kwargs,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""Call the evaluations API.
|
|
132
|
+
|
|
133
|
+
``base`` overrides the ``/custom-agent-evaluations`` prefix for the handful of
|
|
134
|
+
routes that live on a different router (see ``_api_root``).
|
|
135
|
+
|
|
136
|
+
``retry=False`` disables the backoff loop entirely. Use it for any request that is
|
|
137
|
+
both slow and billable: the loop retries on ``requests.RequestException``, which
|
|
138
|
+
includes read timeouts, so a synchronous endpoint that outlives its timeout would
|
|
139
|
+
otherwise be re-invoked — and paid for — up to four times.
|
|
140
|
+
"""
|
|
141
|
+
url = f"{base or self._base_url}{path}"
|
|
142
|
+
schedule = [0.0] + (_RETRY_BACKOFF if retry else [])
|
|
108
143
|
last_exc: Optional[Exception] = None
|
|
109
|
-
for attempt, wait in enumerate(
|
|
144
|
+
for attempt, wait in enumerate(schedule):
|
|
110
145
|
if wait:
|
|
111
146
|
time.sleep(wait)
|
|
112
147
|
try:
|
|
@@ -120,14 +155,22 @@ class EvaluationsClient:
|
|
|
120
155
|
raise AgentXAuthError("Invalid or missing API key")
|
|
121
156
|
if resp.status_code == 422:
|
|
122
157
|
raise AgentXValidationError(resp.text)
|
|
123
|
-
if
|
|
158
|
+
if (
|
|
159
|
+
resp.status_code in _RETRYABLE_STATUS
|
|
160
|
+
and retry
|
|
161
|
+
and attempt < _MAX_RETRIES - 1
|
|
162
|
+
):
|
|
124
163
|
logger.debug(
|
|
125
164
|
"Retryable status %d (attempt %d)", resp.status_code, attempt + 1
|
|
126
165
|
)
|
|
127
|
-
last_exc = AgentXEvaluationsError(
|
|
166
|
+
last_exc = AgentXEvaluationsError(
|
|
167
|
+
f"HTTP {resp.status_code}", status_code=resp.status_code
|
|
168
|
+
)
|
|
128
169
|
continue
|
|
129
170
|
if not resp.ok:
|
|
130
|
-
raise AgentXEvaluationsError(
|
|
171
|
+
raise AgentXEvaluationsError(
|
|
172
|
+
f"HTTP {resp.status_code}: {resp.text}", status_code=resp.status_code
|
|
173
|
+
)
|
|
131
174
|
try:
|
|
132
175
|
return resp.json()
|
|
133
176
|
except Exception:
|
|
@@ -303,6 +346,10 @@ class EvaluationsClient:
|
|
|
303
346
|
# Starts the durable analysis job and returns immediately (e.g. {"jobId": ..., "status":
|
|
304
347
|
# "pending"}); poll get_analysis_status() until it reaches a terminal status, then call
|
|
305
348
|
# get_report(). mode/quality_mode/judges mirror the dashboard's AnalyzeEvaluationRequest.
|
|
349
|
+
#
|
|
350
|
+
# On self-host the fallback route runs the analysis synchronously and returns only when
|
|
351
|
+
# it is done, so the caller's poll loop sees a terminal status on its first check. The
|
|
352
|
+
# request is therefore given the full analysis timeout and, critically, no retries.
|
|
306
353
|
payload: Dict[str, Any] = {}
|
|
307
354
|
if mode is not None:
|
|
308
355
|
payload["mode"] = mode
|
|
@@ -310,23 +357,132 @@ class EvaluationsClient:
|
|
|
310
357
|
payload["qualityMode"] = quality_mode
|
|
311
358
|
if judges is not None:
|
|
312
359
|
payload["judges"] = [{"model": m} for m in judges]
|
|
313
|
-
|
|
360
|
+
|
|
361
|
+
if not self._analysis_on_dashboard_router:
|
|
362
|
+
try:
|
|
363
|
+
return self._request(
|
|
364
|
+
"POST", f"/runs/{run_id}/analyze", json=payload, timeout=30
|
|
365
|
+
)
|
|
366
|
+
except AgentXEvaluationsError as exc:
|
|
367
|
+
if not self._note_missing_analysis_route(exc, "analyze"):
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
return self._request(
|
|
371
|
+
"POST",
|
|
372
|
+
f"/evaluate/analyze/{run_id}",
|
|
373
|
+
base=self._api_root,
|
|
374
|
+
json=payload,
|
|
375
|
+
timeout=_SELF_HOST_ANALYZE_TIMEOUT,
|
|
376
|
+
retry=False,
|
|
377
|
+
)
|
|
314
378
|
|
|
315
379
|
def get_analysis_status(self, run_id: str) -> AnalysisStatus:
|
|
316
|
-
|
|
380
|
+
if not self._analysis_on_dashboard_router:
|
|
381
|
+
try:
|
|
382
|
+
return AnalysisStatus(
|
|
383
|
+
**self._request("GET", f"/runs/{run_id}/analyze-status")
|
|
384
|
+
)
|
|
385
|
+
except AgentXEvaluationsError as exc:
|
|
386
|
+
if not self._note_missing_analysis_route(exc, "analyze-status"):
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
data = self._request(
|
|
390
|
+
"GET", f"/evaluate/analyze/{run_id}/status", base=self._api_root
|
|
391
|
+
)
|
|
317
392
|
return AnalysisStatus(**data)
|
|
318
393
|
|
|
319
394
|
def get_run(self, run_id: str) -> Dict[str, Any]:
|
|
320
395
|
return self._request("GET", f"/runs/{run_id}")
|
|
321
396
|
|
|
322
397
|
def get_report(self, run_id: str) -> Report:
|
|
323
|
-
|
|
324
|
-
|
|
398
|
+
if not self._analysis_on_dashboard_router:
|
|
399
|
+
try:
|
|
400
|
+
return Report(**self._request("GET", f"/runs/{run_id}/report"))
|
|
401
|
+
except AgentXEvaluationsError as exc:
|
|
402
|
+
if not self._note_missing_analysis_route(exc, "report"):
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
return self._report_from_dashboard(run_id)
|
|
325
406
|
|
|
326
407
|
def get_missing_results(self, run_id: str) -> List[Dict[str, Any]]:
|
|
327
408
|
data = self._request("GET", f"/runs/{run_id}/missing-results")
|
|
328
409
|
return data if isinstance(data, list) else data.get("missing", [])
|
|
329
410
|
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
# Self-host analysis fallback
|
|
413
|
+
#
|
|
414
|
+
# The self-host engine (AgentX-trace-eval) mounts two routers: the SDK's
|
|
415
|
+
# /custom-agent-evaluations, and /evaluate for the dashboard. Its
|
|
416
|
+
# /custom-agent-evaluations router implements the run lifecycle - runs, results,
|
|
417
|
+
# finalize, gate - but not /analyze, /analyze-status or /report, which exist only on
|
|
418
|
+
# /evaluate. Hosted AgentX serves all of them from the SDK's own router.
|
|
419
|
+
#
|
|
420
|
+
# So the three analysis calls try the SDK route first and fall back to the dashboard
|
|
421
|
+
# route on a 404, which means hosted behaviour is byte-for-byte unchanged: it never
|
|
422
|
+
# 404s, so it never falls back. The outcome is cached on the client so the probe costs
|
|
423
|
+
# one request per process, not one per call.
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def _api_root(self) -> str:
|
|
428
|
+
"""The API base with the ``/custom-agent-evaluations`` suffix removed."""
|
|
429
|
+
suffix = "/custom-agent-evaluations"
|
|
430
|
+
if self._base_url.endswith(suffix):
|
|
431
|
+
return self._base_url[: -len(suffix)]
|
|
432
|
+
return self._base_url
|
|
433
|
+
|
|
434
|
+
def _note_missing_analysis_route(
|
|
435
|
+
self, exc: AgentXEvaluationsError, route: str
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Return True if ``exc`` is the 404 that means "this engine is self-host".
|
|
438
|
+
|
|
439
|
+
Only a 404 qualifies. Anything else - auth, validation, a 500, a dead connection -
|
|
440
|
+
is a real failure on a route that does exist, and must propagate rather than be
|
|
441
|
+
retried against a different endpoint that would mask it.
|
|
442
|
+
"""
|
|
443
|
+
if exc.status_code != 404:
|
|
444
|
+
return False
|
|
445
|
+
if self._analysis_on_dashboard_router is None:
|
|
446
|
+
logger.info(
|
|
447
|
+
"%s is not served from %s; using the dashboard router at %s "
|
|
448
|
+
"(self-host engine)",
|
|
449
|
+
route,
|
|
450
|
+
self._base_url,
|
|
451
|
+
self._api_root,
|
|
452
|
+
)
|
|
453
|
+
self._analysis_on_dashboard_router = True
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
def _report_from_dashboard(self, run_id: str) -> Report:
|
|
457
|
+
"""Assemble a Report from the dashboard's evaluation record.
|
|
458
|
+
|
|
459
|
+
Self-host has no /report route; it returns the analysis nested inside the
|
|
460
|
+
evaluation itself, under ``analysis.analysis`` with its statistics one level up.
|
|
461
|
+
The field names already match the Report models, so this is a reshape, not a
|
|
462
|
+
translation.
|
|
463
|
+
"""
|
|
464
|
+
record = self._request("GET", f"/evaluate/{run_id}", base=self._api_root)
|
|
465
|
+
envelope = record.get("analysis") or {}
|
|
466
|
+
body = envelope.get("analysis") or {}
|
|
467
|
+
|
|
468
|
+
if not envelope:
|
|
469
|
+
raise AgentXEvaluationsError(
|
|
470
|
+
f"Run {run_id} has no analysis to report. Nothing has analyzed it yet, "
|
|
471
|
+
"or the analysis failed - call analyze_run() first."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
dataset_id = record.get("datasetId")
|
|
475
|
+
if isinstance(dataset_id, dict): # populated reference, not a bare id
|
|
476
|
+
dataset_id = dataset_id.get("_id") or dataset_id.get("id")
|
|
477
|
+
|
|
478
|
+
return Report(
|
|
479
|
+
runId=run_id,
|
|
480
|
+
datasetId=dataset_id or "",
|
|
481
|
+
status=envelope.get("status") or "completed",
|
|
482
|
+
statistics=envelope.get("statistics"),
|
|
483
|
+
**body,
|
|
484
|
+
)
|
|
485
|
+
|
|
330
486
|
|
|
331
487
|
# ---------------------------------------------------------------------------
|
|
332
488
|
# Helpers
|
|
@@ -362,11 +362,18 @@ class EvaluationRunContext:
|
|
|
362
362
|
try:
|
|
363
363
|
report = self._client.get_report(self._run.run_id)
|
|
364
364
|
except Exception as exc:
|
|
365
|
+
# Deliberately not status="completed". A placeholder that claims completion is
|
|
366
|
+
# indistinguishable from a real report of an evaluation that scored nothing -
|
|
367
|
+
# print_report renders empty statistics and no recommendations either way - so
|
|
368
|
+
# the one signal that something went wrong used to be a logger.warning that is
|
|
369
|
+
# invisible unless the caller configured logging. Say it on stdout, and let the
|
|
370
|
+
# status carry the truth for anything reading the object.
|
|
371
|
+
print(f" {red('✗')} Could not fetch the report: {dim(str(exc))}")
|
|
365
372
|
logger.warning("Could not fetch report: %s", exc)
|
|
366
373
|
report = Report(
|
|
367
374
|
runId=self._run.run_id,
|
|
368
375
|
datasetId=self._dataset.id,
|
|
369
|
-
status="
|
|
376
|
+
status="unavailable",
|
|
370
377
|
)
|
|
371
378
|
|
|
372
379
|
self._report = report
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.6.28"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The analysis calls' self-host fallback.
|
|
2
|
+
|
|
3
|
+
Self-host (AgentX-trace-eval) serves whole-run analysis from its dashboard router, not from
|
|
4
|
+
the /custom-agent-evaluations router the SDK targets. Older engines lack the SDK routes
|
|
5
|
+
entirely, so the three analysis calls try the SDK route and fall back on a 404.
|
|
6
|
+
|
|
7
|
+
What these tests pin down is the part that is easy to get wrong: the fallback must be
|
|
8
|
+
invisible to hosted AgentX, must not swallow anything other than a 404, and must not retry a
|
|
9
|
+
billable synchronous request.
|
|
10
|
+
|
|
11
|
+
No network and no API key - the HTTP session is replaced with a recorder.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from agentx.evaluations.client import (
|
|
17
|
+
AgentXAuthError,
|
|
18
|
+
AgentXEvaluationsError,
|
|
19
|
+
EvaluationsClient,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
API_ROOT = "https://example.test/api/v1"
|
|
23
|
+
SDK_ROOT = f"{API_ROOT}/custom-agent-evaluations"
|
|
24
|
+
RUN = "run-123"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeResponse:
|
|
28
|
+
def __init__(self, status_code: int, payload=None):
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self._payload = payload if payload is not None else {}
|
|
31
|
+
self.text = str(self._payload)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ok(self) -> bool:
|
|
35
|
+
return self.status_code < 400
|
|
36
|
+
|
|
37
|
+
def json(self):
|
|
38
|
+
return self._payload
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeSession:
|
|
42
|
+
"""Records every call and answers from a {(method, url): [responses]} routing table."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, routes):
|
|
45
|
+
self.routes = routes
|
|
46
|
+
self.calls = []
|
|
47
|
+
self.headers = {}
|
|
48
|
+
|
|
49
|
+
def request(self, method, url, **kwargs):
|
|
50
|
+
self.calls.append((method, url, kwargs))
|
|
51
|
+
key = (method, url)
|
|
52
|
+
if key not in self.routes:
|
|
53
|
+
return FakeResponse(404, {"message": "Not found"})
|
|
54
|
+
answer = self.routes[key]
|
|
55
|
+
if isinstance(answer, list):
|
|
56
|
+
return answer.pop(0) if len(answer) > 1 else answer[0]
|
|
57
|
+
return answer
|
|
58
|
+
|
|
59
|
+
def urls(self, method=None):
|
|
60
|
+
return [u for m, u, _ in self.calls if method is None or m == method]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_client(routes):
|
|
64
|
+
client = EvaluationsClient(api_key="k", base_url=API_ROOT)
|
|
65
|
+
session = FakeSession(routes)
|
|
66
|
+
client._session = session
|
|
67
|
+
return client, session
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
STATUS_BODY = {
|
|
71
|
+
"evaluationId": RUN,
|
|
72
|
+
"jobId": RUN,
|
|
73
|
+
"status": "completed",
|
|
74
|
+
"progress": {"overallPercentage": 100, "currentLevel": None, "levels": {}},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
REPORT_BODY = {
|
|
78
|
+
"runId": RUN,
|
|
79
|
+
"datasetId": "ds-1",
|
|
80
|
+
"status": "completed",
|
|
81
|
+
"summary": "It went fine.",
|
|
82
|
+
"recommendations": [{"category": "instructions", "priority": "high"}],
|
|
83
|
+
"statistics": {"numberOfRuns": 4, "averageRating": 7.5, "minRating": 5, "maxRating": 9},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Hosted AgentX: the SDK routes answer, so nothing may change
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_hosted_uses_the_sdk_routes_and_never_probes_the_dashboard():
|
|
93
|
+
client, session = make_client(
|
|
94
|
+
{
|
|
95
|
+
("POST", f"{SDK_ROOT}/runs/{RUN}/analyze"): FakeResponse(200, {"status": "pending"}),
|
|
96
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(200, STATUS_BODY),
|
|
97
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/report"): FakeResponse(200, REPORT_BODY),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
client.analyze_run(RUN)
|
|
102
|
+
assert client.get_analysis_status(RUN).status == "completed"
|
|
103
|
+
assert client.get_report(RUN).summary == "It went fine."
|
|
104
|
+
|
|
105
|
+
assert all("/custom-agent-evaluations/" in url for url in session.urls())
|
|
106
|
+
assert client._analysis_on_dashboard_router is None, "hosted must never flip the flag"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Self-host: the SDK routes 404, the dashboard routes answer
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_analysis_status_falls_back_to_the_dashboard_router_on_404():
|
|
115
|
+
client, session = make_client(
|
|
116
|
+
{("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY)}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert client.get_analysis_status(RUN).is_terminal is True
|
|
120
|
+
assert session.urls("GET") == [
|
|
121
|
+
f"{SDK_ROOT}/runs/{RUN}/analyze-status",
|
|
122
|
+
f"{API_ROOT}/evaluate/analyze/{RUN}/status",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_the_fallback_is_probed_once_then_remembered():
|
|
127
|
+
client, session = make_client(
|
|
128
|
+
{("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY)}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
client.get_analysis_status(RUN)
|
|
132
|
+
client.get_analysis_status(RUN)
|
|
133
|
+
|
|
134
|
+
# One 404 probe, not one per call.
|
|
135
|
+
assert session.urls("GET").count(f"{SDK_ROOT}/runs/{RUN}/analyze-status") == 1
|
|
136
|
+
assert client._analysis_on_dashboard_router is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_report_is_assembled_from_the_dashboard_evaluation_record():
|
|
140
|
+
client, _ = make_client(
|
|
141
|
+
{
|
|
142
|
+
("GET", f"{API_ROOT}/evaluate/{RUN}"): FakeResponse(
|
|
143
|
+
200,
|
|
144
|
+
{
|
|
145
|
+
# datasetId arrives as a populated reference here, not a bare id.
|
|
146
|
+
"datasetId": {"_id": "ds-1", "name": "Support"},
|
|
147
|
+
"analysis": {
|
|
148
|
+
"status": "completed",
|
|
149
|
+
"statistics": {"numberOfRuns": 16, "averageRating": 6.21875,
|
|
150
|
+
"minRating": 1, "maxRating": 9.5},
|
|
151
|
+
"analysis": {
|
|
152
|
+
"summary": "Uneven.",
|
|
153
|
+
"recommendations": [
|
|
154
|
+
{"category": "instructions", "priority": "high",
|
|
155
|
+
"recommendation": "Cite the doc id.", "reasoning": "None cited."}
|
|
156
|
+
],
|
|
157
|
+
# A key the SDK's Report does not model; must not raise.
|
|
158
|
+
"overallAssessment": "mixed",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
report = client.get_report(RUN)
|
|
167
|
+
|
|
168
|
+
assert report.run_id == RUN
|
|
169
|
+
assert report.dataset_id == "ds-1", "the populated reference must be unwrapped to its id"
|
|
170
|
+
assert report.status == "completed"
|
|
171
|
+
assert report.summary == "Uneven."
|
|
172
|
+
assert report.statistics.average_rating == 6.21875
|
|
173
|
+
assert report.statistics.min_rating == 1
|
|
174
|
+
assert len(report.recommendations) == 1
|
|
175
|
+
assert report.recommendations[0].category == "instructions"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_report_says_so_when_nothing_has_analyzed_the_run():
|
|
179
|
+
client, _ = make_client(
|
|
180
|
+
{("GET", f"{API_ROOT}/evaluate/{RUN}"): FakeResponse(200, {"datasetId": "ds-1"})}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
with pytest.raises(AgentXEvaluationsError, match="no analysis"):
|
|
184
|
+
client.get_report(RUN)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_the_synchronous_fallback_analyze_is_never_retried():
|
|
188
|
+
"""A read timeout on a billable sync endpoint must not re-run the judges."""
|
|
189
|
+
import requests
|
|
190
|
+
|
|
191
|
+
client, session = make_client({})
|
|
192
|
+
|
|
193
|
+
def always_times_out(method, url, **kwargs):
|
|
194
|
+
session.calls.append((method, url, kwargs))
|
|
195
|
+
if url.endswith("/analyze"):
|
|
196
|
+
return FakeResponse(404, {"message": "Not found"})
|
|
197
|
+
raise requests.exceptions.ReadTimeout("too slow")
|
|
198
|
+
|
|
199
|
+
session.request = always_times_out
|
|
200
|
+
|
|
201
|
+
with pytest.raises(AgentXEvaluationsError):
|
|
202
|
+
client.analyze_run(RUN)
|
|
203
|
+
|
|
204
|
+
dashboard_posts = [u for u in session.urls("POST") if "/evaluate/analyze/" in u]
|
|
205
|
+
assert len(dashboard_posts) == 1, f"retried a billable request: {dashboard_posts}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_the_fallback_request_gets_the_long_analysis_timeout():
|
|
209
|
+
client, session = make_client(
|
|
210
|
+
{("POST", f"{API_ROOT}/evaluate/analyze/{RUN}"): FakeResponse(200, {"status": "completed"})}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
client.analyze_run(RUN, judges=["gpt-5.5"])
|
|
214
|
+
|
|
215
|
+
method, url, kwargs = session.calls[-1]
|
|
216
|
+
assert url == f"{API_ROOT}/evaluate/analyze/{RUN}"
|
|
217
|
+
assert kwargs["timeout"] > 60, "a synchronous judge pass needs more than the 30s default"
|
|
218
|
+
assert kwargs["json"]["judges"] == [{"model": "gpt-5.5"}]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Only a 404 means "wrong engine"
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize("status", [400, 403, 500])
|
|
227
|
+
def test_failures_that_are_not_404_propagate_untouched(status):
|
|
228
|
+
client, session = make_client(
|
|
229
|
+
{
|
|
230
|
+
("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(status, {"e": "boom"}),
|
|
231
|
+
# Present, and must not be reached.
|
|
232
|
+
("GET", f"{API_ROOT}/evaluate/analyze/{RUN}/status"): FakeResponse(200, STATUS_BODY),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
with pytest.raises(AgentXEvaluationsError) as caught:
|
|
237
|
+
client.get_analysis_status(RUN)
|
|
238
|
+
|
|
239
|
+
assert caught.value.status_code == status
|
|
240
|
+
assert not [u for u in session.urls() if "/evaluate/" in u], "masked a real failure"
|
|
241
|
+
assert client._analysis_on_dashboard_router is None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_auth_errors_are_not_mistaken_for_a_missing_route():
|
|
245
|
+
client, session = make_client(
|
|
246
|
+
{("GET", f"{SDK_ROOT}/runs/{RUN}/analyze-status"): FakeResponse(401, {"e": "nope"})}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
with pytest.raises(AgentXAuthError):
|
|
250
|
+
client.get_analysis_status(RUN)
|
|
251
|
+
assert not [u for u in session.urls() if "/evaluate/" in u]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = "0.6.27"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|