switchroom 0.21.1 → 0.21.4

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.
@@ -136,6 +136,7 @@ if [ -n "$TELEGRAM_STATE" ] && [ -d "$TELEGRAM_STATE" ]; then
136
136
  # under `2>/dev/null`, so the breadcrumb is a debug/manual-run diagnostic.
137
137
  TELEGRAM_ROWS=$(python3 - "$HISTORY_DB" "$MAX_MESSAGES" "$TARGET_CHAT_ID" "$TARGET_THREAD_ID" <<'PYEOF'
138
138
  import sys, sqlite3, datetime
139
+ from urllib.parse import quote
139
140
 
140
141
  db_path = sys.argv[1]
141
142
  limit = int(sys.argv[2])
@@ -143,7 +144,28 @@ target_chat = sys.argv[3] if len(sys.argv) > 3 else ""
143
144
  target_thread = sys.argv[4] if len(sys.argv) > 4 else ""
144
145
 
145
146
  try:
146
- conn = sqlite3.connect(db_path)
147
+ # READ-ONLY, ALWAYS. `sqlite3.connect(path)` defaults to READ-WRITE, which
148
+ # makes this briefing assembler a writer on the gateway's live WAL DB. A
149
+ # read-write connection that closes as the LAST connection runs SQLite's
150
+ # checkpoint-and-delete path and UNLINKS `history.db-wal` / `-shm` — and
151
+ # this script runs at agent boot, precisely when the gateway is down or
152
+ # starting and this process IS the last connection. Any handle still
153
+ # mapped to those inodes then writes into deleted files: every INSERT
154
+ # reports success and every row is gone at the next restart (the
155
+ # `/proc/<pid>/fd/N -> history.db-wal (deleted)` signature that #4595
156
+ # sweeps for after the fact).
157
+ #
158
+ # `mode=ro` removes that primitive entirely: a read-only connection cannot
159
+ # checkpoint and cannot unlink a sidecar. It still reads a WAL database
160
+ # correctly in every boot state we care about — sidecars present, `-shm`
161
+ # absent, and `-shm` absent with an unwritable directory (SQLite falls back
162
+ # to a heap wal-index rather than failing). Every statement below is a
163
+ # SELECT or a PRAGMA table_info, so nothing here needs write access.
164
+ #
165
+ # The path is percent-encoded: a `?` or `#` in the state dir would
166
+ # otherwise be parsed as the URI's query/fragment delimiter and silently
167
+ # truncate the filename.
168
+ conn = sqlite3.connect("file:" + quote(db_path) + "?mode=ro", uri=True)
147
169
  conn.row_factory = sqlite3.Row
148
170
  cur = conn.cursor()
149
171
 
@@ -20351,6 +20351,8 @@ var EXTERNAL_SPEND_TOP_N = 3;
20351
20351
  var DEFAULT_LITELLM_BASE = "http://127.0.0.1:4010";
20352
20352
  var EXTERNAL_SPEND_CACHE_TTL_MS = 90000;
20353
20353
  var EXTERNAL_SPEND_FETCH_TIMEOUT_MS = 5000;
20354
+ var EXTERNAL_SPEND_PAGE_SIZE = 1000;
20355
+ var EXTERNAL_SPEND_MAX_PAGES = 50;
20354
20356
  var LITELLM_MASTER_KEY_STATE_BASENAME = "litellm-master-key";
20355
20357
  var BARE_EXTERNAL_NEEDLES = [
20356
20358
  "gpt-oss",
@@ -20447,54 +20449,72 @@ function summarizeExternalSpend(days, now = new Date) {
20447
20449
  const top = Object.entries(byModel).filter(([, usd]) => usd > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, EXTERNAL_SPEND_TOP_N).map(([name, usd]) => ({ label: shortModelLabel(name), usd }));
20448
20450
  return { day24hUsd, day7dUsd, top };
20449
20451
  }
20450
- function normalizeSpendLogRows(body) {
20451
- if (Array.isArray(body))
20452
- return body;
20453
- if (body && typeof body === "object") {
20454
- const data = body.data;
20455
- if (Array.isArray(data))
20456
- return data;
20452
+ function normalizeDailyActivityRows(body) {
20453
+ const results = body && typeof body === "object" && Array.isArray(body.results) ? body.results : [];
20454
+ const rows = [];
20455
+ for (const day of results) {
20456
+ if (!day || typeof day !== "object")
20457
+ continue;
20458
+ const models = {};
20459
+ const breakdown = day.breakdown?.models ?? {};
20460
+ for (const [name, entry] of Object.entries(breakdown)) {
20461
+ const raw = entry?.metrics?.spend;
20462
+ const n = typeof raw === "number" ? raw : Number(raw);
20463
+ if (!Number.isFinite(n))
20464
+ continue;
20465
+ models[name] = (models[name] ?? 0) + n;
20466
+ }
20467
+ rows.push({ startTime: day.date, models });
20457
20468
  }
20458
- return [];
20469
+ return rows;
20459
20470
  }
20460
20471
  async function fetchAndSummarizeExternalSpend(opts) {
20461
20472
  const now = opts.now ?? new Date;
20462
20473
  const base = (opts.baseUrl ?? DEFAULT_LITELLM_BASE).replace(/\/+$/, "");
20463
20474
  const today = utcDateString(now);
20464
20475
  const start = addUtcDays(today, -6);
20465
- const end = addUtcDays(today, 1);
20466
- const url = `${base}/spend/logs?start_date=${encodeURIComponent(start)}` + `&end_date=${encodeURIComponent(end)}`;
20467
20476
  const fetchImpl = opts.fetchImpl ?? fetch;
20468
20477
  const timeoutMs = opts.timeoutMs ?? EXTERNAL_SPEND_FETCH_TIMEOUT_MS;
20469
- const ac = new AbortController;
20470
- const timer = setTimeout(() => ac.abort(), timeoutMs);
20471
- try {
20472
- const res = await fetchImpl(url, {
20473
- method: "GET",
20474
- headers: {
20475
- Authorization: `Bearer ${opts.adminKey}`,
20476
- Accept: "application/json"
20477
- },
20478
- signal: ac.signal
20479
- });
20480
- if (!res.ok) {
20481
- console.warn(`external-spend: LiteLLM /spend/logs returned HTTP ${res.status} — ` + `External /usage row will be blank (check master key / proxy)`);
20482
- return null;
20483
- }
20484
- let body;
20478
+ const rows = [];
20479
+ for (let page = 1;page <= EXTERNAL_SPEND_MAX_PAGES; page++) {
20480
+ const url = `${base}/user/daily/activity?start_date=${encodeURIComponent(start)}` + `&end_date=${encodeURIComponent(today)}` + `&page=${page}&page_size=${EXTERNAL_SPEND_PAGE_SIZE}&timezone=0`;
20481
+ const ac = new AbortController;
20482
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
20485
20483
  try {
20486
- body = await res.json();
20484
+ const res = await fetchImpl(url, {
20485
+ method: "GET",
20486
+ headers: {
20487
+ Authorization: `Bearer ${opts.adminKey}`,
20488
+ Accept: "application/json"
20489
+ },
20490
+ signal: ac.signal
20491
+ });
20492
+ if (!res.ok) {
20493
+ console.warn(`external-spend: LiteLLM /user/daily/activity returned HTTP ${res.status} — ` + `External /usage row will be blank (check master key / proxy)`);
20494
+ return null;
20495
+ }
20496
+ let body;
20497
+ try {
20498
+ body = await res.json();
20499
+ } catch (err) {
20500
+ console.warn(`external-spend: failed to parse /user/daily/activity JSON: ${err?.message ?? err}`);
20501
+ return null;
20502
+ }
20503
+ rows.push(...normalizeDailyActivityRows(body));
20504
+ const meta = body && typeof body === "object" ? body.metadata : undefined;
20505
+ if (!meta?.has_more)
20506
+ break;
20507
+ if (page === EXTERNAL_SPEND_MAX_PAGES) {
20508
+ console.warn(`external-spend: LiteLLM /user/daily/activity still reports has_more at ` + `page ${EXTERNAL_SPEND_MAX_PAGES} (pagination cap) — External /usage ` + `total is under-reported (collected ${rows.length} day rows)`);
20509
+ }
20487
20510
  } catch (err) {
20488
- console.warn(`external-spend: failed to parse /spend/logs JSON: ${err?.message ?? err}`);
20511
+ console.warn(`external-spend: LiteLLM /user/daily/activity fetch failed: ${err?.message ?? err}`);
20489
20512
  return null;
20513
+ } finally {
20514
+ clearTimeout(timer);
20490
20515
  }
20491
- return summarizeExternalSpend(normalizeSpendLogRows(body), now);
20492
- } catch (err) {
20493
- console.warn(`external-spend: LiteLLM /spend/logs fetch failed: ${err?.message ?? err}`);
20494
- return null;
20495
- } finally {
20496
- clearTimeout(timer);
20497
20516
  }
20517
+ return summarizeExternalSpend(rows, now);
20498
20518
  }
20499
20519
 
20500
20520
  // src/auth/broker/mirror-write.ts