Skip to content

Commit 3bb9d24

Browse files
committed
Track LLM token usage in reports
1 parent 326a2b4 commit 3bb9d24

16 files changed

Lines changed: 323 additions & 39 deletions

src/skillspector/llm_analyzer_base.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import asyncio
3131
from collections import defaultdict
3232
from dataclasses import dataclass, field
33-
from typing import Literal
33+
from typing import Literal, TypedDict
3434

3535
from langchain_core.messages import BaseMessage
3636
from pydantic import BaseModel, Field, field_validator
@@ -114,6 +114,38 @@ class LLMAnalysisResult(BaseModel):
114114
findings: list[LLMFinding] = Field(default_factory=list)
115115

116116

117+
class LLMTokenUsage(TypedDict):
118+
"""Provider-normalized token usage for LLM calls."""
119+
120+
input_tokens: int
121+
output_tokens: int
122+
total_tokens: int
123+
124+
125+
def _empty_token_usage() -> LLMTokenUsage:
126+
return {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
127+
128+
129+
def _extract_token_usage(raw: object) -> LLMTokenUsage:
130+
usage = getattr(raw, "usage_metadata", None) or {}
131+
if not isinstance(usage, dict):
132+
return _empty_token_usage()
133+
input_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
134+
output_tokens = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0)
135+
total_tokens = int(usage.get("total_tokens") or input_tokens + output_tokens)
136+
return {
137+
"input_tokens": input_tokens,
138+
"output_tokens": output_tokens,
139+
"total_tokens": total_tokens,
140+
}
141+
142+
143+
def _add_token_usage(total: LLMTokenUsage, usage: LLMTokenUsage) -> None:
144+
total["input_tokens"] += usage["input_tokens"]
145+
total["output_tokens"] += usage["output_tokens"]
146+
total["total_tokens"] += usage["total_tokens"]
147+
148+
117149
def estimate_tokens(text: str) -> int:
118150
"""Approximate token count from character length."""
119151
return len(text) // CHARS_PER_TOKEN
@@ -275,8 +307,36 @@ def __init__(self, base_prompt: str, model: str):
275307
self._input_budget = get_max_input_tokens(model)
276308
self._llm = get_chat_model(model=model)
277309
self._structured_llm = (
278-
self._llm.with_structured_output(self.response_schema) if self.response_schema else None
310+
self._llm.with_structured_output(self.response_schema, include_raw=True)
311+
if self.response_schema
312+
else None
279313
)
314+
self._llm_usage = _empty_token_usage()
315+
316+
@property
317+
def llm_usage(self) -> LLMTokenUsage:
318+
"""Cumulative token usage from the most recent batch run."""
319+
return dict(self._llm_usage) # type: ignore[return-value]
320+
321+
def _reset_llm_usage(self) -> None:
322+
self._llm_usage = _empty_token_usage()
323+
324+
def _record_usage_from_raw(self, raw: object) -> None:
325+
_add_token_usage(self._llm_usage, _extract_token_usage(raw))
326+
327+
def _unwrap_structured_response(self, response: object) -> object:
328+
if not isinstance(response, dict) or not {"raw", "parsed", "parsing_error"} <= set(
329+
response
330+
):
331+
return response
332+
raw = response.get("raw")
333+
self._record_usage_from_raw(raw)
334+
parsing_error = response.get("parsing_error")
335+
if parsing_error is not None:
336+
if isinstance(parsing_error, BaseException):
337+
raise parsing_error
338+
raise ValueError(str(parsing_error))
339+
return response.get("parsed")
280340

281341
# -- Batching -----------------------------------------------------------
282342

@@ -376,6 +436,7 @@ def run_batches(
376436
:meth:`parse_response` returns :class:`Finding` objects; subclasses may
377437
return dicts or other types.
378438
"""
439+
self._reset_llm_usage()
379440
results: list[tuple[Batch, list]] = []
380441
for batch in batches:
381442
prompt = self.build_prompt(batch, **kwargs)
@@ -386,9 +447,11 @@ def run_batches(
386447
len(batch.findings),
387448
)
388449
if self._structured_llm:
389-
response = self._structured_llm.invoke(prompt)
450+
response = self._unwrap_structured_response(self._structured_llm.invoke(prompt))
390451
else:
391-
response = _message_text(self._llm.invoke(prompt))
452+
raw_response = self._llm.invoke(prompt)
453+
self._record_usage_from_raw(raw_response)
454+
response = _message_text(raw_response)
392455
logger.debug("LLM response for %s", batch.file_label)
393456
parsed = self.parse_response(response, batch)
394457
results.append((batch, parsed))
@@ -417,6 +480,7 @@ async def arun_batches(
417480
418481
The return type mirrors :meth:`run_batches`.
419482
"""
483+
self._reset_llm_usage()
420484
sem = asyncio.Semaphore(max_concurrency)
421485

422486
async def _process(batch: Batch) -> tuple[Batch, list]:
@@ -429,9 +493,13 @@ async def _process(batch: Batch) -> tuple[Batch, list]:
429493
len(batch.findings),
430494
)
431495
if self._structured_llm:
432-
response = await self._structured_llm.ainvoke(prompt)
496+
response = self._unwrap_structured_response(
497+
await self._structured_llm.ainvoke(prompt)
498+
)
433499
else:
434-
response = _message_text(await self._llm.ainvoke(prompt))
500+
raw_response = await self._llm.ainvoke(prompt)
501+
self._record_usage_from_raw(raw_response)
502+
response = _message_text(raw_response)
435503
logger.debug("LLM response for %s", batch.file_label)
436504
return (batch, self.parse_response(response, batch))
437505

src/skillspector/llm_utils.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from typing import NoReturn
4040

4141
from langchain_core.language_models.chat_models import BaseChatModel
42+
from pydantic import BaseModel
4243

4344
from skillspector.model_info import get_max_input_tokens, get_max_output_tokens
4445
from skillspector.providers import (
@@ -161,11 +162,19 @@ class _StructuredAgentCLIModel:
161162
``complete()``, then parses and validates the response into *schema*.
162163
"""
163164

164-
def __init__(self, provider: object, model: str, max_output_tokens: int, schema: type) -> None:
165+
def __init__(
166+
self,
167+
provider: object,
168+
model: str,
169+
max_output_tokens: int,
170+
schema: type[BaseModel],
171+
include_raw: bool = False,
172+
) -> None:
165173
self._provider = provider
166174
self._model = model
167175
self._max_output_tokens = max_output_tokens
168176
self._schema = schema
177+
self._include_raw = include_raw
169178

170179
def _augment(self, prompt: str) -> str:
171180
schema_json = json.dumps(self._schema.model_json_schema(), indent=2)
@@ -182,7 +191,17 @@ def invoke(self, prompt: str) -> object:
182191
model=self._model,
183192
max_output_tokens=self._max_output_tokens,
184193
)
185-
return self._schema.model_validate(_extract_json_object(raw))
194+
try:
195+
parsed = self._schema.model_validate(_extract_json_object(raw))
196+
parsing_error = None
197+
except Exception as exc:
198+
parsed = None
199+
parsing_error = exc
200+
if not self._include_raw:
201+
raise
202+
if self._include_raw:
203+
return {"raw": _AgentCLIMessage(raw), "parsed": parsed, "parsing_error": parsing_error}
204+
return parsed
186205

187206
async def ainvoke(self, prompt: str) -> object:
188207
return await asyncio.to_thread(self.invoke, prompt)
@@ -227,9 +246,11 @@ def invoke(self, prompt: str) -> _AgentCLIMessage:
227246
async def ainvoke(self, prompt: str) -> _AgentCLIMessage:
228247
return await asyncio.to_thread(self.invoke, prompt)
229248

230-
def with_structured_output(self, schema: type) -> _StructuredAgentCLIModel:
249+
def with_structured_output(
250+
self, schema: type[BaseModel], *, include_raw: bool = False
251+
) -> _StructuredAgentCLIModel:
231252
return _StructuredAgentCLIModel(
232-
self._provider, self._model, self._max_output_tokens, schema
253+
self._provider, self._model, self._max_output_tokens, schema, include_raw=include_raw
233254
)
234255

235256

src/skillspector/nodes/analyzers/semantic_developer_intent.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
ANALYZER_ID = "semantic_developer_intent"
3333
logger = get_logger(__name__)
3434

35+
36+
class _NoUsage:
37+
llm_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
38+
39+
3540
ANALYZER_PROMPT = """\
3641
You are a developer-intent auditor for AI agent skills. Your job is to
3742
detect mismatches between what a skill *claims* to do (its manifest and
@@ -179,12 +184,22 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
179184
results = asyncio.run(analyzer.arun_batches(batches))
180185
findings = analyzer.collect_findings(results)
181186
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
182-
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
187+
return {
188+
"findings": findings,
189+
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=True, **analyzer.llm_usage)],
190+
}
183191
except ValueError:
184192
raise
185193
except Exception as exc:
186194
logger.warning("%s failed: %s", ANALYZER_ID, exc)
187195
return {
188196
"findings": [],
189-
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=str(exc))],
197+
"llm_call_log": [
198+
llm_call_record(
199+
ANALYZER_ID,
200+
ok=False,
201+
error=str(exc),
202+
**locals().get("analyzer", _NoUsage()).llm_usage,
203+
)
204+
],
190205
}

src/skillspector/nodes/analyzers/semantic_quality_policy.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
ANALYZER_ID = "semantic_quality_policy"
3333
logger = get_logger(__name__)
3434

35+
36+
class _NoUsage:
37+
llm_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
38+
39+
3540
ANALYZER_PROMPT = """\
3641
You are a quality and safety auditor for AI agent skills. Your job is to
3742
review a single skill file and report findings that fall into the categories
@@ -148,12 +153,22 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
148153
results = asyncio.run(analyzer.arun_batches(batches))
149154
findings = analyzer.collect_findings(results)
150155
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
151-
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
156+
return {
157+
"findings": findings,
158+
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=True, **analyzer.llm_usage)],
159+
}
152160
except ValueError:
153161
raise
154162
except Exception as exc:
155163
logger.warning("%s failed: %s", ANALYZER_ID, exc)
156164
return {
157165
"findings": [],
158-
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=str(exc))],
166+
"llm_call_log": [
167+
llm_call_record(
168+
ANALYZER_ID,
169+
ok=False,
170+
error=str(exc),
171+
**locals().get("analyzer", _NoUsage()).llm_usage,
172+
)
173+
],
159174
}

src/skillspector/nodes/analyzers/semantic_security_discovery.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
ANALYZER_ID = "semantic_security_discovery"
2828
logger = get_logger(__name__)
2929

30+
31+
class _NoUsage:
32+
llm_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
33+
34+
3035
ANALYZER_PROMPT = """\
3136
You are a security analyzer for AI agent skill files. Your task is to identify \
3237
**intent and attack-phrasing risks** — issues that evade regex/static detection because \
@@ -90,14 +95,22 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
9095
results = analyzer.run_batches(batches)
9196
findings = analyzer.collect_findings(results)
9297
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
93-
return {"findings": findings, "llm_call_log": [llm_call_record(ANALYZER_ID, ok=True)]}
98+
return {
99+
"findings": findings,
100+
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=True, **analyzer.llm_usage)],
101+
}
94102
except ValidationError as exc:
95103
# Malformed LLM response — degrade gracefully rather than crashing the graph
96104
logger.warning("%s: LLM returned malformed response: %s", ANALYZER_ID, exc)
97105
return {
98106
"findings": [],
99107
"llm_call_log": [
100-
llm_call_record(ANALYZER_ID, ok=False, error=f"malformed LLM response: {exc}")
108+
llm_call_record(
109+
ANALYZER_ID,
110+
ok=False,
111+
error=f"malformed LLM response: {exc}",
112+
**locals().get("analyzer", _NoUsage()).llm_usage,
113+
)
101114
],
102115
}
103116
except ValueError:
@@ -106,5 +119,12 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
106119
logger.warning("%s failed: %s", ANALYZER_ID, exc)
107120
return {
108121
"findings": [],
109-
"llm_call_log": [llm_call_record(ANALYZER_ID, ok=False, error=str(exc))],
122+
"llm_call_log": [
123+
llm_call_record(
124+
ANALYZER_ID,
125+
ok=False,
126+
error=str(exc),
127+
**locals().get("analyzer", _NoUsage()).llm_usage,
128+
)
129+
],
110130
}

src/skillspector/nodes/meta_analyzer.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
logger = get_logger(__name__)
4545

4646

47+
class _NoUsage:
48+
llm_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
49+
50+
4751
# ---------------------------------------------------------------------------
4852
# Structured output schemas
4953
# ---------------------------------------------------------------------------
@@ -565,13 +569,20 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse:
565569
)
566570
return {
567571
"filtered_findings": filtered,
568-
"llm_call_log": [llm_call_record("meta_analyzer", ok=True)],
572+
"llm_call_log": [llm_call_record("meta_analyzer", ok=True, **analyzer.llm_usage)],
569573
}
570574
except ValueError:
571575
raise
572576
except Exception as e:
573577
logger.warning("LLM call failed, passing all findings through (fail-closed): %s", e)
574578
return {
575579
"filtered_findings": _passthrough_with_defaults(findings),
576-
"llm_call_log": [llm_call_record("meta_analyzer", ok=False, error=str(e))],
580+
"llm_call_log": [
581+
llm_call_record(
582+
"meta_analyzer",
583+
ok=False,
584+
error=str(e),
585+
**locals().get("analyzer", _NoUsage()).llm_usage,
586+
)
587+
],
577588
}

0 commit comments

Comments
 (0)