agentprof 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.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprof/server/app.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""The FastAPI application: session list, analysed sessions, node details and live updates."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
14
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
15
|
+
|
|
16
|
+
from agentprof.analysis.agent_summary import agent_ids
|
|
17
|
+
from agentprof.model import Node, Session, iter_nodes
|
|
18
|
+
from agentprof.pricing import PriceTable
|
|
19
|
+
from agentprof.registry import EventKind, Registry, RegistryEvent, SessionUnavailable
|
|
20
|
+
from agentprof.server.pricing_schema import PricingOut, pricing_out
|
|
21
|
+
from agentprof.server.schemas import (
|
|
22
|
+
AgentSummaryOut,
|
|
23
|
+
ApiEndpointOut,
|
|
24
|
+
ApiIndexOut,
|
|
25
|
+
DiagnosticsDetailOut,
|
|
26
|
+
FindingOut,
|
|
27
|
+
NodeDetailOut,
|
|
28
|
+
ProjectedSessionOut,
|
|
29
|
+
SessionOut,
|
|
30
|
+
SummaryOut,
|
|
31
|
+
activity_flags,
|
|
32
|
+
agent_summary_out,
|
|
33
|
+
diagnostics_detail_out,
|
|
34
|
+
diagnostics_out,
|
|
35
|
+
finding_out,
|
|
36
|
+
node_detail_out,
|
|
37
|
+
node_out,
|
|
38
|
+
session_out,
|
|
39
|
+
summary_out,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_KEEP_ALIVE_S = 15.0
|
|
43
|
+
_STATIC_DIR = Path(__file__).parent / "static"
|
|
44
|
+
_ASSETS_PREFIX = "assets/"
|
|
45
|
+
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"} # Vite puts a content hash in asset names
|
|
46
|
+
_NO_CACHE = {"Cache-Control": "no-cache"}
|
|
47
|
+
_SESSION_FIELDS = ("session", "diagnostics", "tree")
|
|
48
|
+
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
49
|
+
_PLACEHOLDER_PAGE = """<!doctype html>
|
|
50
|
+
<html lang="en">
|
|
51
|
+
<head><meta charset="utf-8"><title>agentprof</title><!-- API discovery: /api --></head>
|
|
52
|
+
<body>
|
|
53
|
+
<h1>agentprof</h1>
|
|
54
|
+
<p>The frontend assets are missing: the browser UI is not built yet.</p>
|
|
55
|
+
<p>The API is running: <a href="/api/sessions">/api/sessions</a> · <a href="/docs">/docs</a></p>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Broadcaster:
|
|
62
|
+
"""Hands registry events from any thread to the queues of connected event-stream clients.
|
|
63
|
+
|
|
64
|
+
A `None` in a queue tells its stream to end.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
69
|
+
self._queues: set[asyncio.Queue[RegistryEvent | None]] = set()
|
|
70
|
+
|
|
71
|
+
def bind(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
72
|
+
self._loop = loop
|
|
73
|
+
|
|
74
|
+
def open(self) -> asyncio.Queue[RegistryEvent | None]:
|
|
75
|
+
queue: asyncio.Queue[RegistryEvent | None] = asyncio.Queue()
|
|
76
|
+
self._queues.add(queue)
|
|
77
|
+
return queue
|
|
78
|
+
|
|
79
|
+
def close(self, queue: asyncio.Queue[RegistryEvent | None]) -> None:
|
|
80
|
+
self._queues.discard(queue)
|
|
81
|
+
|
|
82
|
+
def publish(self, event: RegistryEvent) -> None:
|
|
83
|
+
"""Thread-safe: deliver `event` on the server's event loop; dropped before the server has started."""
|
|
84
|
+
loop = self._loop
|
|
85
|
+
if loop is None or loop.is_closed():
|
|
86
|
+
return
|
|
87
|
+
loop.call_soon_threadsafe(self._deliver, event)
|
|
88
|
+
|
|
89
|
+
def _deliver(self, event: RegistryEvent | None) -> None:
|
|
90
|
+
for queue in list(self._queues):
|
|
91
|
+
queue.put_nowait(event)
|
|
92
|
+
|
|
93
|
+
def end_streams(self) -> None:
|
|
94
|
+
"""Thread-safe: make every connected stream end, so a shutting-down server need not wait for the browser."""
|
|
95
|
+
loop = self._loop
|
|
96
|
+
if loop is None or loop.is_closed():
|
|
97
|
+
return
|
|
98
|
+
loop.call_soon_threadsafe(self._deliver, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_event(event: RegistryEvent, registry: Registry) -> str | None:
|
|
102
|
+
"""The server-sent-event message for `event`, or `None` if its row vanished meanwhile."""
|
|
103
|
+
if event.kind is EventKind.REMOVED:
|
|
104
|
+
return f"event: removed\ndata: {json.dumps({'id': event.session_id})}\n\n"
|
|
105
|
+
row = registry.row(event.session_id)
|
|
106
|
+
if row is None:
|
|
107
|
+
return None
|
|
108
|
+
return f"event: updated\ndata: {summary_out(row).model_dump_json()}\n\n"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def event_stream(
|
|
112
|
+
queue: asyncio.Queue[RegistryEvent | None],
|
|
113
|
+
format_message: Callable[[RegistryEvent], str | None],
|
|
114
|
+
is_disconnected: Callable[[], Awaitable[bool]],
|
|
115
|
+
keep_alive_s: float,
|
|
116
|
+
) -> AsyncIterator[str]:
|
|
117
|
+
"""Server-sent-event messages for queued events, with a keep-alive comment when idle."""
|
|
118
|
+
yield ": connected\n\n"
|
|
119
|
+
while not await is_disconnected():
|
|
120
|
+
try:
|
|
121
|
+
event = await asyncio.wait_for(queue.get(), timeout=keep_alive_s)
|
|
122
|
+
except TimeoutError:
|
|
123
|
+
yield ": keep-alive\n\n"
|
|
124
|
+
continue
|
|
125
|
+
if event is None:
|
|
126
|
+
return
|
|
127
|
+
message = format_message(event)
|
|
128
|
+
if message is not None:
|
|
129
|
+
yield message
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def static_file(static_dir: Path, path: str) -> Path | None:
|
|
133
|
+
"""The existing file under `static_dir` that `path` names, or `None`; never resolves outside `static_dir`."""
|
|
134
|
+
if not path:
|
|
135
|
+
return None
|
|
136
|
+
root = static_dir.resolve()
|
|
137
|
+
candidate = (root / path).resolve()
|
|
138
|
+
return candidate if candidate.is_relative_to(root) and candidate.is_file() else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def wants_session_json(request: Request) -> bool:
|
|
142
|
+
"""Return whether a session page request explicitly asks for JSON."""
|
|
143
|
+
if request.query_params.get("format") == "json":
|
|
144
|
+
return True
|
|
145
|
+
for item in request.headers.get("accept", "").lower().split(","):
|
|
146
|
+
media_type, *parameters = item.split(";")
|
|
147
|
+
if media_type.strip() != "application/json":
|
|
148
|
+
continue
|
|
149
|
+
quality = 1.0
|
|
150
|
+
for parameter in parameters:
|
|
151
|
+
name, _, value = parameter.strip().partition("=")
|
|
152
|
+
if name == "q":
|
|
153
|
+
try:
|
|
154
|
+
quality = float(value)
|
|
155
|
+
except ValueError:
|
|
156
|
+
quality = 0.0
|
|
157
|
+
break
|
|
158
|
+
if 0 < quality <= 1:
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def session_html(html: str, session_id: str) -> str:
|
|
164
|
+
"""Add the session's JSON alternate and an API discovery hint to the SPA shell."""
|
|
165
|
+
url = f"/api/sessions/{quote(session_id, safe='')}"
|
|
166
|
+
hint = "" if "<!-- API discovery: /api -->" in html else "<!-- API discovery: /api -->\n"
|
|
167
|
+
markup = f'<link rel="alternate" type="application/json" href="{url}">\n{hint}'
|
|
168
|
+
if "</head>" in html:
|
|
169
|
+
return html.replace("</head>", f"{markup}</head>", 1)
|
|
170
|
+
return f"{markup}{html}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _mark_omitted(node: dict[str, Any], source: Node, depth: int) -> None:
|
|
174
|
+
"""Mark omitted descendants on a tree already mapped only to `depth`."""
|
|
175
|
+
node["children_omitted"] = bool(source.children) if depth == 0 else False
|
|
176
|
+
if depth > 0:
|
|
177
|
+
for mapped, child in zip(node["children"], source.children, strict=True):
|
|
178
|
+
_mark_omitted(mapped, child, depth - 1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _project_session(session: Session, mtime: float, depth: int | None, fields: str | None) -> JSONResponse:
|
|
182
|
+
"""Select documented top-level groups and optionally bound tree depth."""
|
|
183
|
+
selected = _SESSION_FIELDS if fields is None else tuple(part.strip() for part in fields.split(","))
|
|
184
|
+
if not selected or any(part not in _SESSION_FIELDS for part in selected):
|
|
185
|
+
raise HTTPException(status_code=422, detail="fields must contain: session, diagnostics, tree")
|
|
186
|
+
projected: dict[str, Any] = {}
|
|
187
|
+
if "session" in selected:
|
|
188
|
+
projected.update(
|
|
189
|
+
id=session.id,
|
|
190
|
+
agent=session.agent,
|
|
191
|
+
title=session.title,
|
|
192
|
+
workspace=session.workspace,
|
|
193
|
+
mtime=mtime,
|
|
194
|
+
sources=session.sources,
|
|
195
|
+
)
|
|
196
|
+
if "diagnostics" in selected:
|
|
197
|
+
projected["diagnostics"] = diagnostics_out(session.diagnostics).model_dump(mode="json")
|
|
198
|
+
if "tree" in selected:
|
|
199
|
+
root = node_out(
|
|
200
|
+
session.root,
|
|
201
|
+
agent_ids(session.root),
|
|
202
|
+
frontier=session.root.end.value,
|
|
203
|
+
max_depth=depth,
|
|
204
|
+
activity_flags=activity_flags(session.root, session.root.end.value) if depth is not None else None,
|
|
205
|
+
).model_dump(mode="json")
|
|
206
|
+
if depth is not None:
|
|
207
|
+
_mark_omitted(root, session.root, depth)
|
|
208
|
+
projected["root"] = root
|
|
209
|
+
return JSONResponse(projected)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _sorted_findings(root: Node) -> list[FindingOut]:
|
|
213
|
+
"""Flatten attached findings and order known avoidable USD, severity, then event time."""
|
|
214
|
+
attached: list[tuple[FindingOut, float | None]] = []
|
|
215
|
+
for node in iter_nodes(root):
|
|
216
|
+
for finding in node.findings:
|
|
217
|
+
observed = finding.evidence.get("call_start_ms")
|
|
218
|
+
time = (
|
|
219
|
+
float(observed)
|
|
220
|
+
if isinstance(observed, (int, float)) and not isinstance(observed, bool)
|
|
221
|
+
else node.start.value
|
|
222
|
+
)
|
|
223
|
+
attached.append((finding_out(finding), time))
|
|
224
|
+
|
|
225
|
+
def order(item: tuple[FindingOut, float | None]) -> tuple[bool, float, int, bool, float, str, str]:
|
|
226
|
+
finding, time = item
|
|
227
|
+
usd = finding.estimated_avoidable_cost.usd
|
|
228
|
+
return (
|
|
229
|
+
usd is None,
|
|
230
|
+
-usd if usd is not None else 0.0,
|
|
231
|
+
_SEVERITY_ORDER.get(finding.severity, len(_SEVERITY_ORDER)),
|
|
232
|
+
time is None,
|
|
233
|
+
time if time is not None else 0.0,
|
|
234
|
+
finding.heuristic_id,
|
|
235
|
+
finding.node_id,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return [finding for finding, _ in sorted(attached, key=order)]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def create_app(registry: Registry, static_dir: Path = _STATIC_DIR, pricing: PriceTable | None = None) -> FastAPI:
|
|
242
|
+
"""Build the application with the effective pricing table and built frontend."""
|
|
243
|
+
broadcaster = Broadcaster()
|
|
244
|
+
price_table = pricing if pricing is not None else PriceTable.load()
|
|
245
|
+
|
|
246
|
+
@asynccontextmanager
|
|
247
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
248
|
+
broadcaster.bind(asyncio.get_running_loop())
|
|
249
|
+
unsubscribe = registry.subscribe(broadcaster.publish)
|
|
250
|
+
yield
|
|
251
|
+
unsubscribe()
|
|
252
|
+
|
|
253
|
+
app = FastAPI(title="agentprof", lifespan=lifespan)
|
|
254
|
+
app.state.end_streams = broadcaster.end_streams
|
|
255
|
+
|
|
256
|
+
@app.get("/api/", include_in_schema=False)
|
|
257
|
+
@app.get("/api", summary="Discover the public API")
|
|
258
|
+
def api_index() -> ApiIndexOut:
|
|
259
|
+
endpoints: list[ApiEndpointOut] = []
|
|
260
|
+
for path, methods in app.openapi()["paths"].items():
|
|
261
|
+
if not path.startswith("/api"):
|
|
262
|
+
continue
|
|
263
|
+
for method, operation in methods.items():
|
|
264
|
+
if method not in {"get", "post", "put", "patch", "delete"}:
|
|
265
|
+
continue
|
|
266
|
+
endpoints.append(
|
|
267
|
+
ApiEndpointOut(
|
|
268
|
+
method=method.upper(),
|
|
269
|
+
path=path,
|
|
270
|
+
parameters=[parameter["name"] for parameter in operation.get("parameters", [])],
|
|
271
|
+
description=operation.get("summary") or operation.get("description") or path,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
endpoints.sort(key=lambda endpoint: (endpoint.path, endpoint.method))
|
|
275
|
+
return ApiIndexOut(endpoints=endpoints)
|
|
276
|
+
|
|
277
|
+
@app.get("/api/sessions")
|
|
278
|
+
def list_sessions() -> list[SummaryOut]:
|
|
279
|
+
return [summary_out(row) for row in registry.rows()]
|
|
280
|
+
|
|
281
|
+
@app.get("/api/pricing", summary="Inspect the effective price table")
|
|
282
|
+
def get_pricing() -> PricingOut:
|
|
283
|
+
return pricing_out(price_table)
|
|
284
|
+
|
|
285
|
+
@app.get("/api/sessions/events")
|
|
286
|
+
async def session_events(request: Request) -> StreamingResponse:
|
|
287
|
+
queue = broadcaster.open()
|
|
288
|
+
|
|
289
|
+
async def stream() -> AsyncIterator[str]:
|
|
290
|
+
try:
|
|
291
|
+
messages = event_stream(
|
|
292
|
+
queue, lambda event: format_event(event, registry), request.is_disconnected, _KEEP_ALIVE_S
|
|
293
|
+
)
|
|
294
|
+
async for message in messages:
|
|
295
|
+
yield message
|
|
296
|
+
finally:
|
|
297
|
+
broadcaster.close(queue)
|
|
298
|
+
|
|
299
|
+
return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
|
|
300
|
+
|
|
301
|
+
@app.get("/api/sessions/{session_id}/summary", summary="Summarize a session by agent")
|
|
302
|
+
def get_agent_summary(session_id: str) -> AgentSummaryOut:
|
|
303
|
+
try:
|
|
304
|
+
session, mtime = registry.session(session_id)
|
|
305
|
+
except KeyError as error:
|
|
306
|
+
raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
|
|
307
|
+
except SessionUnavailable as error:
|
|
308
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
309
|
+
return agent_summary_out(session, mtime)
|
|
310
|
+
|
|
311
|
+
@app.get("/api/sessions/{session_id}/diagnostics", summary="Inspect session parse diagnostics")
|
|
312
|
+
def get_diagnostics(session_id: str) -> DiagnosticsDetailOut:
|
|
313
|
+
try:
|
|
314
|
+
session, _ = registry.session(session_id)
|
|
315
|
+
except KeyError as error:
|
|
316
|
+
raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
|
|
317
|
+
except SessionUnavailable as error:
|
|
318
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
319
|
+
return diagnostics_detail_out(session.diagnostics)
|
|
320
|
+
|
|
321
|
+
@app.get("/api/sessions/{session_id}/findings", summary="List a session's findings by estimated cost and severity")
|
|
322
|
+
def get_findings(session_id: str) -> list[FindingOut]:
|
|
323
|
+
try:
|
|
324
|
+
session, _ = registry.session(session_id)
|
|
325
|
+
except KeyError as error:
|
|
326
|
+
raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
|
|
327
|
+
except SessionUnavailable as error:
|
|
328
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
329
|
+
return _sorted_findings(session.root)
|
|
330
|
+
|
|
331
|
+
@app.get("/api/sessions/{session_id}", response_model=SessionOut | ProjectedSessionOut)
|
|
332
|
+
def get_session(
|
|
333
|
+
session_id: str,
|
|
334
|
+
depth: Annotated[
|
|
335
|
+
int | None,
|
|
336
|
+
Query(
|
|
337
|
+
ge=0,
|
|
338
|
+
description="Maximum depth from root (root is 0); truncated nodes report children_omitted.",
|
|
339
|
+
),
|
|
340
|
+
] = None,
|
|
341
|
+
fields: Annotated[
|
|
342
|
+
str | None,
|
|
343
|
+
Query(description="Groups: session (identity and sources), diagnostics, tree (root); comma-separated."),
|
|
344
|
+
] = None,
|
|
345
|
+
) -> SessionOut | JSONResponse:
|
|
346
|
+
try:
|
|
347
|
+
session, mtime = registry.session(session_id)
|
|
348
|
+
except KeyError as error:
|
|
349
|
+
raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
|
|
350
|
+
except SessionUnavailable as error:
|
|
351
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
352
|
+
return (
|
|
353
|
+
session_out(session, mtime)
|
|
354
|
+
if depth is None and fields is None
|
|
355
|
+
else _project_session(session, mtime, depth, fields)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@app.get("/api/sessions/{session_id}/nodes/{node_id}")
|
|
359
|
+
def get_node(session_id: str, node_id: str) -> NodeDetailOut:
|
|
360
|
+
try:
|
|
361
|
+
session, _ = registry.session(session_id)
|
|
362
|
+
except KeyError as error:
|
|
363
|
+
raise HTTPException(status_code=404, detail=f"unknown session {session_id!r}") from error
|
|
364
|
+
except SessionUnavailable as error:
|
|
365
|
+
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
366
|
+
node = next((node for node in iter_nodes(session.root) if node.node_id == node_id), None)
|
|
367
|
+
if node is None:
|
|
368
|
+
raise HTTPException(status_code=404, detail=f"unknown node {node_id!r}")
|
|
369
|
+
return node_detail_out(node)
|
|
370
|
+
|
|
371
|
+
index_file = static_dir / "index.html"
|
|
372
|
+
|
|
373
|
+
@app.get("/sessions/{session_id}", include_in_schema=False, response_model=None)
|
|
374
|
+
def session_page(session_id: str, request: Request) -> SessionOut | JSONResponse | HTMLResponse:
|
|
375
|
+
if wants_session_json(request):
|
|
376
|
+
return get_session(session_id)
|
|
377
|
+
html = index_file.read_text(encoding="utf-8") if index_file.is_file() else _PLACEHOLDER_PAGE
|
|
378
|
+
return HTMLResponse(session_html(html, session_id), headers=_NO_CACHE)
|
|
379
|
+
|
|
380
|
+
if index_file.is_file():
|
|
381
|
+
|
|
382
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
383
|
+
def frontend(path: str) -> FileResponse:
|
|
384
|
+
if path == "api" or path.startswith("api/"):
|
|
385
|
+
raise HTTPException(status_code=404, detail=f"unknown API path /{path}")
|
|
386
|
+
file = static_file(static_dir, path)
|
|
387
|
+
if file is not None:
|
|
388
|
+
return FileResponse(file, headers=_IMMUTABLE if path.startswith(_ASSETS_PREFIX) else _NO_CACHE)
|
|
389
|
+
if not path:
|
|
390
|
+
return FileResponse(index_file, headers=_NO_CACHE)
|
|
391
|
+
raise HTTPException(status_code=404, detail=f"unknown path /{path}")
|
|
392
|
+
|
|
393
|
+
else:
|
|
394
|
+
|
|
395
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
396
|
+
def index() -> str:
|
|
397
|
+
return _PLACEHOLDER_PAGE
|
|
398
|
+
|
|
399
|
+
return app
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Write the API's OpenAPI schema; the frontend generates its TypeScript types from it.
|
|
4
|
+
|
|
5
|
+
Run: `uv run python -m agentprof.server.openapi OUTPUT.json`
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentprof.registry import Registry
|
|
14
|
+
from agentprof.server.app import create_app
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def openapi_schema() -> dict[str, Any]:
|
|
18
|
+
return create_app(Registry([])).openapi()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
args = sys.argv[1:] if argv is None else argv
|
|
23
|
+
if len(args) != 1:
|
|
24
|
+
print("usage: python -m agentprof.server.openapi OUTPUT.json", file=sys.stderr)
|
|
25
|
+
return 2
|
|
26
|
+
Path(args[0]).write_text(json.dumps(openapi_schema(), indent=2) + "\n", encoding="utf-8")
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
sys.exit(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Public response schema for the effective price table."""
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from agentprof.pricing import PriceTable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelPriceOut(BaseModel):
|
|
11
|
+
"""USD rate per million tokens for each token kind."""
|
|
12
|
+
|
|
13
|
+
input: float
|
|
14
|
+
output: float
|
|
15
|
+
cache_read: float
|
|
16
|
+
cache_write_5m: float
|
|
17
|
+
cache_write_1h: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PricingOut(BaseModel):
|
|
21
|
+
"""The configured price table and its model matching rule."""
|
|
22
|
+
|
|
23
|
+
source: str | None
|
|
24
|
+
date: str | None
|
|
25
|
+
unit: str
|
|
26
|
+
usd_per_credit: float
|
|
27
|
+
matching_rule: str
|
|
28
|
+
models: dict[str, ModelPriceOut]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pricing_out(table: PriceTable) -> PricingOut:
|
|
32
|
+
"""Expose the rates actually used by adapters and the configured metadata."""
|
|
33
|
+
return PricingOut(
|
|
34
|
+
source=table.source,
|
|
35
|
+
date=table.date,
|
|
36
|
+
unit="USD per million tokens",
|
|
37
|
+
usd_per_credit=table.usd_per_credit,
|
|
38
|
+
matching_rule="longest_model_prefix",
|
|
39
|
+
models={prefix: ModelPriceOut(**vars(price)) for prefix, price in table.models.items()},
|
|
40
|
+
)
|