aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--deckit/modules/llm.py24
-rw-r--r--static/deck.css26
-rw-r--r--static/modules/llm.js29
-rw-r--r--tests/test_llm.py20
4 files changed, 87 insertions, 12 deletions
diff --git a/deckit/modules/llm.py b/deckit/modules/llm.py
index 310eabf..fb45097 100644
--- a/deckit/modules/llm.py
+++ b/deckit/modules/llm.py
@@ -9,7 +9,7 @@ import urllib.request
from ..module import Module
-ENDPOINTS = ("/api/claude_limits", "/api/claude_session", "/api/summary")
+ENDPOINTS = ("/api/claude_limits", "/api/claude_session", "/api/summary", "/api/last_session")
def _fetch_for(base):
@@ -37,6 +37,22 @@ def _opencode(rows) -> dict:
}
+def _claude_model(session) -> str | None:
+ models = (session or {}).get("models") or []
+ return models[0].get("model") if models else None
+
+
+def _opencode_session(sessions) -> dict:
+ """The latest opencode session's dominant model from /api/last_session."""
+ for block in sessions or []:
+ if not isinstance(block, dict) or block.get("program") != "opencode":
+ continue
+ rows = block.get("rows") or []
+ top = max(rows, key=lambda r: r.get("calls") or 0) if rows else {}
+ return {"model": top.get("model"), "provider": top.get("provider")}
+ return {"model": None, "provider": None}
+
+
def make(cfg: dict) -> Module:
base = cfg.get("llm", {}).get("url", "http://127.0.0.1:8420").rstrip("/")
fetch = cfg.get("fetch") or _fetch_for(base)
@@ -53,6 +69,8 @@ def make(cfg: dict) -> Module:
latest = limits[-1]
else:
latest = {}
+ claude_session = (data.get("/api/claude_session") or {}).get("session")
+ sessions = (data.get("/api/last_session") or {}).get("sessions") or []
return {
"offline": False,
"claude": {
@@ -65,8 +83,10 @@ def make(cfg: dict) -> Module:
"extra_limit": latest.get("extra_monthly_limit"),
"extra_utilization": latest.get("extra_utilization"),
"extra_currency": latest.get("extra_currency"),
+ "model": _claude_model(claude_session),
},
- "opencode": _opencode(data.get("/api/summary") or []),
+ "opencode": {**_opencode(data.get("/api/summary") or []),
+ **_opencode_session(sessions)},
}
return Module(name="llm", title="LLM usage", state=state)
diff --git a/static/deck.css b/static/deck.css
index 0e44646..79bf374 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -258,6 +258,32 @@ select {
.wclose { flex: none; width: 36px; height: 36px; padding: 0; }
.windows .launches { display: flex; flex-wrap: wrap; gap: 8px; }
+.llm { display: flex; flex-direction: column; gap: 10px; }
+.llm-sec {
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: var(--udt-mantle);
+ border: 1px solid var(--udt-surface1);
+}
+.llm-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+.llm-head .ico { width: 18px; height: 18px; color: var(--udt-accent); flex: none; }
+.llm-head h3 {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--udt-subtext0);
+ flex: none;
+}
+.llm-head .model {
+ margin-left: auto;
+ max-width: 60%;
+ font-size: 12px;
+ color: var(--udt-overlay0);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.gauges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 12px; }
.gauge { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 12px; background: var(--udt-surface1); }
.glabel { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--udt-overlay0); }
diff --git a/static/modules/llm.js b/static/modules/llm.js
index 0af8d7b..9d05f29 100644
--- a/static/modules/llm.js
+++ b/static/modules/llm.js
@@ -8,6 +8,21 @@ const tokens = (n) => {
return String(n);
};
+const CLAUDE_ICON = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3 1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2L12 3Z"/></svg>';
+const OPENCODE_ICON = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m7 10 2 2-2 2"/><path d="M12.5 14H17"/></svg>';
+
+function section(icon, title, model) {
+ const sec = document.createElement("section");
+ sec.className = "llm-sec";
+ const head = document.createElement("header");
+ head.className = "llm-head";
+ head.innerHTML = `${icon}<h3></h3><span class="model"></span>`;
+ head.querySelector("h3").textContent = title;
+ head.querySelector(".model").textContent = model || "—";
+ sec.append(head);
+ return sec;
+}
+
function gauge(label, value) {
const d = document.createElement("div");
d.className = "gauge";
@@ -26,7 +41,7 @@ function gauge(label, value) {
return d;
}
-function big(label, value, sub) {
+function stat(label, value, sub) {
const d = document.createElement("div");
d.className = "lbig";
const l = document.createElement("span");
@@ -59,6 +74,8 @@ export default {
}
const c = state.claude ?? {};
const o = state.opencode ?? {};
+
+ const claude = section(CLAUDE_ICON, "Claude Code", c.model);
const gauges = document.createElement("div");
gauges.className = "gauges";
gauges.append(
@@ -66,9 +83,11 @@ export default {
gauge("5h", c.five_hour_pct),
gauge("7d", c.seven_day_pct),
);
- box.append(gauges);
- box.append(
- big("opencode spend", money(o.cost), `${tokens(o.tokens)} · ${o.calls ?? 0} calls`),
- );
+ claude.append(gauges);
+ box.append(claude);
+
+ const opencode = section(OPENCODE_ICON, "opencode", o.model);
+ opencode.append(stat("spend", money(o.cost), `${tokens(o.tokens)} · ${o.calls ?? 0} calls`));
+ box.append(opencode);
},
};
diff --git a/tests/test_llm.py b/tests/test_llm.py
index 354b5fe..ea28b08 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -19,7 +19,7 @@ class Down(unittest.TestCase):
class Up(unittest.TestCase):
def test_aggregates_opencode_and_ignores_other_programs(self):
data = {"/api/claude_limits": [{"context_pct": 12}],
- "/api/claude_session": {"context_used": 1200},
+ "/api/claude_session": {"session": {"models": [{"model": "claude-opus-4"}]}},
"/api/summary": [
{"program": "opencode", "provider": "deepseek", "model": "v4",
"total_cost_usd": 1.5, "tokens": 1000, "calls": 3},
@@ -27,24 +27,34 @@ class Up(unittest.TestCase):
"total_cost_usd": 0.5, "tokens": 500, "calls": 1},
{"program": "assistant", "provider": "x", "model": "y",
"total_cost_usd": 9.0, "tokens": 9999, "calls": 9},
- ]}
+ ],
+ "/api/last_session": {"sessions": [
+ {"program": "opencode", "rows": [
+ {"provider": "together", "model": "qwen", "calls": 1},
+ {"provider": "deepseek", "model": "v4", "calls": 3},
+ ]},
+ ]}}
st = llm.make({"llm": {"url": "http://x"},
"fetch": lambda u, timeout=2: data[u]}).state()
self.assertFalse(st["offline"])
self.assertEqual(st["claude"]["context_pct"], 12)
+ self.assertEqual(st["claude"]["model"], "claude-opus-4")
self.assertEqual(st["opencode"]["cost"], 2.0)
self.assertEqual(st["opencode"]["tokens"], 1500)
self.assertEqual(st["opencode"]["calls"], 4)
- self.assertEqual(st["opencode"]["models"][0]["model"], "v4")
+ self.assertEqual(st["opencode"]["model"], "v4")
def test_handles_the_real_latest_shape(self):
data = {"/api/claude_limits": {"latest": {"context_pct": 42, "five_hour_pct": 10}},
- "/api/claude_session": {},
- "/api/summary": []}
+ "/api/claude_session": {"session": None},
+ "/api/summary": [],
+ "/api/last_session": {"sessions": []}}
st = llm.make({"llm": {"url": "http://x"},
"fetch": lambda u, timeout=2: data[u]}).state()
self.assertEqual(st["claude"]["context_pct"], 42)
self.assertEqual(st["claude"]["five_hour_pct"], 10)
+ self.assertIsNone(st["claude"]["model"])
+ self.assertIsNone(st["opencode"]["model"])
if __name__ == "__main__":