Skip to content

feat(sdk): Run Receipt — per-run token/cost/timing visibility (fixes silent token-tracking crash)#467

Open
segevshlomovIBM wants to merge 8 commits into
mainfrom
feat/run-receipt
Open

feat(sdk): Run Receipt — per-run token/cost/timing visibility (fixes silent token-tracking crash)#467
segevshlomovIBM wants to merge 8 commits into
mainfrom
feat/run-receipt

Conversation

@segevshlomovIBM

@segevshlomovIBM segevshlomovIBM commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Run Receipt — per-run token usage, estimated cost, and LLM-vs-tools time split — behind a new advanced_features.run_receipt flag (default off, zero behavior change when disabled), and fixes a silent token-tracking crash on the default providers.

Closes #466

The problem

  • TokenUsageTracker.on_llm_end dereferenced response.llm_output without a None-guard, so token usage silently crashed and was lost on LiteLLM/watsonx/streaming responses.
  • Token usage was only accumulated into a single process-global counter — no per-run breakdown, concurrent runs cross-contaminate.
  • No cost computation existed anywhere in the live serving path; answering "what did this run cost?" required deploying Langfuse.
  • No per-run LLM-vs-tool wall-time split, despite tool duration_ms already being recorded.

What this PR does

When run_receipt = true, every CugaAgent.invoke() returns result.receipt:

┌─ Run Receipt ──────────────────────┐
│ model: openai/gpt-oss-120b         │
│ tokens: 7,450 in / 208 out (7,658) │
│ est. cost: $0.0013                 │
│ llm calls: 2   tool calls: 1       │
│ time: 6.1s (llm 3.1s / tools 0.4s) │
│ slowest tool: get_accounts 0.4s    │
└────────────────────────────────────┘

(real output from a watsonx gpt-oss-120b run)

  • Per-invoke callback collector (RunMetricsCollector) — one instance per invoke(), so concurrent runs never share counters. Reads AIMessage.usage_metadata with a legacy llm_output["token_usage"] fallback, and captures every LLM call in the run (main loop, reflection, summarizer).
  • Static price table (backend/llm/pricing.py) seeded with the models shipped in configurations/models/*.toml; unknown/self-hosted models degrade to est. cost: n/a.
  • Tool timings reuse the existing duration_ms from tool-call tracking (same semantics as track_tool_calls); the returned result.tool_calls stays gated on the caller's flag — unchanged behavior.
  • Fail-safe by design: every receipt code path is guarded; any failure yields receipt=None, never affects the run.
  • Bug fix (flag-independent): None-guard + usage_metadata fallback in TokenUsageTracker — kept as a separate first commit so it can be split out if desired.

Verification

  • 21 new tests: guard fix (4), pricing (6), receipt/collector (9), SDK integration incl. flag-off zero-change (2).
  • Full CI-equivalent gate (run_tests.sh --skip-stability) green locally.
  • Manual E2E, offline (CUGA_MOCK_LLM=true) and real (watsonx gpt-oss-120b — the exact provider whose token tracking previously crashed): flag on → receipt above; flag off → receipt is None, behavior identical to main.
  • Security review of the diff: no findings.

Out of scope / follow-ups

  • Emit the receipt as a final SSE event on the server /stream path.
  • Receipt support for agent.stream() (e.g. agent.last_receipt).
  • Optional hardening: a timings_only tracker mode so receipt-forced tracking records only {name, duration_ms} instead of full tool arguments/results in thread state.

Summary by CodeRabbit

  • New Features
    • Added optional “Run Receipt” to agent execution results, including token usage, estimated USD cost, model name, and an LLM vs tool timing breakdown.
    • Introduced a configuration flag to enable receipts, plus a “timings-only” tool-call tracking mode to avoid collecting full tool payloads.
  • Bug Fixes
    • Improved token usage extraction when provider metadata is missing or incomplete.
    • Enhanced model pricing estimation, including cache-read token costing behavior.
  • Documentation
    • Documented the run_receipt flag and the receipt fields/output.
  • Tests
    • Added unit and integration coverage for receipts, pricing, token tracking, and timings-only behavior.

- TokenUsageTracker.on_llm_end dereferenced response.llm_output without a
  None-guard, raising inside the callback for LiteLLM/watsonx/streaming
  responses and silently losing token counts
- keep the legacy llm_output.token_usage path, fall back to the message's
  usage_metadata (modern LangChain field) when absent
- add unit tests covering both paths, the fallback, and the no-usage no-op

Refs #466
- (pattern, input, output) USD-per-1M-token entries for the models shipped
  in configurations/models/*.toml; longest-pattern-first substring matching
  on a normalized model name (provider prefixes stripped)
- estimate_cost() degrades to None for unknown/self-hosted models and never
  raises, so callers can rely on it unconditionally
- groundwork for per-run cost reporting on InvokeResult

Refs #466
- RunMetricsCollector: per-invoke LangChain callback accumulating token
  usage per model (usage_metadata with legacy token_usage fallback) and
  LLM wall time; one instance per run so concurrent runs never share state
- RunReceipt pydantic model with a dependency-free box-style __str__ render
- build_run_receipt() aggregates collector + tracked tool durations; cost
  reported only when every used model has a known price
- new advanced_features.run_receipt flag, default false (zero change when
  off); every code path is fail-safe and can never affect the run

Refs #466
- new InvokeResult.receipt field, populated only when
  advanced_features.run_receipt is on; None otherwise
- invoke() creates a per-run collector, passes it via _apply_callbacks
  extra_callbacks on both the normal and HITL-resume paths, and forces
  track_tool_calls internally for timings while result.tool_calls stays
  gated on the caller's flag (behavior unchanged)
- multi-turn threads: snapshot prior tool_calls count so the receipt covers
  only this invocation; resume path is thread-cumulative (documented)
- mock model now emits estimated usage_metadata so receipts work offline
- integration test drives CugaAgent.invoke() with a real BaseChatModel
  fake: flag on -> full receipt; flag off -> receipt is None

Refs #466
- new Run Receipt section under the SDK docs: flag snippet, example box
  output, structured access, pricing-table pointer
- note tool-timing semantics (same coverage as track_tool_calls)

Refs #466
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf877df1-a55c-4aaa-9de4-0758781bf7a0

📥 Commits

Reviewing files that changed from the base of the PR and between f8e781d and 62d7eaf.

📒 Files selected for processing (11)
  • README.md
  • src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/sandbox_node.py
  • src/cuga/backend/cuga_graph/nodes/cuga_lite/tracking/tracker.py
  • src/cuga/backend/cuga_graph/utils/agent_loop.py
  • src/cuga/backend/cuga_graph/utils/run_receipt.py
  • src/cuga/backend/llm/pricing.py
  • src/cuga/sdk.py
  • tests/unit/test_model_pricing.py
  • tests/unit/test_run_receipt.py
  • tests/unit/test_token_usage_tracker_guard.py
  • tests/unit/test_tool_tracker_timings_only.py
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/cuga/backend/cuga_graph/utils/agent_loop.py
  • tests/unit/test_run_receipt.py
  • src/cuga/backend/cuga_graph/utils/run_receipt.py

📝 Walkthrough

Walkthrough

Adds an opt-in “Run Receipt” to CugaAgent.invoke() with per-run token usage, estimated cost, LLM/tool timing, and tool details. It includes pricing resolution, defensive token extraction, timings-only tracking, configuration, SDK wiring, tests, and documentation.

Changes

Run Receipt implementation

Layer / File(s) Summary
Defensive token usage extraction
src/cuga/backend/cuga_graph/utils/agent_loop.py, tests/unit/test_token_usage_tracker_guard.py
Handles missing LLM output and falls back to message usage metadata without raising.
Model pricing and usage fixtures
src/cuga/backend/llm/pricing.py, src/cuga/backend/llm/load_test_mock.py, tests/unit/test_model_pricing.py
Adds LiteLLM-first pricing lookup, static fallback pricing, cache-aware cost estimation, mock usage metadata, and pricing tests.
Receipt models and aggregation
src/cuga/backend/cuga_graph/utils/run_receipt.py, tests/unit/test_run_receipt.py
Adds receipt models, per-run metrics collection, tool/token aggregation, cost computation, formatted rendering, and fail-safe tests.
Timings-only tool tracking
src/cuga/backend/cuga_graph/nodes/cuga_lite/tracking/tracker.py, src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/sandbox_node.py, tests/unit/test_tool_tracker_timings_only.py
Adds tracking mode that retains tool identity and duration while omitting arguments, results, and errors.
SDK integration, configuration, and documentation
src/cuga/sdk.py, src/cuga/config.py, src/cuga/settings.toml, src/cuga/backend/cuga_graph/nodes/cuga_lite/tests/test_run_receipt_integration.py, README.md
Wires receipt collection into normal and resume invocations, adds the opt-in setting and result field, and documents and tests the feature.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant CugaAgent
  participant RunMetricsCollector
  participant AgentGraph
  participant build_run_receipt

  Caller->>CugaAgent: invoke(message)
  CugaAgent->>RunMetricsCollector: start collection
  CugaAgent->>AgentGraph: run with receipt callback
  AgentGraph-->>CugaAgent: result and tool timings
  CugaAgent->>build_run_receipt: aggregate metrics and estimate cost
  build_run_receipt-->>CugaAgent: RunReceipt
  CugaAgent-->>Caller: InvokeResult with receipt
Loading

Possibly related PRs

Suggested labels: complexity: high

Suggested reviewers: sami-marreed, offerakrabi

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.39% 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 summarizes the main feature and related token-tracking fix.
Linked Issues check ✅ Passed The changes implement the opt-in run receipt, per-run metrics, cost estimation, and the llm_output fallback required by #466.
Out of Scope Changes check ✅ Passed No clear out-of-scope code changes are evident; the added tests, docs, and supporting pricing/tracking updates align with #466.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/run-receipt

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.

@segevshlomovIBM segevshlomovIBM self-assigned this Jul 9, 2026
@coderabbitai coderabbitai Bot added complexity: medium Moderate scope — multiple files or non-trivial logic readability: good Clear PR goal and description; easy to review labels Jul 9, 2026

@haroldship haroldship left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks — this is a well-built feature: the fail-safe discipline is consistent, the per-run collector correctly avoids the global ActivityTracker's concurrency problem, and test coverage of the aggregation logic is solid. The on_llm_end crash fix alone is worth landing.

Requesting changes for two behavioral issues, plus a few accuracy/design questions in the inline comments:

Blocking

  1. Enabling run_receipt silently turns on full tool-payload capture (sdk.py). Forcing track_tool_calls=True means every tool call's arguments and results are appended to state.tool_calls and persisted by the checkpointer, accumulating unboundedly across turns on a thread — data retention and state growth the caller never opted into. A metrics flag shouldn't capture payloads; a lightweight mode (name + duration only) or at minimum prominent documentation is needed.
  2. Resume-path receipt mixes scopes (sdk.py). Tokens/LLM time cover only the resume segment while tool timings are thread-cumulative, so tool_time_s can exceed wall_time_s and counts include earlier turns. The consumer has no way to tell. The normal path's snapshot-slice approach can be applied here too via graph.get_state() before resuming.

Non-blocking, see inline comments

  • Pricing: consider litellm.model_cost (already a core dependency, maintained upstream) as the primary source with the static table as fallback — the table already disagrees with litellm on both gpt-oss models.
  • The docstring says self-hosted RITS models "resolve to None", but rits/openai/gpt-oss-120b normalizes to a priced entry — which behavior is intended?
  • Cached/reasoning token details (input_token_details / output_token_details) are ignored, so cost overestimates on cache-heavy runs.
  • response.generations[0][0] in TokenUsageTracker.on_llm_end can still IndexError on an empty generations list — same silent-loss class this PR fixes; a guard would make the fix complete.

token_usage = (response.llm_output or {}).get("token_usage") or {}
total_tokens = token_usage.get("total_tokens")
if total_tokens is None:
message = getattr(response.generations[0][0], "message", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

generations[0][0] can crash if malformed response

cost = f"${self.cost_usd:.4f}" if self.cost_usd is not None else "n/a (unknown model)"
lines = [
f"model: {', '.join(self.models) if self.models else 'unknown'}",
f"tokens: {self.input_tokens:,} in / {self.output_tokens:,} out ({self.total_tokens:,})",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could there be cached or reasoning tokens?


# (substring pattern, USD per 1M input tokens, USD per 1M output tokens)
MODEL_PRICES: list[Tuple[str, float, float]] = [
("gpt-4.1-mini", 0.40, 1.60),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since litellm is already a core dependency, could litellm.get_model_info() / litellm.model_cost be the primary price source, with this table only as a fallback for models litellm doesn't know? It's maintained upstream and includes cache-read pricing. Two caveats from trying it:

  1. Bare open-weight names aren't keys — gpt-oss-120b needs a provider prefix (groq/openai/gpt-oss-120b, azure_ai/gpt-oss-120b), so the lookup should try the raw (unstripped) model name before the normalized one.
  2. This table already disagrees with litellm on both gpt-oss models (120b: 0.15/0.75 here vs 0.15/0.60 on groq/azure/bedrock in litellm; 20b: 0.10/0.50 vs 0.07/0.30). These look like Groq's launch prices, since reduced — which is the drift this hard-coded approach invites. (minimax-m3 and all the gpt-4.x/gpt-4o entries match litellm exactly — only the two gpt-oss models drifted.)

Note: this is the bundled/GitHub price map in the litellm package, not our LiteLLM proxy — no network dependency beyond an optional GitHub fetch that already falls back to a local copy (LITELLM_LOCAL_MODEL_COST_MAP=True forces offline). If we ever pull live prices from our proxy's /model/info instead, that one is behind VPN and would need fail-safe + caching.

Comment thread src/cuga/backend/llm/pricing.py Outdated
@@ -0,0 +1,56 @@
"""Static price estimates for the models shipped in configurations/models/*.toml.

Prices are public list prices in USD per 1M tokens and are estimates by design —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rits/openai/gpt-oss-120b normalizes to gpt-oss-120b and gets the public list price, but the docstring says self-hosted RITS deployments "resolve to None." If pricing RITS usage at notional market rate is intended, the docstring should say so; if not, RITS prefixes should short-circuit to None.

Comment thread src/cuga/sdk.py
thread_id=thread_id,
error=error_msg,
variables=_hitl_variables,
receipt=self._build_run_receipt(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On the resume path the collector is fresh (tokens/llm_time cover only this resume segment) but tool timings cover the whole thread history, so tool_time_s can exceed wall_time_s and tool_call_count includes calls from before the interrupt and from earlier turns. The code comment acknowledges it, but the receipt gives its consumer no signal that its fields have different scopes. This is fixable the same way as the normal path: read len(state.values.get("tool_calls", [])) via self.graph.get_state(run_config) (already called later at line 2331) before ainvoke and slice.

Comment thread src/cuga/sdk.py Outdated
receipt_started_at = time.monotonic()
# Tool durations for the receipt require tracking; the returned
# result.tool_calls stays gated on the caller's track_tool_calls.
run_config["configurable"]["track_tool_calls"] = True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Forcing track_tool_calls=True doesn't just collect timings. ToolCallTracker.record_call stores the full sanitized arguments and result of every tool call (tracking/tracker.py:98-107), and sandbox_node.py:241 appends those records to state.tool_calls, which is persisted by the checkpointer and accumulates across turns on a thread (that's why this PR needs the snapshot-slice at line 2455). So a metrics flag turns on full payload capture the caller never opted into: unbounded state growth on long-lived threads, and retention of potentially sensitive tool outputs. Consider a lightweight tracking mode (name + duration_ms only) when tracking is receipt-forced, or at minimum document this — the README says "zero overhead when disabled" but not what enabling costs.

- ToolCallTracker.start_tracking(timings_only=True) records tool name/app/
  operation/duration but nulls arguments, results, and error payloads
- sandbox node maps the "timings_only" track_tool_calls sentinel onto it
- review feedback on #467: a metrics flag must not silently capture tool
  payloads into checkpointed thread state

Refs #466
- when run_receipt forces tracking without the caller's track_tool_calls,
  pass the "timings_only" sentinel so no payloads reach thread state
- resume path now snapshots the prior tool_calls count via graph.get_state()
  before resuming, so the receipt's tool metrics cover only the resume
  segment — matching the token/time scope of the per-invoke collector
- README: document what enabling the receipt records, and the litellm-based
  pricing source
- review feedback on #467 (both blocking items)

Refs #466
…ards

- litellm's bundled model_cost map is now the primary price source (raw
  model name tried before the normalized one), with the static table as
  fallback; gpt-oss entries corrected to litellm's current list prices
- rits/... model names short-circuit to None — self-hosted serving is never
  billed at notional market rate (docstring now matches behavior)
- cache-read input tokens are collected from usage_metadata, billed at the
  model's cache-read rate when known, and surfaced on the receipt
- empty/malformed generations no longer abort usage collection in either
  TokenUsageTracker or RunMetricsCollector (llm_output fallback still lands)
- review feedback on #467 (non-blocking items)

Refs #466

@haroldship haroldship left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wow - looks great

@segevshlomovIBM

segevshlomovIBM commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

All of the review points are fixed, verified end-to-end, and pushed.

I added a timings_only tracking mode so that when the receipt forces tool tracking, only tool name/app/duration reach the checkpointed state, never arguments, results, or errors, and the resume path now snapshots the prior tool_calls count via graph.get_state() so the receipt covers only the resume segment (both blockers). Pricing switched to litellm's bundled model_cost map as the primary source (static table as fallback, gpt-oss prices corrected, rits/... short-circuits to None), with cache-read tokens now collected and billed at the cache-read rate. I also guarded the empty-generations crash in both usage collectors, updated docs/tests (95 passing, re-verified E2E on watsonx), and pushed the 3 commits to the PR

@haroldship

Copy link
Copy Markdown
Collaborator

@sami-marreed I've approved this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity: medium Moderate scope — multiple files or non-trivial logic readability: good Clear PR goal and description; easy to review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Run Receipt: per-run token/cost/timing visibility; fix silent token-tracking crash

2 participants