Skip to content

Commit 72dd42a

Browse files
Merge pull request #540 from roboflow/joao/adds-wf-evals-commands
feat: add Workflow Evals to the SDK and CLI
2 parents 73db1b9 + 96320fa commit 72dd42a

8 files changed

Lines changed: 3134 additions & 0 deletions

File tree

‎CLI-COMMANDS.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,42 @@ roboflow eval compare --project my-project --version 3 --frontier-metric mAP5095
457457
Reads existing evaluations; does not start new ones. Requires `model-eval:read`
458458
and workspace Model Evaluation access.
459459

460+
### Evaluate Workflows (Workflow Evals)
461+
462+
```bash
463+
# Discover the engine catalog and what your key may do.
464+
roboflow workflow-eval capabilities --json
465+
roboflow workflow-eval evaluator list
466+
roboflow workflow-eval schema get spec --json
467+
468+
# Author: Spec, Eval Dataset, Cases, Eval.
469+
roboflow workflow-eval spec create --body @spec.json --name "Boolean answer"
470+
roboflow workflow-eval dataset create --body @dataset.json
471+
roboflow workflow-eval case upload ./image.png --dataset <dataset-id> # prints artifactId
472+
roboflow workflow-eval case add --dataset <dataset-id> --body @case.json
473+
roboflow workflow-eval case import --dataset <dataset-id> --input-field image --from-dataset my-project:valid
474+
roboflow workflow-eval create --name "Answer accuracy" --spec <spec-id> --dataset <dataset-id>
475+
476+
# Bind a Workflow, run it, and read results.
477+
roboflow workflow-eval binding suggest --spec <spec-id> --dataset <dataset-id> --workflow <workflow-id> --json
478+
roboflow workflow-eval run start --eval <eval-id> --body @run.json --wait
479+
roboflow workflow-eval execution overview <execution-id> --eval <eval-id> --run <run-id> --json
480+
roboflow workflow-eval execution results <execution-id> --eval <eval-id> --run <run-id> --failed-check
481+
roboflow workflow-eval compare --eval <eval-id> -x <execution-a> -x <execution-b> --json
482+
roboflow workflow-eval export start --eval <eval-id> --run <run-id> --format csv --wait
483+
484+
# Agent guidance served by the installed engine.
485+
roboflow workflow-eval agent manifest --json
486+
roboflow workflow-eval agent skill skill:create-eval
487+
```
488+
489+
`--body` accepts inline JSON, `@file.json`, a file path, or `-` for stdin. Commands that create
490+
resources or start work send a fresh `Idempotency-Key` (override with `--idempotency-key` to retry
491+
safely); updates take the resource's current `--revision`. Deleting an Eval or Run shows its impact
492+
and asks for confirmation (`--yes` to skip). Requires the Workflow Evals feature and the
493+
`workflow-evals:read|write|run|export` scopes; running saved Workflows also needs `workflow:read`
494+
and `model:infer`. The same API is available in Python via `rf.workspace().workflow_evals()`.
495+
460496
### Workspace stats and billing
461497
462498
```bash
@@ -560,6 +596,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
560596
| `device` | List, get, create, and observe RFDM devices (v2 deployment API) |
561597
| `eval` | Inspect model evaluation runs (mAP, confusion matrix, recommendations, ...) |
562598
| `workflow` | Manage workflows |
599+
| `workflow-eval` | Evaluate Workflows: Specs, Eval Datasets, Cases, Runs, results, exports |
563600
| `folder` | Manage workspace folders |
564601
| `annotation` | Annotation batches and jobs |
565602
| `autolabel` | Auto-label batches with hosted foundation or Roboflow models |
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""Transport for the public Workflow Evals API.
2+
3+
Every route lives under ``{API_URL}/workspaces/{workspace}/workflow-evals``.
4+
This module owns the HTTP mechanics shared by all of them — bearer auth,
5+
``Idempotency-Key`` / ``If-Match`` / ``Deletion-Key`` headers, the
6+
Workflow Evals error envelope, SSE parsing for AI drafting and signed
7+
uploads. The per-endpoint surface lives in
8+
:class:`roboflow.core.workflow_evals.WorkflowEvals`.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import uuid
15+
from typing import Any, Dict, Iterator, Optional
16+
from urllib.parse import quote
17+
18+
import requests
19+
20+
from roboflow.adapters.rfapi import RoboflowError
21+
from roboflow.config import API_URL
22+
23+
DEFAULT_TIMEOUT = 60
24+
25+
26+
class WorkflowEvalError(RoboflowError):
27+
"""Error returned by the Workflow Evals API.
28+
29+
The server responds with ``{"error": {code, category, retryable,
30+
requestId, message, details?, resource?}}``. Authentication failures may
31+
use the platform ``{"error": {message, type, hint?}}`` envelope instead,
32+
in which case only ``message`` (and ``hint``) are populated.
33+
"""
34+
35+
def __init__(
36+
self,
37+
message: str,
38+
status_code: Optional[int] = None,
39+
*,
40+
code: Optional[str] = None,
41+
category: Optional[str] = None,
42+
retryable: Optional[bool] = None,
43+
request_id: Optional[str] = None,
44+
details: Optional[Dict[str, Any]] = None,
45+
resource: Optional[Dict[str, Any]] = None,
46+
hint: Optional[str] = None,
47+
body: Any = None,
48+
) -> None:
49+
super().__init__(message, status_code=status_code)
50+
self.message = message
51+
self.code = code
52+
self.category = category
53+
self.retryable = retryable
54+
self.request_id = request_id
55+
self.details = details or {}
56+
self.resource = resource
57+
self.hint = hint
58+
self.body = body
59+
60+
def to_dict(self) -> Dict[str, Any]:
61+
"""Return the error as the CLI's ``{"message": ..., ...}`` payload."""
62+
payload: Dict[str, Any] = {"message": self.message}
63+
for key, value in (
64+
("status", self.status_code),
65+
("code", self.code),
66+
("category", self.category),
67+
("retryable", self.retryable),
68+
("requestId", self.request_id),
69+
("resource", self.resource),
70+
("hint", self.hint),
71+
):
72+
if value is not None:
73+
payload[key] = value
74+
if self.details:
75+
payload["details"] = self.details
76+
return payload
77+
78+
79+
def error_from_response(response: requests.Response) -> WorkflowEvalError:
80+
"""Translate a non-2xx response into a :class:`WorkflowEvalError`."""
81+
body: Any = None
82+
try:
83+
body = response.json()
84+
except ValueError:
85+
pass
86+
error = body.get("error") if isinstance(body, dict) else None
87+
if isinstance(error, dict):
88+
return WorkflowEvalError(
89+
str(error.get("message") or error.get("code") or response.reason or "Request failed"),
90+
status_code=response.status_code,
91+
code=error.get("code"),
92+
category=error.get("category") or error.get("type"),
93+
retryable=error.get("retryable"),
94+
request_id=error.get("requestId"),
95+
details=error.get("details") if isinstance(error.get("details"), dict) else None,
96+
resource=error.get("resource"),
97+
hint=error.get("hint"),
98+
body=body,
99+
)
100+
if isinstance(error, str):
101+
message = body.get("message") or error
102+
return WorkflowEvalError(str(message), status_code=response.status_code, code=error, body=body)
103+
text = (response.text or "").strip() or response.reason or "Request failed"
104+
return WorkflowEvalError(text, status_code=response.status_code, body=body)
105+
106+
107+
def new_idempotency_key() -> str:
108+
"""Return a fresh UUID v4, the format the API requires for ``Idempotency-Key``."""
109+
return str(uuid.uuid4())
110+
111+
112+
def base_url(workspace_url: str) -> str:
113+
return f"{API_URL}/workspaces/{quote(workspace_url, safe='')}/workflow-evals"
114+
115+
116+
def segment(value: Any) -> str:
117+
"""Percent-encode one path segment (resource IDs like ``skill:create-eval``)."""
118+
return quote(str(value), safe="")
119+
120+
121+
def _headers(
122+
api_key: str,
123+
*,
124+
idempotency_key: Optional[str] = None,
125+
revision: Optional[Any] = None,
126+
deletion_key: Optional[str] = None,
127+
accept: str = "application/json",
128+
) -> Dict[str, str]:
129+
headers = {"Authorization": f"Bearer {api_key}", "Accept": accept}
130+
if idempotency_key:
131+
headers["Idempotency-Key"] = idempotency_key
132+
if revision is not None:
133+
headers["If-Match"] = str(revision)
134+
if deletion_key:
135+
headers["Deletion-Key"] = deletion_key
136+
return headers
137+
138+
139+
def _clean_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
140+
if not params:
141+
return None
142+
cleaned: Dict[str, Any] = {}
143+
for key, value in params.items():
144+
if value is None:
145+
continue
146+
if isinstance(value, bool):
147+
cleaned[key] = "true" if value else "false"
148+
elif isinstance(value, (list, tuple)):
149+
if value:
150+
cleaned[key] = list(value)
151+
else:
152+
cleaned[key] = value
153+
return cleaned or None
154+
155+
156+
def request(
157+
api_key: str,
158+
workspace_url: str,
159+
method: str,
160+
path: str = "",
161+
*,
162+
params: Optional[Dict[str, Any]] = None,
163+
body: Any = None,
164+
idempotency_key: Optional[str] = None,
165+
revision: Optional[Any] = None,
166+
deletion_key: Optional[str] = None,
167+
timeout: float = DEFAULT_TIMEOUT,
168+
) -> Any:
169+
"""Call one Workflow Evals route and return the decoded JSON body.
170+
171+
Returns ``None`` for ``204 No Content``. Raises :class:`WorkflowEvalError`
172+
for any non-2xx response.
173+
"""
174+
response = requests.request(
175+
method.upper(),
176+
f"{base_url(workspace_url)}{path}",
177+
params=_clean_params(params),
178+
json=body,
179+
headers=_headers(api_key, idempotency_key=idempotency_key, revision=revision, deletion_key=deletion_key),
180+
timeout=timeout,
181+
)
182+
if not 200 <= response.status_code < 300:
183+
raise error_from_response(response)
184+
if response.status_code == 204 or not response.content:
185+
return None
186+
try:
187+
return response.json()
188+
except ValueError:
189+
raise WorkflowEvalError(
190+
f"Expected JSON from {method.upper()} {path or '/'}", status_code=response.status_code
191+
) from None
192+
193+
194+
def stream_events(
195+
api_key: str,
196+
workspace_url: str,
197+
path: str,
198+
*,
199+
body: Any,
200+
idempotency_key: Optional[str] = None,
201+
timeout: float = 600,
202+
) -> Iterator[Dict[str, Any]]:
203+
"""POST to an SSE route and yield ``{"event": name, "data": payload}`` dicts.
204+
205+
``data`` is decoded as JSON when possible, otherwise left as text. The
206+
stream ends after the server's terminal ``done`` event.
207+
"""
208+
response = requests.post(
209+
f"{base_url(workspace_url)}{path}",
210+
json=body,
211+
headers=_headers(api_key, idempotency_key=idempotency_key, accept="text/event-stream"),
212+
stream=True,
213+
timeout=timeout,
214+
)
215+
if not 200 <= response.status_code < 300:
216+
raise error_from_response(response)
217+
try:
218+
# Iterate raw bytes: the server omits a charset, so requests would decode the
219+
# stream as ISO-8859-1. parse_sse decodes each line as UTF-8.
220+
yield from parse_sse(response.iter_lines())
221+
finally:
222+
response.close()
223+
224+
225+
def parse_sse(lines: Any) -> Iterator[Dict[str, Any]]:
226+
"""Parse ``text/event-stream`` lines into event dicts."""
227+
event = "message"
228+
data_lines: list = []
229+
for raw in lines:
230+
line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
231+
if line is None:
232+
continue
233+
if line == "":
234+
if data_lines:
235+
yield {"event": event, "data": _decode_data("\n".join(data_lines))}
236+
event, data_lines = "message", []
237+
continue
238+
if line.startswith(":"):
239+
continue
240+
field, _, value = line.partition(":")
241+
value = value[1:] if value.startswith(" ") else value
242+
if field == "event":
243+
event = value
244+
elif field == "data":
245+
data_lines.append(value)
246+
if data_lines:
247+
yield {"event": event, "data": _decode_data("\n".join(data_lines))}
248+
249+
250+
def _decode_data(data: str) -> Any:
251+
try:
252+
return json.loads(data)
253+
except ValueError:
254+
return data
255+
256+
257+
def put_signed_upload(upload_url: str, data: bytes, headers: Dict[str, str], *, timeout: float = 300) -> None:
258+
"""PUT bytes to a signed upload URL with exactly the headers the API returned."""
259+
response = requests.put(upload_url, data=data, headers=headers, timeout=timeout)
260+
if not 200 <= response.status_code < 300:
261+
raise WorkflowEvalError(
262+
f"Signed upload failed ({response.status_code}): {response.text}", status_code=response.status_code
263+
)

‎roboflow/cli/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def _walk(group: Any, prefix: str = "") -> None:
207207
from roboflow.cli.handlers.video import video_app # noqa: E402
208208
from roboflow.cli.handlers.vision_events import vision_events_app # noqa: E402
209209
from roboflow.cli.handlers.workflow import workflow_app # noqa: E402
210+
from roboflow.cli.handlers.workflow_eval import workflow_eval_app # noqa: E402
210211
from roboflow.cli.handlers.workspace import workspace_app # noqa: E402
211212

212213
# Register ALL commands in alphabetical order for clean --help output
@@ -239,6 +240,7 @@ def _walk(group: Any, prefix: str = "") -> None:
239240
app.add_typer(video_app, name="video")
240241
app.add_typer(vision_events_app, name="vision-events")
241242
app.add_typer(workflow_app, name="workflow")
243+
app.add_typer(workflow_eval_app, name="workflow-eval")
242244
app.add_typer(workspace_app, name="workspace")
243245

244246
# Hidden aliases (loaded last — still functional but not in --help)

0 commit comments

Comments
 (0)