Tokens counter
This commit is contained in:
parent
f9ba636837
commit
2584600dbf
@ -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 = {
|
entry = {
|
||||||
"role": role,
|
"role": role,
|
||||||
"text": text,
|
"text": text,
|
||||||
@ -65,6 +65,9 @@ def log(app, role, text, model_info=None):
|
|||||||
if model_info:
|
if model_info:
|
||||||
# Display pair: (provider, model)
|
# Display pair: (provider, model)
|
||||||
entry["model_info"] = model_info
|
entry["model_info"] = model_info
|
||||||
|
if usage:
|
||||||
|
# Token pemakaian: prompt/completion/total dari provider
|
||||||
|
entry["usage"] = usage
|
||||||
app.log.append(entry)
|
app.log.append(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@ -76,20 +79,23 @@ def _pair(model_info):
|
|||||||
return (model_info.get("provider"), model_info.get("model"))
|
return (model_info.get("provider"), model_info.get("model"))
|
||||||
|
|
||||||
|
|
||||||
def _tag_ai_logs(app, prev_entries, pair):
|
def _tag_ai_logs(app, prev_entries, pair, usage=None):
|
||||||
"""Tempelkan info model yang benar-benar dipakai ke entri log AI yang baru
|
"""Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri
|
||||||
dibuat pada langkah ini.
|
log AI yang baru dibuat pada langkah ini.
|
||||||
|
|
||||||
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
|
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
|
||||||
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
|
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
|
||||||
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
|
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
|
||||||
"""
|
"""
|
||||||
if not pair:
|
if not pair and not usage:
|
||||||
return
|
return
|
||||||
seen = {id(e) for e in prev_entries}
|
seen = {id(e) for e in prev_entries}
|
||||||
for entry in app.log:
|
for entry in app.log:
|
||||||
if entry.get("role") == "ai" and id(entry) not in seen:
|
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):
|
class CursesObserver(agent_loop.AgentObserver):
|
||||||
@ -109,10 +115,13 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.pair = None
|
self.pair = None
|
||||||
|
self.usage = None # token pemakaian langkah LLM terakhir
|
||||||
self._buf = []
|
self._buf = []
|
||||||
self._placeholder = None
|
self._placeholder = None
|
||||||
self._stamp = None
|
self._stamp = None
|
||||||
self._stamp_step = 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 ---
|
# ------------------------------------------------------------- lifecycle ---
|
||||||
def on_run_start(self):
|
def on_run_start(self):
|
||||||
@ -126,6 +135,9 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
self._stamp_step = ntro.start()
|
self._stamp_step = ntro.start()
|
||||||
self._buf = []
|
self._buf = []
|
||||||
self._placeholder = None
|
self._placeholder = None
|
||||||
|
self.usage = None
|
||||||
|
self._tool_total = 0
|
||||||
|
self._tool_seen = 0
|
||||||
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
|
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
|
||||||
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
|
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
|
||||||
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
|
# 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):
|
def on_step_end(self, step):
|
||||||
ntro.end(self._stamp_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)
|
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()
|
self._pop_thinking()
|
||||||
if warning:
|
if warning:
|
||||||
log(self.app, "system", f" {warning}")
|
log(self.app, "system", f" {warning}")
|
||||||
@ -155,15 +170,22 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
|
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
|
||||||
# konten yang sama akan di-log utuh di sini.
|
# konten yang sama akan di-log utuh di sini.
|
||||||
self._drop_placeholder()
|
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():
|
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)
|
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
||||||
|
|
||||||
def on_tool_call(self, name, arguments):
|
def on_tool_call(self, name, arguments):
|
||||||
log(self.app, "tool_call", json.dumps({
|
entry = log(self.app, "tool_call", json.dumps({
|
||||||
"name": name,
|
"name": name,
|
||||||
"arguments": arguments,
|
"arguments": arguments,
|
||||||
}), model_info=self.pair)
|
}), model_info=self.pair)
|
||||||
|
self._tool_seen += 1
|
||||||
|
if self.usage and self._tool_seen >= self._tool_total:
|
||||||
|
entry["usage"] = self.usage
|
||||||
self._follow()
|
self._follow()
|
||||||
|
|
||||||
# ---------------------------------------------------------------- final ---
|
# ---------------------------------------------------------------- final ---
|
||||||
@ -176,15 +198,25 @@ class CursesObserver(agent_loop.AgentObserver):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if result.is_multimodal:
|
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:
|
elif content is None:
|
||||||
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
|
||||||
pass
|
entry = None
|
||||||
elif self._placeholder is None and content:
|
elif self._placeholder is None and content:
|
||||||
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
|
||||||
# delta) — pastikan teks tetap terlihat.
|
# delta) — pastikan teks tetap terlihat.
|
||||||
log(self.app, "ai", content, model_info=self.pair)
|
entry = log(self.app, "ai", content,
|
||||||
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks final
|
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)
|
_tag_ai_logs(self.app, self._log_prev, self.pair)
|
||||||
log(self.app, "sep", "")
|
log(self.app, "sep", "")
|
||||||
|
|||||||
@ -214,16 +214,21 @@ class HendrikTUI:
|
|||||||
pair = (mi.get("provider"), mi.get("model")) if mi else None
|
pair = (mi.get("provider"), mi.get("model")) if mi else None
|
||||||
# Log tool calls jika ada
|
# Log tool calls jika ada
|
||||||
if msg.get("tool_calls"):
|
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({
|
log(self, "tool_call", json.dumps({
|
||||||
"name": tc["function"]["name"],
|
"name": tc["function"]["name"],
|
||||||
"arguments": tc["function"]["arguments"],
|
"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
|
# 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
|
next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None
|
||||||
if next_role in ("user", None):
|
if next_role in ("user", None):
|
||||||
if msg.get("content"):
|
if msg.get("content"):
|
||||||
log(self, "ai", msg["content"])
|
log(self, "ai", msg["content"], usage=msg.get("usage"))
|
||||||
if pair:
|
if pair:
|
||||||
self.log[-1]["model_info"] = pair
|
self.log[-1]["model_info"] = pair
|
||||||
elif msg["role"] == "tool":
|
elif msg["role"] == "tool":
|
||||||
|
|||||||
@ -25,6 +25,7 @@ C_INPUT_BORDER = theme.C_INPUT_BORDER
|
|||||||
C_STATUS_INFO = theme.C_STATUS_INFO
|
C_STATUS_INFO = theme.C_STATUS_INFO
|
||||||
C_WELCOME = theme.C_WELCOME
|
C_WELCOME = theme.C_WELCOME
|
||||||
C_TOOL_CALL = theme.C_TOOL_CALL
|
C_TOOL_CALL = theme.C_TOOL_CALL
|
||||||
|
C_USAGE = theme.C_USAGE
|
||||||
|
|
||||||
|
|
||||||
def draw(app, stdscr):
|
def draw(app, stdscr):
|
||||||
@ -68,6 +69,35 @@ def _model_info_line(item):
|
|||||||
return f" {m_name} " if m_name else ""
|
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):
|
def draw_chat(app, stdscr):
|
||||||
# Area chat — dari baris 2 sampai baris (h - 11).
|
# Area chat — dari baris 2 sampai baris (h - 11).
|
||||||
# Bisa di-scroll dengan Page Up / Page Down.
|
# Bisa di-scroll dengan Page Up / Page Down.
|
||||||
@ -159,6 +189,10 @@ def draw_chat(app, stdscr):
|
|||||||
label = f" {app.character_name} ({item['time']}) "
|
label = f" {app.character_name} ({item['time']}) "
|
||||||
_add_row([(C_AI, label)])
|
_add_row([(C_AI, label)])
|
||||||
_wrap_render(text, indent=1, color=C_INPUT, bold=False)
|
_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":
|
elif role == "system":
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
_add_row([(C_SYSTEM, lines[0])])
|
_add_row([(C_SYSTEM, lines[0])])
|
||||||
@ -208,6 +242,11 @@ def draw_chat(app, stdscr):
|
|||||||
(C_TOOL_CALL, "unknown"),
|
(C_TOOL_CALL, "unknown"),
|
||||||
(C_AI, f" ({item['time']}) "),
|
(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
|
# Blank line di bawah
|
||||||
_add_blank()
|
_add_blank()
|
||||||
elif role == "error":
|
elif role == "error":
|
||||||
|
|||||||
@ -21,6 +21,7 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
|||||||
C_WELCOME = 14 # welcome art: light blue
|
C_WELCOME = 14 # welcome art: light blue
|
||||||
C_TOOL_CALL = 15 # tool call: kuning terang
|
C_TOOL_CALL = 15 # tool call: kuning terang
|
||||||
C_TOOL_RESULT = 16 # tool result: magenta muda
|
C_TOOL_RESULT = 16 # tool result: magenta muda
|
||||||
|
C_USAGE = 22 # token usage: abu-abu
|
||||||
|
|
||||||
# -- Menu bar colors (id 17+) --
|
# -- Menu bar colors (id 17+) --
|
||||||
C_MENU = 17 # background menubar (sama dengan header)
|
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_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_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_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
||||||
|
curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default
|
||||||
|
|
||||||
# Menu bar
|
# Menu bar
|
||||||
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||||
|
|||||||
@ -67,18 +67,20 @@ class SessionStore:
|
|||||||
def append(self, message):
|
def append(self, message):
|
||||||
if not self.keep_model_info:
|
if not self.keep_model_info:
|
||||||
message.pop('model_info', None)
|
message.pop('model_info', None)
|
||||||
|
message.pop('usage', None) # metadata display, bukan payload LLM
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
if self._on_append:
|
if self._on_append:
|
||||||
self._on_append(message)
|
self._on_append(message)
|
||||||
|
|
||||||
class AgentResult:
|
class AgentResult:
|
||||||
# Hasil satu kali jalan loop.
|
# 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.content = content # str | None
|
||||||
self.session_ended = session_ended # tool end_session pernah dipanggil
|
self.session_ended = session_ended # tool end_session pernah dipanggil
|
||||||
self.model_info = model_info or {} # model yang benar-benar menjawab
|
self.model_info = model_info or {} # model yang benar-benar menjawab
|
||||||
self.warning = warning # mis. fallback chain model
|
self.warning = warning # mis. fallback chain model
|
||||||
self.is_multimodal = is_multimodal
|
self.is_multimodal = is_multimodal
|
||||||
|
self.usage = usage # token turn ini: prompt/completion/total (dict | None)
|
||||||
|
|
||||||
|
|
||||||
class AgentObserver:
|
class AgentObserver:
|
||||||
@ -100,8 +102,11 @@ class AgentObserver:
|
|||||||
def on_step_start(self, step):
|
def on_step_start(self, step):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_response(self, model_info, warning):
|
def on_response(self, model_info, warning, usage=None):
|
||||||
"""llm.chat() sudah kembali, sebelum engine memproses response."""
|
"""llm.chat() sudah kembali, sebelum engine memproses response.
|
||||||
|
|
||||||
|
`usage` : token pemakaian satu permintaan (prompt/completion/total),
|
||||||
|
None jika provider tidak mengirimnya."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_step_end(self, step):
|
def on_step_end(self, step):
|
||||||
@ -182,6 +187,25 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
warning = None
|
warning = None
|
||||||
stream_cb = observer.on_stream_chunk if observer.wants_streaming else 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()
|
observer.on_run_start()
|
||||||
|
|
||||||
for step in range(max_iterations):
|
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.
|
# auto-switch / rotasi key / fallback tercatat dengan benar.
|
||||||
model_info = getattr(response, "model_info", None) or {}
|
model_info = getattr(response, "model_info", None) or {}
|
||||||
warning = getattr(response, "warning", None)
|
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:
|
if response.tool_calls:
|
||||||
amsg = {
|
amsg = {
|
||||||
@ -209,6 +235,11 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
}
|
}
|
||||||
if model_info:
|
if model_info:
|
||||||
amsg['model_info'] = 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)
|
store.append(amsg)
|
||||||
|
|
||||||
observer.on_tool_calls(response.content or "",
|
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}
|
msg = {'role': 'assistant', 'content': response.content}
|
||||||
if model_info:
|
if model_info:
|
||||||
msg['model_info'] = 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)
|
store.append(msg)
|
||||||
|
|
||||||
if is_multimodal:
|
if is_multimodal:
|
||||||
@ -258,6 +293,7 @@ def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
|
|||||||
model_info=model_info,
|
model_info=model_info,
|
||||||
warning=warning,
|
warning=warning,
|
||||||
is_multimodal=is_multimodal,
|
is_multimodal=is_multimodal,
|
||||||
|
usage=dict(turn_usage) if any_usage else None,
|
||||||
)
|
)
|
||||||
observer.on_final(final)
|
observer.on_final(final)
|
||||||
observer.on_run_end()
|
observer.on_run_end()
|
||||||
|
|||||||
@ -35,6 +35,8 @@ class LLMClient:
|
|||||||
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
|
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
|
||||||
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
|
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
|
||||||
self.warning = None
|
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
|
# Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda
|
||||||
# dengan model awal permintaan karena auto-switch/fallback.
|
# dengan model awal permintaan karena auto-switch/fallback.
|
||||||
self.model_info = model_info or {}
|
self.model_info = model_info or {}
|
||||||
@ -232,23 +234,36 @@ class LLMClient:
|
|||||||
# ------------------------------------------------------- single attempt ---
|
# ------------------------------------------------------- single attempt ---
|
||||||
|
|
||||||
def _chat_once(self, base_url, model, api_key, messages, tools=None,
|
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"
|
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 = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": msgs_clean,
|
||||||
"stream": True # Enable streaming
|
"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:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
payload["tool_choice"] = "auto"
|
payload["tool_choice"] = "auto"
|
||||||
if tool_reminder:
|
if tool_reminder:
|
||||||
# Inject tool reminder sebagai system message sebelum user message terakhir
|
# Inject tool reminder sebagai system message sebelum user message terakhir
|
||||||
for i in range(len(messages) - 1, -1, -1):
|
for i in range(len(msgs_clean) - 1, -1, -1):
|
||||||
if messages[i].get("role") == "user":
|
if msgs_clean[i].get("role") == "user":
|
||||||
payload["messages"] = messages[:i] + [
|
payload["messages"] = msgs_clean[:i] + [
|
||||||
{"role": "system", "content": tool_reminder}
|
{"role": "system", "content": tool_reminder}
|
||||||
] + messages[i:]
|
] + msgs_clean[i:]
|
||||||
break
|
break
|
||||||
|
|
||||||
# Hanya kirim parameter reasoning jika diminta eksplisit
|
# Hanya kirim parameter reasoning jika diminta eksplisit
|
||||||
@ -268,6 +283,7 @@ class LLMClient:
|
|||||||
reasoning_content = ""
|
reasoning_content = ""
|
||||||
full_multimodal_content = []
|
full_multimodal_content = []
|
||||||
stream_started = False
|
stream_started = False
|
||||||
|
usage = None # prompt/completion/total tokens dari chunk terakhir
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.cancel_requested = False
|
self.cancel_requested = False
|
||||||
@ -292,9 +308,15 @@ class LLMClient:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
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
|
# Parse delta dari chunk
|
||||||
delta = chunk.get('choices', [{}])[0].get('delta', {})
|
choices = chunk.get('choices') or []
|
||||||
finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None)
|
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
|
# Stream reasoning content jika ada
|
||||||
if 'reasoning_content' in delta:
|
if 'reasoning_content' in delta:
|
||||||
@ -399,6 +421,16 @@ class LLMClient:
|
|||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 ""
|
detail = f" - {body_text[:500]}" if body_text else ""
|
||||||
if e.code in (401, 403):
|
if e.code in (401, 403):
|
||||||
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
|
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
|
||||||
@ -448,4 +480,6 @@ class LLMClient:
|
|||||||
message.pop('reasoning_content', None)
|
message.pop('reasoning_content', None)
|
||||||
message.pop('reasoning', None)
|
message.pop('reasoning', None)
|
||||||
|
|
||||||
return self.Message(message)
|
result = self.Message(message)
|
||||||
|
result.usage = usage
|
||||||
|
return result
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user