agentloop-profiler 0.4.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.
Potentially problematic release.
This version of agentloop-profiler might be problematic. Click here for more details.
- agentloop/__init__.py +68 -0
- agentloop/audit.py +34 -0
- agentloop/autoinstrument.py +102 -0
- agentloop/ci.py +135 -0
- agentloop/cli.py +876 -0
- agentloop/client.py +170 -0
- agentloop/config.py +50 -0
- agentloop/costs.py +15 -0
- agentloop/decorators.py +160 -0
- agentloop/demo.py +283 -0
- agentloop/doctor.py +344 -0
- agentloop/events.py +49 -0
- agentloop/exporters.py +60 -0
- agentloop/findings.py +215 -0
- agentloop/graph.py +312 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/langgraph.py +140 -0
- agentloop/integrations/openai.py +369 -0
- agentloop/integrations/openai_agents.py +212 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/issues.py +88 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +123 -0
- agentloop/optimizer.py +323 -0
- agentloop/otel.py +302 -0
- agentloop/otel_ids.py +81 -0
- agentloop/patches.py +583 -0
- agentloop/plan_export.py +72 -0
- agentloop/quality.py +222 -0
- agentloop/replay.py +424 -0
- agentloop/runtime.py +244 -0
- agentloop/savings.py +135 -0
- agentloop/server.py +292 -0
- agentloop/store.py +840 -0
- agentloop/timing.py +124 -0
- agentloop/tracer.py +356 -0
- agentloop/value.py +243 -0
- agentloop/version.py +3 -0
- agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
- agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
- agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
- agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +698 -0
- dashboard/value_view.py +101 -0
agentloop/savings.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Compatible-selection model for combining per-recommendation savings.
|
|
2
|
+
|
|
3
|
+
Optimization cards (and the findings derived from them) that touch the same
|
|
4
|
+
spans compete for the same work: applying one changes or removes the spans the
|
|
5
|
+
other targeted, so their estimates are alternatives, not additive. A realizable
|
|
6
|
+
plan therefore applies a pairwise span-disjoint subset of the recommendations.
|
|
7
|
+
|
|
8
|
+
``select_compatible`` picks that subset: latency savings is the primary
|
|
9
|
+
objective and cost savings breaks ties, so both reported totals always come
|
|
10
|
+
from the same subset — the (latency, cost) pair is achievable by one concrete
|
|
11
|
+
plan rather than a mix of incompatible alternatives. Items without affected
|
|
12
|
+
spans do not compete with anything and always count.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Iterator, Sequence
|
|
19
|
+
|
|
20
|
+
# Beyond this many mutually overlapping items the exact search (exponential in
|
|
21
|
+
# the size of the overlap-connected component) falls back to a greedy pick.
|
|
22
|
+
_EXACT_SEARCH_LIMIT = 16
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SavingsItem:
|
|
27
|
+
spans: frozenset[str]
|
|
28
|
+
latency_ms: float = 0.0
|
|
29
|
+
cost_usd: float = 0.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Selection:
|
|
34
|
+
indices: tuple[int, ...]
|
|
35
|
+
latency_ms: float
|
|
36
|
+
cost_usd: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def select_compatible(items: Sequence[SavingsItem]) -> Selection:
|
|
40
|
+
"""Return the compatible (span-disjoint) subset with the best savings.
|
|
41
|
+
|
|
42
|
+
Maximizes total latency savings, breaking ties by total cost savings.
|
|
43
|
+
Span-free items are always selected.
|
|
44
|
+
"""
|
|
45
|
+
chosen = [index for index, item in enumerate(items) if not item.spans]
|
|
46
|
+
spanned = [index for index, item in enumerate(items) if item.spans]
|
|
47
|
+
neighbor_sets: dict[int, set[int]] = {index: set() for index in spanned}
|
|
48
|
+
for position, left in enumerate(spanned):
|
|
49
|
+
for right in spanned[position + 1 :]:
|
|
50
|
+
if items[left].spans & items[right].spans:
|
|
51
|
+
neighbor_sets[left].add(right)
|
|
52
|
+
neighbor_sets[right].add(left)
|
|
53
|
+
adjacency = {index: frozenset(neighbors) for index, neighbors in neighbor_sets.items()}
|
|
54
|
+
for component in _components(spanned, adjacency):
|
|
55
|
+
if len(component) > _EXACT_SEARCH_LIMIT:
|
|
56
|
+
chosen.extend(_greedy_pick(items, component))
|
|
57
|
+
else:
|
|
58
|
+
chosen.extend(_exact_pick(items, component, adjacency))
|
|
59
|
+
chosen.sort()
|
|
60
|
+
return Selection(
|
|
61
|
+
indices=tuple(chosen),
|
|
62
|
+
latency_ms=sum(items[index].latency_ms for index in chosen),
|
|
63
|
+
cost_usd=sum(items[index].cost_usd for index in chosen),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _components(nodes: list[int], adjacency: dict[int, frozenset[int]]) -> Iterator[list[int]]:
|
|
68
|
+
seen: set[int] = set()
|
|
69
|
+
for start in nodes:
|
|
70
|
+
if start in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(start)
|
|
73
|
+
stack = [start]
|
|
74
|
+
component: list[int] = []
|
|
75
|
+
while stack:
|
|
76
|
+
node = stack.pop()
|
|
77
|
+
component.append(node)
|
|
78
|
+
for neighbor in adjacency[node]:
|
|
79
|
+
if neighbor not in seen:
|
|
80
|
+
seen.add(neighbor)
|
|
81
|
+
stack.append(neighbor)
|
|
82
|
+
yield component
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _savings_key(items: Sequence[SavingsItem], index: int) -> tuple[float, float]:
|
|
86
|
+
return (items[index].latency_ms, items[index].cost_usd)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _exact_pick(
|
|
90
|
+
items: Sequence[SavingsItem], component: list[int], adjacency: dict[int, frozenset[int]]
|
|
91
|
+
) -> list[int]:
|
|
92
|
+
order = sorted(component, key=lambda index: _savings_key(items, index), reverse=True)
|
|
93
|
+
suffix_latency = [0.0] * (len(order) + 1)
|
|
94
|
+
for position in range(len(order) - 1, -1, -1):
|
|
95
|
+
suffix_latency[position] = suffix_latency[position + 1] + items[order[position]].latency_ms
|
|
96
|
+
best_key = (float("-inf"), float("-inf"))
|
|
97
|
+
best: list[int] = []
|
|
98
|
+
|
|
99
|
+
def search(
|
|
100
|
+
position: int, blocked: frozenset[int], latency: float, cost: float, picked: list[int]
|
|
101
|
+
) -> None:
|
|
102
|
+
nonlocal best_key, best
|
|
103
|
+
if latency + suffix_latency[position] < best_key[0]:
|
|
104
|
+
return
|
|
105
|
+
if position == len(order):
|
|
106
|
+
if (latency, cost) > best_key:
|
|
107
|
+
best_key = (latency, cost)
|
|
108
|
+
best = list(picked)
|
|
109
|
+
return
|
|
110
|
+
index = order[position]
|
|
111
|
+
if index not in blocked:
|
|
112
|
+
picked.append(index)
|
|
113
|
+
search(
|
|
114
|
+
position + 1,
|
|
115
|
+
blocked | adjacency[index],
|
|
116
|
+
latency + items[index].latency_ms,
|
|
117
|
+
cost + items[index].cost_usd,
|
|
118
|
+
picked,
|
|
119
|
+
)
|
|
120
|
+
picked.pop()
|
|
121
|
+
search(position + 1, blocked, latency, cost, picked)
|
|
122
|
+
|
|
123
|
+
search(0, frozenset(), 0.0, 0.0, [])
|
|
124
|
+
return best
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _greedy_pick(items: Sequence[SavingsItem], component: list[int]) -> list[int]:
|
|
128
|
+
picked: list[int] = []
|
|
129
|
+
used_spans: set[str] = set()
|
|
130
|
+
for index in sorted(component, key=lambda index: _savings_key(items, index), reverse=True):
|
|
131
|
+
if items[index].spans & used_spans:
|
|
132
|
+
continue
|
|
133
|
+
picked.append(index)
|
|
134
|
+
used_spans |= items[index].spans
|
|
135
|
+
return picked
|
agentloop/server.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
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.issues import build_issue_drafts
|
|
13
|
+
from agentloop.optimizer import build_optimization_plan
|
|
14
|
+
from agentloop.quality import build_quality_report
|
|
15
|
+
from agentloop.store import TraceProjectConflictError, TraceStore, get_store
|
|
16
|
+
from agentloop.tracer import AgentTrace
|
|
17
|
+
from agentloop.value import build_value_report
|
|
18
|
+
from agentloop.version import __version__
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="AgentLoop API", version=__version__)
|
|
21
|
+
|
|
22
|
+
_cors_origins = get_cors_origins()
|
|
23
|
+
if _cors_origins:
|
|
24
|
+
app.add_middleware(
|
|
25
|
+
CORSMiddleware,
|
|
26
|
+
allow_origins=_cors_origins,
|
|
27
|
+
allow_credentials=True,
|
|
28
|
+
allow_methods=["*"],
|
|
29
|
+
allow_headers=["*"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TracePayload(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
run_id: str
|
|
36
|
+
started_at: str | None = None
|
|
37
|
+
ended_at: str | None = None
|
|
38
|
+
elapsed_ms: float | None = Field(default=None, ge=0)
|
|
39
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
events: list[dict[str, Any]] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CreateApiKeyPayload(BaseModel):
|
|
44
|
+
project_id: str = "default"
|
|
45
|
+
name: str = "default"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QualityReportPayload(BaseModel):
|
|
49
|
+
fixtures: list[dict[str, Any]] = Field(default_factory=list)
|
|
50
|
+
baseline_run_id: str | None = None
|
|
51
|
+
candidate_run_id: str | None = None
|
|
52
|
+
min_score: float | None = Field(default=None, ge=0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def store() -> TraceStore:
|
|
56
|
+
db = get_store()
|
|
57
|
+
db.init()
|
|
58
|
+
return db
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_project(
|
|
62
|
+
x_agentloop_key: str | None = Header(default=None),
|
|
63
|
+
db: TraceStore = Depends(store),
|
|
64
|
+
) -> str:
|
|
65
|
+
if x_agentloop_key:
|
|
66
|
+
key_record = db.verify_api_key(x_agentloop_key)
|
|
67
|
+
if key_record is not None:
|
|
68
|
+
return str(key_record["project_id"])
|
|
69
|
+
|
|
70
|
+
expected = get_api_key()
|
|
71
|
+
if require_api_key() and expected and hmac.compare_digest(x_agentloop_key, expected):
|
|
72
|
+
return "default"
|
|
73
|
+
|
|
74
|
+
raise HTTPException(status_code=401, detail="invalid AgentLoop API key")
|
|
75
|
+
|
|
76
|
+
if require_api_key():
|
|
77
|
+
raise HTTPException(status_code=401, detail="missing AgentLoop API key")
|
|
78
|
+
|
|
79
|
+
return "default"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_admin(x_agentloop_admin_key: str | None = Header(default=None)) -> None:
|
|
83
|
+
admin_key = get_admin_api_key()
|
|
84
|
+
if admin_key:
|
|
85
|
+
if x_agentloop_admin_key and hmac.compare_digest(x_agentloop_admin_key, admin_key):
|
|
86
|
+
return
|
|
87
|
+
raise HTTPException(status_code=401, detail="invalid AgentLoop admin API key")
|
|
88
|
+
|
|
89
|
+
if require_api_key():
|
|
90
|
+
raise HTTPException(
|
|
91
|
+
status_code=503,
|
|
92
|
+
detail="AGENTLOOP_ADMIN_API_KEY is required to create API keys when API auth is enabled",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/health")
|
|
97
|
+
def health() -> dict[str, str | bool]:
|
|
98
|
+
return {"status": "ok", "version": __version__, "auth_required": require_api_key()}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.get("/readyz")
|
|
102
|
+
def readyz(db: TraceStore = Depends(store)) -> dict[str, str]:
|
|
103
|
+
db.usage_summary(project_id="default")
|
|
104
|
+
return {"status": "ready"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.post("/api-keys")
|
|
108
|
+
def create_api_key(
|
|
109
|
+
payload: CreateApiKeyPayload,
|
|
110
|
+
_: None = Depends(resolve_admin),
|
|
111
|
+
db: TraceStore = Depends(store),
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
return db.create_api_key(project_id=payload.project_id, name=payload.name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.post("/traces")
|
|
117
|
+
def ingest_trace(
|
|
118
|
+
payload: TracePayload,
|
|
119
|
+
project_id: str = Depends(resolve_project),
|
|
120
|
+
db: TraceStore = Depends(store),
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
trace = AgentTrace.from_dict(payload.model_dump())
|
|
123
|
+
try:
|
|
124
|
+
db.save_trace(trace, project_id=project_id)
|
|
125
|
+
except TraceProjectConflictError as exc:
|
|
126
|
+
raise HTTPException(status_code=409, detail=str(exc)) from None
|
|
127
|
+
return {"ok": True, "run_id": trace.run_id, "project_id": project_id, "report": trace.report()}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _selected_project(project_id_filter: str | None, authenticated_project: str) -> str:
|
|
131
|
+
if (
|
|
132
|
+
require_api_key()
|
|
133
|
+
and project_id_filter is not None
|
|
134
|
+
and project_id_filter != authenticated_project
|
|
135
|
+
):
|
|
136
|
+
raise HTTPException(status_code=403, detail="project access denied")
|
|
137
|
+
return project_id_filter or authenticated_project
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.get("/traces")
|
|
141
|
+
def list_traces(
|
|
142
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
143
|
+
project_id: str = Depends(resolve_project),
|
|
144
|
+
db: TraceStore = Depends(store),
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
147
|
+
return {"project_id": selected_project, "traces": db.list_traces(project_id=selected_project)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
|
|
151
|
+
trace = db.get_trace(run_id=run_id, project_id=project_id)
|
|
152
|
+
if trace is None:
|
|
153
|
+
raise HTTPException(status_code=404, detail="trace not found")
|
|
154
|
+
return trace
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.get("/traces/{run_id}/report")
|
|
158
|
+
def get_report(
|
|
159
|
+
run_id: str,
|
|
160
|
+
project_id: str = Depends(resolve_project),
|
|
161
|
+
db: TraceStore = Depends(store),
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
return _load_trace_or_404(db, run_id, project_id).report()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.get("/traces/{run_id}/optimize")
|
|
167
|
+
def optimize_trace(
|
|
168
|
+
run_id: str,
|
|
169
|
+
project_id: str = Depends(resolve_project),
|
|
170
|
+
db: TraceStore = Depends(store),
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
return build_optimization_plan(_load_trace_or_404(db, run_id, project_id))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.get("/traces/{run_id}/diagnose")
|
|
176
|
+
def diagnose_trace(
|
|
177
|
+
run_id: str,
|
|
178
|
+
project_id: str = Depends(resolve_project),
|
|
179
|
+
db: TraceStore = Depends(store),
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
diagnosis = build_diagnosis(_load_trace_or_404(db, run_id, project_id))
|
|
182
|
+
db.save_diagnosis(diagnosis, project_id=project_id)
|
|
183
|
+
return diagnosis
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.get("/findings")
|
|
187
|
+
def list_findings(
|
|
188
|
+
status: str | None = Query(default=None),
|
|
189
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
190
|
+
project_id: str = Depends(resolve_project),
|
|
191
|
+
db: TraceStore = Depends(store),
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
194
|
+
return {
|
|
195
|
+
"project_id": selected_project,
|
|
196
|
+
"findings": db.list_findings(project_id=selected_project, status=status),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.get("/optimization-queue")
|
|
201
|
+
def optimization_queue(
|
|
202
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
203
|
+
project_id: str = Depends(resolve_project),
|
|
204
|
+
db: TraceStore = Depends(store),
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
207
|
+
return {
|
|
208
|
+
"project_id": selected_project,
|
|
209
|
+
"queue": db.optimization_queue(project_id=selected_project),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.get("/optimization-queue/github-issues")
|
|
214
|
+
def github_issue_drafts(
|
|
215
|
+
limit: int = Query(default=5, ge=1, le=20),
|
|
216
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
217
|
+
project_id: str = Depends(resolve_project),
|
|
218
|
+
db: TraceStore = Depends(store),
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
221
|
+
queue = db.optimization_queue(project_id=selected_project)
|
|
222
|
+
return {
|
|
223
|
+
"project_id": selected_project,
|
|
224
|
+
"issue_drafts": build_issue_drafts(queue, limit=limit),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.post("/quality-report")
|
|
229
|
+
def quality_report_endpoint(
|
|
230
|
+
payload: QualityReportPayload,
|
|
231
|
+
project_id: str = Depends(resolve_project),
|
|
232
|
+
db: TraceStore = Depends(store),
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
scorer_types = {
|
|
235
|
+
str(fixture["scorer"].get("type", "")).lower()
|
|
236
|
+
for fixture in payload.fixtures
|
|
237
|
+
if isinstance(fixture.get("scorer"), dict)
|
|
238
|
+
}
|
|
239
|
+
if "custom" in scorer_types:
|
|
240
|
+
raise HTTPException(
|
|
241
|
+
status_code=400,
|
|
242
|
+
detail="custom Python scorers are not accepted by the HTTP API",
|
|
243
|
+
)
|
|
244
|
+
if "regex" in scorer_types:
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=400,
|
|
247
|
+
detail="raw regex scorers are not accepted; use glob, contains, or exact_match",
|
|
248
|
+
)
|
|
249
|
+
baseline_trace = (
|
|
250
|
+
_load_trace_or_404(db, payload.baseline_run_id, project_id)
|
|
251
|
+
if payload.baseline_run_id
|
|
252
|
+
else None
|
|
253
|
+
)
|
|
254
|
+
candidate_trace = (
|
|
255
|
+
_load_trace_or_404(db, payload.candidate_run_id, project_id)
|
|
256
|
+
if payload.candidate_run_id
|
|
257
|
+
else None
|
|
258
|
+
)
|
|
259
|
+
return build_quality_report(
|
|
260
|
+
payload.fixtures,
|
|
261
|
+
baseline_trace=baseline_trace,
|
|
262
|
+
candidate_trace=candidate_trace,
|
|
263
|
+
min_score=payload.min_score,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.get("/traces/{run_id}/value")
|
|
268
|
+
def value_report(
|
|
269
|
+
run_id: str,
|
|
270
|
+
runs_per_month: int = Query(default=1000, ge=0),
|
|
271
|
+
engineer_hourly_rate_usd: float = Query(default=150.0, ge=0),
|
|
272
|
+
incident_cost_usd: float = Query(default=500.0, ge=0),
|
|
273
|
+
project_id: str = Depends(resolve_project),
|
|
274
|
+
db: TraceStore = Depends(store),
|
|
275
|
+
) -> dict[str, Any]:
|
|
276
|
+
trace = _load_trace_or_404(db, run_id, project_id)
|
|
277
|
+
return build_value_report(
|
|
278
|
+
trace,
|
|
279
|
+
runs_per_month=runs_per_month,
|
|
280
|
+
engineer_hourly_rate_usd=engineer_hourly_rate_usd,
|
|
281
|
+
incident_cost_usd=incident_cost_usd,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.get("/usage")
|
|
286
|
+
def usage_summary(
|
|
287
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
288
|
+
project_id: str = Depends(resolve_project),
|
|
289
|
+
db: TraceStore = Depends(store),
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
292
|
+
return db.usage_summary(project_id=selected_project)
|