lonoboard 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.
lonoboard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lonoboard: a live dashboard for the machines running your AI models, drawn like a 1990s ANSI BBS."""
2
+
3
+ __version__ = "0.1.0"
lonoboard/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from lonoboard.cli import main
2
+
3
+ main()
@@ -0,0 +1,2 @@
1
+ """The board: lonoboard's text-mode application, served over telnet and to the web terminal."""
2
+ from lonoboard.board.session import Board, Session # noqa: F401
lonoboard/board/hub.py ADDED
@@ -0,0 +1,290 @@
1
+ """Shared data for sessions.
2
+
3
+ A hub holds what every session on a board renders from: the latest snapshot, telemetry history,
4
+ recent requests, usage, the event log and fleet summaries. Hub is fed by this machine's
5
+ collector; RemoteHub by another machine's lonoboard API, so a session can view any board in the fleet
6
+ just by switching hubs.
7
+ """
8
+ import threading
9
+ import time
10
+ from collections import deque
11
+
12
+ from lonoboard.board.model import expected, healthy, status
13
+ from lonoboard.board.render import SVC_COLORS, dur
14
+ from lonoboard.util import get_json, stream_chat
15
+
16
+ HISTORY_LEN = 450
17
+
18
+
19
+ class BaseHub:
20
+ remote = False
21
+ label = None
22
+
23
+ def __init__(self):
24
+ self.cond = threading.Condition()
25
+ self.sessions = set()
26
+ self.data = None
27
+ self.history = deque(maxlen=HISTORY_LEN)
28
+ self.callers, self.usage, self.fleet = [], None, {}
29
+ self.events = deque(maxlen=300)
30
+ self.colors = {}
31
+ self.busy_prev, self.busy_start = {}, {}
32
+ self.last_t = 0
33
+ self.wake = threading.Event()
34
+ self.force = False
35
+ self.started = time.time()
36
+ self.thread = None
37
+ self.error = None
38
+ self.auth = {} # Authorization header for password-protected peers
39
+
40
+ @property
41
+ def hist(self):
42
+ return list(self.history)
43
+
44
+ def color(self, name):
45
+ if name not in self.colors:
46
+ self.colors[name] = SVC_COLORS[len(self.colors) % len(SVC_COLORS)]
47
+ return self.colors[name]
48
+
49
+ def join(self, sess):
50
+ with self.cond:
51
+ self.sessions.add(sess)
52
+ self.force = True
53
+ self.wake.set()
54
+ self.ensure_running()
55
+
56
+ def leave(self, sess):
57
+ with self.cond:
58
+ self.sessions.discard(sess)
59
+
60
+ def notify(self):
61
+ with self.cond:
62
+ for s in self.sessions:
63
+ s.dirty.set()
64
+
65
+ def ensure_running(self):
66
+ if not self.thread or not self.thread.is_alive():
67
+ self.thread = threading.Thread(target=self.run, daemon=True, name=f"lonoboard-{type(self).__name__}")
68
+ self.thread.start()
69
+
70
+ def log(self, segs, t=None):
71
+ self.events.append((t or time.time(), segs))
72
+
73
+ def events_json(self):
74
+ return [[t, [list(s) for s in segs]] for t, segs in self.events]
75
+
76
+ # --- turning snapshots into events ---
77
+ def ingest(self, d, extend_history=True):
78
+ for s in d["services"]:
79
+ self.color(s["name"])
80
+ pts = d.pop("history", [])
81
+ if extend_history:
82
+ self.history.extend(pts)
83
+ for p in pts:
84
+ names = set(p.get("busy", {})) | set(self.busy_prev)
85
+ for n in sorted(names):
86
+ b, was = p.get("busy", {}).get(n, 0), self.busy_prev.get(n, 0)
87
+ if not was and b:
88
+ self.busy_start[n] = p["t"]
89
+ self.log([(n, self.color(n)), (f" started generating · {b} slot{'s' if b > 1 else ''} busy", 7)], p["t"])
90
+ elif was and not b:
91
+ self.log([(n, self.color(n)), (f' finished after {dur(p["t"] - self.busy_start.get(n, p["t"]))}', 7)], p["t"])
92
+ elif b > was:
93
+ self.log([(n, self.color(n)), (f" now {b} slots busy", 7)], p["t"])
94
+ self.busy_prev[n] = b
95
+ if pts:
96
+ self.last_t = pts[-1]["t"]
97
+ if self.data is None:
98
+ self.log([("monitoring started", 10), (f' on {d["host"]["board"]["name"]} · {len(healthy(d))} of {len(expected(d))} services healthy', 7)])
99
+ else:
100
+ self.diff(self.data, d)
101
+ self.data = d
102
+
103
+ def diff(self, a, b):
104
+ hist = self.hist
105
+ prev = {s["name"]: s for s in a["services"]}
106
+ bucket = lambda k: "ok" if k == "busy" else k
107
+ for s in b["services"]:
108
+ p = prev.get(s["name"])
109
+ n = (s["name"], self.color(s["name"]))
110
+ if not p:
111
+ self.log([("new service ", 7), n, (f' on :{s["port"]}', 7)])
112
+ continue
113
+ k0, k1 = status(p, hist), status(s, hist)
114
+ if bucket(k0[0]) != bucket(k1[0]):
115
+ if k1[0] in ("ok", "busy"):
116
+ self.log([n, (" is back online", 10)])
117
+ else:
118
+ self.log([n, (f' is {k1[1] or "down"}', 12)])
119
+ if (s["run"].get("restarts") or 0) > (p["run"].get("restarts") or 0):
120
+ self.log([n, (f' restarted (#{s["run"]["restarts"]})', 14)])
121
+ for name in set(prev) - {s["name"] for s in b["services"]}:
122
+ self.log([("service ", 7), (name, self.color(name)), (" went away", 12)])
123
+
124
+
125
+ class Hub(BaseHub):
126
+ """This machine's board, fed by the collector (or the demo)."""
127
+
128
+ def __init__(self, collector):
129
+ super().__init__()
130
+ self.collector = collector
131
+
132
+ @property
133
+ def hist(self):
134
+ return list(self.collector.history)
135
+
136
+ def run(self):
137
+ due = {"snap": 0, "callers": 0, "usage": 0, "fleet": 0}
138
+ every = {"callers": 10, "usage": 60, "fleet": 15}
139
+ while True:
140
+ now = time.time()
141
+ watching = bool(self.sessions)
142
+ if self.force:
143
+ due = dict.fromkeys(due, 0)
144
+ self.force = False
145
+ snap_every = 2.5 if watching else 15
146
+ try:
147
+ if now >= due["snap"]:
148
+ self.ingest(self.collector.snapshot(since=self.last_t, max_age=1.0), extend_history=False)
149
+ due["snap"] = now + snap_every
150
+ if watching:
151
+ for k, fn in (("callers", self.load_callers), ("usage", self.load_usage), ("fleet", self.load_fleet)):
152
+ if now >= due[k]:
153
+ fn()
154
+ due[k] = now + every[k]
155
+ except Exception as e: # noqa: BLE001 - keep the board alive whatever a source does
156
+ self.log([("collector error: ", 12), (str(e)[:80], 7)])
157
+ self.notify()
158
+ self.wake.wait(max(0.2, min(due.values()) - time.time()) if watching else snap_every)
159
+ self.wake.clear()
160
+
161
+ def load_callers(self):
162
+ self.callers = self.collector.callers(30)
163
+
164
+ def load_usage(self):
165
+ self.usage = self.collector.usage_summary(24)
166
+
167
+ def load_fleet(self):
168
+ for p in (self.data or {}).get("fleet", []):
169
+ before = self.fleet.get(p["name"])
170
+ auth = self.auth if p.get("source") == "config" else {} # the password only goes to listed peers
171
+ summary = get_json(f"{p['url']}/api/summary", 3, auth)
172
+ self.fleet[p["name"]] = dict(summary, ok=True) if isinstance(summary, dict) else {"ok": False}
173
+ after = self.fleet[p["name"]]
174
+ if before is not None and before.get("ok") != after.get("ok"):
175
+ self.log([(p["name"], 15), (" is reachable", 10) if after.get("ok") else (" is unreachable", 12)])
176
+
177
+ def logs(self, name, n=30):
178
+ return self.collector.logs(name, n)
179
+
180
+ def chat_stream(self, service, model, messages, cancelled):
181
+ return self.collector.chat_stream(service, model, messages, cancelled)
182
+
183
+
184
+ class RemoteHub(BaseHub):
185
+ """Another machine's board, read through its lonoboard API."""
186
+ remote = True
187
+
188
+ def __init__(self, url, name):
189
+ super().__init__()
190
+ self.url, self.label = url.rstrip("/"), name
191
+
192
+ def run(self):
193
+ due = {"snap": 0, "callers": 0, "usage": 0, "events": 0}
194
+ every = {"snap": 2.5, "callers": 10, "usage": 60, "events": 5}
195
+ idle_since = None
196
+ while True:
197
+ if not self.sessions: # nobody's looking: stop after a while
198
+ idle_since = idle_since or time.time()
199
+ if time.time() - idle_since > 60:
200
+ return
201
+ else:
202
+ idle_since = None
203
+ if self.force:
204
+ due = dict.fromkeys(due, 0)
205
+ self.force = False
206
+ now = time.time()
207
+ try:
208
+ if now >= due["snap"]:
209
+ d = get_json(f"{self.url}/api?since={self.last_t}", 5, self.auth)
210
+ self.error = None if d is not None else f"can't reach {self.label}"
211
+ if d is not None:
212
+ for s in d["services"]:
213
+ self.color(s["name"])
214
+ pts = d.pop("history", [])
215
+ self.history.extend(pts)
216
+ self.last_t = pts[-1]["t"] if pts else self.last_t
217
+ self.data = d
218
+ due["snap"] = now + every["snap"]
219
+ if now >= due["callers"]:
220
+ self.callers = (get_json(f"{self.url}/api/callers", 5, self.auth) or {}).get("callers") or self.callers
221
+ due["callers"] = now + every["callers"]
222
+ if now >= due["usage"]:
223
+ self.usage = get_json(f"{self.url}/api/usage", 5, self.auth) or self.usage
224
+ due["usage"] = now + every["usage"]
225
+ if now >= due["events"]:
226
+ ev = (get_json(f"{self.url}/api/events", 5, self.auth) or {}).get("events")
227
+ if ev is not None:
228
+ self.events = deque(((t, [tuple(s) for s in segs]) for t, segs in ev), maxlen=300)
229
+ due["events"] = now + every["events"]
230
+ except Exception as e: # noqa: BLE001
231
+ self.error = str(e)[:80]
232
+ self.notify()
233
+ self.wake.wait(max(0.2, min(due.values()) - time.time()))
234
+ self.wake.clear()
235
+
236
+ def logs(self, name, n=30):
237
+ return (get_json(f"{self.url}/api/logs/{name}?n={n}", 5, self.auth) or {}).get("lines") or []
238
+
239
+ def chat_stream(self, service, model, messages, cancelled):
240
+ sv = next((s for s in (self.data or {}).get("services", []) if s["name"] == service), None)
241
+ urls = [u for u in ((sv or {}).get("urls") or {}).get("remote", []) if u.startswith("http")]
242
+ if not sv or not sv.get("chat") or not urls:
243
+ return iter([("error", f"{service} on {self.label} isn't reachable for chat from here")])
244
+ return stream_chat(urls[0], model, messages, cancelled=cancelled)
245
+
246
+
247
+ class Chat:
248
+ """One conversation with one model, streamed through a hub."""
249
+
250
+ def __init__(self, service, model):
251
+ self.service, self.model = service, model
252
+ self.msgs = [] # {'role', 'content', 'reasoning', 'stats', 'error', 't0', 'think_s'}
253
+ self.busy = False
254
+ self.cancel = False
255
+ self.scroll = 0 # lines up from the bottom
256
+
257
+ @property
258
+ def name(self):
259
+ return self.service if not self.model or self.model == self.service else f"{self.service} · {self.model}"
260
+
261
+ def send(self, text, sess):
262
+ self.msgs.append({"role": "user", "content": text})
263
+ reply = {"role": "assistant", "content": "", "reasoning": "", "stats": None, "error": None,
264
+ "t0": time.time(), "think_s": None}
265
+ self.msgs.append(reply)
266
+ self.busy, self.cancel, self.scroll = True, False, 0
267
+ threading.Thread(target=self._stream, args=(reply, sess.hub, sess), daemon=True).start()
268
+
269
+ def _stream(self, reply, hub, sess):
270
+ history = [{"role": m["role"], "content": m["content"]} for m in self.msgs[:-1] if m["content"]]
271
+ last_paint = 0
272
+ for kind, value in hub.chat_stream(self.service, self.model, history, lambda: self.cancel or not sess.alive):
273
+ if kind == "reasoning":
274
+ reply["reasoning"] += value
275
+ elif kind == "content":
276
+ if reply["think_s"] is None and reply["reasoning"]:
277
+ reply["think_s"] = time.time() - reply["t0"]
278
+ reply["content"] += value
279
+ elif kind == "stats":
280
+ reply["stats"] = value
281
+ elif kind == "error":
282
+ reply["error"] = value
283
+ break
284
+ if time.time() - last_paint > 0.06:
285
+ sess.dirty.set()
286
+ last_paint = time.time()
287
+ if reply["think_s"] is None and reply["reasoning"]:
288
+ reply["think_s"] = time.time() - reply["t0"]
289
+ self.busy = False
290
+ sess.dirty.set()
@@ -0,0 +1,109 @@
1
+ """The board's logo: hand-drawn 8x10 pixel glyphs rendered as half-block cells, with
2
+ dithered shading and an outside-only drop shadow."""
3
+
4
+ # Logo: per-pixel ramps (used where a cell holds one lit pixel) and per-cell ramps with
5
+ # ░▒▓ shading (used where both pixels of a cell are lit) - the dithered blends ANSI
6
+ # artists used between colours, instead of hard bands.
7
+ PX_A = [15, 15, 11, 11, 11, 3, 3, 9, 9, 1]
8
+ CELL_A = [('█', 15, 0), ('▓', 11, 15), ('▒', 3, 11), ('▒', 9, 3), ('▓', 1, 9)]
9
+ PX_B = [15, 15, 14, 14, 14, 12, 12, 4, 4, 4]
10
+ CELL_B = [('█', 15, 0), ('▓', 14, 15), ('▒', 12, 14), ('▓', 4, 12), ('█', 4, 0)]
11
+ PX_D = [7, 7, 7, 7, 7, 7, 8, 8, 8, 8]
12
+ CELL_D = [('█', 7, 0), ('█', 7, 0), ('▓', 8, 7), ('█', 8, 0), ('█', 8, 0)]
13
+ SHADOW = 8
14
+
15
+ # 8x10 glyphs, two-pixel strokes; every hostname character gets one (lowercase is drawn as upper)
16
+ G = {
17
+ 'A': ['.######.', '########', '##....##', '##....##', '########', '########', '##....##', '##....##', '##....##', '##....##'],
18
+ 'B': ['#######.', '########', '##....##', '##....##', '#######.', '#######.', '##....##', '##....##', '########', '#######.'],
19
+ 'C': ['.######.', '########', '##......', '##......', '##......', '##......', '##......', '##......', '########', '.######.'],
20
+ 'D': ['######..', '#######.', '##...###', '##....##', '##....##', '##....##', '##....##', '##...###', '#######.', '######..'],
21
+ 'E': ['########', '########', '##......', '##......', '######..', '######..', '##......', '##......', '########', '########'],
22
+ 'F': ['########', '########', '##......', '##......', '######..', '######..', '##......', '##......', '##......', '##......'],
23
+ 'G': ['.######.', '########', '##......', '##......', '##..####', '##..####', '##....##', '##....##', '########', '.######.'],
24
+ 'H': ['##....##', '##....##', '##....##', '##....##', '########', '########', '##....##', '##....##', '##....##', '##....##'],
25
+ 'I': ['######', '######', '..##..', '..##..', '..##..', '..##..', '..##..', '..##..', '######', '######'],
26
+ 'J': ['......##', '......##', '......##', '......##', '......##', '......##', '##....##', '##....##', '########', '.######.'],
27
+ 'K': ['##...###', '##..###.', '##.###..', '#####...', '####....', '#####...', '##.###..', '##..###.', '##...###', '##....##'],
28
+ 'L': ['##......', '##......', '##......', '##......', '##......', '##......', '##......', '##......', '########', '########'],
29
+ 'M': ['##....##', '###..###', '########', '##.##.##', '##.##.##', '##....##', '##....##', '##....##', '##....##', '##....##'],
30
+ 'N': ['##....##', '###...##', '####..##', '##.##.##', '##..####', '##...###', '##....##', '##....##', '##....##', '##....##'],
31
+ 'O': ['.######.', '########', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '########', '.######.'],
32
+ 'P': ['#######.', '########', '##....##', '##....##', '########', '#######.', '##......', '##......', '##......', '##......'],
33
+ 'Q': ['.######.', '########', '##....##', '##....##', '##....##', '##....##', '##..####', '##...###', '########', '.#######'],
34
+ 'R': ['#######.', '########', '##....##', '##....##', '########', '#######.', '##..##..', '##...##.', '##....##', '##....##'],
35
+ 'S': ['.######.', '########', '##......', '##......', '#######.', '.#######', '......##', '......##', '########', '.######.'],
36
+ 'T': ['########', '########', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...'],
37
+ 'U': ['##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '########', '.######.'],
38
+ 'V': ['##....##', '##....##', '##....##', '##....##', '##....##', '###..###', '.##..##.', '.######.', '..####..', '...##...'],
39
+ 'W': ['##....##', '##....##', '##....##', '##....##', '##.##.##', '##.##.##', '########', '########', '###..###', '##....##'],
40
+ 'X': ['##....##', '###..###', '.######.', '..####..', '...##...', '...##...', '..####..', '.######.', '###..###', '##....##'],
41
+ 'Y': ['##....##', '##....##', '###..###', '.######.', '..####..', '...##...', '...##...', '...##...', '...##...', '...##...'],
42
+ 'Z': ['########', '########', '.....###', '....###.', '...###..', '..###...', '.###....', '###.....', '########', '########'],
43
+ '-': ['......', '......', '......', '......', '######', '######', '......', '......', '......', '......'],
44
+ '.': ['..', '..', '..', '..', '..', '..', '..', '..', '##', '##'],
45
+ '_': ['........', '........', '........', '........', '........', '........', '........', '........', '########', '########'],
46
+ '0': ['.######.', '########', '##...###', '##..####', '##.##.##', '####..##', '###...##', '##....##', '########', '.######.'],
47
+ '1': ['...##...', '.####...', '#####...', '...##...', '...##...', '...##...', '...##...', '...##...', '########', '########'],
48
+ '2': ['.######.', '########', '##....##', '......##', '.######.', '#######.', '##......', '##......', '########', '########'],
49
+ '3': ['#######.', '########', '......##', '......##', '.######.', '.#######', '......##', '......##', '########', '#######.'],
50
+ '4': ['##....##', '##....##', '##....##', '##....##', '########', '.#######', '......##', '......##', '......##', '......##'],
51
+ '5': ['########', '########', '##......', '##......', '#######.', '.#######', '......##', '......##', '########', '#######.'],
52
+ '6': ['.######.', '#######.', '##......', '##......', '#######.', '########', '##....##', '##....##', '########', '.######.'],
53
+ '7': ['########', '########', '......##', '.....##.', '....##..', '...##...', '...##...', '...##...', '...##...', '...##...'],
54
+ '8': ['.######.', '########', '##....##', '##....##', '.######.', '########', '##....##', '##....##', '########', '.######.'],
55
+ '9': ['.######.', '########', '##....##', '##....##', '########', '.#######', '......##', '......##', '.#######', '.######.'],
56
+ }
57
+
58
+ # ---------- the logo ----------
59
+
60
+ def logo(parts, gap=2):
61
+ """Hand-drawn 8x10 glyphs -> pixel grid with an outside-only drop shadow."""
62
+ glyphs = [(G[ch], ramp) for text, ramp in parts for ch in text.upper()]
63
+ w = sum(len(g[0]) + gap for g, _ in glyphs) - gap + 1
64
+ h = 11
65
+ P = [[None] * w for _ in range(h + 1)]
66
+ x = 0
67
+ for g, ramp in glyphs:
68
+ for y, row in enumerate(g):
69
+ for i, c in enumerate(row):
70
+ if c == '#':
71
+ P[y][x + i] = ('L', ramp, y)
72
+ x += len(g[0]) + gap
73
+ # flood-fill the background from the edges so closed counters (P, A, R...) stay unshaded
74
+ outside = [[False] * w for _ in range(h + 1)]
75
+ stack = [(0, i) for i in range(w)] + [(h, i) for i in range(w)] + [(y, 0) for y in range(h + 1)] + [(y, w - 1) for y in range(h + 1)]
76
+ while stack:
77
+ y, i = stack.pop()
78
+ if 0 <= y <= h and 0 <= i < w and not outside[y][i] and P[y][i] is None:
79
+ outside[y][i] = True
80
+ stack += [(y + 1, i), (y - 1, i), (y, i + 1), (y, i - 1)]
81
+ for y in range(h):
82
+ for i in range(w - 1):
83
+ if P[y][i] and P[y][i][0] == 'L' and P[y + 1][i + 1] is None and outside[y + 1][i + 1]:
84
+ P[y + 1][i + 1] = ('S',)
85
+ return w, h + 1, P
86
+
87
+
88
+ RAMPS = {'A': (PX_A, CELL_A), 'B': (PX_B, CELL_B), 'D': (PX_D, CELL_D)}
89
+
90
+
91
+ def draw_logo(s, x0, y0, px):
92
+ w, h, P = px
93
+ for cy in range(h // 2):
94
+ for cx in range(w):
95
+ t, b = P[cy * 2][cx], P[cy * 2 + 1][cx]
96
+ if t is None and b is None:
97
+ continue
98
+ if t and b and t[0] == 'L' and b[0] == 'L' and t[1] == b[1]:
99
+ ch, f, bg = RAMPS[t[1]][1][cy] # fully lit cell: dithered blend
100
+ s.put(x0 + cx, y0 + cy, ch, f, bg)
101
+ continue
102
+ # a lone lit pixel takes the dominant colour of its row's blend, so edges match the fill
103
+ col = lambda p: SHADOW if p[0] == 'S' else RAMPS[p[1]][1][p[2] // 2][1]
104
+ if t and b:
105
+ s.put(x0 + cx, y0 + cy, '▀', col(t), col(b))
106
+ elif t:
107
+ s.put(x0 + cx, y0 + cy, '▀', col(t))
108
+ else:
109
+ s.put(x0 + cx, y0 + cy, '▄', col(b))
@@ -0,0 +1,48 @@
1
+ """Rules over a snapshot: what state a service is in and which ones should be up."""
2
+
3
+
4
+ def busy_of(sv, hist):
5
+ b = (sv.get("model") or {}).get("slots_busy")
6
+ if b is None and hist:
7
+ b = hist[-1].get("busy", {}).get(sv["name"], 0)
8
+ return b or 0
9
+
10
+
11
+ def status(sv, hist):
12
+ """(kind, text): kind is busy | ok | warn | starting | bad | off."""
13
+ run, health = sv.get("run") or {}, sv.get("health")
14
+ st = run.get("state")
15
+ if health == "ok":
16
+ return ("busy", None) if busy_of(sv, hist) > 0 else ("ok", None)
17
+ if st in ("activating", "reloading") or health == "loading":
18
+ return "starting", None
19
+ if st == "failed":
20
+ return "bad", "failed"
21
+ if st == "active":
22
+ return "warn", "not responding" if health in (None, "unreachable") else str(health).lower()
23
+ if not run.get("manager"): # something we only know by its URL
24
+ return "bad", "unreachable"
25
+ return ("bad", "down") if run.get("enabled") else ("off", None)
26
+
27
+
28
+ def expected(d):
29
+ """Services that should be running: enabled, active, or simply listed (no manager to ask)."""
30
+ return [s for s in d["services"]
31
+ if s["run"].get("enabled") or s["run"].get("state") == "active" or not s["run"].get("manager")]
32
+
33
+
34
+ def healthy(d):
35
+ return [s for s in expected(d) if s.get("health") == "ok"]
36
+
37
+
38
+ def chat_capable(sv):
39
+ return bool((sv.get("chat") or {}).get("models"))
40
+
41
+
42
+ def chat_targets(d):
43
+ """[(service, model)] that can be chatted with."""
44
+ return [(s["name"], m) for s in d["services"] for m in (s.get("chat") or {}).get("models") or []]
45
+
46
+
47
+ def requests_today(d):
48
+ return sum((s.get("model") or {}).get("requests_today") or 0 for s in d["services"])