agentloop-profiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/server.py ADDED
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+
10
+ from agentloop.config import get_admin_api_key, get_api_key, get_cors_origins, require_api_key
11
+ from agentloop.findings import build_diagnosis
12
+ from agentloop.intervention_service import create_stored_intervention
13
+ from agentloop.interventions import (
14
+ InterventionConflictError,
15
+ InterventionReferenceError,
16
+ InterventionValidationError,
17
+ )
18
+ from agentloop.issues import build_issue_drafts
19
+ from agentloop.optimizer import build_optimization_plan
20
+ from agentloop.quality import QualityValidationError, build_quality_report
21
+ from agentloop.ranking import RankingSort, sort_ranked
22
+ from agentloop.schema import TraceValidationError, validate_elapsed_ms
23
+ from agentloop.store import (
24
+ DEFAULT_PAGE_SIZE,
25
+ MAX_PAGE_SIZE,
26
+ FindingNotFoundError,
27
+ FindingTransitionError,
28
+ InvalidCursorError,
29
+ TraceProjectConflictError,
30
+ TraceStore,
31
+ get_store,
32
+ )
33
+ from agentloop.tracer import AgentTrace
34
+ from agentloop.value import build_value_report
35
+ from agentloop.version import __version__
36
+
37
+ app = FastAPI(
38
+ title="AgentLoop API",
39
+ version=__version__,
40
+ description="API v1 is served under /v1. The info version is the server package version. "
41
+ "Unversioned application routes are deprecated aliases retained throughout 0.x; "
42
+ "their earliest removal is 1.0 with release notice. Health/readiness aliases remain supported.",
43
+ )
44
+ versioned = APIRouter(tags=["API v1"])
45
+ legacy = APIRouter(deprecated=True, tags=["Deprecated aliases"])
46
+
47
+ _cors_origins = get_cors_origins()
48
+ if _cors_origins:
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=_cors_origins,
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+
58
+ class TracePayload(BaseModel):
59
+ name: str
60
+ run_id: str
61
+ schema_version: str | None = None
62
+ started_at: str | None = None
63
+ ended_at: str | None = None
64
+ elapsed_ms: float | None = Field(default=None, ge=0)
65
+ metadata: dict[str, Any] = Field(default_factory=dict)
66
+ events: list[dict[str, Any]] = Field(default_factory=list)
67
+
68
+ @field_validator("elapsed_ms", mode="before")
69
+ @classmethod
70
+ def validate_elapsed(cls, value):
71
+ try:
72
+ return validate_elapsed_ms(value)
73
+ except TraceValidationError as exc:
74
+ # Keep non-finite input out of Pydantic's JSON error response as well
75
+ # as storage, and preserve the native schema's field/reason contract.
76
+ raise HTTPException(
77
+ status_code=422, detail={"field": exc.field, "reason": exc.reason}
78
+ ) from None
79
+
80
+
81
+ class CreateApiKeyPayload(BaseModel):
82
+ project_id: str = "default"
83
+ name: str = "default"
84
+
85
+
86
+ class InterventionPayload(BaseModel):
87
+ model_config = ConfigDict(extra="forbid")
88
+ baseline_run_id: str
89
+ candidate_run_id: str
90
+ target_finding_ids: list[str]
91
+ intervention_type: str
92
+ configuration: dict[str, Any] = Field(default_factory=dict)
93
+ metadata: dict[str, Any] = Field(default_factory=dict)
94
+ gates: dict[str, Any] = Field(default_factory=dict)
95
+ quality_fixtures: list[dict[str, Any]] | None = None
96
+
97
+
98
+ class QualityReportPayload(BaseModel):
99
+ fixtures: list[dict[str, Any]]
100
+ baseline_run_id: str | None = None
101
+ candidate_run_id: str | None = None
102
+ min_score: float | None = Field(default=None, ge=0, le=1)
103
+
104
+ @field_validator("min_score", mode="before")
105
+ @classmethod
106
+ def validate_min_score_type(cls, value: Any) -> Any:
107
+ if value is not None and (isinstance(value, bool) or not isinstance(value, int | float)):
108
+ raise ValueError("min_score must be a number between 0 and 1")
109
+ return value
110
+
111
+
112
+ def store() -> TraceStore:
113
+ db = get_store()
114
+ db.init()
115
+ return db
116
+
117
+
118
+ def resolve_project(
119
+ x_agentloop_key: str | None = Header(default=None),
120
+ db: TraceStore = Depends(store),
121
+ ) -> str:
122
+ if x_agentloop_key:
123
+ key_record = db.verify_api_key(x_agentloop_key)
124
+ if key_record is not None:
125
+ return str(key_record["project_id"])
126
+
127
+ expected = get_api_key()
128
+ if require_api_key() and expected and hmac.compare_digest(x_agentloop_key, expected):
129
+ return "default"
130
+
131
+ raise HTTPException(status_code=401, detail="invalid AgentLoop API key")
132
+
133
+ if require_api_key():
134
+ raise HTTPException(status_code=401, detail="missing AgentLoop API key")
135
+
136
+ return "default"
137
+
138
+
139
+ def resolve_admin(x_agentloop_admin_key: str | None = Header(default=None)) -> None:
140
+ admin_key = get_admin_api_key()
141
+ if admin_key:
142
+ if x_agentloop_admin_key and hmac.compare_digest(x_agentloop_admin_key, admin_key):
143
+ return
144
+ raise HTTPException(status_code=401, detail="invalid AgentLoop admin API key")
145
+
146
+ if require_api_key():
147
+ raise HTTPException(
148
+ status_code=503,
149
+ detail="AGENTLOOP_ADMIN_API_KEY is required to create API keys when API auth is enabled",
150
+ )
151
+
152
+
153
+ @versioned.get("/health")
154
+ @app.get("/health")
155
+ def health() -> dict[str, str | bool]:
156
+ return {"status": "ok", "version": __version__, "auth_required": require_api_key()}
157
+
158
+
159
+ @versioned.get("/readyz")
160
+ @app.get("/readyz")
161
+ def readyz(db: TraceStore = Depends(store)) -> dict[str, str]:
162
+ db.usage_summary(project_id="default")
163
+ return {"status": "ready"}
164
+
165
+
166
+ @versioned.post("/api-keys")
167
+ @legacy.post("/api-keys")
168
+ def create_api_key(
169
+ payload: CreateApiKeyPayload,
170
+ _: None = Depends(resolve_admin),
171
+ db: TraceStore = Depends(store),
172
+ ) -> dict[str, Any]:
173
+ return db.create_api_key(project_id=payload.project_id, name=payload.name)
174
+
175
+
176
+ @versioned.post("/traces")
177
+ @legacy.post("/traces")
178
+ def ingest_trace(
179
+ payload: TracePayload,
180
+ project_id: str = Depends(resolve_project),
181
+ db: TraceStore = Depends(store),
182
+ ) -> dict[str, Any]:
183
+ try:
184
+ trace = AgentTrace.from_dict(payload.model_dump())
185
+ except TraceValidationError as exc:
186
+ raise HTTPException(
187
+ status_code=422, detail={"field": exc.field, "reason": exc.reason}
188
+ ) from None
189
+ try:
190
+ db.save_trace(trace, project_id=project_id)
191
+ except TraceProjectConflictError as exc:
192
+ raise HTTPException(status_code=409, detail=str(exc)) from None
193
+ return {"ok": True, "run_id": trace.run_id, "project_id": project_id, "report": trace.report()}
194
+
195
+
196
+ def _selected_project(project_id_filter: str | None, authenticated_project: str) -> str:
197
+ if (
198
+ require_api_key()
199
+ and project_id_filter is not None
200
+ and project_id_filter != authenticated_project
201
+ ):
202
+ raise HTTPException(status_code=403, detail="project access denied")
203
+ return project_id_filter or authenticated_project
204
+
205
+
206
+ @versioned.get("/traces")
207
+ @legacy.get("/traces")
208
+ def list_traces(
209
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
210
+ page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
211
+ cursor: str | None = Query(default=None),
212
+ project_id: str = Depends(resolve_project),
213
+ db: TraceStore = Depends(store),
214
+ ) -> dict[str, Any]:
215
+ """List traces for a project.
216
+
217
+ Without `page_size`/`cursor`, returns the full (bounded) list for
218
+ backward compatibility. Pass `page_size` to opt into keyset pagination:
219
+ the response then includes `next_cursor` — pass it back as `cursor` to
220
+ fetch the next page; `next_cursor: null` means there are no more rows.
221
+ """
222
+ selected_project = _selected_project(project_id_filter, project_id)
223
+ if page_size is None and cursor is None:
224
+ return {
225
+ "project_id": selected_project,
226
+ "traces": db.list_traces(project_id=selected_project),
227
+ "next_cursor": None,
228
+ }
229
+ try:
230
+ page = db.list_traces_page(
231
+ project_id=selected_project, limit=page_size or DEFAULT_PAGE_SIZE, cursor=cursor
232
+ )
233
+ except InvalidCursorError as exc:
234
+ raise HTTPException(status_code=400, detail=str(exc)) from None
235
+ return {
236
+ "project_id": selected_project,
237
+ "traces": page["items"],
238
+ "next_cursor": page["next_cursor"],
239
+ }
240
+
241
+
242
+ def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
243
+ trace = db.get_trace(run_id=run_id, project_id=project_id)
244
+ if trace is None:
245
+ raise HTTPException(status_code=404, detail="trace not found")
246
+ return trace
247
+
248
+
249
+ @versioned.get("/traces/{run_id}/report")
250
+ @legacy.get("/traces/{run_id}/report")
251
+ def get_report(
252
+ run_id: str,
253
+ project_id: str = Depends(resolve_project),
254
+ db: TraceStore = Depends(store),
255
+ ) -> dict[str, Any]:
256
+ return _load_trace_or_404(db, run_id, project_id).report()
257
+
258
+
259
+ @versioned.get("/traces/{run_id}/optimization")
260
+ @legacy.get("/traces/{run_id}/optimize")
261
+ def optimize_trace(
262
+ run_id: str,
263
+ project_id: str = Depends(resolve_project),
264
+ db: TraceStore = Depends(store),
265
+ ) -> dict[str, Any]:
266
+ return build_optimization_plan(_load_trace_or_404(db, run_id, project_id))
267
+
268
+
269
+ @legacy.get(
270
+ "/traces/{run_id}/diagnose",
271
+ deprecated=True,
272
+ description="Read-only compatibility alias for GET /v1/traces/{run_id}/diagnosis. "
273
+ "Available throughout 0.x; use POST /v1/traces/{run_id}/diagnosis to persist findings.",
274
+ )
275
+ @versioned.get("/traces/{run_id}/diagnosis")
276
+ @legacy.get("/traces/{run_id}/diagnosis")
277
+ def diagnose_trace(
278
+ run_id: str,
279
+ sort_by: RankingSort = RankingSort.PRIORITY,
280
+ project_id: str = Depends(resolve_project),
281
+ db: TraceStore = Depends(store),
282
+ ) -> dict[str, Any]:
283
+ """Compute current diagnosis without changing persisted findings."""
284
+ return build_diagnosis(_load_trace_or_404(db, run_id, project_id), sort_by=sort_by.value)
285
+
286
+
287
+ @versioned.post("/traces/{run_id}/diagnosis")
288
+ @legacy.post("/traces/{run_id}/diagnosis")
289
+ def persist_trace_diagnosis(
290
+ run_id: str,
291
+ sort_by: RankingSort = RankingSort.PRIORITY,
292
+ project_id: str = Depends(resolve_project),
293
+ db: TraceStore = Depends(store),
294
+ ) -> dict[str, Any]:
295
+ """Recompute and persist findings, preserving existing lifecycle decisions."""
296
+ diagnosis = build_diagnosis(_load_trace_or_404(db, run_id, project_id), sort_by=sort_by.value)
297
+ db.save_diagnosis(diagnosis, project_id=project_id)
298
+ return diagnosis
299
+
300
+
301
+ @versioned.post("/interventions")
302
+ @legacy.post("/interventions")
303
+ def create_intervention_endpoint(
304
+ payload: InterventionPayload,
305
+ project_id: str = Depends(resolve_project),
306
+ db: TraceStore = Depends(store),
307
+ ) -> dict[str, Any]:
308
+ """Snapshot persisted findings and compare stored traces in the authenticated project."""
309
+ try:
310
+ return create_stored_intervention(db, payload.model_dump(exclude_none=True), project_id)
311
+ except InterventionReferenceError as exc:
312
+ raise HTTPException(status_code=404, detail=str(exc)) from None
313
+ except InterventionConflictError as exc:
314
+ raise HTTPException(status_code=409, detail=str(exc)) from None
315
+ except (InterventionValidationError, QualityValidationError) as exc:
316
+ raise HTTPException(status_code=422, detail=str(exc)) from None
317
+
318
+
319
+ @versioned.get("/interventions/{intervention_id}")
320
+ @legacy.get("/interventions/{intervention_id}")
321
+ def get_intervention_endpoint(
322
+ intervention_id: str,
323
+ project_id: str = Depends(resolve_project),
324
+ db: TraceStore = Depends(store),
325
+ ) -> dict[str, Any]:
326
+ record = db.get_intervention(intervention_id, project_id=project_id)
327
+ if record is None:
328
+ raise HTTPException(status_code=404, detail="intervention not found")
329
+ return record
330
+
331
+
332
+ @versioned.get("/findings")
333
+ @legacy.get("/findings")
334
+ def list_findings(
335
+ status: str | None = Query(default=None),
336
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
337
+ page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
338
+ cursor: str | None = Query(default=None),
339
+ project_id: str = Depends(resolve_project),
340
+ db: TraceStore = Depends(store),
341
+ ) -> dict[str, Any]:
342
+ """List findings for a project. See `list_traces` for the pagination contract.
343
+
344
+ Paginated results are ordered by recency (most recently updated first),
345
+ not by the priority order `optimization-queue` uses.
346
+ """
347
+ selected_project = _selected_project(project_id_filter, project_id)
348
+ if page_size is None and cursor is None:
349
+ return {
350
+ "project_id": selected_project,
351
+ "findings": db.list_findings(project_id=selected_project, status=status),
352
+ "next_cursor": None,
353
+ }
354
+ try:
355
+ page = db.list_findings_page(
356
+ project_id=selected_project,
357
+ status=status,
358
+ limit=page_size or DEFAULT_PAGE_SIZE,
359
+ cursor=cursor,
360
+ )
361
+ except InvalidCursorError as exc:
362
+ raise HTTPException(status_code=400, detail=str(exc)) from None
363
+ return {
364
+ "project_id": selected_project,
365
+ "findings": page["items"],
366
+ "next_cursor": page["next_cursor"],
367
+ }
368
+
369
+
370
+ class FindingStatusPayload(BaseModel):
371
+ status: str
372
+
373
+
374
+ @versioned.post("/findings/{run_id}/{finding_id}/status")
375
+ @legacy.post("/findings/{run_id}/{finding_id}/status")
376
+ def update_finding_status(
377
+ run_id: str,
378
+ finding_id: str,
379
+ payload: FindingStatusPayload,
380
+ project_id: str = Depends(resolve_project),
381
+ db: TraceStore = Depends(store),
382
+ ) -> dict[str, Any]:
383
+ try:
384
+ return db.update_finding_status(project_id, run_id, finding_id, payload.status)
385
+ except FindingNotFoundError as exc:
386
+ raise HTTPException(status_code=404, detail=str(exc)) from None
387
+ except FindingTransitionError as exc:
388
+ raise HTTPException(status_code=409, detail=str(exc)) from None
389
+ except ValueError as exc:
390
+ raise HTTPException(status_code=400, detail=str(exc)) from None
391
+
392
+
393
+ @versioned.get("/optimization-queue")
394
+ @legacy.get("/optimization-queue")
395
+ def optimization_queue(
396
+ sort_by: RankingSort = RankingSort.PRIORITY,
397
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
398
+ project_id: str = Depends(resolve_project),
399
+ db: TraceStore = Depends(store),
400
+ ) -> dict[str, Any]:
401
+ selected_project = _selected_project(project_id_filter, project_id)
402
+ return {
403
+ "project_id": selected_project,
404
+ "sort_by": sort_by.value,
405
+ "queue": sort_ranked(db.optimization_queue(project_id=selected_project), sort_by.value),
406
+ }
407
+
408
+
409
+ @versioned.get("/optimization-queue/github-issues")
410
+ @legacy.get("/optimization-queue/github-issues")
411
+ def github_issue_drafts(
412
+ limit: int = Query(default=5, ge=1, le=20),
413
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
414
+ project_id: str = Depends(resolve_project),
415
+ db: TraceStore = Depends(store),
416
+ ) -> dict[str, Any]:
417
+ selected_project = _selected_project(project_id_filter, project_id)
418
+ queue = db.optimization_queue(project_id=selected_project)
419
+ return {
420
+ "project_id": selected_project,
421
+ "issue_drafts": build_issue_drafts(queue, limit=limit),
422
+ }
423
+
424
+
425
+ @versioned.post("/quality-reports")
426
+ @legacy.post("/quality-report")
427
+ def quality_report_endpoint(
428
+ payload: QualityReportPayload,
429
+ project_id: str = Depends(resolve_project),
430
+ db: TraceStore = Depends(store),
431
+ ) -> dict[str, Any]:
432
+ scorer_types = {
433
+ str(fixture["scorer"].get("type", "")).strip().lower()
434
+ for fixture in payload.fixtures
435
+ if isinstance(fixture.get("scorer"), dict)
436
+ }
437
+ if "custom" in scorer_types:
438
+ raise HTTPException(
439
+ status_code=400,
440
+ detail="custom Python scorers are not accepted by the HTTP API",
441
+ )
442
+ baseline_trace = (
443
+ _load_trace_or_404(db, payload.baseline_run_id, project_id)
444
+ if payload.baseline_run_id
445
+ else None
446
+ )
447
+ candidate_trace = (
448
+ _load_trace_or_404(db, payload.candidate_run_id, project_id)
449
+ if payload.candidate_run_id
450
+ else None
451
+ )
452
+ try:
453
+ return build_quality_report(
454
+ payload.fixtures,
455
+ baseline_trace=baseline_trace,
456
+ candidate_trace=candidate_trace,
457
+ min_score=payload.min_score,
458
+ )
459
+ except QualityValidationError as exc:
460
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
461
+
462
+
463
+ @versioned.get("/traces/{run_id}/value")
464
+ @legacy.get("/traces/{run_id}/value")
465
+ def value_report(
466
+ run_id: str,
467
+ runs_per_month: int = Query(default=1000, ge=0),
468
+ engineer_hourly_rate_usd: float = Query(default=150.0, ge=0),
469
+ incident_cost_usd: float = Query(default=500.0, ge=0),
470
+ project_id: str = Depends(resolve_project),
471
+ db: TraceStore = Depends(store),
472
+ ) -> dict[str, Any]:
473
+ trace = _load_trace_or_404(db, run_id, project_id)
474
+ return build_value_report(
475
+ trace,
476
+ runs_per_month=runs_per_month,
477
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
478
+ incident_cost_usd=incident_cost_usd,
479
+ )
480
+
481
+
482
+ @versioned.get("/usage")
483
+ @legacy.get("/usage")
484
+ def usage_summary(
485
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
486
+ project_id: str = Depends(resolve_project),
487
+ db: TraceStore = Depends(store),
488
+ ) -> dict[str, Any]:
489
+ selected_project = _selected_project(project_id_filter, project_id)
490
+ return db.usage_summary(project_id=selected_project)
491
+
492
+
493
+ app.include_router(versioned, prefix="/v1")
494
+ app.include_router(legacy)