haliosai-cli 2.0.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.
@@ -0,0 +1,373 @@
1
+ """Shared CLI configuration, credentials, HTTP, and project helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import platform
10
+ import re
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from typing import Any
16
+
17
+ import httpx
18
+ import typer
19
+
20
+ DEFAULT_BASE_URL = "https://app.halios.ai"
21
+
22
+
23
+ class ApiError(typer.BadParameter):
24
+ def __init__(self, status_code: int, detail: Any):
25
+ self.status_code = status_code
26
+ self.detail = detail
27
+ super().__init__(f"Halios API {status_code}: {detail}")
28
+
29
+
30
+ def normalize_url(value: str) -> str:
31
+ return (value if "://" in value else f"http://{value}").rstrip("/")
32
+
33
+
34
+ def credentials_path() -> pathlib.Path:
35
+ if os.getenv("HALIOS_CONFIG_HOME"):
36
+ root = pathlib.Path(os.environ["HALIOS_CONFIG_HOME"])
37
+ elif os.getenv("XDG_CONFIG_HOME"):
38
+ root = pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "halios"
39
+ elif platform.system() == "Darwin":
40
+ root = pathlib.Path.home() / "Library" / "Application Support" / "halios"
41
+ elif platform.system() == "Windows" and os.getenv("APPDATA"):
42
+ root = pathlib.Path(os.environ["APPDATA"]) / "Halios"
43
+ else:
44
+ root = pathlib.Path.home() / ".config" / "halios"
45
+ return root / "credentials.json"
46
+
47
+
48
+ def _read_credentials() -> dict[str, Any]:
49
+ path = credentials_path()
50
+ if not path.exists():
51
+ return {"version": 1, "profiles": {}}
52
+ try:
53
+ value = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError) as exc:
55
+ raise typer.BadParameter(f"Invalid CLI credential file: {exc}") from exc
56
+ return value if isinstance(value, dict) else {"version": 1, "profiles": {}}
57
+
58
+
59
+ def _write_credentials(value: dict[str, Any]) -> None:
60
+ path = credentials_path()
61
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
62
+ descriptor, temporary_name = tempfile.mkstemp(prefix="credentials-", dir=path.parent)
63
+ try:
64
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
65
+ json.dump(value, handle, indent=2, sort_keys=True)
66
+ handle.write("\n")
67
+ os.chmod(temporary_name, 0o600)
68
+ os.replace(temporary_name, path)
69
+ os.chmod(path, 0o600)
70
+ finally:
71
+ if os.path.exists(temporary_name):
72
+ os.unlink(temporary_name)
73
+
74
+
75
+ def save_profile(
76
+ profile: str,
77
+ *,
78
+ base_url: str,
79
+ api_key: str,
80
+ organization_id: str | None = None,
81
+ api_key_id: int | None = None,
82
+ expires_at: str | None = None,
83
+ ) -> None:
84
+ payload = _read_credentials()
85
+ profiles = payload.setdefault("profiles", {})
86
+ previous = profiles.get(profile) if isinstance(profiles.get(profile), dict) else {}
87
+ profiles[profile] = {
88
+ **previous,
89
+ "base_url": normalize_url(base_url),
90
+ "api_key": api_key,
91
+ "organization_id": organization_id,
92
+ "api_key_id": api_key_id,
93
+ "expires_at": expires_at,
94
+ }
95
+ _write_credentials(payload)
96
+
97
+
98
+ def delete_profile(profile: str) -> bool:
99
+ payload = _read_credentials()
100
+ removed = payload.setdefault("profiles", {}).pop(profile, None) is not None
101
+ if removed:
102
+ _write_credentials(payload)
103
+ return removed
104
+
105
+
106
+ def save_agent_ingest_token(profile: str, agent_id: str, token: str) -> None:
107
+ payload = _read_credentials()
108
+ entry = payload.setdefault("profiles", {}).get(profile)
109
+ if not isinstance(entry, dict):
110
+ raise typer.BadParameter(f"CLI profile '{profile}' is not logged in")
111
+ agents = entry.setdefault("agents", {})
112
+ agents[agent_id] = {"otlp_token": token}
113
+ _write_credentials(payload)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Credentials:
118
+ profile: str
119
+ base_url: str
120
+ api_key: str
121
+ organization_id: str | None
122
+ otlp_token: str | None = None
123
+ api_key_id: int | None = None
124
+ expires_at: str | None = None
125
+
126
+
127
+ def stored_profile_credentials(profile: str = "default") -> Credentials | None:
128
+ """Return one stored profile without allowing environment variables to replace its key."""
129
+ entry = _read_credentials().get("profiles", {}).get(profile)
130
+ if not isinstance(entry, dict) or not entry.get("api_key"):
131
+ return None
132
+ raw_key_id = entry.get("api_key_id")
133
+ return Credentials(
134
+ profile=profile,
135
+ base_url=normalize_url(str(entry.get("base_url") or DEFAULT_BASE_URL)),
136
+ api_key=str(entry["api_key"]),
137
+ organization_id=entry.get("organization_id"),
138
+ api_key_id=raw_key_id if isinstance(raw_key_id, int) else None,
139
+ expires_at=str(entry["expires_at"]) if entry.get("expires_at") else None,
140
+ )
141
+
142
+
143
+ def resolve_credentials(profile: str = "default", agent_id: str | None = None) -> Credentials:
144
+ payload = _read_credentials()
145
+ entry = payload.get("profiles", {}).get(profile)
146
+ env_key = os.getenv("HALIOS_API_KEY")
147
+ if not isinstance(entry, dict) and not env_key:
148
+ raise typer.BadParameter("Not logged in. Run `halios auth login` or set HALIOS_API_KEY.")
149
+ entry = entry if isinstance(entry, dict) else {}
150
+ base_url = normalize_url(
151
+ os.getenv("HALIOS_BASE_URL") or str(entry.get("base_url") or DEFAULT_BASE_URL)
152
+ )
153
+ # INTENT: CI runners need both control-plane and agent-scoped ingest credentials without
154
+ # writing a persistent profile to the ephemeral filesystem.
155
+ token = os.getenv("HALIOS_OTLP_TOKEN")
156
+ if agent_id:
157
+ agent_entry = (entry.get("agents") or {}).get(agent_id)
158
+ if not token and isinstance(agent_entry, dict):
159
+ token = agent_entry.get("otlp_token")
160
+ return Credentials(
161
+ profile=profile,
162
+ base_url=base_url,
163
+ api_key=env_key or str(entry.get("api_key") or ""),
164
+ organization_id=entry.get("organization_id"),
165
+ otlp_token=token,
166
+ api_key_id=entry.get("api_key_id") if isinstance(entry.get("api_key_id"), int) else None,
167
+ expires_at=str(entry["expires_at"]) if entry.get("expires_at") else None,
168
+ )
169
+
170
+
171
+ class ApiClient:
172
+ def __init__(self, credentials: Credentials):
173
+ from ._version import __version__
174
+
175
+ self.credentials = credentials
176
+ self.client = httpx.Client(
177
+ base_url=credentials.base_url,
178
+ headers={
179
+ "Authorization": f"Bearer {credentials.api_key}",
180
+ "User-Agent": f"haliosai-cli/{__version__}",
181
+ },
182
+ timeout=30.0,
183
+ )
184
+
185
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
186
+ response = self.client.request(method, path, **kwargs)
187
+ if response.is_error:
188
+ try:
189
+ detail = response.json().get("detail", response.text)
190
+ except (ValueError, AttributeError):
191
+ detail = response.text
192
+ raise ApiError(response.status_code, detail)
193
+ return response.json() if response.content else None
194
+
195
+ def close(self) -> None:
196
+ self.client.close()
197
+
198
+ def __enter__(self) -> "ApiClient":
199
+ return self
200
+
201
+ def __exit__(self, *_args: Any) -> None:
202
+ self.close()
203
+
204
+
205
+ def load_yaml(path: pathlib.Path) -> dict[str, Any]:
206
+ import yaml
207
+
208
+ if not path.exists():
209
+ raise typer.BadParameter(f"Missing required file: {path}")
210
+ value = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
211
+ if not isinstance(value, dict):
212
+ raise typer.BadParameter(f"YAML root must be an object: {path}")
213
+ return value
214
+
215
+
216
+ def write_yaml(path: pathlib.Path, value: dict[str, Any]) -> None:
217
+ import yaml
218
+
219
+ path.parent.mkdir(parents=True, exist_ok=True)
220
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f"{path.name}-", dir=path.parent)
221
+ try:
222
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
223
+ yaml.safe_dump(value, handle, sort_keys=False)
224
+ os.replace(temporary_name, path)
225
+ finally:
226
+ if os.path.exists(temporary_name):
227
+ os.unlink(temporary_name)
228
+
229
+
230
+ def atomic_write_text(path: pathlib.Path, content: str) -> None:
231
+ """Replace one text file atomically, preserving its existing permission bits."""
232
+ path.parent.mkdir(parents=True, exist_ok=True)
233
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f"{path.name}-", dir=path.parent)
234
+ try:
235
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
236
+ handle.write(content)
237
+ handle.flush()
238
+ os.fsync(handle.fileno())
239
+ if path.exists():
240
+ os.chmod(temporary_name, path.stat().st_mode & 0o777)
241
+ os.replace(temporary_name, path)
242
+ finally:
243
+ if os.path.exists(temporary_name):
244
+ os.unlink(temporary_name)
245
+
246
+
247
+ def evaluation_suite_digest(eval_plan: dict[str, Any], scenarios: dict[str, Any]) -> str:
248
+ canonical = json.dumps(
249
+ {"eval": eval_plan, "scenarios": scenarios},
250
+ sort_keys=True,
251
+ separators=(",", ":"),
252
+ ensure_ascii=False,
253
+ )
254
+ return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
255
+
256
+
257
+ def write_suite_checkout(
258
+ root: pathlib.Path,
259
+ *,
260
+ eval_plan: dict[str, Any],
261
+ scenarios: dict[str, Any],
262
+ revision: int,
263
+ digest: str | None,
264
+ ) -> None:
265
+ """Replace both canonical YAML values and their shared checkout revision."""
266
+ halios_dir = root / ".halios"
267
+ write_yaml(halios_dir / "eval.yml", eval_plan)
268
+ write_yaml(halios_dir / "scenarios.yml", scenarios)
269
+ config_path = halios_dir / "config.toml"
270
+ content = config_path.read_text(encoding="utf-8")
271
+ suite_block = (
272
+ f'[suite]\nrevision = {revision}\ndigest = "{str(digest or "").replace(chr(34), "")}"\n'
273
+ )
274
+ if "[suite]" in content:
275
+ content = re.sub(r"(?ms)^\[suite\]\n.*?(?=^\[|\Z)", suite_block + "\n", content)
276
+ else:
277
+ content = content.rstrip() + "\n\n" + suite_block
278
+ descriptor, temporary_name = tempfile.mkstemp(prefix="config-", dir=halios_dir)
279
+ try:
280
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
281
+ handle.write(content)
282
+ os.replace(temporary_name, config_path)
283
+ finally:
284
+ if os.path.exists(temporary_name):
285
+ os.unlink(temporary_name)
286
+
287
+
288
+ def preserve_suite_recovery(
289
+ *,
290
+ agent_id: str,
291
+ eval_plan: dict[str, Any],
292
+ scenarios: dict[str, Any],
293
+ ) -> pathlib.Path:
294
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
295
+ parent = credentials_path().parent / "recovery" / agent_id
296
+ parent.mkdir(parents=True, exist_ok=True)
297
+ recovery = pathlib.Path(tempfile.mkdtemp(prefix=f"{timestamp}-", dir=parent))
298
+ write_yaml(recovery / "eval.yml", eval_plan)
299
+ write_yaml(recovery / "scenarios.yml", scenarios)
300
+ return recovery
301
+
302
+
303
+ def load_project_config(root: pathlib.Path | None = None) -> tuple[pathlib.Path, dict[str, Any]]:
304
+ project_root = (root or pathlib.Path.cwd()).resolve()
305
+ path = project_root / ".halios" / "config.toml"
306
+ if not path.exists():
307
+ raise typer.BadParameter("No .halios/config.toml. Run `halios project init --agent ...`.")
308
+ try:
309
+ import tomllib
310
+ except ImportError: # pragma: no cover - Python 3.10
311
+ import tomli as tomllib # type: ignore[no-redef]
312
+ with path.open("rb") as handle:
313
+ config = tomllib.load(handle)
314
+ return project_root, config
315
+
316
+
317
+ def git_provenance(root: pathlib.Path) -> dict[str, Any]:
318
+ from ._version import __version__
319
+
320
+ def run(*args: str) -> str | None:
321
+ result = subprocess.run(
322
+ ["git", *args], cwd=root, text=True, capture_output=True, check=False
323
+ )
324
+ return result.stdout.strip() if result.returncode == 0 else None
325
+
326
+ commit_sha = run("rev-parse", "HEAD")
327
+ branch = run("branch", "--show-current")
328
+ repository = run("config", "--get", "remote.origin.url")
329
+ tracked_dirty = any(
330
+ subprocess.run(["git", *args], cwd=root, check=False).returncode != 0
331
+ for args in (("diff", "--quiet"), ("diff", "--cached", "--quiet"))
332
+ )
333
+ definition_paths = [
334
+ ".halios/config.toml",
335
+ ".halios/eval.yml",
336
+ ".halios/scenarios.yml",
337
+ ]
338
+ definitions_tracked = (
339
+ subprocess.run(
340
+ ["git", "ls-files", "--error-unmatch", *definition_paths],
341
+ cwd=root,
342
+ stdout=subprocess.DEVNULL,
343
+ stderr=subprocess.DEVNULL,
344
+ check=False,
345
+ ).returncode
346
+ == 0
347
+ )
348
+ github_run_id = os.getenv("GITHUB_RUN_ID")
349
+ github_repository = os.getenv("GITHUB_REPOSITORY")
350
+ github_server = os.getenv("GITHUB_SERVER_URL")
351
+ pipeline_url = os.getenv("CI_PIPELINE_URL")
352
+ if github_run_id and github_repository and github_server:
353
+ pipeline_url = f"{github_server}/{github_repository}/actions/runs/{github_run_id}"
354
+ return {
355
+ "repository": repository,
356
+ "branch": branch,
357
+ "commit_sha": commit_sha,
358
+ "dirty_worktree": tracked_dirty or not definitions_tracked,
359
+ "actor": os.getenv("GITHUB_ACTOR") or os.getenv("GITLAB_USER_LOGIN") or os.getenv("USER"),
360
+ "pipeline_id": github_run_id or os.getenv("CI_PIPELINE_ID"),
361
+ "pipeline_url": pipeline_url,
362
+ "job_id": os.getenv("GITHUB_JOB") or os.getenv("CI_JOB_ID"),
363
+ "environment": os.getenv("DEPLOYMENT_ENVIRONMENT")
364
+ or ("ci" if os.getenv("CI") else "local"),
365
+ "client_name": os.getenv("HALIOS_CLIENT_NAME") or "halios-cli",
366
+ "client_version": __version__,
367
+ # INTENT: Device identity is opt-in. Do not turn a raw hostname into a
368
+ # durable product identifier or leak it into shared evaluation data.
369
+ "source_label": os.getenv("HALIOS_SOURCE_LABEL"),
370
+ "service_name": os.getenv("OTEL_SERVICE_NAME"),
371
+ "service_instance_id": os.getenv("OTEL_SERVICE_INSTANCE_ID")
372
+ or os.getenv("HALIOS_SOURCE_ID"),
373
+ }
@@ -0,0 +1,175 @@
1
+ """Read-only trace evidence commands for coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ import typer
9
+
10
+ from .cli_support import ApiClient, load_project_config, resolve_credentials
11
+
12
+ app = typer.Typer(help="Inspect trace evidence and production failures.", no_args_is_help=True)
13
+
14
+
15
+ def _context():
16
+ _root, config = load_project_config()
17
+ agent_id = str((config.get("agent") or {}).get("id") or "")
18
+ credentials = resolve_credentials(str(config.get("profile") or "default"), agent_id)
19
+ return agent_id, credentials
20
+
21
+
22
+ def _emit(value: object, json_output: bool) -> None:
23
+ if json_output:
24
+ typer.echo(json.dumps(value, indent=2, sort_keys=True, default=str))
25
+ else:
26
+ items = value.get("data", value) if isinstance(value, dict) else value
27
+ if isinstance(items, list):
28
+ for item in items:
29
+ if isinstance(item, dict):
30
+ typer.echo(
31
+ "\t".join(
32
+ str(item.get(key) or "") for key in ("trace_id", "status", "created_at")
33
+ )
34
+ )
35
+ else:
36
+ typer.echo(json.dumps(value, indent=2, default=str))
37
+
38
+
39
+ @app.command("list")
40
+ def list_traces(
41
+ environment: str | None = typer.Option(None, "--environment"),
42
+ limit: int = typer.Option(50, "--limit", min=1, max=100),
43
+ json_output: bool = typer.Option(False, "--json"),
44
+ ) -> None:
45
+ agent_id, credentials = _context()
46
+ params = {"agent_id": agent_id, "limit": limit}
47
+ if environment:
48
+ params["traffic_scope"] = environment
49
+ with ApiClient(credentials) as api:
50
+ result = api.request("GET", "/api/v1/traces", params=params)
51
+ _emit(result, json_output)
52
+
53
+
54
+ @app.command("show")
55
+ def show(
56
+ trace_id: str,
57
+ include: str = typer.Option("spans,checks", "--include"),
58
+ json_output: bool = typer.Option(False, "--json"),
59
+ ) -> None:
60
+ _agent_id, credentials = _context()
61
+ with ApiClient(credentials) as api:
62
+ result = api.request("GET", f"/api/v1/traces/{trace_id}")
63
+ allowed = {item.strip() for item in include.split(",") if item.strip()}
64
+ if isinstance(result, dict):
65
+ if "spans" not in allowed:
66
+ result.pop("spans", None)
67
+ if "checks" not in allowed:
68
+ result.pop("check_executions", None)
69
+ _emit(result, json_output)
70
+
71
+
72
+ @app.command("failures")
73
+ def failures(
74
+ environment: str = typer.Option("production", "--environment"),
75
+ limit: int = typer.Option(100, "--limit", min=1, max=100),
76
+ json_output: bool = typer.Option(False, "--json"),
77
+ ) -> None:
78
+ """List failed evaluator evidence for the selected environment."""
79
+ agent_id, credentials = _context()
80
+ with ApiClient(credentials) as api:
81
+ result = api.request(
82
+ "GET",
83
+ f"/api/v1/agents/{agent_id}/check-executions",
84
+ params={
85
+ "mode": "evaluator",
86
+ "evaluation_context": environment,
87
+ "limit": limit,
88
+ "include_progress": False,
89
+ },
90
+ )
91
+ items = result.get("data") or []
92
+ result["data"] = [
93
+ item
94
+ for item in items
95
+ if item.get("passed") is False
96
+ or item.get("triggered") is True
97
+ or item.get("status") in {"failed", "error"}
98
+ or item.get("error")
99
+ ]
100
+ _emit(result, json_output)
101
+
102
+
103
+ @app.command("cluster")
104
+ def cluster(
105
+ environment: str = typer.Option("production", "--environment"),
106
+ json_output: bool = typer.Option(False, "--json"),
107
+ ) -> None:
108
+ """Cluster recent evaluator failures in one evidence environment."""
109
+ agent_id, credentials = _context()
110
+ with ApiClient(credentials) as api:
111
+ result = api.request(
112
+ "GET",
113
+ "/api/v1/scenarios/failure-clusters",
114
+ params={"agent_id": agent_id, "evaluation_context": environment},
115
+ )
116
+ _emit(result, json_output)
117
+
118
+
119
+ @app.command("verify")
120
+ def verify(
121
+ trace_id: str,
122
+ json_output: bool = typer.Option(False, "--json"),
123
+ ) -> None:
124
+ """Fail closed unless a stored runtime trace has usable standard OTel evidence."""
125
+ _agent_id, credentials = _context()
126
+ with ApiClient(credentials) as api:
127
+ detail = api.request("GET", f"/api/v1/traces/{trace_id}")
128
+ spans = detail.get("spans") or []
129
+ issues: list[str] = []
130
+ if not re.fullmatch(r"[0-9a-f]{32}", trace_id) or trace_id == "0" * 32:
131
+ issues.append("trace id is not a valid non-zero W3C trace id")
132
+ if detail.get("trace_id") != trace_id:
133
+ issues.append("trace identity does not match")
134
+ if not spans:
135
+ issues.append("trace contains no spans")
136
+ span_ids = {str(span.get("span_id")) for span in spans if isinstance(span, dict)}
137
+ roots = [span for span in spans if isinstance(span, dict) and not span.get("parent_span_id")]
138
+ if len(roots) != 1:
139
+ issues.append(f"trace must contain exactly one root span; found {len(roots)}")
140
+ for span in spans:
141
+ if not isinstance(span, dict):
142
+ issues.append("trace contains a malformed span")
143
+ continue
144
+ span_id = str(span.get("span_id") or "")
145
+ if not re.fullmatch(r"[0-9a-f]{16}", span_id) or span_id == "0" * 16:
146
+ issues.append(f"span {span_id or '<missing>'} has an invalid W3C span id")
147
+ parent = span.get("parent_span_id")
148
+ if parent and str(parent) not in span_ids:
149
+ issues.append(f"span {span.get('span_id')} has a missing parent")
150
+ if not span.get("ended_at"):
151
+ issues.append(f"span {span.get('span_id')} has no end time")
152
+ has_semantic_content = any(
153
+ isinstance(span, dict) and (span.get("input") or span.get("output")) for span in spans
154
+ )
155
+ if not has_semantic_content:
156
+ issues.append("trace has no captured input or output evidence")
157
+ attributes = [span.get("attributes") or {} for span in spans if isinstance(span, dict)]
158
+ if not any(value.get("resource.service.name") for value in attributes):
159
+ issues.append("trace has no service.name resource identity")
160
+ if not any(
161
+ value.get("resource.deployment.environment.name")
162
+ or value.get("resource.deployment.environment")
163
+ for value in attributes
164
+ ):
165
+ issues.append("trace has no deployment.environment.name resource identity")
166
+ result = {
167
+ "trace_id": trace_id,
168
+ "verified": not issues,
169
+ "span_count": len(spans),
170
+ "root_count": len(roots),
171
+ "issues": issues,
172
+ }
173
+ _emit(result, json_output)
174
+ if issues:
175
+ raise typer.Exit(2)
halios_cli/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ """Packaged strict schemas used by the Halios CLI."""
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.halios.ai/eval.schema.json",
4
+ "title": "Halios evaluation plan",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "name", "goals", "risks", "checks", "reliability_bar"],
8
+ "properties": {
9
+ "version": { "const": 1 },
10
+ "name": { "$ref": "#/$defs/nonEmptyString" },
11
+ "goals": { "$ref": "#/$defs/nonEmptyStringList" },
12
+ "risks": { "$ref": "#/$defs/nonEmptyStringList" },
13
+ "checks": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "items": { "$ref": "#/$defs/check" }
17
+ },
18
+ "reliability_bar": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["min_pass_rate", "hard_gates_must_pass"],
22
+ "properties": {
23
+ "min_pass_rate": { "type": "number", "minimum": 0, "maximum": 1 },
24
+ "hard_gates_must_pass": { "type": "boolean" }
25
+ }
26
+ }
27
+ },
28
+ "$defs": {
29
+ "nonEmptyString": { "type": "string", "minLength": 1 },
30
+ "stableId": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "maxLength": 120,
34
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
35
+ },
36
+ "nonEmptyStringList": {
37
+ "type": "array",
38
+ "minItems": 1,
39
+ "items": { "$ref": "#/$defs/nonEmptyString" }
40
+ },
41
+ "rule": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "required": ["id", "type", "config"],
45
+ "properties": {
46
+ "id": { "$ref": "#/$defs/stableId" },
47
+ "name": { "$ref": "#/$defs/nonEmptyString" },
48
+ "type": {
49
+ "type": "string",
50
+ "enum": [
51
+ "classifier", "contains", "equals", "exists", "fuzzy", "greater_than",
52
+ "is_empty", "is_null", "json_schema", "less_than", "llm_judge", "not_empty",
53
+ "not_null", "one_of", "regex"
54
+ ]
55
+ },
56
+ "field": { "$ref": "#/$defs/nonEmptyString" },
57
+ "operator": { "type": "string", "enum": ["AND", "OR"] },
58
+ "key": { "type": ["string", "null"] },
59
+ "config": { "type": "object" }
60
+ }
61
+ },
62
+ "evaluationConfig": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": ["task_name", "score_threshold"],
66
+ "properties": {
67
+ "task_name": { "$ref": "#/$defs/nonEmptyString" },
68
+ "task_slug": { "$ref": "#/$defs/nonEmptyString" },
69
+ "aggregate_method": {
70
+ "type": "string",
71
+ "enum": ["average", "weighted_average", "max", "min"]
72
+ },
73
+ "score_threshold": { "type": "number", "minimum": 0, "maximum": 1 },
74
+ "tags": { "type": "array", "items": { "type": "string" } },
75
+ "per_event": { "type": "boolean" }
76
+ }
77
+ },
78
+ "check": {
79
+ "type": "object",
80
+ "additionalProperties": false,
81
+ "required": ["id", "name", "category", "target", "scope", "pass_logic", "rules", "evaluation_config"],
82
+ "properties": {
83
+ "id": { "$ref": "#/$defs/stableId" },
84
+ "name": { "$ref": "#/$defs/nonEmptyString" },
85
+ "description": { "type": ["string", "null"] },
86
+ "category": { "type": "string", "enum": ["safety", "quality", "compliance", "custom"] },
87
+ "protected": { "type": "boolean" },
88
+ "target": {
89
+ "type": "string",
90
+ "enum": ["user_message", "assistant_message", "tool_usage", "full_conversation"]
91
+ },
92
+ "scope": {
93
+ "type": "string",
94
+ "enum": ["all", "last_n", "first_n", "after_tool", "tool_name", "input_arguments", "output_values", "tool_call_context", "entire"]
95
+ },
96
+ "tool_name": { "$ref": "#/$defs/nonEmptyString" },
97
+ "field": { "$ref": "#/$defs/nonEmptyString" },
98
+ "scope_params": { "type": "object" },
99
+ "pass_logic": { "type": "string", "enum": ["all", "any"] },
100
+ "rules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/rule" } },
101
+ "evaluation_config": { "$ref": "#/$defs/evaluationConfig" }
102
+ }
103
+ }
104
+ }
105
+ }