bitfrost 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.
bitfrost/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Bitfrost — drop-in OpenTelemetry observability for Python LLM apps.
2
+
3
+ Made by `Voight <https://voight.xyz>`_. Licensed under MIT.
4
+
5
+ Public API
6
+ ----------
7
+
8
+ .. code-block:: python
9
+
10
+ from bitfrost import BitfrostOptions, EventPayload, PrivacyLevel
11
+
12
+ Backends ship in :mod:`bitfrost.backends`:
13
+
14
+ .. code-block:: python
15
+
16
+ from bitfrost.backends.console import ConsoleBackend
17
+ from bitfrost.backends.voight import VoightBackend
18
+ from bitfrost.backends.otlp import OTLPBackend
19
+ from bitfrost.backends.jsonl import JSONLBackend
20
+ from bitfrost.backends.sqlite import SQLiteBackend
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from bitfrost.instrument import (
26
+ instrument_anthropic,
27
+ instrument_auto,
28
+ instrument_litellm,
29
+ instrument_openai,
30
+ instrument_smolagents,
31
+ quickstart,
32
+ )
33
+ from bitfrost.types import (
34
+ BitfrostOptions,
35
+ EventPayload,
36
+ EventType,
37
+ Outcome,
38
+ PrivacyLevel,
39
+ TokenBreakdown,
40
+ ToolCallRecord,
41
+ )
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "BitfrostOptions",
47
+ "EventPayload",
48
+ "EventType",
49
+ "Outcome",
50
+ "PrivacyLevel",
51
+ "TokenBreakdown",
52
+ "ToolCallRecord",
53
+ "__version__",
54
+ "instrument_anthropic",
55
+ "instrument_auto",
56
+ "instrument_litellm",
57
+ "instrument_openai",
58
+ "instrument_smolagents",
59
+ "quickstart",
60
+ ]
bitfrost/_brand.py ADDED
@@ -0,0 +1,196 @@
1
+ """Shared brand identity for Bitfrost surfaces (CLI, TUI, web dashboard).
2
+
3
+ One source of truth for the wordmark, the colour palette, and the
4
+ welcome splash so the three surfaces read as the same product. The web
5
+ frontend mirrors :data:`PALETTE` in its CSS (CSS can't import Python);
6
+ keep them in sync.
7
+
8
+ The name nods to the Bifrost — the rainbow bridge of Norse myth that
9
+ links worlds — which is what an exporter does: bridge your LLM runtime
10
+ to wherever you watch it. The splash leads with that bridge: a small
11
+ iridescent arch (the only colourful element) beside a metallic wordmark.
12
+
13
+ The arch + wordmark art below were pre-rendered once (PIL + pyfiglet) and
14
+ embedded as plain strings — neither library is a runtime dependency. The
15
+ gradients are applied at draw time with pure arithmetic via ``rich``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Sequence
21
+
22
+ # Palette — dark, platinum-forward, with a teal→violet "bridge" accent.
23
+ # Mirrored in src/bitfrost/serve/static/styles.css (:root variables).
24
+ PALETTE = {
25
+ "bg": "#0c0e0d", # near-black canvas
26
+ "surface": "#14171a", # card
27
+ "surface2": "#1a1e22", # raised card / hover
28
+ "border": "#23282d",
29
+ "fg": "#e6e7ea", # platinum text
30
+ "fg_muted": "#8b9197",
31
+ "accent": "#5eead4", # teal — primary accent
32
+ "accent2": "#a78bfa", # violet — bridge gradient end
33
+ "success": "#4ade80",
34
+ "failed": "#f87171",
35
+ "pending": "#fbbf24",
36
+ }
37
+
38
+ # Metallic platinum stops (logo's silver band) — for the wordmark, top→bottom.
39
+ _METAL: list[tuple[int, int, int]] = [
40
+ (0xF6, 0xF6, 0xFB),
41
+ (0xC7, 0xC8, 0xD2),
42
+ (0xB9, 0xBA, 0xC6),
43
+ (0x9A, 0x9B, 0xA7),
44
+ (0x8A, 0x8B, 0x97),
45
+ ]
46
+ # Iridescent stops (logo's rainbow inner line) — for the arch, left→right.
47
+ _IRID: list[tuple[int, int, int]] = [
48
+ (0x8B, 0x7B, 0xF0),
49
+ (0x46, 0xC5, 0x85),
50
+ (0xEA, 0xBF, 0x4E),
51
+ (0xEF, 0x5A, 0x6A),
52
+ ]
53
+
54
+ # The logo arch, rasterised to braille (pre-rendered). The ONLY colourful
55
+ # element in the splash — coloured left→right with the iridescent stops.
56
+ ARCH_BRAILLE: list[str] = [
57
+ " ⢀⣠⣤⣶⠶⠾⠿⠿⠷⠶⣶⣦⣄⡀ ",
58
+ " ⣴⡿⠋⠁ ⠙⢿⣦ ",
59
+ ]
60
+
61
+ # Wordmark (pyfiglet 'ansi_shadow', pre-rendered) — coloured top→bottom
62
+ # with the metallic stops.
63
+ WORDMARK: list[str] = [
64
+ "██████╗ ██╗████████╗███████╗██████╗ ██████╗ ███████╗████████╗",
65
+ "██╔══██╗██║╚══██╔══╝██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝",
66
+ "██████╔╝██║ ██║ █████╗ ██████╔╝██║ ██║███████╗ ██║ ",
67
+ "██╔══██╗██║ ██║ ██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ",
68
+ "██████╔╝██║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ",
69
+ "╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ",
70
+ ]
71
+
72
+ # Plain-text fallback wordmark for when ``rich`` isn't installed.
73
+ BANNER = r"""
74
+ ┌┐ ┬┌┬┐┌─┐┬─┐┌─┐┌─┐┌┬┐
75
+ ├┴┐│ │ ├┤ ├┬┘│ │└─┐ │
76
+ └─┘┴ ┴ └ ┴└─└─┘└─┘ ┴ by voight.xyz
77
+ ═══════════════════════ bridge your LLM telemetry
78
+ """.strip("\n")
79
+
80
+
81
+ def banner(tagline: str | None = None) -> str:
82
+ """Return the plain-text wordmark, optionally replacing the tagline."""
83
+
84
+ if tagline is None:
85
+ return BANNER
86
+ lines = BANNER.splitlines()
87
+ lines[-1] = "═" * 23 + " " + tagline
88
+ return "\n".join(lines)
89
+
90
+
91
+ def _hex_at(fraction: float, stops: Sequence[tuple[int, int, int]]) -> str:
92
+ """Interpolate a multi-stop gradient and return a ``#rrggbb`` string."""
93
+
94
+ f = max(0.0, min(1.0, fraction))
95
+ seg = f * (len(stops) - 1)
96
+ i = int(seg)
97
+ if i >= len(stops) - 1:
98
+ r, g, b = stops[-1]
99
+ return f"#{r:02x}{g:02x}{b:02x}"
100
+ t = seg - i
101
+ a, c = stops[i], stops[i + 1]
102
+ r, g, b = (round(a[k] + (c[k] - a[k]) * t) for k in range(3))
103
+ return f"#{r:02x}{g:02x}{b:02x}"
104
+
105
+
106
+ def render_splash(*, full: bool = True, console: object | None = None) -> None:
107
+ """Print the Bitfrost welcome splash to the terminal.
108
+
109
+ Shown only at startup — bare ``bitfrost`` (full), and ``watch`` /
110
+ ``serve`` (compact: lockup only, ``full=False``). Uses ``rich`` for
111
+ the gradient lockup + info panel; if ``rich`` isn't installed it falls
112
+ back to the plain :func:`banner`.
113
+
114
+ The arch is the only multi-coloured element; the wordmark is metallic
115
+ platinum and everything else uses the Voight palette.
116
+ """
117
+
118
+ try:
119
+ from rich.box import ROUNDED
120
+ from rich.console import Console
121
+ from rich.panel import Panel
122
+ from rich.table import Table
123
+ from rich.text import Text
124
+ except ImportError: # pragma: no cover - exercised only without [rich]
125
+ print(banner())
126
+ return
127
+
128
+ con = console if isinstance(console, Console) else Console()
129
+
130
+ # Arch — iridescent, the only colourful element.
131
+ arch = Text()
132
+ for line in ARCH_BRAILLE:
133
+ width = max(1, len(line) - 1)
134
+ for i, ch in enumerate(line):
135
+ if ch == " ":
136
+ arch.append(" ")
137
+ else:
138
+ arch.append(ch, style=_hex_at(i / width, _IRID))
139
+ arch.append("\n")
140
+
141
+ # Wordmark — metallic platinum, top→bottom.
142
+ wm = Text()
143
+ rows = max(1, len(WORDMARK) - 1)
144
+ for i, line in enumerate(WORDMARK):
145
+ wm.append(line + "\n", style=_hex_at(i / rows, _METAL))
146
+
147
+ # Side-by-side lockup when the terminal is wide enough; otherwise stack
148
+ # the arch above the wordmark so neither wraps on an 80-column terminal.
149
+ needed = len(WORDMARK[0]) + len(ARCH_BRAILLE[0]) + 2
150
+ if con.size.width >= needed:
151
+ lockup = Table.grid(padding=(0, 2))
152
+ lockup.add_column()
153
+ lockup.add_column()
154
+ lockup.add_row(Text("\n") + arch, wm)
155
+ con.print(lockup)
156
+ else:
157
+ con.print(arch)
158
+ con.print(wm)
159
+
160
+ if not full:
161
+ return
162
+
163
+ try:
164
+ from bitfrost import __version__
165
+ except Exception: # pragma: no cover - defensive
166
+ __version__ = "0.1.0"
167
+
168
+ con.print()
169
+ info = Text()
170
+ info.append("Drop-in OpenTelemetry observability for Python LLM apps.\n", style="#b4b4bd")
171
+ info.append(f"v{__version__} · MIT · Python 3.10-3.13\n\n", style="#5c5c66")
172
+
173
+ def _section(title: str, items: list[str]) -> None:
174
+ info.append(f"{title}\n", style=f"bold {PALETTE['accent']}")
175
+ info.append(" " + " · ".join(items) + "\n\n", style=PALETTE["fg"])
176
+
177
+ _section("Backends", ["console", "sqlite", "jsonl", "otlp", "voight", "tee"])
178
+ _section("Commands", ["watch", "replay", "query", "vacuum", "tui", "serve"])
179
+ _section("Instruments", ["openai", "anthropic", "litellm", "smolagents"])
180
+ info.append("run ", style="#5c5c66")
181
+ info.append("bitfrost --help", style=PALETTE["accent2"])
182
+
183
+ con.print(
184
+ Panel(
185
+ info,
186
+ title=f"[bold {PALETTE['fg']}]bitfrost[/] [{PALETTE['fg_muted']}]by voight.xyz[/]",
187
+ title_align="left",
188
+ border_style=PALETTE["accent"],
189
+ box=ROUNDED,
190
+ padding=(1, 3),
191
+ width=84,
192
+ )
193
+ )
194
+
195
+
196
+ __all__ = ["ARCH_BRAILLE", "BANNER", "PALETTE", "WORDMARK", "banner", "render_splash"]
bitfrost/_readers.py ADDED
@@ -0,0 +1,260 @@
1
+ """Readers that turn a captured log (JSONL file or SQLite DB) back into events.
2
+
3
+ The CLI commands (``watch``, ``replay``, ``query``) all need to pull events
4
+ out of a file the user captured earlier. Two storage formats ship in v0.1:
5
+
6
+ - **JSONL** (:class:`~bitfrost.backends.jsonl.JSONLBackend`) — one JSON
7
+ object per line, each line a full :class:`~bitfrost.types.EventPayload`.
8
+ - **SQLite** (:class:`~bitfrost.backends.sqlite.SQLiteBackend`) — flat
9
+ columns plus a ``metadata`` JSON column.
10
+
11
+ Both readers expose the same surface:
12
+
13
+ - ``read_all()`` → every event as a payload dict, in capture order.
14
+ - ``tail(marker)`` → ``(new_events, new_marker)`` since the last poll, so
15
+ ``bitfrost watch`` can incrementally stream new events without re-reading
16
+ the whole file. The marker is opaque to callers: a byte offset for
17
+ JSONL, a rowid for SQLite. Start a watch loop with ``marker=0``.
18
+
19
+ :class:`SQLiteReader` additionally exposes ``query(sql)`` — a **read-only**
20
+ SQL passthrough for ``bitfrost query`` — opened with SQLite's ``mode=ro``
21
+ URI so a fat-fingered ``DROP TABLE`` can't damage the user's capture.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import json
28
+ import sqlite3
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+
33
+ class JSONLReader:
34
+ """Read events from a ``.jsonl`` capture file."""
35
+
36
+ def __init__(self, path: str | Path) -> None:
37
+ self._path = Path(path)
38
+
39
+ def read_all(self) -> list[dict[str, Any]]:
40
+ """Return every well-formed event in the file, in order.
41
+
42
+ Blank lines and malformed JSON are skipped silently — a partially
43
+ written final line (the writer crashed mid-append) shouldn't make
44
+ the whole replay unreadable.
45
+ """
46
+
47
+ events: list[dict[str, Any]] = []
48
+ if not self._path.exists():
49
+ return events
50
+ with self._path.open("r", encoding="utf-8") as fh:
51
+ for line in fh:
52
+ event = _parse_line(line)
53
+ if event is not None:
54
+ events.append(event)
55
+ return events
56
+
57
+ def tail(self, marker: int = 0) -> tuple[list[dict[str, Any]], int]:
58
+ """Return events appended since byte offset ``marker``.
59
+
60
+ Reads only complete lines: if the file currently ends mid-line
61
+ (a write in progress), the partial trailing line is left for the
62
+ next poll by rewinding the returned marker to the start of it.
63
+ """
64
+
65
+ if not self._path.exists():
66
+ return [], marker
67
+ events: list[dict[str, Any]] = []
68
+ with self._path.open("rb") as fh:
69
+ fh.seek(marker)
70
+ data = fh.read()
71
+ new_marker = fh.tell()
72
+ if not data:
73
+ return [], marker
74
+ text = data.decode("utf-8", "replace")
75
+ # If the chunk doesn't end on a newline, hold back the partial
76
+ # last line so we re-read it whole next time.
77
+ if not text.endswith("\n"):
78
+ last_nl = text.rfind("\n")
79
+ if last_nl == -1:
80
+ # No complete line yet — wait for more.
81
+ return [], marker
82
+ consumed = last_nl + 1
83
+ new_marker = marker + len(text[:consumed].encode("utf-8"))
84
+ text = text[:consumed]
85
+ for line in text.splitlines():
86
+ event = _parse_line(line)
87
+ if event is not None:
88
+ events.append(event)
89
+ return events, new_marker
90
+
91
+
92
+ class SQLiteReader:
93
+ """Read events from a SQLite capture DB written by ``SQLiteBackend``."""
94
+
95
+ def __init__(self, path: str | Path) -> None:
96
+ self._path = Path(path)
97
+
98
+ def read_all(self) -> list[dict[str, Any]]:
99
+ """Return every event reconstructed into payload-dict shape, in order."""
100
+
101
+ if not self._path.exists():
102
+ return []
103
+ conn = self._connect_ro()
104
+ try:
105
+ cursor = conn.execute(
106
+ "SELECT rowid, agent_id, session_id, timestamp, event_type, "
107
+ "model, duration_ms, outcome, tool_executed, input, metadata "
108
+ "FROM events ORDER BY rowid"
109
+ )
110
+ return [_row_to_event(row) for row in cursor.fetchall()]
111
+ finally:
112
+ conn.close()
113
+
114
+ def tail(self, marker: int = 0) -> tuple[list[dict[str, Any]], int]:
115
+ """Return events with ``rowid > marker``; new marker is the max rowid.
116
+
117
+ rowid is SQLite's monotonic insertion counter, so it gives a stable
118
+ "everything since last poll" cursor even when two events share a
119
+ millisecond timestamp.
120
+ """
121
+
122
+ if not self._path.exists():
123
+ return [], marker
124
+ conn = self._connect_ro()
125
+ try:
126
+ cursor = conn.execute(
127
+ "SELECT rowid, agent_id, session_id, timestamp, event_type, "
128
+ "model, duration_ms, outcome, tool_executed, input, metadata "
129
+ "FROM events WHERE rowid > ? ORDER BY rowid",
130
+ (marker,),
131
+ )
132
+ rows = cursor.fetchall()
133
+ finally:
134
+ conn.close()
135
+ if not rows:
136
+ return [], marker
137
+ events = [_row_to_event(row) for row in rows]
138
+ new_marker = max(int(row[0]) for row in rows)
139
+ return events, new_marker
140
+
141
+ def query(self, sql: str) -> tuple[list[str], list[tuple[Any, ...]]]:
142
+ """Run a read-only SQL query, returning ``(column_names, rows)``.
143
+
144
+ The connection is opened ``mode=ro`` so any mutation (INSERT,
145
+ UPDATE, DELETE, DROP, …) raises ``sqlite3.OperationalError`` rather
146
+ than touching the user's capture. ``bitfrost query`` surfaces that
147
+ as a friendly error.
148
+ """
149
+
150
+ conn = self._connect_ro()
151
+ try:
152
+ cursor = conn.execute(sql)
153
+ columns = [d[0] for d in cursor.description] if cursor.description else []
154
+ rows = cursor.fetchall()
155
+ return columns, rows
156
+ finally:
157
+ conn.close()
158
+
159
+ def _connect_ro(self) -> sqlite3.Connection:
160
+ """Open a read-only connection via the ``file:…?mode=ro`` URI."""
161
+
162
+ uri = f"file:{self._path}?mode=ro"
163
+ return sqlite3.connect(uri, uri=True)
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Helpers
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def _parse_line(line: str) -> dict[str, Any] | None:
172
+ """Parse one JSONL line into an event dict, or ``None`` if not usable."""
173
+
174
+ stripped = line.strip()
175
+ if not stripped:
176
+ return None
177
+ try:
178
+ parsed = json.loads(stripped)
179
+ except (json.JSONDecodeError, ValueError):
180
+ return None
181
+ return parsed if isinstance(parsed, dict) else None
182
+
183
+
184
+ def _row_to_event(row: tuple[Any, ...]) -> dict[str, Any]:
185
+ """Reconstruct a render-ready event dict from a SQLite row.
186
+
187
+ Column order matches the SELECT in :meth:`SQLiteReader.read_all`:
188
+ ``(rowid, agent_id, session_id, timestamp, event_type, model,
189
+ duration_ms, outcome, tool_executed, input, metadata)``.
190
+
191
+ ``metadata`` already carries ``tokens`` / ``provider`` / ``sessionId``
192
+ and ``input`` carries the (privacy-filtered) prompt, so the
193
+ reconstructed dict renders identically to a live event.
194
+ """
195
+
196
+ (
197
+ _rowid,
198
+ agent_id,
199
+ _session_id,
200
+ timestamp,
201
+ event_type,
202
+ model,
203
+ duration_ms,
204
+ outcome,
205
+ tool_executed,
206
+ input_raw,
207
+ metadata_raw,
208
+ ) = row
209
+ try:
210
+ metadata = json.loads(metadata_raw) if metadata_raw else {}
211
+ except (json.JSONDecodeError, ValueError, TypeError):
212
+ metadata = {}
213
+ if not isinstance(metadata, dict):
214
+ metadata = {}
215
+
216
+ event: dict[str, Any] = {
217
+ "agentId": agent_id,
218
+ "type": event_type,
219
+ "model": model,
220
+ "durationMs": duration_ms,
221
+ "outcome": outcome,
222
+ "timestamp": timestamp,
223
+ "metadata": metadata,
224
+ }
225
+ if tool_executed:
226
+ event["toolExecuted"] = tool_executed
227
+ if input_raw:
228
+ with contextlib.suppress(json.JSONDecodeError, ValueError, TypeError):
229
+ event["input"] = json.loads(input_raw)
230
+ return event
231
+
232
+
233
+ def make_reader(path: str | Path, fmt: str | None = None) -> JSONLReader | SQLiteReader:
234
+ """Return the right reader for ``path``, auto-detecting by extension.
235
+
236
+ ``fmt`` overrides detection: ``"jsonl"`` or ``"sqlite"`` / ``"db"``.
237
+ Detection falls back to SQLite for ``.db`` / ``.sqlite`` / ``.sqlite3``
238
+ and JSONL for ``.jsonl`` / ``.ndjson``; anything else raises so the CLI
239
+ can tell the user to pass an explicit flag.
240
+ """
241
+
242
+ if fmt is not None:
243
+ fmt_l = fmt.lower()
244
+ if fmt_l == "jsonl":
245
+ return JSONLReader(path)
246
+ if fmt_l in ("sqlite", "db"):
247
+ return SQLiteReader(path)
248
+ msg = f"unknown reader format: {fmt!r} (expected 'jsonl' or 'sqlite')"
249
+ raise ValueError(msg)
250
+
251
+ suffix = Path(path).suffix.lower()
252
+ if suffix in (".db", ".sqlite", ".sqlite3"):
253
+ return SQLiteReader(path)
254
+ if suffix in (".jsonl", ".ndjson"):
255
+ return JSONLReader(path)
256
+ msg = f"cannot infer format from extension {suffix!r}; pass --db or --jsonl explicitly"
257
+ raise ValueError(msg)
258
+
259
+
260
+ __all__ = ["JSONLReader", "SQLiteReader", "make_reader"]