From 2584600dbfb98153107048e4d0e141303ab2738e Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 13 Oct 2026 09:38:30 +0700 Subject: [PATCH] Tokens counter --- interfaces/tui/agent.py | 58 +++++++++++++++++++++++++++++++--------- interfaces/tui/app.py | 11 +++++--- interfaces/tui/render.py | 39 +++++++++++++++++++++++++++ interfaces/tui/theme.py | 2 ++ lib/agent_loop.py | 44 +++++++++++++++++++++++++++--- services/llm_client.py | 52 ++++++++++++++++++++++++++++------- 6 files changed, 177 insertions(+), 29 deletions(-) diff --git a/interfaces/tui/agent.py b/interfaces/tui/agent.py index c89790e..f1dfb1d 100644 --- a/interfaces/tui/agent.py +++ b/interfaces/tui/agent.py @@ -56,7 +56,7 @@ WELCOME_ART = """ ╚══════════════════════════════════════════╝ """ -def log(app, role, text, model_info=None): +def log(app, role, text, model_info=None, usage=None): entry = { "role": role, "text": text, @@ -65,6 +65,9 @@ def log(app, role, text, model_info=None): if model_info: # Display pair: (provider, model) entry["model_info"] = model_info + if usage: + # Token pemakaian: prompt/completion/total dari provider + entry["usage"] = usage app.log.append(entry) return entry @@ -76,20 +79,23 @@ def _pair(model_info): return (model_info.get("provider"), model_info.get("model")) -def _tag_ai_logs(app, prev_entries, pair): - """Tempelkan info model yang benar-benar dipakai ke entri log AI yang baru - dibuat pada langkah ini. +def _tag_ai_logs(app, prev_entries, pair, usage=None): + """Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri + log AI yang baru dibuat pada langkah ini. Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries` menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python. """ - if not pair: + if not pair and not usage: return seen = {id(e) for e in prev_entries} for entry in app.log: if entry.get("role") == "ai" and id(entry) not in seen: - entry["model_info"] = pair + if pair: + entry["model_info"] = pair + if usage: + entry["usage"] = usage class CursesObserver(agent_loop.AgentObserver): @@ -109,10 +115,13 @@ class CursesObserver(agent_loop.AgentObserver): def __init__(self, app): self.app = app self.pair = None + self.usage = None # token pemakaian langkah LLM terakhir self._buf = [] self._placeholder = None self._stamp = None self._stamp_step = None + self._tool_total = 0 # jumlah tool call yang dijadwalkan langkah ini + self._tool_seen = 0 # tool call yang sudah di-log langkah ini # ------------------------------------------------------------- lifecycle --- def on_run_start(self): @@ -126,6 +135,9 @@ class CursesObserver(agent_loop.AgentObserver): self._stamp_step = ntro.start() self._buf = [] self._placeholder = None + self.usage = None + self._tool_total = 0 + self._tool_seen = 0 log(self.app, "system", f" step {step + 1} \u2014 Thinking...") # Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut # terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop. @@ -135,8 +147,11 @@ class CursesObserver(agent_loop.AgentObserver): def on_step_end(self, step): ntro.end(self._stamp_step) - def on_response(self, model_info, warning): + def on_response(self, model_info, warning, usage=None): self.pair = _pair(model_info) + # Token pemakaian langkah LLM ini (untuk label usage di entri ai). + # Total satu turn sudah diakumulasi engine di AgentResult.usage. + self.usage = usage self._pop_thinking() if warning: log(self.app, "system", f" {warning}") @@ -155,15 +170,22 @@ class CursesObserver(agent_loop.AgentObserver): # Narasi assistant sebelum tools; placeholder streaming dibuang karena # konten yang sama akan di-log utuh di sini. self._drop_placeholder() + # Baris token satu panggilan LLM dititipkan ke tool_call TERAKHIR dari + # langkah ini (lihat on_tool_call), supaya urutan render tetap benar. + self._tool_total = len(names) + self._tool_seen = 0 if content and content.strip(): - log(self.app, "ai", content, model_info=self.pair) + log(self.app, "ai", content, model_info=self.pair, usage=self.usage) _tag_ai_logs(self.app, self._log_prev, self.pair) def on_tool_call(self, name, arguments): - log(self.app, "tool_call", json.dumps({ + entry = log(self.app, "tool_call", json.dumps({ "name": name, "arguments": arguments, }), model_info=self.pair) + self._tool_seen += 1 + if self.usage and self._tool_seen >= self._tool_total: + entry["usage"] = self.usage self._follow() # ---------------------------------------------------------------- final --- @@ -176,15 +198,25 @@ class CursesObserver(agent_loop.AgentObserver): return if result.is_multimodal: - log(self.app, "ai", "[Image generated]", model_info=self.pair) + entry = log(self.app, "ai", "[Image generated]", + model_info=self.pair, usage=result.usage) elif content is None: # Respons kosong: tidak ada yang ditampilkan selain pemisah. - pass + entry = None elif self._placeholder is None and content: # Tidak ada chunk streaming sama sekali (provider tidak mengirim # delta) — pastikan teks tetap terlihat. - log(self.app, "ai", content, model_info=self.pair) - # placeholder streaming tidak perlu diisi ulang: isinya sudah teks final + entry = log(self.app, "ai", content, + model_info=self.pair, usage=result.usage) + else: + # placeholder streaming tidak perlu diisi ulang: isinya sudah teks + # final. Gunakan sebagai target tempelan usage. + entry = self._placeholder + + # Total token satu turn (akumulasi semua langkah LLM) ditaruh di + # entri respons final, dirender setelah teks pesan. + if entry is not None and result.usage: + entry["usage"] = result.usage _tag_ai_logs(self.app, self._log_prev, self.pair) log(self.app, "sep", "") diff --git a/interfaces/tui/app.py b/interfaces/tui/app.py index 38c7bc7..eaab6d5 100644 --- a/interfaces/tui/app.py +++ b/interfaces/tui/app.py @@ -214,16 +214,21 @@ class HendrikTUI: pair = (mi.get("provider"), mi.get("model")) if mi else None # Log tool calls jika ada if msg.get("tool_calls"): - for tc in msg["tool_calls"]: + tcl = msg["tool_calls"] + for j, tc in enumerate(tcl): + # Baris token langkah ini dititipkan di tool_call terakhir + # (konvensi sama dengan CursesObserver.on_tool_call). + last = (j == len(tcl) - 1) log(self, "tool_call", json.dumps({ "name": tc["function"]["name"], "arguments": tc["function"]["arguments"], - }), model_info=pair) + }), model_info=pair, + usage=msg.get("usage") if last else None) # Log AI content jika ada dan ini adalah response final next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None if next_role in ("user", None): if msg.get("content"): - log(self, "ai", msg["content"]) + log(self, "ai", msg["content"], usage=msg.get("usage")) if pair: self.log[-1]["model_info"] = pair elif msg["role"] == "tool": diff --git a/interfaces/tui/render.py b/interfaces/tui/render.py index c663e79..e9ffbc6 100644 --- a/interfaces/tui/render.py +++ b/interfaces/tui/render.py @@ -25,6 +25,7 @@ C_INPUT_BORDER = theme.C_INPUT_BORDER C_STATUS_INFO = theme.C_STATUS_INFO C_WELCOME = theme.C_WELCOME C_TOOL_CALL = theme.C_TOOL_CALL +C_USAGE = theme.C_USAGE def draw(app, stdscr): @@ -68,6 +69,35 @@ def _model_info_line(item): return f" {m_name} " if m_name else "" +def _fmt_tokens(n): + """1234 -> '1.2k', 999 -> '999'.""" + if n >= 1000: + return f"{n / 1000:.1f}k".replace(".0k", "k") + return str(n) + + +def _usage_line(item, label="tokens"): + """Baris token usage dari entri log; "" jika tidak ada. + + Contoh: ' tokens in 48.2k → out 260 · total 48.5k · ctx 48.2k '. + `ctx` = ukuran context window saat itu (prompt tokens panggilan terakhir; + pada riwayat lama tanpa data itu, jatuh ke total in). + `label` membedakan cakupan angka: 'tokens' untuk total satu turn (blok ai + final), 'step tokens' untuk satu panggilan LLM (blok tool_call).""" + usage = item.get("usage") + if not isinstance(usage, dict): + return "" + try: + pt = int(usage.get("prompt_tokens") or 0) + ct = int(usage.get("completion_tokens") or 0) + tt = int(usage.get("total_tokens") or (pt + ct)) + ctx = int(usage.get("context_tokens") or pt or tt) + except (TypeError, ValueError): + return "" + return (f" {label} in {_fmt_tokens(pt)} → out {_fmt_tokens(ct)}" + f" · total {_fmt_tokens(tt)} · ctx {_fmt_tokens(ctx)} ") + + def draw_chat(app, stdscr): # Area chat — dari baris 2 sampai baris (h - 11). # Bisa di-scroll dengan Page Up / Page Down. @@ -159,6 +189,10 @@ def draw_chat(app, stdscr): label = f" {app.character_name} ({item['time']}) " _add_row([(C_AI, label)]) _wrap_render(text, indent=1, color=C_INPUT, bold=False) + # Baris token usage setelah teks pesan respons + usage_line = _usage_line(item) + if usage_line: + _add_row([(C_USAGE, usage_line, False)]) elif role == "system": lines = text.split("\n") _add_row([(C_SYSTEM, lines[0])]) @@ -208,6 +242,11 @@ def draw_chat(app, stdscr): (C_TOOL_CALL, "unknown"), (C_AI, f" ({item['time']}) "), ]) + # Baris token panggilan LLM yang menghasilkan tool call ini, + # ditempel di tool_call terakhir langkah (lihat agent.CursesObserver). + usage_line = _usage_line(item, label="step tokens") + if usage_line: + _add_row([(C_USAGE, usage_line, False)]) # Blank line di bawah _add_blank() elif role == "error": diff --git a/interfaces/tui/theme.py b/interfaces/tui/theme.py index d2c1ad7..aba7cdb 100644 --- a/interfaces/tui/theme.py +++ b/interfaces/tui/theme.py @@ -21,6 +21,7 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu) C_WELCOME = 14 # welcome art: light blue C_TOOL_CALL = 15 # tool call: kuning terang C_TOOL_RESULT = 16 # tool result: magenta muda +C_USAGE = 22 # token usage: abu-abu # -- Menu bar colors (id 17+) -- C_MENU = 17 # background menubar (sama dengan header) @@ -49,6 +50,7 @@ def init_colors(): curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta + curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default # Menu bar curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE) diff --git a/lib/agent_loop.py b/lib/agent_loop.py index 421f89b..0ebb275 100644 --- a/lib/agent_loop.py +++ b/lib/agent_loop.py @@ -67,18 +67,20 @@ class SessionStore: def append(self, message): if not self.keep_model_info: message.pop('model_info', None) + message.pop('usage', None) # metadata display, bukan payload LLM self.messages.append(message) if self._on_append: self._on_append(message) class AgentResult: # Hasil satu kali jalan loop. - def __init__(self, content=None, session_ended=False, model_info=None, warning=None, is_multimodal=False): + def __init__(self, content=None, session_ended=False, model_info=None, warning=None, is_multimodal=False, usage=None): self.content = content # str | None self.session_ended = session_ended # tool end_session pernah dipanggil self.model_info = model_info or {} # model yang benar-benar menjawab self.warning = warning # mis. fallback chain model self.is_multimodal = is_multimodal + self.usage = usage # token turn ini: prompt/completion/total (dict | None) class AgentObserver: @@ -100,8 +102,11 @@ class AgentObserver: def on_step_start(self, step): pass - def on_response(self, model_info, warning): - """llm.chat() sudah kembali, sebelum engine memproses response.""" + def on_response(self, model_info, warning, usage=None): + """llm.chat() sudah kembali, sebelum engine memproses response. + + `usage` : token pemakaian satu permintaan (prompt/completion/total), + None jika provider tidak mengirimnya.""" pass def on_step_end(self, step): @@ -182,6 +187,25 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, warning = None stream_cb = observer.on_stream_chunk if observer.wants_streaming else None + # Akumulator token satu turn (semua langkah LLM dijumlahkan — tiap + # panggilan mengirim ulang konteks sehingga dihitung ulang oleh provider). + any_usage = False + turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + + def _accumulate(u): + nonlocal any_usage + if not isinstance(u, dict): + return + any_usage = True + for k in ("prompt_tokens", "completion_tokens", "total_tokens"): + v = u.get(k) + if isinstance(v, (int, float)): + turn_usage[k] += int(v) + # Ukuran context window saat itu = prompt tokens panggilan terakhir. + pt = u.get("prompt_tokens") + if isinstance(pt, (int, float)): + turn_usage["context_tokens"] = int(pt) + observer.on_run_start() for step in range(max_iterations): @@ -199,7 +223,9 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, # auto-switch / rotasi key / fallback tercatat dengan benar. model_info = getattr(response, "model_info", None) or {} warning = getattr(response, "warning", None) - observer.on_response(model_info, warning) + usage = getattr(response, "usage", None) + _accumulate(usage) + observer.on_response(model_info, warning, usage) if response.tool_calls: amsg = { @@ -209,6 +235,11 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, } if model_info: amsg['model_info'] = model_info + if isinstance(usage, dict) and usage: + # Usage per langkah (bukan akumulasi turn) — dipakai interface + # untuk menampilkan token di blok tool call yang dihasilkannya. + # Hanya metadata display: dibuang sebelum jadi payload LLM. + amsg['usage'] = dict(usage) store.append(amsg) observer.on_tool_calls(response.content or "", @@ -243,6 +274,10 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, msg = {'role': 'assistant', 'content': response.content} if model_info: msg['model_info'] = model_info + if any_usage: + # Total token turn ini — ikut dipersist supaya tampil saat + # session dibuka kembali. + msg['usage'] = dict(turn_usage) store.append(msg) if is_multimodal: @@ -258,6 +293,7 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, model_info=model_info, warning=warning, is_multimodal=is_multimodal, + usage=dict(turn_usage) if any_usage else None, ) observer.on_final(final) observer.on_run_end() diff --git a/services/llm_client.py b/services/llm_client.py index 87f25b9..073a971 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -35,6 +35,8 @@ class LLMClient: self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip dari content self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls self.warning = None + # Pemakaian token dari provider (prompt/completion/total) jika ada. + self.usage = None # Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda # dengan model awal permintaan karena auto-switch/fallback. self.model_info = model_info or {} @@ -232,23 +234,36 @@ class LLMClient: # ------------------------------------------------------- single attempt --- def _chat_once(self, base_url, model, api_key, messages, tools=None, - on_stream_chunk=None, disable_reasoning=False, tool_reminder=None): + on_stream_chunk=None, disable_reasoning=False, tool_reminder=None, + include_usage=True): url = f"{base_url.rstrip('/')}/chat/completions" + # Sanitasi: buang metadata display (model_info/usage) dari pesan sebelum + # jadi payload — beberapa provider memvalidasi field pesan dengan ketat. + msgs_clean = [ + {k: v for k, v in m.items() if k not in ("model_info", "usage")} + if isinstance(m, dict) else m + for m in (messages or []) + ] payload = { "model": model, - "messages": messages, + "messages": msgs_clean, "stream": True # Enable streaming } + if include_usage: + # Minta provider menyertakan pemakaian token (input/output) pada + # chunk terakhir stream. Provider yang tidak mendukungnya akan + # di-retry tanpa parameter ini (lihat penanganan HTTP 400). + payload["stream_options"] = {"include_usage": True} if tools: payload["tools"] = tools payload["tool_choice"] = "auto" if tool_reminder: # Inject tool reminder sebagai system message sebelum user message terakhir - for i in range(len(messages) - 1, -1, -1): - if messages[i].get("role") == "user": - payload["messages"] = messages[:i] + [ + for i in range(len(msgs_clean) - 1, -1, -1): + if msgs_clean[i].get("role") == "user": + payload["messages"] = msgs_clean[:i] + [ {"role": "system", "content": tool_reminder} - ] + messages[i:] + ] + msgs_clean[i:] break # Hanya kirim parameter reasoning jika diminta eksplisit @@ -268,6 +283,7 @@ class LLMClient: reasoning_content = "" full_multimodal_content = [] stream_started = False + usage = None # prompt/completion/total tokens dari chunk terakhir try: self.cancel_requested = False @@ -292,9 +308,15 @@ class LLMClient: except json.JSONDecodeError: continue + # Pemakaian token datang di chunk penutup (choices kosong + # pada banyak provider) — ambil selama masih None / terbaru. + if chunk.get('usage'): + usage = chunk['usage'] + # Parse delta dari chunk - delta = chunk.get('choices', [{}])[0].get('delta', {}) - finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None) + choices = chunk.get('choices') or [] + delta = choices[0].get('delta', {}) if choices else {} + finish_reason = choices[0].get('finish_reason', None) if choices else None # Stream reasoning content jika ada if 'reasoning_content' in delta: @@ -399,6 +421,16 @@ class LLMClient: return result except Exception: pass + if include_usage and e.code in (400, 422) and "stream_options" in (body_text or "").lower(): + # Provider menolak parameter stream_options — ulangi permintaan + # tanpanya; token usage tidak tersedia tapi respons tetap jalan. + return self._chat_once( + base_url, model, api_key, messages, tools=tools, + on_stream_chunk=on_stream_chunk, + disable_reasoning=disable_reasoning, + tool_reminder=tool_reminder, + include_usage=False, + ) detail = f" - {body_text[:500]}" if body_text else "" if e.code in (401, 403): raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}") @@ -448,4 +480,6 @@ class LLMClient: message.pop('reasoning_content', None) message.pop('reasoning', None) - return self.Message(message) + result = self.Message(message) + result.usage = usage + return result