diff --git a/wsai/backends/claude.py b/wsai/backends/claude.py
index b4fd9d4..6f99e6b 100644
--- a/wsai/backends/claude.py
+++ b/wsai/backends/claude.py
@@ -176,4 +176,9 @@ class ClaudeBrain:
messages=msgs,
)
text = "".join(b.text for b in resp.content if b.type == "text")
- return Reply(text=text.strip(), ts=time.monotonic())
+ usage = None
+ u = getattr(resp, "usage", None)
+ if u is not None:
+ usage = {"input": getattr(u, "input_tokens", 0) or 0,
+ "output": getattr(u, "output_tokens", 0) or 0}
+ return Reply(text=text.strip(), ts=time.monotonic(), usage=usage)
diff --git a/wsai/dashboard.py b/wsai/dashboard.py
index 7fef884..2d754cb 100644
--- a/wsai/dashboard.py
+++ b/wsai/dashboard.py
@@ -475,6 +475,9 @@ class Dashboard:
try:
reply = self._submit(self.brain.respond(heard, None, list(self._history)))
text = (reply.text or "").strip()
+ u = getattr(reply, "usage", None)
+ if u:
+ self.monitor.add_claude_usage(u.get("input", 0), u.get("output", 0))
except Exception as exc: # noqa: BLE001
log.exception("brain failed")
self.monitor.log("error", f"두뇌 응답 실패: {exc}")
@@ -723,6 +726,7 @@ PAGE = r"""
0대화 수
0오류
+
0클로드 토큰
0초가동시간
·연결
@@ -803,6 +807,7 @@ function fmtTime(wall){
return d.toLocaleTimeString('ko-KR',{hour12:false}) +
'.' + String(d.getMilliseconds()).padStart(3,'0');
}
+function fmtNum(n){ n=n||0; if(n>=1e6) return (n/1e6).toFixed(2)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'k'; return ''+n; }
function fmtUptime(s){
s = Math.floor(s||0);
const h=Math.floor(s/3600), m=Math.floor(s%3600/60), sec=s%60;
@@ -817,6 +822,10 @@ function renderStatus(s){
$('s-turns').textContent = s.turns_total ?? 0;
$('s-errors').textContent = s.errors_total ?? 0;
$('s-up').textContent = fmtUptime(s.uptime_s);
+ const ci=s.claude_input_tokens||0, co=s.claude_output_tokens||0, cr=s.claude_requests||0;
+ const cel=$('s-claude');
+ if(cel){ cel.textContent = fmtNum(ci+co);
+ cel.parentElement.title = '클로드 사용량 (서버 시작 후) — 입력 '+ci.toLocaleString()+' · 출력 '+co.toLocaleString()+' 토큰 · 요청 '+cr+'회'; }
const listening = s.listening;
$('dot').className = 'dot ' + (listening ? 'live' : 'off');
$('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨');
diff --git a/wsai/interfaces.py b/wsai/interfaces.py
index 0be55b0..1da9cfc 100644
--- a/wsai/interfaces.py
+++ b/wsai/interfaces.py
@@ -63,6 +63,7 @@ class Reply:
text: str
ts: float
+ usage: dict | None = None # Claude token usage for this reply, when known
# --------------------------------------------------------------------------- #
diff --git a/wsai/monitor.py b/wsai/monitor.py
index 1d7a013..23cac2d 100644
--- a/wsai/monitor.py
+++ b/wsai/monitor.py
@@ -169,6 +169,10 @@ class Monitor:
"components": {},
"turns_total": 0,
"errors_total": 0,
+ # Claude usage since server start (this bot's own consumption).
+ "claude_requests": 0,
+ "claude_input_tokens": 0,
+ "claude_output_tokens": 0,
}
self._lock = threading.Lock()
self._subs: list["queue.Queue[str]"] = []
@@ -192,6 +196,14 @@ class Monitor:
s["uptime_s"] = round(_now_wall() - s["started_wall"], 1)
return s
+ def add_claude_usage(self, input_tokens: int, output_tokens: int) -> None:
+ """Accumulate one Claude call's token usage for the dashboard."""
+ with self._lock:
+ self._status["claude_requests"] += 1
+ self._status["claude_input_tokens"] += int(input_tokens or 0)
+ self._status["claude_output_tokens"] += int(output_tokens or 0)
+ self._broadcast({"type": "status", "status": self.status_snapshot()})
+
def log(self, level: str, message: str) -> None:
"""A free-form lifecycle/error line (startup, disconnect, crash…)."""
with self._lock: