agentbridge-cli 0.1.11__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.
- agentbridge/__init__.py +8 -0
- agentbridge/_build_info.py +1 -0
- agentbridge/config.py +85 -0
- agentbridge/dashboard.py +494 -0
- agentbridge/models.py +334 -0
- agentbridge/pool.py +338 -0
- agentbridge/server.py +2881 -0
- agentbridge/templates/dashboard/base.html +160 -0
- agentbridge/templates/dashboard/chat.html +1361 -0
- agentbridge/templates/dashboard/detail.html +142 -0
- agentbridge/templates/dashboard/page.html +900 -0
- agentbridge/templates/dashboard/pool.html +9 -0
- agentbridge/templates/dashboard/requests.html +67 -0
- agentbridge_cli-0.1.11.dist-info/METADATA +157 -0
- agentbridge_cli-0.1.11.dist-info/RECORD +18 -0
- agentbridge_cli-0.1.11.dist-info/WHEEL +4 -0
- agentbridge_cli-0.1.11.dist-info/entry_points.txt +2 -0
- agentbridge_cli-0.1.11.dist-info/licenses/LICENSE +21 -0
agentbridge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GIT_HASH = "unknown"
|
agentbridge/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""User configuration paths and .env loading for AgentBridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR_ENV = "AGENTBRIDGE_CONFIG_DIR"
|
|
9
|
+
DEFAULT_POOL_SIZE = 1
|
|
10
|
+
DEFAULT_ENV_CONTENT = """# AgentBridge local configuration
|
|
11
|
+
# Keep API keys on this machine only. Values in the process environment override this file.
|
|
12
|
+
OPENROUTER_API_KEY=
|
|
13
|
+
OPENROUTER_SITE_URL=
|
|
14
|
+
OPENROUTER_APP_NAME=agentbridge
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def user_config_dir(*, create: bool = False) -> Path:
|
|
18
|
+
"""Return the user config directory, defaulting to ~/.config/agentbridge."""
|
|
19
|
+
raw = os.environ.get(CONFIG_DIR_ENV)
|
|
20
|
+
path = Path(raw).expanduser() if raw else Path.home() / ".config" / "agentbridge"
|
|
21
|
+
if create:
|
|
22
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_env_path(*, create_parent: bool = False) -> Path:
|
|
27
|
+
"""Return the AgentBridge .env path."""
|
|
28
|
+
return user_config_dir(create=create_parent) / ".env"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_default_env(path: Path) -> None:
|
|
32
|
+
path.write_text(DEFAULT_ENV_CONTENT, encoding="utf-8")
|
|
33
|
+
try:
|
|
34
|
+
path.chmod(0o600)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _unquote_env_value(value: str) -> str:
|
|
40
|
+
value = value.strip()
|
|
41
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
42
|
+
return value[1:-1]
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_user_env(*, create: bool = False) -> Path | None:
|
|
47
|
+
"""Load ~/.config/agentbridge/.env into os.environ without overriding existing values."""
|
|
48
|
+
path = user_env_path(create_parent=create)
|
|
49
|
+
if create and not path.exists():
|
|
50
|
+
_write_default_env(path)
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
55
|
+
line = raw_line.strip()
|
|
56
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
57
|
+
continue
|
|
58
|
+
key, value = line.split("=", 1)
|
|
59
|
+
key = key.strip()
|
|
60
|
+
if not key or key.startswith("#"):
|
|
61
|
+
continue
|
|
62
|
+
parsed_value = _unquote_env_value(value)
|
|
63
|
+
if parsed_value:
|
|
64
|
+
os.environ.setdefault(key, parsed_value)
|
|
65
|
+
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def session_log_dir(*, create: bool = False) -> Path:
|
|
70
|
+
"""Return the session log directory, defaulting inside the AgentBridge config dir."""
|
|
71
|
+
raw = os.environ.get("LOG_DIR")
|
|
72
|
+
if raw:
|
|
73
|
+
path = Path(os.path.expandvars(raw)).expanduser()
|
|
74
|
+
else:
|
|
75
|
+
path = user_config_dir(create=create) / "logs" / "sessions"
|
|
76
|
+
if create:
|
|
77
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ensure_user_config() -> Path:
|
|
82
|
+
"""Create the AgentBridge config directory, .env, and default log directory."""
|
|
83
|
+
load_user_env(create=True)
|
|
84
|
+
session_log_dir(create=True)
|
|
85
|
+
return user_config_dir(create=True)
|
agentbridge/dashboard.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Dashboard state tracking and FastAPI routes."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
12
|
+
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
|
13
|
+
from fastapi.templating import Jinja2Templates
|
|
14
|
+
|
|
15
|
+
from .config import session_log_dir
|
|
16
|
+
from .models import AVAILABLE_MODELS
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
_REQUEST_ID_PATTERN = re.compile(r"chatcmpl-[a-f0-9]{8,32}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# In-memory state
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
class _ActiveRequest:
|
|
27
|
+
"""Tracks a single in-flight request."""
|
|
28
|
+
|
|
29
|
+
__slots__ = (
|
|
30
|
+
"request_id",
|
|
31
|
+
"model",
|
|
32
|
+
"start_time",
|
|
33
|
+
"messages",
|
|
34
|
+
"buffered_text",
|
|
35
|
+
"_subscribers",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
request_id: str,
|
|
41
|
+
model: str,
|
|
42
|
+
messages: list[dict] | None = None,
|
|
43
|
+
):
|
|
44
|
+
self.request_id = request_id
|
|
45
|
+
self.model = model
|
|
46
|
+
self.start_time = time.monotonic()
|
|
47
|
+
self.messages = messages or []
|
|
48
|
+
self.buffered_text = ""
|
|
49
|
+
self._subscribers: list[asyncio.Queue] = []
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"request_id": self.request_id,
|
|
54
|
+
"model": self.model,
|
|
55
|
+
"elapsed_s": round(time.monotonic() - self.start_time, 2),
|
|
56
|
+
"messages": self.messages,
|
|
57
|
+
"buffered_text": self.buffered_text,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DashboardState:
|
|
62
|
+
"""Tracks active requests so the dashboard can display them and stream tokens."""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self._active: dict[str, _ActiveRequest] = {}
|
|
66
|
+
self._change_event = asyncio.Event()
|
|
67
|
+
self._pool_change_event = asyncio.Event()
|
|
68
|
+
|
|
69
|
+
def _notify(self) -> None:
|
|
70
|
+
"""Signal that the active requests list has changed."""
|
|
71
|
+
self._change_event.set()
|
|
72
|
+
|
|
73
|
+
def notify_pool_change(self) -> None:
|
|
74
|
+
"""Signal that pool status has changed."""
|
|
75
|
+
self._pool_change_event.set()
|
|
76
|
+
|
|
77
|
+
async def wait_for_pool_change(self, timeout: float = 5.0) -> None:
|
|
78
|
+
"""Wait for a pool change notification or timeout, then clear the event."""
|
|
79
|
+
self._pool_change_event.clear()
|
|
80
|
+
try:
|
|
81
|
+
await asyncio.wait_for(self._pool_change_event.wait(), timeout=timeout)
|
|
82
|
+
except asyncio.TimeoutError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
async def wait_for_change(self, timeout: float = 2.0) -> None:
|
|
86
|
+
"""Wait for a change notification or timeout, then clear the event."""
|
|
87
|
+
self._change_event.clear()
|
|
88
|
+
try:
|
|
89
|
+
await asyncio.wait_for(self._change_event.wait(), timeout=timeout)
|
|
90
|
+
except asyncio.TimeoutError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def request_started(
|
|
94
|
+
self,
|
|
95
|
+
request_id: str,
|
|
96
|
+
model: str,
|
|
97
|
+
messages: list[dict] | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._active[request_id] = _ActiveRequest(
|
|
100
|
+
request_id,
|
|
101
|
+
model,
|
|
102
|
+
messages=messages,
|
|
103
|
+
)
|
|
104
|
+
self._notify()
|
|
105
|
+
|
|
106
|
+
def chunk_received(self, request_id: str, text: str) -> None:
|
|
107
|
+
req = self._active.get(request_id)
|
|
108
|
+
if req is None:
|
|
109
|
+
return
|
|
110
|
+
req.buffered_text += text
|
|
111
|
+
msg = {"type": "chunk", "text": text}
|
|
112
|
+
for q in req._subscribers:
|
|
113
|
+
try:
|
|
114
|
+
q.put_nowait(msg)
|
|
115
|
+
except asyncio.QueueFull:
|
|
116
|
+
pass # Drop for slow consumers
|
|
117
|
+
|
|
118
|
+
def request_completed(self, request_id: str) -> None:
|
|
119
|
+
req = self._active.pop(request_id, None)
|
|
120
|
+
if req is None:
|
|
121
|
+
return
|
|
122
|
+
for q in req._subscribers:
|
|
123
|
+
try:
|
|
124
|
+
q.put_nowait({"type": "done"})
|
|
125
|
+
except asyncio.QueueFull:
|
|
126
|
+
pass # Drop for slow consumers
|
|
127
|
+
self._notify()
|
|
128
|
+
|
|
129
|
+
def request_errored(self, request_id: str, error: str) -> None:
|
|
130
|
+
req = self._active.pop(request_id, None)
|
|
131
|
+
if req is None:
|
|
132
|
+
return
|
|
133
|
+
for q in req._subscribers:
|
|
134
|
+
try:
|
|
135
|
+
q.put_nowait({"type": "error", "error": error})
|
|
136
|
+
except asyncio.QueueFull:
|
|
137
|
+
pass # Drop for slow consumers
|
|
138
|
+
self._notify()
|
|
139
|
+
|
|
140
|
+
def get_active_requests(self) -> list[dict]:
|
|
141
|
+
return [r.to_dict() for r in self._active.values()]
|
|
142
|
+
|
|
143
|
+
def subscribe(self, request_id: str) -> asyncio.Queue | None:
|
|
144
|
+
req = self._active.get(request_id)
|
|
145
|
+
if req is None:
|
|
146
|
+
return None
|
|
147
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=100)
|
|
148
|
+
req._subscribers.append(q)
|
|
149
|
+
return q
|
|
150
|
+
|
|
151
|
+
def unsubscribe(self, request_id: str, queue: asyncio.Queue) -> None:
|
|
152
|
+
req = self._active.get(request_id)
|
|
153
|
+
if req is None:
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
req._subscribers.remove(queue)
|
|
157
|
+
except ValueError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Route helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _sse_data_lines(text: str) -> str:
|
|
166
|
+
"""Return text formatted as SSE data lines."""
|
|
167
|
+
if "\n" in text:
|
|
168
|
+
return "\n".join(f"data: {line}" for line in text.splitlines())
|
|
169
|
+
return f"data: {text}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_request_id(request_id: str) -> None:
|
|
173
|
+
"""Reject malformed request IDs before constructing filesystem paths."""
|
|
174
|
+
if _REQUEST_ID_PATTERN.fullmatch(request_id) is None:
|
|
175
|
+
raise HTTPException(status_code=400, detail="Invalid request ID")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates" / "dashboard"
|
|
179
|
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_log_file(path: Path) -> dict | None:
|
|
183
|
+
"""Parse a session log file (JSON format) into a structured dict.
|
|
184
|
+
|
|
185
|
+
Returns dict with session data or None if the file cannot be parsed.
|
|
186
|
+
Only accepts dicts with a ``request_id`` key (skips attachment manifests, etc.).
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
with open(path) as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
if not isinstance(data, dict) or "request_id" not in data:
|
|
192
|
+
return None
|
|
193
|
+
return data
|
|
194
|
+
except (OSError, json.JSONDecodeError):
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _get_recent_logs(limit: int = 20) -> list[dict]:
|
|
199
|
+
"""Read recent session log files, newest first."""
|
|
200
|
+
log_dir = session_log_dir()
|
|
201
|
+
if not log_dir.exists():
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
def _mtime(f: Path) -> float:
|
|
205
|
+
try:
|
|
206
|
+
return f.stat().st_mtime
|
|
207
|
+
except OSError:
|
|
208
|
+
return 0.0
|
|
209
|
+
|
|
210
|
+
log_files = sorted(
|
|
211
|
+
log_dir.glob("*.json"),
|
|
212
|
+
key=_mtime,
|
|
213
|
+
reverse=True,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
results = []
|
|
217
|
+
for path in log_files[:limit]:
|
|
218
|
+
parsed = _parse_log_file(path)
|
|
219
|
+
if parsed is not None:
|
|
220
|
+
results.append(parsed)
|
|
221
|
+
|
|
222
|
+
return results
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Router factory
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def create_dashboard_router(
|
|
230
|
+
state: DashboardState,
|
|
231
|
+
pool_status_fn: Callable[[], dict],
|
|
232
|
+
) -> APIRouter:
|
|
233
|
+
"""Create and return a dashboard APIRouter.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
state: DashboardState instance for tracking active requests.
|
|
237
|
+
pool_status_fn: Callable returning pool status dict with keys
|
|
238
|
+
'size' and 'in_use'.
|
|
239
|
+
"""
|
|
240
|
+
router = APIRouter()
|
|
241
|
+
|
|
242
|
+
@router.get("/dashboard", response_class=HTMLResponse)
|
|
243
|
+
async def dashboard_page(request: Request):
|
|
244
|
+
"""Serve the main dashboard page."""
|
|
245
|
+
return templates.TemplateResponse(
|
|
246
|
+
request,
|
|
247
|
+
"page.html",
|
|
248
|
+
{"active_view": "monitor"},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@router.get("/dashboard/chat", response_class=HTMLResponse)
|
|
252
|
+
async def dashboard_chat_page(request: Request):
|
|
253
|
+
"""Serve the chat completion test console."""
|
|
254
|
+
return templates.TemplateResponse(
|
|
255
|
+
request,
|
|
256
|
+
"chat.html",
|
|
257
|
+
{
|
|
258
|
+
"available_models": AVAILABLE_MODELS,
|
|
259
|
+
"default_model": "claudecode/sonnet",
|
|
260
|
+
"active_view": "chat",
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@router.get("/dashboard/pool", response_class=HTMLResponse)
|
|
265
|
+
async def dashboard_pool(request: Request):
|
|
266
|
+
"""Render pool status fragment."""
|
|
267
|
+
status = pool_status_fn()
|
|
268
|
+
return templates.TemplateResponse(
|
|
269
|
+
request,
|
|
270
|
+
"pool.html",
|
|
271
|
+
{
|
|
272
|
+
"size": status.get("size", 0),
|
|
273
|
+
"in_use": status.get("in_use", 0),
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@router.get("/dashboard/pool/stream")
|
|
278
|
+
async def dashboard_pool_stream(request: Request):
|
|
279
|
+
"""SSE endpoint that pushes pool status HTML on change."""
|
|
280
|
+
|
|
281
|
+
async def event_stream():
|
|
282
|
+
try:
|
|
283
|
+
while True:
|
|
284
|
+
if await request.is_disconnected():
|
|
285
|
+
break
|
|
286
|
+
status = pool_status_fn()
|
|
287
|
+
rendered = templates.get_template("pool.html").render(
|
|
288
|
+
size=status.get("size", 0),
|
|
289
|
+
in_use=status.get("in_use", 0),
|
|
290
|
+
)
|
|
291
|
+
sse_data = _sse_data_lines(rendered)
|
|
292
|
+
yield f"event: message\n{sse_data}\n\n"
|
|
293
|
+
await state.wait_for_pool_change(timeout=5.0)
|
|
294
|
+
except asyncio.CancelledError:
|
|
295
|
+
pass
|
|
296
|
+
except Exception:
|
|
297
|
+
logger.exception("Error in pool SSE stream")
|
|
298
|
+
|
|
299
|
+
return StreamingResponse(
|
|
300
|
+
event_stream(),
|
|
301
|
+
media_type="text/event-stream",
|
|
302
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _get_merged_requests(limit: int = 20) -> list[dict]:
|
|
306
|
+
"""Merge active and completed requests into a single list.
|
|
307
|
+
|
|
308
|
+
Active requests appear first (newest first), then completed (newest first).
|
|
309
|
+
Each item has an ``is_active`` flag.
|
|
310
|
+
"""
|
|
311
|
+
active = state.get_active_requests()
|
|
312
|
+
for req in active:
|
|
313
|
+
req["is_active"] = True
|
|
314
|
+
active.sort(key=lambda r: r["elapsed_s"]) # lowest elapsed = newest
|
|
315
|
+
|
|
316
|
+
completed = _get_recent_logs(limit=limit)
|
|
317
|
+
for log in completed:
|
|
318
|
+
log["is_active"] = False
|
|
319
|
+
timing = log.get("timing") if isinstance(log.get("timing"), dict) else {}
|
|
320
|
+
if log.get("duration_ms") is None:
|
|
321
|
+
log["duration_ms"] = timing.get("duration_ms", 0)
|
|
322
|
+
if log.get("input_tokens") is None:
|
|
323
|
+
usage_data = log.get("usage")
|
|
324
|
+
if usage_data:
|
|
325
|
+
log["input_tokens"] = usage_data.get("input_tokens")
|
|
326
|
+
log["output_tokens"] = usage_data.get("output_tokens")
|
|
327
|
+
|
|
328
|
+
merged = active + completed
|
|
329
|
+
return merged[:limit]
|
|
330
|
+
|
|
331
|
+
@router.get("/dashboard/requests")
|
|
332
|
+
async def dashboard_requests(request: Request):
|
|
333
|
+
"""SSE endpoint that pushes unified requests HTML on change."""
|
|
334
|
+
|
|
335
|
+
async def event_stream():
|
|
336
|
+
try:
|
|
337
|
+
while True:
|
|
338
|
+
if await request.is_disconnected():
|
|
339
|
+
break
|
|
340
|
+
merged = _get_merged_requests()
|
|
341
|
+
rendered = templates.get_template("requests.html").render(
|
|
342
|
+
requests=merged
|
|
343
|
+
)
|
|
344
|
+
sse_data = _sse_data_lines(rendered)
|
|
345
|
+
yield f"event: message\n{sse_data}\n\n"
|
|
346
|
+
await state.wait_for_change(timeout=2.0)
|
|
347
|
+
except asyncio.CancelledError:
|
|
348
|
+
pass
|
|
349
|
+
except Exception:
|
|
350
|
+
logger.exception("Error in requests SSE stream")
|
|
351
|
+
|
|
352
|
+
return StreamingResponse(
|
|
353
|
+
event_stream(),
|
|
354
|
+
media_type="text/event-stream",
|
|
355
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@router.get("/dashboard/request/{request_id}", response_class=HTMLResponse)
|
|
359
|
+
async def dashboard_request_detail(request_id: str, request: Request):
|
|
360
|
+
"""Render request detail — checks active state first, then log file."""
|
|
361
|
+
_validate_request_id(request_id)
|
|
362
|
+
|
|
363
|
+
# Check active requests first
|
|
364
|
+
active_requests = state.get_active_requests()
|
|
365
|
+
active = None
|
|
366
|
+
for req in active_requests:
|
|
367
|
+
if req["request_id"] == request_id:
|
|
368
|
+
active = req
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
if active is not None:
|
|
372
|
+
return templates.TemplateResponse(
|
|
373
|
+
request,
|
|
374
|
+
"detail.html",
|
|
375
|
+
{
|
|
376
|
+
"request_id": request_id,
|
|
377
|
+
"model": active["model"],
|
|
378
|
+
"timestamp": "",
|
|
379
|
+
"duration_ms": int(active["elapsed_s"] * 1000),
|
|
380
|
+
"input_tokens": None,
|
|
381
|
+
"output_tokens": None,
|
|
382
|
+
"error": None,
|
|
383
|
+
"exception_type": None,
|
|
384
|
+
"is_active": True,
|
|
385
|
+
"buffered_text": active.get("buffered_text", ""),
|
|
386
|
+
"messages": active.get("messages", []),
|
|
387
|
+
"response": None,
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Fall back to log file
|
|
392
|
+
log_dir = session_log_dir()
|
|
393
|
+
log_path = log_dir / f"{request_id}.json"
|
|
394
|
+
parsed = _parse_log_file(log_path)
|
|
395
|
+
if parsed is None:
|
|
396
|
+
raise HTTPException(status_code=404, detail="Request not found")
|
|
397
|
+
|
|
398
|
+
timing = parsed.get("timing", {})
|
|
399
|
+
usage = parsed.get("usage", {})
|
|
400
|
+
return templates.TemplateResponse(
|
|
401
|
+
request,
|
|
402
|
+
"detail.html",
|
|
403
|
+
{
|
|
404
|
+
"request_id": parsed.get("request_id", request_id),
|
|
405
|
+
"model": parsed.get("model"),
|
|
406
|
+
"timestamp": parsed.get("timestamp", ""),
|
|
407
|
+
"duration_ms": timing.get("duration_ms", 0),
|
|
408
|
+
"input_tokens": usage.get("input_tokens"),
|
|
409
|
+
"output_tokens": usage.get("output_tokens"),
|
|
410
|
+
"error": parsed.get("error"),
|
|
411
|
+
"exception_type": parsed.get("exception_type"),
|
|
412
|
+
"is_active": False,
|
|
413
|
+
"buffered_text": "",
|
|
414
|
+
"messages": parsed.get("messages", []),
|
|
415
|
+
"response": parsed.get("response"),
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
@router.get("/dashboard/log/{request_id}")
|
|
420
|
+
async def dashboard_log(request_id: str):
|
|
421
|
+
"""Serve the raw JSON log for a completed request."""
|
|
422
|
+
_validate_request_id(request_id)
|
|
423
|
+
|
|
424
|
+
log_dir = session_log_dir()
|
|
425
|
+
log_path = log_dir / f"{request_id}.json"
|
|
426
|
+
if not log_path.is_file():
|
|
427
|
+
raise HTTPException(status_code=404, detail="Request log not found")
|
|
428
|
+
if not log_path.resolve().is_relative_to(log_dir.resolve()):
|
|
429
|
+
raise HTTPException(status_code=400, detail="Invalid request ID")
|
|
430
|
+
|
|
431
|
+
return FileResponse(log_path, media_type="application/json")
|
|
432
|
+
|
|
433
|
+
@router.get("/dashboard/attachment/{request_id}/{filename}")
|
|
434
|
+
async def dashboard_attachment(request_id: str, filename: str):
|
|
435
|
+
"""Serve a saved attachment file."""
|
|
436
|
+
_validate_request_id(request_id)
|
|
437
|
+
# Validate filename — no path traversal
|
|
438
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
439
|
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
440
|
+
|
|
441
|
+
log_dir = session_log_dir()
|
|
442
|
+
file_path = log_dir / f"{request_id}_attachments" / filename
|
|
443
|
+
# Resolved-path containment check to prevent path traversal
|
|
444
|
+
if not file_path.resolve().is_relative_to(log_dir.resolve()):
|
|
445
|
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
446
|
+
if not file_path.is_file():
|
|
447
|
+
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
448
|
+
|
|
449
|
+
return FileResponse(file_path)
|
|
450
|
+
|
|
451
|
+
@router.get("/dashboard/stream/{request_id}")
|
|
452
|
+
async def dashboard_stream(request_id: str):
|
|
453
|
+
"""SSE endpoint for live token streaming."""
|
|
454
|
+
_validate_request_id(request_id)
|
|
455
|
+
queue = state.subscribe(request_id)
|
|
456
|
+
if queue is None:
|
|
457
|
+
raise HTTPException(status_code=404, detail="Request not active")
|
|
458
|
+
|
|
459
|
+
async def event_stream():
|
|
460
|
+
try:
|
|
461
|
+
while True:
|
|
462
|
+
msg = await queue.get()
|
|
463
|
+
if msg["type"] == "chunk":
|
|
464
|
+
escaped = (
|
|
465
|
+
msg["text"]
|
|
466
|
+
.replace("&", "&")
|
|
467
|
+
.replace("<", "<")
|
|
468
|
+
.replace(">", ">")
|
|
469
|
+
)
|
|
470
|
+
data_lines = _sse_data_lines(escaped)
|
|
471
|
+
yield f"event: chunk\n{data_lines}\n\n"
|
|
472
|
+
elif msg["type"] == "done":
|
|
473
|
+
yield "event: done\ndata: complete\n\n"
|
|
474
|
+
return
|
|
475
|
+
elif msg["type"] == "error":
|
|
476
|
+
escaped_err = (
|
|
477
|
+
msg["error"]
|
|
478
|
+
.replace("&", "&")
|
|
479
|
+
.replace("<", "<")
|
|
480
|
+
.replace(">", ">")
|
|
481
|
+
)
|
|
482
|
+
data_lines = _sse_data_lines(escaped_err)
|
|
483
|
+
yield f"event: error\n{data_lines}\n\n"
|
|
484
|
+
return
|
|
485
|
+
finally:
|
|
486
|
+
state.unsubscribe(request_id, queue)
|
|
487
|
+
|
|
488
|
+
return StreamingResponse(
|
|
489
|
+
event_stream(),
|
|
490
|
+
media_type="text/event-stream",
|
|
491
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return router
|