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: