agentops-accelerator 0.3.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.
Files changed (142) hide show
  1. agentops/__init__.py +10 -0
  2. agentops/__main__.py +6 -0
  3. agentops/agent/__init__.py +12 -0
  4. agentops/agent/_legacy_ids.py +92 -0
  5. agentops/agent/analyzer.py +207 -0
  6. agentops/agent/checks/__init__.py +1 -0
  7. agentops/agent/checks/catalog.py +880 -0
  8. agentops/agent/checks/errors.py +279 -0
  9. agentops/agent/checks/foundry_config.py +75 -0
  10. agentops/agent/checks/latency.py +84 -0
  11. agentops/agent/checks/opex.py +157 -0
  12. agentops/agent/checks/opex_workspace.py +874 -0
  13. agentops/agent/checks/posture.py +36 -0
  14. agentops/agent/checks/posture_rules/__init__.py +53 -0
  15. agentops/agent/checks/posture_rules/content_filter.py +59 -0
  16. agentops/agent/checks/posture_rules/diagnostics.py +74 -0
  17. agentops/agent/checks/posture_rules/local_auth.py +55 -0
  18. agentops/agent/checks/posture_rules/managed_identity.py +59 -0
  19. agentops/agent/checks/posture_rules/network.py +68 -0
  20. agentops/agent/checks/regression.py +78 -0
  21. agentops/agent/checks/release_readiness.py +182 -0
  22. agentops/agent/checks/safety.py +247 -0
  23. agentops/agent/checks/spec_conformance.py +375 -0
  24. agentops/agent/cockpit.py +5159 -0
  25. agentops/agent/config.py +240 -0
  26. agentops/agent/findings.py +113 -0
  27. agentops/agent/history.py +142 -0
  28. agentops/agent/knowledge/__init__.py +182 -0
  29. agentops/agent/knowledge/waf-checklist.csv +39 -0
  30. agentops/agent/llm_assist/__init__.py +16 -0
  31. agentops/agent/llm_assist/_base.py +124 -0
  32. agentops/agent/llm_assist/_bundle_rule.py +154 -0
  33. agentops/agent/llm_assist/_client.py +347 -0
  34. agentops/agent/llm_assist/_dataset_rules.py +191 -0
  35. agentops/agent/llm_assist/_engine.py +106 -0
  36. agentops/agent/llm_assist/_prompt_rules.py +291 -0
  37. agentops/agent/llm_assist/_spec_rules.py +235 -0
  38. agentops/agent/production_telemetry.py +430 -0
  39. agentops/agent/report.py +207 -0
  40. agentops/agent/server/__init__.py +1 -0
  41. agentops/agent/server/app.py +84 -0
  42. agentops/agent/server/auth.py +94 -0
  43. agentops/agent/server/chat.py +44 -0
  44. agentops/agent/server/protocol.py +72 -0
  45. agentops/agent/sources/__init__.py +1 -0
  46. agentops/agent/sources/azure_monitor.py +523 -0
  47. agentops/agent/sources/azure_resources.py +602 -0
  48. agentops/agent/sources/foundry_control.py +174 -0
  49. agentops/agent/sources/results_history.py +494 -0
  50. agentops/agent/sources/spec_detectors/__init__.py +42 -0
  51. agentops/agent/sources/spec_detectors/_base.py +58 -0
  52. agentops/agent/sources/spec_detectors/agents_md.py +75 -0
  53. agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
  54. agentops/agent/time_range.py +117 -0
  55. agentops/cli/__init__.py +1 -0
  56. agentops/cli/app.py +4823 -0
  57. agentops/core/__init__.py +1 -0
  58. agentops/core/agentops_config.py +592 -0
  59. agentops/core/config_loader.py +22 -0
  60. agentops/core/evaluators.py +480 -0
  61. agentops/core/release_evidence.py +56 -0
  62. agentops/core/results.py +117 -0
  63. agentops/mcp/__init__.py +10 -0
  64. agentops/mcp/server.py +232 -0
  65. agentops/pipeline/__init__.py +8 -0
  66. agentops/pipeline/cloud_results.py +189 -0
  67. agentops/pipeline/cloud_runner.py +901 -0
  68. agentops/pipeline/comparison.py +108 -0
  69. agentops/pipeline/diagnostics.py +51 -0
  70. agentops/pipeline/invocations.py +535 -0
  71. agentops/pipeline/official_eval.py +414 -0
  72. agentops/pipeline/orchestrator.py +775 -0
  73. agentops/pipeline/prompt_deploy.py +377 -0
  74. agentops/pipeline/publisher.py +121 -0
  75. agentops/pipeline/reporter.py +202 -0
  76. agentops/pipeline/runtime.py +409 -0
  77. agentops/pipeline/thresholds.py +84 -0
  78. agentops/services/__init__.py +1 -0
  79. agentops/services/cicd.py +720 -0
  80. agentops/services/eval_analysis.py +848 -0
  81. agentops/services/evidence_pack.py +757 -0
  82. agentops/services/initializer.py +86 -0
  83. agentops/services/preflight.py +470 -0
  84. agentops/services/setup_wizard.py +709 -0
  85. agentops/services/skills.py +643 -0
  86. agentops/services/trace_promotion.py +300 -0
  87. agentops/services/workflow_analysis.py +1129 -0
  88. agentops/templates/.gitignore +15 -0
  89. agentops/templates/__init__.py +1 -0
  90. agentops/templates/agent-server/Dockerfile +23 -0
  91. agentops/templates/agent-server/README.md +61 -0
  92. agentops/templates/agent-server/main.bicep +94 -0
  93. agentops/templates/agent.yaml +87 -0
  94. agentops/templates/agentops.yaml +58 -0
  95. agentops/templates/foundry.svg +71 -0
  96. agentops/templates/icon.png +0 -0
  97. agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
  98. agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
  99. agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
  100. agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
  101. agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
  102. agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
  103. agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
  104. agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
  105. agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
  106. agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
  107. agentops/templates/project.gitignore +36 -0
  108. agentops/templates/sample-traces.jsonl +3 -0
  109. agentops/templates/skills/agentops-agent/SKILL.md +137 -0
  110. agentops/templates/skills/agentops-config/SKILL.md +113 -0
  111. agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
  112. agentops/templates/skills/agentops-eval/SKILL.md +189 -0
  113. agentops/templates/skills/agentops-report/SKILL.md +71 -0
  114. agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
  115. agentops/templates/smoke.jsonl +3 -0
  116. agentops/templates/waf-checklist.README.md +84 -0
  117. agentops/templates/waf-checklist.csv +22 -0
  118. agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
  119. agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
  120. agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
  121. agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
  122. agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
  123. agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
  124. agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
  125. agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
  126. agentops/templates/workflows/agentops-pr.yml +148 -0
  127. agentops/templates/workflows/agentops-watchdog.yml +122 -0
  128. agentops/utils/__init__.py +1 -0
  129. agentops/utils/azd_env.py +435 -0
  130. agentops/utils/azure_endpoints.py +62 -0
  131. agentops/utils/colors.py +47 -0
  132. agentops/utils/dotenv_loader.py +105 -0
  133. agentops/utils/foundry_discovery.py +229 -0
  134. agentops/utils/logging.py +59 -0
  135. agentops/utils/telemetry.py +554 -0
  136. agentops/utils/yaml.py +36 -0
  137. agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
  138. agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
  139. agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
  140. agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
  141. agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
  142. agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
agentops/mcp/server.py ADDED
@@ -0,0 +1,232 @@
1
+ """AgentOps MCP server (stdio transport).
2
+
3
+ This module is imported lazily - the ``mcp`` extra is optional. Importing
4
+ this file triggers ``import mcp.server.fastmcp`` which fails with a clear
5
+ message if the extra is not installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ def _build_server() -> Any:
16
+ """Construct and return the FastMCP server.
17
+
18
+ Imports of ``mcp`` happen inside the function so that ``agentops --help``
19
+ keeps working when the optional extra is not installed.
20
+ """
21
+ try:
22
+ from mcp.server.fastmcp import FastMCP
23
+ except ImportError as exc: # pragma: no cover - exercised only without extra
24
+ raise RuntimeError(
25
+ "The MCP server requires the 'mcp' extra. "
26
+ "Install it with: pip install agentops-accelerator[mcp]"
27
+ ) from exc
28
+
29
+ server = FastMCP("agentops")
30
+
31
+ @server.tool()
32
+ def agentops_init(directory: str = ".", force: bool = False) -> Dict[str, Any]:
33
+ """Initialise an AgentOps workspace at ``directory``.
34
+
35
+ Creates ``agentops.yaml`` at the project root and a tiny seed
36
+ dataset under ``.agentops/data/smoke.jsonl``. Set ``force=True``
37
+ to overwrite existing files.
38
+ """
39
+ from agentops.services.initializer import initialize_flat_workspace
40
+
41
+ result = initialize_flat_workspace(Path(directory), force=force)
42
+ return {
43
+ "workspace_dir": str(result.workspace_dir),
44
+ "created_files": [str(p) for p in result.created_files],
45
+ "overwritten_files": [str(p) for p in result.overwritten_files],
46
+ "skipped_files": [str(p) for p in result.skipped_files],
47
+ }
48
+
49
+ @server.tool()
50
+ def agentops_eval_run(
51
+ config_path: Optional[str] = None,
52
+ output_dir: Optional[str] = None,
53
+ baseline: Optional[str] = None,
54
+ ) -> Dict[str, Any]:
55
+ """Run an AgentOps evaluation and return a summary.
56
+
57
+ ``config_path`` defaults to ``agentops.yaml`` in the current
58
+ directory. ``output_dir`` defaults to
59
+ ``.agentops/results/latest``. ``baseline`` is an optional path to
60
+ a previous ``results.json`` for comparison.
61
+ """
62
+ from agentops.core.config_loader import load_agentops_config
63
+ from agentops.pipeline.orchestrator import (
64
+ RunOptions,
65
+ exit_code_from,
66
+ run_evaluation,
67
+ )
68
+
69
+ config = Path(config_path) if config_path else Path("agentops.yaml")
70
+ if not config.exists():
71
+ return {
72
+ "ok": False,
73
+ "exit_code": 1,
74
+ "error": f"config not found at {config}",
75
+ }
76
+ try:
77
+ config_obj = load_agentops_config(config)
78
+ except Exception as exc: # noqa: BLE001
79
+ return {
80
+ "ok": False,
81
+ "exit_code": 1,
82
+ "error": f"failed to load config: {exc}",
83
+ }
84
+
85
+ out_dir = (
86
+ Path(output_dir)
87
+ if output_dir
88
+ else config.parent / ".agentops" / "results" / "latest"
89
+ )
90
+ options = RunOptions(
91
+ config_path=config.resolve(),
92
+ output_dir=out_dir,
93
+ baseline_path=Path(baseline).resolve() if baseline else None,
94
+ )
95
+ try:
96
+ run = run_evaluation(config_obj, options=options)
97
+ except Exception as exc: # noqa: BLE001
98
+ return {"ok": False, "exit_code": 1, "error": f"evaluation failed: {exc}"}
99
+
100
+ return {
101
+ "ok": True,
102
+ "exit_code": exit_code_from(run),
103
+ "output_dir": str(out_dir),
104
+ "results_json": str(out_dir / "results.json"),
105
+ "report_md": str(out_dir / "report.md"),
106
+ "passed": bool(run.summary.overall_passed),
107
+ "metrics": dict(run.aggregate_metrics or {}),
108
+ }
109
+
110
+ @server.tool()
111
+ def agentops_report_show(report_path: Optional[str] = None) -> Dict[str, Any]:
112
+ """Return the contents of ``report.md`` for a finished run.
113
+
114
+ Defaults to ``.agentops/results/latest/report.md``.
115
+ """
116
+ path = (
117
+ Path(report_path)
118
+ if report_path
119
+ else Path(".agentops/results/latest/report.md")
120
+ )
121
+ if not path.exists():
122
+ return {"ok": False, "error": f"report not found at {path}"}
123
+ return {
124
+ "ok": True,
125
+ "path": str(path),
126
+ "markdown": path.read_text(encoding="utf-8"),
127
+ }
128
+
129
+ @server.tool()
130
+ def agentops_results_summary(results_path: Optional[str] = None) -> Dict[str, Any]:
131
+ """Return a compact JSON summary extracted from ``results.json``.
132
+
133
+ Defaults to ``.agentops/results/latest/results.json``.
134
+ """
135
+ path = (
136
+ Path(results_path)
137
+ if results_path
138
+ else Path(".agentops/results/latest/results.json")
139
+ )
140
+ if not path.exists():
141
+ return {"ok": False, "error": f"results not found at {path}"}
142
+ try:
143
+ data = json.loads(path.read_text(encoding="utf-8"))
144
+ except Exception as exc: # noqa: BLE001
145
+ return {"ok": False, "error": f"failed to parse results.json: {exc}"}
146
+
147
+ summary = data.get("summary") or {}
148
+ return {
149
+ "ok": True,
150
+ "path": str(path),
151
+ "version": data.get("version"),
152
+ "target": data.get("target"),
153
+ "rows": len(data.get("rows") or []),
154
+ "metrics": summary.get("metrics") or {},
155
+ "thresholds": summary.get("thresholds") or {},
156
+ "overall_passed": summary.get("overall_passed"),
157
+ }
158
+
159
+ @server.tool()
160
+ def agentops_dataset_add(
161
+ dataset_path: str,
162
+ rows: List[Dict[str, Any]],
163
+ ) -> Dict[str, Any]:
164
+ """Append JSONL rows to ``dataset_path``.
165
+
166
+ Creates the parent directory if needed. Each row must be a JSON
167
+ object - typical keys are ``input``, ``expected``, ``context``,
168
+ and ``tool_calls`` depending on the agent type.
169
+ """
170
+ if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
171
+ return {"ok": False, "error": "rows must be a list of JSON objects"}
172
+ path = Path(dataset_path)
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ with path.open("a", encoding="utf-8") as fh:
175
+ for row in rows:
176
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
177
+ return {"ok": True, "path": str(path), "appended": len(rows)}
178
+
179
+ @server.tool()
180
+ def agentops_list_runs(workspace_dir: str = ".") -> Dict[str, Any]:
181
+ """List historical runs under ``<workspace_dir>/.agentops/results/``."""
182
+ results_dir = Path(workspace_dir) / ".agentops" / "results"
183
+ if not results_dir.is_dir():
184
+ return {"ok": True, "runs": []}
185
+ runs: List[Dict[str, Any]] = []
186
+ for entry in sorted(results_dir.iterdir(), key=lambda p: p.name):
187
+ if not entry.is_dir():
188
+ continue
189
+ results_json = entry / "results.json"
190
+ run_info: Dict[str, Any] = {
191
+ "name": entry.name,
192
+ "path": str(entry),
193
+ "has_results": results_json.exists(),
194
+ }
195
+ if results_json.exists():
196
+ try:
197
+ payload = json.loads(results_json.read_text(encoding="utf-8"))
198
+ summary = payload.get("summary") or {}
199
+ run_info["overall_passed"] = summary.get("overall_passed")
200
+ run_info["metrics"] = summary.get("metrics") or {}
201
+ except Exception: # noqa: BLE001
202
+ pass
203
+ runs.append(run_info)
204
+ return {"ok": True, "runs": runs}
205
+
206
+ @server.tool()
207
+ def agentops_workflow_init(
208
+ directory: str = ".",
209
+ force: bool = False,
210
+ ) -> Dict[str, Any]:
211
+ """Generate GitHub Actions workflows for AgentOps evaluation."""
212
+ from agentops.services.cicd import generate_cicd_workflows
213
+
214
+ result = generate_cicd_workflows(directory=Path(directory), force=force)
215
+ return {
216
+ "ok": True,
217
+ "created_files": [str(p) for p in result.created_files],
218
+ "overwritten_files": [str(p) for p in result.overwritten_files],
219
+ "skipped_files": [str(p) for p in result.skipped_files],
220
+ }
221
+
222
+ return server
223
+
224
+
225
+ def serve_stdio() -> None:
226
+ """Entry point for ``agentops mcp serve``.
227
+
228
+ Builds the FastMCP server and runs it on the stdio transport. This
229
+ function blocks until the client disconnects.
230
+ """
231
+ server = _build_server()
232
+ server.run()
@@ -0,0 +1,8 @@
1
+ """AgentOps 1.0 evaluation pipeline.
2
+
3
+ Public entry point: :func:`agentops.pipeline.orchestrator.run_evaluation`.
4
+ """
5
+
6
+ from agentops.pipeline.orchestrator import run_evaluation
7
+
8
+ __all__ = ["run_evaluation"]
@@ -0,0 +1,189 @@
1
+ """Map Foundry cloud eval output items into AgentOps result shapes.
2
+
3
+ When ``execution: cloud`` is used in ``agentops.yaml``, the agent and
4
+ evaluators run server-side via the Foundry / OpenAI Evals API. We then
5
+ download per-row ``output_items`` from Foundry and reshape them into the
6
+ same :class:`RowResult` / :class:`RunResult` schema that local execution
7
+ produces, so downstream consumers (``report.md``, ``--baseline`` diffing,
8
+ CI gates) behave identically regardless of where the run executed.
9
+
10
+ The cloud output schema is intentionally loose: we accept multiple field
11
+ spellings (``output_text`` / ``output`` / ``message``; ``score`` /
12
+ ``value`` / ``passed``) and fall back gracefully when a field is absent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from agentops.core.results import RowMetric, RowResult
21
+
22
+
23
+ def rows_from_cloud_output_items(
24
+ output_items: List[Dict[str, Any]],
25
+ ) -> List[RowResult]:
26
+ """Build a list of :class:`RowResult` from raw Foundry output items.
27
+
28
+ ``output_items`` is the list returned by
29
+ ``cloud_runner._list_output_items``. Each item is a dict with at
30
+ least ``datasource_item``, ``sample`` and ``results``; missing keys
31
+ yield blank fields rather than raising.
32
+ """
33
+ rows: List[RowResult] = []
34
+ for index, item in enumerate(output_items):
35
+ rows.append(_row_from_item(index, item))
36
+ return rows
37
+
38
+
39
+ def _row_from_item(index: int, item: Dict[str, Any]) -> RowResult:
40
+ datasource = _as_dict(item.get("datasource_item")) or {}
41
+ sample = _as_dict(item.get("sample")) or {}
42
+ results = item.get("results") or []
43
+
44
+ metrics: List[RowMetric] = []
45
+ if isinstance(results, list):
46
+ for entry in results:
47
+ metric = _metric_from_result(entry)
48
+ if metric is not None:
49
+ metrics.append(metric)
50
+
51
+ return RowResult(
52
+ row_index=index,
53
+ input=_as_str(datasource.get("input")),
54
+ expected=_optional_str(datasource.get("expected")),
55
+ response=_extract_response_text(sample),
56
+ context=_optional_str(datasource.get("context")),
57
+ latency_seconds=None, # Foundry-side latency is not client-perceived.
58
+ tool_calls=datasource.get("tool_calls") if isinstance(datasource.get("tool_calls"), list) else None,
59
+ metrics=metrics,
60
+ error=_extract_item_error(item),
61
+ )
62
+
63
+
64
+ def _metric_from_result(entry: Any) -> Optional[RowMetric]:
65
+ if not isinstance(entry, dict):
66
+ return None
67
+ name = entry.get("name") or entry.get("metric")
68
+ if not isinstance(name, str) or not name:
69
+ return None
70
+ score = _coerce_float(
71
+ entry.get("score"),
72
+ entry.get("value"),
73
+ entry.get("result"),
74
+ )
75
+ if score is None and isinstance(entry.get("passed"), bool):
76
+ score = 1.0 if entry["passed"] else 0.0
77
+ reason = entry.get("reason") if isinstance(entry.get("reason"), str) else None
78
+ err = entry.get("error") if isinstance(entry.get("error"), str) else None
79
+ return RowMetric(name=name, value=score, error=err, reason=reason)
80
+
81
+
82
+ def _extract_response_text(sample: Dict[str, Any]) -> str:
83
+ """Reach into a Foundry sample payload and pull a plain text response.
84
+
85
+ Foundry's sample shape varies: sometimes the response is a clean string
86
+ under ``output_text``, sometimes it's a list of output items under
87
+ ``output`` / ``output_items``, and occasionally ``output_text`` is set
88
+ to a JSON-encoded version of the structured output. Try structured
89
+ fields first (they're authoritative), and recurse into JSON-encoded
90
+ strings rather than passing them through as the response.
91
+ """
92
+ # 1. Structured fields are authoritative. Walk them first.
93
+ for key in ("output", "messages", "output_items"):
94
+ text = _text_from_structured(sample.get(key))
95
+ if text:
96
+ return text
97
+
98
+ # 2. Flat string fields. If the value looks like JSON, parse and recurse.
99
+ for key in ("output_text", "text", "content"):
100
+ value = sample.get(key)
101
+ if isinstance(value, str) and value:
102
+ stripped = value.strip()
103
+ if stripped.startswith("[") or stripped.startswith("{"):
104
+ try:
105
+ parsed = json.loads(stripped)
106
+ except (ValueError, TypeError):
107
+ return value
108
+ if isinstance(parsed, list):
109
+ text = _text_from_structured(parsed)
110
+ if text:
111
+ return text
112
+ elif isinstance(parsed, dict):
113
+ return _extract_response_text(parsed)
114
+ # Fall through to raw value if we couldn't extract.
115
+ return value
116
+ if isinstance(value, list):
117
+ text = _text_from_structured(value)
118
+ if text:
119
+ return text
120
+ return ""
121
+
122
+
123
+ def _text_from_structured(value: Any) -> str:
124
+ """Walk a list-of-dicts (output / messages / output_items shape) and
125
+ return the first textual payload encountered, or ``""`` when none is
126
+ found. Iterates in reverse so the assistant's final message wins.
127
+ """
128
+ if not isinstance(value, list):
129
+ return ""
130
+ for entry in reversed(value):
131
+ if not isinstance(entry, dict):
132
+ continue
133
+ # Try flat text fields first.
134
+ for field in ("output_text", "text"):
135
+ candidate = entry.get(field)
136
+ if isinstance(candidate, str) and candidate:
137
+ return candidate
138
+ # Some shapes nest under "content" as either a string or a list of
139
+ # content blocks (OpenAI Responses API: content: [{type, text}, ...]).
140
+ nested = entry.get("content")
141
+ if isinstance(nested, str) and nested:
142
+ return nested
143
+ if isinstance(nested, list):
144
+ text = _text_from_structured(nested)
145
+ if text:
146
+ return text
147
+ return ""
148
+
149
+
150
+ def _extract_item_error(item: Dict[str, Any]) -> Optional[str]:
151
+ err = item.get("error")
152
+ if isinstance(err, str) and err:
153
+ return err
154
+ if isinstance(err, dict):
155
+ msg = err.get("message") or err.get("error")
156
+ if isinstance(msg, str) and msg:
157
+ return msg
158
+ status = item.get("status")
159
+ if isinstance(status, str) and status.lower() in {"failed", "error"}:
160
+ return f"output item status: {status}"
161
+ return None
162
+
163
+
164
+ def _as_dict(value: Any) -> Optional[Dict[str, Any]]:
165
+ return value if isinstance(value, dict) else None
166
+
167
+
168
+ def _as_str(value: Any) -> str:
169
+ return value if isinstance(value, str) else ""
170
+
171
+
172
+ def _optional_str(value: Any) -> Optional[str]:
173
+ return value if isinstance(value, str) and value else None
174
+
175
+
176
+ def _coerce_float(*candidates: Any) -> Optional[float]:
177
+ for value in candidates:
178
+ if value is None:
179
+ continue
180
+ if isinstance(value, bool):
181
+ return 1.0 if value else 0.0
182
+ if isinstance(value, (int, float)):
183
+ return float(value)
184
+ if isinstance(value, str):
185
+ try:
186
+ return float(value)
187
+ except ValueError:
188
+ continue
189
+ return None