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.
@@ -0,0 +1,8 @@
1
+ """AgentBridge - OpenAI-compatible API for Claude Code SDK and Codex CLI."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("agentbridge-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
@@ -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)
@@ -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("<", "&lt;")
468
+ .replace(">", "&gt;")
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("&", "&amp;")
479
+ .replace("<", "&lt;")
480
+ .replace(">", "&gt;")
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