Skip to content

feat(scanner): expose LLM token usage in ScanResult JSON output - #146

Merged
vineethsai7 merged 19 commits into
cisco-ai-defense:mainfrom
amber-beasley-liatrio:feat/llm-token-usage-in-scan-result
Aug 3, 2026
Merged

feat(scanner): expose LLM token usage in ScanResult JSON output#146
vineethsai7 merged 19 commits into
cisco-ai-defense:mainfrom
amber-beasley-liatrio:feat/llm-token-usage-in-scan-result

Conversation

@amber-beasley-liatrio

@amber-beasley-liatrio amber-beasley-liatrio commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Description

Problem

LiteLLM and the Google GenAI SDK both return prompt/completion token counts on every response, but skill-scanner discarded them right after extracting the text content — nothing in the JSON scan output told a caller how many tokens an LLM-as-a-judge scan actually consumed. Downstream pipelines that need to attribute LLM call cost per scan had no option but to monkey-patch LiteLLM internals or proxy HTTP traffic.

Solution

This PR adds an additive llm_usage field to ScanResult.to_dict(){input_tokens, output_tokens, total_tokens} — aggregated across every LLM call a scan makes (per-file analyzer, consensus-judging runs, and meta-analysis, including its follow-up pass), and omitted entirely when no LLM call was made.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Performance improvement
  • Code refactoring
  • Test coverage improvement

Related Issues

Closes #136

Changes Made

  • llm_request_handler.py: extract token usage at the provider-response boundary for both the LiteLLM path (_extract_token_usage) and the Google GenAI SDK path (_extract_google_sdk_token_usage), normalized to the same {input_tokens, output_tokens, total_tokens} shape regardless of provider. Exposed via LLMRequestHandler.last_usage, reset at the top of every make_request() call.
  • llm_analyzer.py / meta_analyzer.py: each analyzer accumulates usage across every LLM call it makes in one run (LLMAnalyzer's consensus-judging loop can call out N times per skill; MetaAnalyzer's follow-up "cover remaining findings" pass is a second call), exposed via a llm_usage property, reset at the start of analyze() / analyze_with_findings().
  • scanner.py: aggregates llm_usage across all LLM analyzers that ran in a scan; only set on ScanResult when at least one analyzer actually reported nonzero usage.
  • meta_analyzer.py: adds merge_meta_analyzer_usage(). MetaAnalyzer always runs as a separate post-processing step after SkillScanner has already built the ScanResult (see cli.py / api/router.py), so its token spend can't be seen by the scanner's own aggregation — this folds it into result.llm_usage afterward. Wired into all 5 call sites where meta-analysis runs post-scan (cli.py: scan_command, scan_all_command, scan_repo_command; api/router.py: single + batch scan).
  • models.py: new optional ScanResult.llm_usage: dict[str, int] | None field, included in to_dict() only when truthy.
  • docs/reference/output-formats.md: documents the new field and its omission behavior.

Testing

Test Coverage

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests pass locally
  • Test coverage maintained or improved

144 new/updated tests across test_llm_request_handler.py, test_llm_analyzer.py, test_meta_analyzer.py, test_models.py, and a new test_cli_llm_usage.py. That last file drives the real cli.scan_command() / scan_all_command() entry points end-to-end (mocked provider responses, no internal helpers called directly) specifically to guard against llm_usage silently regressing to counting only the per-file analyzer and dropping meta-analysis spend.

Manual Testing

# Full suite
uv run pytest -q
# 1480 passed, 5 skipped, 1 xfailed

# Benchmark suite (no regression)
uv run python evals/runners/benchmark_runner.py
# Accuracy/Precision/Recall/F1: 100%

Also ran the real CLI entry point (cli.scan_command()) against a fixture skill with mocked LLM (1,234/321 tokens) and meta-analyzer (4,321/654 tokens) responses and inspected the actual JSON output:

"llm_usage": {
  "input_tokens": 5555,
  "output_tokens": 975,
  "total_tokens": 6530
}

Expected: 5555 = 1234 + 4321 and 975 = 321 + 654 (both analyzers' spend summed correctly); field absent entirely on a static-only scan (--use-llm/--enable-meta off).
Actual: matched exactly on both counts.

Checklist

Code Quality

  • Code follows project style guidelines
  • Type hints added where applicable
  • Docstrings added/updated for public APIs
  • No hardcoded credentials or secrets
  • Error handling is comprehensive (usage extraction is None-safe for providers that omit usage)
  • Logging is appropriate

Documentation

  • README updated (if needed) — not needed, no README-level surface change
  • API documentation updated (if needed) — docs/reference/output-formats.md
  • CHANGELOG updated — no CHANGELOG.md in this repo
  • Code comments added for complex logic

Security

  • No new security vulnerabilities introduced
  • Input validation added where needed
  • Follows security best practices from workspace rules
  • No eval/exec on user input without sanitization

Testing

  • Tests pass: uv run pre-commit run --all-files
  • Benchmark passes: uv run python evals/runners/benchmark_runner.py
  • No regressions in existing functionality
  • Edge cases covered (provider omits usage entirely, static-only scan, meta-analysis no-op, batch scan doesn't leak totals across skills)

Performance Impact

  • No significant performance regression
  • Performance benchmarks run (if applicable) — not applicable; change is bookkeeping only, no new LLM calls or control-flow changes
  • Resource usage is acceptable

Additional Notes

One known minor gap, left for a follow-up rather than blocking this PR: if MetaAnalyzer's LLM call succeeds but something after it (e.g. apply_meta_analysis_to_results()) throws, those tokens are silently dropped from llm_usage since the merge only happens on the success path. MetaAnalyzer.analyze_with_findings() already catches its own provider-level errors internally, so this is only reachable in a narrow post-call failure case.

Summary by CodeRabbit

  • New Features

    • Scan results now report aggregated LLM input, output, and total token usage.
    • Usage includes standard and optional meta-analysis calls across individual and batch scans.
    • Usage details are omitted when no LLM calls are made.
  • Documentation

    • Updated JSON output documentation with the new llm_usage field and availability guidance.
  • Tests

    • Added coverage for token tracking, aggregation, serialization, provider responses, and batch scans.

risawe and others added 7 commits July 5, 2026 18:47
…tic HIGH+ findings

Introduces an optional per-finding adjudicator that runs between the
deterministic analyzers (static / pipeline / behavioral / bytecode / yara)
and the LLM analyzer. For each deterministic HIGH or CRITICAL finding it
asks the configured LLM whether the file around the matched line actually
contains the threat the rule was designed to catch, or whether the regex
fired on benign content. Findings identified as literal-regex false
positives (verdict=false_positive, confidence>=threshold) are demoted to
INFO for downstream verdict computation. The original severity and the
LLM's reasoning are preserved in the finding's metadata for audit.

Because the adjudicator runs before the LLM analyzer, demoted findings do
not enter the LLM analyzer's static-finding enrichment context — so a
wrong deterministic HIGH cannot be amplified into further LLM findings
citing the same pattern hit. This addresses the confirmation-cascade
failure mode described in the linked issue without any changes to
llm_analyzer.py.

Safety property (load-bearing): the adjudicator can only demote findings,
never promote them. LLM errors, timeouts, malformed output, or unexpected
verdicts all leave the finding at its original severity. Enabling this
pass cannot introduce false negatives.

Off by default (--adjudicate CLI flag, ScanPolicy.adjudicator.enabled).
Reuses the same env-var conventions as the LLM analyzer for model and
temperature configuration, so Claude 4.x on Bedrock and OpenAI o1-series
work out of the box.

Includes:
- New skill_scanner/core/analyzers/adjudicator.py (self-contained module)
- AdjudicatorPolicy in ScanPolicy
- --adjudicate CLI flag on both scan entry points
- Integration in scanner.py between Phase 1 and Phase 2 analyzers
- Audit records surfaced in ScanResult.scan_metadata.adjudicator
- 11 pytest tests covering the load-bearing safety cases
- docs/architecture/analyzers/adjudicator.md
…round-trip, override precedence, prompt hardening

Six review-driven fixes plus a soften-the-language docs pass.

1. **Path containment** (adjudicator.py:_adjudicate_one) — resolve
   ``skill_dir / file_path`` and reject anything that lands outside the
   skill directory. Absolute paths and ``..`` traversal now short-circuit
   to a "skipped" AdjudicationResult before any file is read.

2. **YAML round-trip for AdjudicatorPolicy** (scan_policy.py:_from_dict
   and _to_dict) — custom policy files with an ``adjudicator:`` section
   were silently discarded on load and never emitted on ``to_yaml``.
   Wired through following the AnalyzersPolicy / LLMAnalysisPolicy
   pattern already in the file.

3. **--adjudicate on scan-repo** (cli.py:scan_repo_command) — the flag
   was defined in _add_common_scan_flags but scan_repo_command didn't
   read it, so ``skill-scanner scan-repo <url> --adjudicate`` silently
   did nothing. Mirrors the toggle already present on scan_command and
   scan_all_command.

4. **Confidence range enforcement** (adjudicator.py:_adjudicate_one) —
   the response contract is confidence 1-5, but the code accepted any
   integer. A malformed ``{"verdict":"false_positive","confidence":999}``
   would demote past any min_fp_confidence threshold. Now validated and
   fails closed on out-of-range values (including 0).

5. **Preserve adjudicator demotion through severity_overrides**
   (scanner.py:_apply_severity_overrides) — a policy severity-override
   entry could raise a demoted finding back to HIGH/CRITICAL, defeating
   the demote-to-INFO contract. _apply_severity_overrides now skips
   findings that carry ``metadata['adjudication']['demoted_to']``.

6. **Prompt-injection hardening** (adjudicator.py:_call_llm and new
   _SYSTEM_PROMPT constant) — scanned file content is untrusted evidence.
   Split the request into a role=system rubric (trusted) and a role=user
   payload (rubric + untrusted evidence), with the system prompt
   explicitly instructing the model to ignore any instructions embedded
   in the file content. Defense in depth: the demote-only invariant
   already bounds the blast radius, but keeping trusted rubric separate
   from untrusted evidence is standard practice for LLM-as-judge flows.

7. **Docs / docstring softening** — replaced "cannot introduce false
   negatives" with a more accurate statement: error paths (LLM
   unavailable, malformed output, out-of-range confidence, path escape)
   preserve severity, but a wrong ``false_positive`` verdict from the
   LLM itself can still demote a real threat. That's why the pass is
   off by default and every demotion is preserved in metadata for review.

New tests (all 16 pass):
- test_out_of_range_confidence_keeps_original_severity
- test_zero_confidence_keeps_original_severity
- test_absolute_path_outside_skill_dir_is_skipped
- test_parent_traversal_is_skipped
- test_llm_receives_system_prompt

Ruff-clean, format-clean.
Add a policy-driven mechanism to demote LLM findings (transitive trust,
supply chain) to LOW when all referenced domains are declared as trusted
in the scan policy.

This addresses systematic false positives for organizations hosting
skills and documentation on self-managed GitLab/GitHub instances, where
the LLM analyzer flags references to internal repos as external threats.

Changes:
- LLMAnalysisPolicy: new trusted_reference_domains field (set[str])
- llm_analyzer._convert_to_findings: demotion logic after existing
  is_internal_file_reading filter
- Two helpers: _references_only_trusted_domains (URL extraction) and
  _mentions_only_trusted_domains (plain-text domain detection)
- default_policy.yaml: empty default list with documentation

The mechanism mirrors known_installer_domains: findings are demoted to
LOW (maintaining visibility) rather than suppressed entirely.
ProviderConfig.validate() required a truthy credential for every provider
except Bedrock and Ollama, and the only credential source it checked for
Vertex was GOOGLE_APPLICATION_CREDENTIALS. This blocked ambient auth via
a GCE/Cloud Run attached service account or Workload Identity, even though
LiteLLM/google-auth already fall back to it automatically when no explicit
credential is passed -- the same pattern already supported for Bedrock's
IAM role. Excludes is_vertex from the check, mirroring the Bedrock/Ollama
precedent, and documents the fallback.
…o-ai-defense#136)

LiteLLM and the Google GenAI SDK both return prompt/completion token
counts on every response, but the scanner discarded them after pulling
out the text content, leaving downstream pipelines with no way to
attribute LLM call cost to a scan without monkey-patching provider
internals.

Extract token usage at the provider-response boundary for both LiteLLM
and Google SDK request paths, accumulate it per analyzer (covering
multi-call cases like consensus-judging runs and meta-analysis
follow-up passes), aggregate across all LLM analyzers in
SkillScanner, and fold in MetaAnalyzer's spend separately since it
runs as a post-processing step outside the scanner's own aggregation.
Surfaces as an additive `llm_usage` field on ScanResult.to_dict(),
omitted entirely on static-only scans.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ef86385-261a-401e-98d1-1519d4769eef

📥 Commits

Reviewing files that changed from the base of the PR and between 77d6594 and 49fe852.

📒 Files selected for processing (4)
  • skill_scanner/cli/cli.py
  • skill_scanner/core/analyzers/llm_analyzer.py
  • skill_scanner/core/scanner.py
  • tests/test_llm_analyzer.py

📝 Walkthrough

Walkthrough

The change adds provider-normalized LLM token tracking, aggregates usage across analyzers and meta-analysis, exposes it through ScanResult, conditionally serializes it in JSON output, and adds CLI, API, unit, integration, and documentation coverage.

Changes

LLM usage reporting

Layer / File(s) Summary
Provider usage extraction
skill_scanner/core/analyzers/llm_request_handler.py, tests/test_llm_request_handler.py
Token counts are normalized from LiteLLM and Google GenAI responses, reset per request, exposed through last_usage, and tested across provider paths.
Analyzer aggregation and result serialization
skill_scanner/core/analyzers/llm_analyzer.py, skill_scanner/core/scanner.py, skill_scanner/core/models.py, tests/test_llm_analyzer.py, tests/test_models.py, docs/reference/output-formats.md
Analyzer usage is accumulated per run, scanner totals are attached to ScanResult, JSON serialization is conditional, and output documentation describes the new field.
Meta-analysis usage merging
skill_scanner/core/analyzers/meta_analyzer.py, skill_scanner/cli/cli.py, skill_scanner/api/router.py, tests/test_meta_analyzer.py, tests/test_cli_llm_usage.py, tests/test_api_endpoints.py
Meta-analyzer usage is accumulated and merged into scan results across CLI and API single- and batch-scan flows, with integration coverage for combined totals and per-skill isolation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SkillScanner
  participant LLMAnalyzer
  participant MetaAnalyzer
  participant ScanResult
  SkillScanner->>LLMAnalyzer: analyze_async
  LLMAnalyzer-->>SkillScanner: cumulative llm_usage
  SkillScanner->>ScanResult: attach analyzer usage
  SkillScanner->>MetaAnalyzer: analyze_with_findings
  MetaAnalyzer-->>SkillScanner: meta findings and llm_usage
  SkillScanner->>ScanResult: merge meta-analyzer usage
  ScanResult-->>SkillScanner: serialized result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: exposing LLM token usage in ScanResult JSON output.
Linked Issues check ✅ Passed The PR satisfies issue [#136] by adding optional aggregated usage, provider support, additive serialization, and comprehensive tests.
Out of Scope Changes check ✅ Passed The documentation, provider handling, analyzer aggregation, CLI/API integration, and tests directly support issue [#136].
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/reference/output-formats.md`:
- Around line 106-114: Update the JSON example’s analyzers_used list to include
llm_analyzer and/or meta_analyzer so it matches the shown non-empty llm_usage
object. Keep the existing usage values and static analyzer entries unchanged.

In `@skill_scanner/api/router.py`:
- Around line 465-466: Update the ScanResponse model to define an optional
llm_usage field, then pass llm_usage=result.llm_usage when constructing the
single-scan response after merge_meta_analyzer_usage updates result. Preserve
existing response fields and behavior when usage is unavailable.

In `@skill_scanner/core/scanner.py`:
- Line 345: Expose the scanner’s computed llm_usage in the single-skill API
flow: add an optional llm_usage field to ScanResponse, update the manual
ScanResponse construction in the relevant router handler to map
result.llm_usage, and add a regression test verifying the field is returned to
API clients.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a6839f3-14b8-477e-97a3-4f4143302954

📥 Commits

Reviewing files that changed from the base of the PR and between 41fec4a and 2998aae.

📒 Files selected for processing (13)
  • docs/reference/output-formats.md
  • skill_scanner/api/router.py
  • skill_scanner/cli/cli.py
  • skill_scanner/core/analyzers/llm_analyzer.py
  • skill_scanner/core/analyzers/llm_request_handler.py
  • skill_scanner/core/analyzers/meta_analyzer.py
  • skill_scanner/core/models.py
  • skill_scanner/core/scanner.py
  • tests/test_cli_llm_usage.py
  • tests/test_llm_analyzer.py
  • tests/test_llm_request_handler.py
  • tests/test_meta_analyzer.py
  • tests/test_models.py

Comment thread docs/reference/output-formats.md
Comment thread skill_scanner/api/router.py
Comment thread skill_scanner/core/scanner.py
vineethsai7 and others added 9 commits July 30, 2026 11:46
- _resolve_api_key() now returns None for Vertex instead of the
  GOOGLE_APPLICATION_CREDENTIALS path, since vertex_ai/gemini-* models
  set both is_vertex and is_gemini, which was causing the file path to
  be written into GEMINI_API_KEY.
- Regenerated configuration-reference.md via generate_reference_docs.py
  instead of hand-editing, and updated the underlying descriptions so
  the doc doesn't drift on next regeneration.

Addresses CodeRabbit review feedbak on cisco-ai-defense#144.
- Fix inconsistent JSON example: analyzers_used now includes
  llm_analyzer/meta_analyzer to match the shown non-empty llm_usage block.
- Expose ScanResult.llm_usage through the /scan API response: ScanResponse
  was missing the field, so single-scan (and /scan-upload, which delegates
  to it) API clients never received token usage even though the scanner
  computed it. Batch scan was unaffected since it serializes via to_dict().
- Add regression tests covering both the combined analyzer+meta-analyzer
  usage case and the omitted-when-disabled case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 94.11765% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
skill_scanner/cli/cli.py 66.66% 3 Missing ⚠️
skill_scanner/api/router.py 87.50% 1 Missing ⚠️
skill_scanner/core/analyzers/llm_analyzer.py 87.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@vineethsai7
vineethsai7 merged commit 05e5e37 into cisco-ai-defense:main Aug 3, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: expose LLM token usage in ScanResult JSON output

6 participants