@@ -57,6 +57,7 @@ def __init__(
5757 self ._tool_calls = 0
5858 self ._iterations = 0
5959 self ._token_used = 0
60+ self ._token_used_source : str | None = None
6061
6162 # ─────────── 内部工具 ───────────
6263
@@ -253,24 +254,100 @@ async def _flush_counters(self, repo: Any = None) -> None:
253254 try :
254255 if repo is None :
255256 repo = await self ._repo ()
256- await repo . update_run (
257- self .run_id ,
258- {
259- "total_tool_calls " : int (self ._tool_calls or 0 ),
260- "total_iterations" : int ( self . _iterations or 0 ),
261- "token_used" : int ( self ._token_used or 0 ),
262- },
263- )
257+ payload : dict [ str , Any ] = {
258+ "total_tool_calls" : int ( self ._tool_calls or 0 ) ,
259+ "total_iterations" : int ( self . _iterations or 0 ),
260+ "token_used " : int (self ._token_used or 0 ),
261+ }
262+ if self ._token_used_source :
263+ payload [ "meta" ] = self . _token_meta ()
264+ await repo . update_run ( self . run_id , payload )
264265 except Exception as e :
265266 logger .debug ("RunRecorder._flush_counters: %s" , e )
266267
267268 def set_token_used (self , used : int ) -> None :
268- """同步 kernel 进程已用 token(finish 时落库)。"""
269+ """Legacy absolute setter. Does not override provider-accrued totals.
270+
271+ Prefer ``note_llm_round_usage`` — kernel snapshots are often 0
272+ (charge skipped when token_budget is None; Rust client returns copies).
273+ """
274+ if self ._token_used_source in ("provider" , "partial" ):
275+ return
269276 try :
270277 self ._token_used = max (0 , int (used or 0 ))
271278 except (TypeError , ValueError ):
272279 pass
273280
281+ def note_llm_round_usage (self , usage : dict [str , Any ] | None ) -> None :
282+ """Accrue provider-reported tokens for this run.
283+
284+ Empty/missing usage is recorded as ``omitted`` rather than a silent 0
285+ that looks like a free run. Estimates are not written here.
286+ """
287+ try :
288+ from backend .services .llm .usage_normalize import finalize_usage
289+
290+ u = finalize_usage (dict (usage ) if isinstance (usage , dict ) else {})
291+ except Exception :
292+ u = dict (usage ) if isinstance (usage , dict ) else {}
293+ try :
294+ prompt = int (u .get ("prompt_tokens" ) or 0 )
295+ completion = int (u .get ("completion_tokens" ) or 0 )
296+ total = int (u .get ("total_tokens" ) or 0 )
297+ except (TypeError , ValueError ):
298+ prompt , completion , total = 0 , 0 , 0
299+ n = total if total > 0 else max (0 , prompt + completion )
300+ if n > 0 :
301+ self ._token_used = int (self ._token_used or 0 ) + n
302+ if self ._token_used_source == "omitted" :
303+ self ._token_used_source = "partial"
304+ else :
305+ self ._token_used_source = "provider"
306+ return
307+ if self ._token_used_source is None :
308+ self ._token_used_source = "omitted"
309+ elif self ._token_used_source == "provider" :
310+ self ._token_used_source = "partial"
311+
312+ def apply_process_ledger (self , process_id : str | None = None ) -> None :
313+ """Copy provider-real usage from usage_ledger.by_process onto this run.
314+
315+ Live packed runs already wrote prompt/completion into
316+ ``~/.tevarn/data/usage_ledger.json`` while ``agent_runs.token_used``
317+ stayed 0 (kernel snapshot copy). Only prompt+completion are used —
318+ estimated rounds store ``tokens`` with prompt=completion=0.
319+ """
320+ pid = (process_id or self .kernel_process_id or "" ).strip ()
321+ if not pid or pid == "system" :
322+ return
323+ try :
324+ from backend .services .usage_ledger import process_cost
325+
326+ bucket = process_cost (pid )
327+ except Exception :
328+ return
329+ if not bucket :
330+ return
331+ try :
332+ prompt = int (bucket .get ("prompt" ) or 0 )
333+ completion = int (bucket .get ("completion" ) or 0 )
334+ except (TypeError , ValueError ):
335+ return
336+ if prompt <= 0 and completion <= 0 :
337+ return
338+ n = prompt + completion
339+ self ._token_used = max (int (self ._token_used or 0 ), n )
340+ if self ._token_used_source in (None , "omitted" ):
341+ self ._token_used_source = "provider"
342+ elif self ._token_used_source == "partial" :
343+ self ._token_used_source = "provider"
344+
345+ def _token_meta (self ) -> dict [str , Any ]:
346+ meta = dict (self .meta or {})
347+ if self ._token_used_source :
348+ meta ["token_used_source" ] = self ._token_used_source
349+ return meta
350+
274351 # ─────────── 终态 ───────────
275352
276353 async def _finish (
@@ -291,13 +368,16 @@ async def _finish(
291368 return
292369 try :
293370 repo = await self ._repo ()
371+ self .apply_process_ledger ()
294372 data : dict [str , Any ] = {
295373 "status" : dst .value ,
296374 "ended_at" : _utcnow (),
297375 "total_tool_calls" : self ._tool_calls ,
298376 "total_iterations" : int (self ._iterations or 0 ),
299377 "token_used" : int (self ._token_used or 0 ),
300378 }
379+ if self ._token_used_source :
380+ data ["meta" ] = self ._token_meta ()
301381 if self .token_limit :
302382 data ["token_limit" ] = int (self .token_limit )
303383 if final_summary :
0 commit comments