LLM Usage and Pricing Propagation#
LLMUsage is the canonical struct that carries token counts and pricing data across Dify's execution graph. It holds prompt_tokens, completion_tokens, total_tokens, prompt_price, completion_price, total_price, currency, and timing fields (latency, time_to_first_token). Every serialization or execution boundary is a potential drop point for these fields — particularly pricing, which requires more than simple token counts.
This article maps the four major boundaries where usage data is preserved or lost, and the fixes applied to each.
Workflow-as-Tool Boundary#
When a workflow is invoked as a tool (WorkflowTool), the inner workflow runs to completion and returns a dict result. Usage is recovered from this dict via _derive_usage_from_result(), which:
- Calls
_extract_usage_dict()to recursively search the result payload for ausagemapping — first at the top level (payload["usage"]), then insidepayload["metadata"]["usage"], then via recursive descent into nested dicts and lists. - Converts the found dict to
LLMUsageviaLLMUsage.from_metadata(). - Falls back to legacy fields
total_tokens/total_price/currencydirectly on thedatadict if no structuredusageis found .
The result is stored in self._latest_usage and exposed via the latest_usage property. The workflow node runtime reads it after tool invocation using getattr(tool, "latest_usage", None) with a fallback to LLMUsage.empty_usage() .
Historical gap (fixed PR #27021, Oct 2025): Before this change, the workflow container did not surface LLM usage to parent nodes; token counts from nested workflow steps were silently discarded. The fix added the _derive_usage_from_result / _extract_usage_dict pipeline to WorkflowTool and updated iteration, loop, tool, and knowledge-retrieval nodes to aggregate and propagate usage .
Agent Backend (dify-agent) Boundary#
The dify-agent backend is a separate process. LLM calls happen inside it and usage flows back to the API layer via SSE terminal events (AgentBackendRunSucceededInternalEvent, etc.). Historically, the model adapter converted LLMUsage → Pydantic AI RequestUsage, which only carries token counts; all pricing fields were silently dropped.
Fix (PR #39201, Jul 2026):
dify-agent/src/dify_agent/adapters/llm/model.py— model/usage mapping now carries full pricing fields (unit prices, computed prompt/completion/total prices, currency) through the agent's internal usage structures.dify-agent/src/dify_agent/protocol/schemas.py—AgentRunUsageextended to includeprompt_unit_price,completion_unit_price,prompt_price,completion_price,total_price,currency,latency, etc.dify-agent/src/dify_agent/runtime/runner.py— aggregation logic now preserves pricing fields across streaming, tool-call, and retry rounds without double-counting cumulative stream usage.
On the API side, _agent_run_usage() in event_adapter.py serializes the complete usage dict, and _llm_usage_from_agent_backend() in app_runner.py reconstructs LLMUsage from it. All four terminal event types (RunSucceeded, DeferredToolCall, RunFailed, RunCancelled) now carry the usage field, so pricing survives even failed or cancelled runs.
Without this fix, Message.answer_token_count, Message.total_price stayed at zero for all agent apps using the backend runner.
Nested Container Nodes (Iteration / Loop)#
Iteration and loop nodes execute child nodes repeatedly. Each child can itself invoke LLM-backed tools. Usage aggregation here follows two mechanisms:
-
Tool-level
latest_usage: After any tool invocation inside a container node, the node runtime callsDifyToolNodeRuntime.get_usage(), which readstool.latest_usageviagetattr. This handlesWorkflowTool,MCPTool, and any tool that exposes the property. -
Container-level accumulation: Iteration and loop nodes accumulate child-node usage into their own metadata, which is then attached to the
NodeRunResultand persisted onWorkflowNodeExecution.execution_metadata. This makes the usage available to monitoring queries and history views.
PR #27021 introduced usage aggregation into iteration, loop, tool, and knowledge-retrieval nodes and added a RAG dataset-retrieval path: the multi-dataset router now returns (dataset_id, router_usage), and _handle_invoke_result() similarly returns (result_text, usage), both accumulated via a _record_usage() helper that calls LLMUsage.plus() to sum fields.
MCP Tools#
MCP tools return usage data in the _meta field of CallToolResult. Before PR #31802 (Feb 2026), this data was ignored.
MCPTool now mirrors the WorkflowTool pattern :
__init__initializesself._latest_usage = LLMUsage.empty_usage()._derive_usage_from_result()readsresult.metaand delegates to_extract_usage_dict()._extract_usage_dict()checks{"usage": {...}}, then{"metadata": {"usage": {...}}}, then top-level token fields (prompt_tokens,completion_tokens,total_tokens, price fields, timing fields), then recurses into nested mappings/lists.latest_usageproperty exposes the result for the samegetattr-based pickup by the tool node runtime.
Because the lookup pattern (getattr(tool, "latest_usage", None)) is duck-typed, any new tool class that exposes a latest_usage: LLMUsage property will automatically participate in usage propagation without changes to the node runtime.
Workflow API Response Surface#
At the API boundary, how much usage is visible to callers depends on which response model is used:
| Field | Workflow streaming (workflow_finished) | Workflow blocking | Chatflow/Advanced Chat |
|---|---|---|---|
total_tokens | ✅ (from graph_runtime_state.total_tokens) | ✅ | ✅ |
prompt_tokens | ⚠️ PR #37531 (open) | ⚠️ PR #37531 (open) | ✅ (via usage metadata) |
completion_tokens | ⚠️ PR #37531 (open) | ⚠️ PR #37531 (open) | ✅ |
total_price / currency | ❌ not in schema | ❌ not in schema | ✅ |
The graph runtime already tracks graph_runtime_state.llm_usage with the full prompt/completion split. PR #37531 (open as of this writing) surfaces prompt_tokens and completion_tokens from llm_usage into WorkflowFinishStreamResponse.Data and the blocking response, without persisting them to the workflow_runs table (which would require changes to the upstream graphon WorkflowExecution entity).
The WorkflowRun database model stores only total_tokens; total_price and currency are not persisted for workflow runs, only for Message rows (chatflow/agent apps).
For advanced chat / chatflow apps, WorkflowFinishStreamResponse usage data flows through generate_task_pipeline.py where total_tokens and pricing appear in the terminal message event's metadata.usage block.
Known Gaps and Historical Bugs#
| Issue | Status | Fix |
|---|---|---|
| Nested workflow-as-tool steps silently dropped all usage | Fixed Oct 2025 | PR #27021 |
Agent backend (dify-agent) stripped pricing fields, leaving total_price = 0 | Fixed Jul 2026 | PR #39201 |
MCP tool _meta usage ignored | Fixed Feb 2026 | PR #31802 |
Workflow run API responses expose total_tokens only, no prompt/completion split | Open PR #37531 | PR #37531 |
workflow_runs table has no total_price / currency columns | Structural gap | No fix yet; pricing not persisted for pure workflow runs |
| Agent usage overwritten (not summed) across rounds in classic CoT/FC runners | Fixed previously | See Agent Cost and Usage Tracking |
Design note: The _extract_usage_dict recursive search pattern (shared by both WorkflowTool and MCPTool) is intentionally lenient — it searches nested dicts and lists to handle heterogeneous MCP server response shapes. The risk is false positives if a payload contains an unrelated usage key; the pattern picks up the first match.