AutomationFlowsGeneral › LLM Usage Tracker & Cost Monitor with Node-level Analytics (v2)

LLM Usage Tracker & Cost Monitor with Node-level Analytics (v2)

ByAmir Safavi-Naini @amirsafavi on n8n.io

> v2: Now it can read multiple types of LLM usages. Better dynamic approach for reading model usage.

Event trigger★★★★☆ complexity18 nodesn8nExecute Workflow TriggerStop And Error
General Trigger: Event Nodes: 18 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #7398 — we link there as the canonical source.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "39pmihe4FUZNNFnQ",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "LLM Cost Tracking",
  "tags": [],
  "nodes": [
    {
      "id": "7e87063c-72a7-4c33-a5d4-9f5ef550da21",
      "name": "Get an execution",
      "type": "n8n-nodes-base.n8n",
      "position": [
        -688,
        320
      ],
      "parameters": {
        "options": {
          "activeWorkflows": true
        },
        "resource": "execution",
        "operation": "get",
        "executionId": "={{ $json.execution_id }}",
        "requestOptions": {}
      },
      "credentials": {
        "n8nApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "fdc6c95b-361d-445b-8306-1712e7235eaf",
      "name": "When Exc.",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "position": [
        -880,
        416
      ],
      "parameters": {
        "workflowInputs": {
          "values": [
            {
              "name": "execution_id",
              "type": "number"
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "bce95431-c1be-47bc-96f3-99b318e78af8",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -240,
        -80
      ],
      "parameters": {
        "width": 352,
        "height": 304,
        "content": "### Defined by User\n- Define the model name and standard name, even if you want to use same name. (Why? because we will use the standard name to find the cost of this model)\n- Model prices are per million token\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "a1f0416a-723c-4724-a8f5-7ff3dbb6b829",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        160,
        128
      ],
      "parameters": {
        "color": 3,
        "width": 416,
        "height": 240,
        "content": "### Check all model names are correctly defined"
      },
      "typeVersion": 1
    },
    {
      "id": "e55084c1-e2b6-4788-9f6e-1cfaeef59165",
      "name": "model prices",
      "type": "n8n-nodes-base.set",
      "position": [
        -32,
        96
      ],
      "parameters": {
        "mode": "raw",
        "include": "selected",
        "options": {},
        "jsonOutput": "{\n  \"model_price_dic\":{\n      \"gpt-5\": { \"input\": 1.25, \"output\": 10.0 },\n      \"gpt-5-mini\": { \"input\": 0.25,  \"output\": 2.0 },\n      \"gpt-5-nano\": { \"input\": 0.05, \"output\": 0.4 },\n      \"gpt-4.1\": { \"input\": 2.0, \"output\": 8.0 },\n      \"gpt-4.1-mini\": { \"input\": 0.4, \"output\": 1.6 },\n      \"gpt-4.1-nano\": { \"input\": 0.1,  \"output\": 0.4 },\n      \"gpt-4o\": { \"input\": 2.5,  \"output\": 10.0 },\n      \"gpt-4o-mini\": { \"input\": 0.15, \"output\": 0.6 },\n      \"o1\": { \"input\": 15.0, \"output\": 60.0 },\n      \"o1-pro\": { \"input\": 150.0,  \"output\": 600.0 },\n      \"o3-pro\": { \"input\": 20.0,  \"output\": 80.0 },\n      \"o3\": { \"input\": 2.0,  \"output\": 8.0 },\n      \"o3-deep-research\": { \"input\": 10.0,\"output\": 40.0 },\n      \"o4-mini\": { \"input\": 1.1,  \"output\": 4.4 },\n      \"o4-mini-deep-research\": { \"input\": 2.0,\"output\": 8.0 },\n      \"o3-mini\": { \"input\": 1.1, \"output\": 4.4 },\n      \"o1-mini\": { \"input\": 1.1,  \"output\": 4.4 }\n    }\n  }",
        "includeFields": "standardize_names_dic, models_used",
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "19971fb6-2456-4629-ba2b-8bcacbeda97f",
      "name": "Standardize names",
      "type": "n8n-nodes-base.set",
      "position": [
        -208,
        96
      ],
      "parameters": {
        "mode": "raw",
        "include": "selected",
        "options": {},
        "jsonOutput": "{\n  \"standardize_names_dic\":\n    {\n    \"gpt-4.1-mini\": \"gpt-4.1-mini\",\n    \"gpt-4\": \"gpt-4\",\n    \"gpt-5\": \"gpt-5\",\n    \"gpt-5-mini\": \"gpt-5-mini\",\n    \"gpt-5-nano\": \"gpt-5-nano\"\n    }\n}\n",
        "includeFields": "models_used",
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "fe9bde19-e1d5-40a9-9219-d79668d7a8c1",
      "name": "Check correctly defined",
      "type": "n8n-nodes-base.code",
      "position": [
        640,
        192
      ],
      "parameters": {
        "jsCode": "// NODE 2: Validate Models Against Dictionaries\n\nconst item = $input.first().json;\nconst models_used = item.models_used || [];\nconst standardize_names_dic = item.standardize_names_dic || {};\nconst model_price_dic = item.model_price_dic || {};\n\n// Check which models are missing from each dictionary\nconst missing_from_names = models_used.filter(model => !standardize_names_dic[model]);\nconst missing_from_prices = models_used.filter(model => !model_price_dic[model]);\n\n// Build result\nif (missing_from_names.length === 0 && missing_from_prices.length === 0) {\n  return [{\n    json: {\n      passed: true,\n      message: \"All models validated successfully\",\n      models_used: models_used,\n      standardize_names_dic: standardize_names_dic,\n      model_price_dic: model_price_dic,\n      original_data: item.original_data\n    }\n  }];\n} else {\n  const errors = [];\n  if (missing_from_names.length > 0) {\n    errors.push(`Missing from standardize_names_dic: ${missing_from_names.join(', ')}`);\n  }\n  if (missing_from_prices.length > 0) {\n    errors.push(`Missing from model_price_dic: ${missing_from_prices.join(', ')}`);\n  }\n  \n  return [{\n    json: {\n      passed: false,\n      message: errors.join(' | '),\n      missing_from_names: missing_from_names,\n      missing_from_prices: missing_from_prices,\n      models_used: models_used\n    }\n  }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "57480286-a52e-4e57-a85f-b588b8b3c74e",
      "name": "Stop and Error",
      "type": "n8n-nodes-base.stopAndError",
      "position": [
        1024,
        144
      ],
      "parameters": {
        "errorMessage": "={{ $json.message }}\n\nThings missed from \"standardized names\":\n{{ $json.missing_from_names?.join(', ') || \"none\" }}\n\nThings missed from \"model price\":\n{{ $json.missing_from_prices?.join(', ') || \"none\" }}"
      },
      "typeVersion": 1
    },
    {
      "id": "fbfa0f9b-ae78-4051-a878-a4ef4861c057",
      "name": "If not passed",
      "type": "n8n-nodes-base.if",
      "position": [
        832,
        192
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f0b929f5-c865-4380-b0d8-ca23ab3b3674",
              "operator": {
                "type": "boolean",
                "operation": "false",
                "singleValue": true
              },
              "leftValue": "={{ $json.passed }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "e1692a9d-fcb3-431b-aa32-17fdb888e749",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        1024,
        368
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "884153b3-4920-45e3-9266-f6f151e96f6f",
      "name": "Calculate cost",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        368
      ],
      "parameters": {
        "jsCode": "const inputData = $input.all();\n\n// Get the two items from input\nconst item1 = inputData[0].json;\nconst item2 = inputData[1]?.json || {};\n\n// Merge the data from both items\nconst data = {...item1, ...item2};\n\n// Get the components\nconst llm_run_data = data.llm_run_data || {};\nconst tokensByModel = data.tokensByModel || {};\nconst model_price_dic = data.model_price_dic || {};\n\n// Deep copy and add costs to llm_run_data\nconst updated_llm_run_data = {};\nObject.keys(llm_run_data).forEach(key => {\n  const item = JSON.parse(JSON.stringify(llm_run_data[key]));\n  const model = item.options?.model || item.model || 'unknown';\n  const prices = model_price_dic[model];\n  \n  if (prices && item.tokenUsage) {\n    const promptTokens = item.tokenUsage.promptTokens || 0;\n    const completionTokens = item.tokenUsage.completionTokens || 0;\n    \n    // Calculate costs (price is per million tokens)\n    const input_cost = (promptTokens / 1000000) * prices.input;\n    const output_cost = (completionTokens / 1000000) * prices.output;\n    \n    item.tokenUsage_cost = {\n      input_cost: input_cost,\n      output_cost: output_cost,\n      total_cost: input_cost + output_cost\n    };\n  } else {\n    item.tokenUsage_cost = {\n      input_cost: 0,\n      output_cost: 0,\n      total_cost: 0\n    };\n  }\n  updated_llm_run_data[key] = item;\n});\n\n// Deep copy and add costs to tokensByModel\nconst updated_tokensByModel = {};\nObject.keys(tokensByModel).forEach(model => {\n  const modelData = JSON.parse(JSON.stringify(tokensByModel[model]));\n  const prices = model_price_dic[model];\n  \n  if (prices) {\n    const promptTokens = modelData.promptTokens || 0;\n    const completionTokens = modelData.completionTokens || 0;\n    \n    // Calculate costs (price is per million tokens)\n    const input_cost = (promptTokens / 1000000) * prices.input;\n    const output_cost = (completionTokens / 1000000) * prices.output;\n    \n    modelData.input_cost = input_cost;\n    modelData.output_cost = output_cost;\n    modelData.total_cost = input_cost + output_cost;\n  } else {\n    modelData.input_cost = 0;\n    modelData.output_cost = 0;\n    modelData.total_cost = 0;\n  }\n  updated_tokensByModel[model] = modelData;\n});\n\n// Calculate total costs across all models\nlet total_input_cost = 0;\nlet total_output_cost = 0;\n\nObject.values(updated_tokensByModel).forEach(modelData => {\n  total_input_cost += modelData.input_cost || 0;\n  total_output_cost += modelData.output_cost || 0;\n});\n\nconst cost = {\n  input_cost: total_input_cost,\n  output_cost: total_output_cost,\n  total_cost: total_input_cost + total_output_cost\n};\n\n// Return merged data with updates\nreturn [{\n  json: {\n    ...data,\n    llm_run_data: updated_llm_run_data,\n    tokensByModel: updated_tokensByModel,\n    cost: cost\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cfb7c844-4921-4d27-828d-e5792e457943",
      "name": "Test id",
      "type": "n8n-nodes-base.manualTrigger",
      "notes": "283\n353",
      "position": [
        -880,
        208
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "765452b4-d9d7-463d-b465-11296af1fc73",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1136,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 240,
        "content": "### Test with execution id\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "102aa26e-b208-44f2-a4a8-11a56046b546",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1120,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 214,
        "content": "Where is the execution ID?\nIn your workflow at the top center, you can see \"Executions,\" and you can select any execution to see the ID, which is a number like 323.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "eab0b1f1-3cb4-4b0b-913e-6c5639b17b26",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        784,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 214,
        "height": 80,
        "content": "In case you did something incorrectly, you can see what models you missed to add and define"
      },
      "typeVersion": 1
    },
    {
      "id": "e275b913-ea04-4c43-b799-d5fa4f31ee65",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1456,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 246,
        "height": 144,
        "content": "You can do anything with this info:\n- Send a follow-up message with cost\n- Send another request to continue a process ..."
      },
      "typeVersion": 1
    },
    {
      "id": "481d382a-cfc8-47cc-9ac2-89a9c3118419",
      "name": "Find Nodes with LLM Use",
      "type": "n8n-nodes-base.code",
      "position": [
        -400,
        384
      ],
      "parameters": {
        "jsCode": "/**\n * n8n JavaScript Code - Simple Version\n * Extracts node execution data and LLM run data from agentic framework\n */\n\n// Get the input data - adjust this based on your n8n node configuration\nconst inputData = $input.first().json;\n\n// Navigate to runData - adjust path if needed\nconst runData = inputData.resultData?.runData || \n                inputData.data?.resultData?.runData || \n                inputData.runData;\n\nif (!runData) {\n  return [{\n    json: {\n      error: \"Could not find runData in input\",\n      hint: \"Check if path to runData is correct\"\n    }\n  }];\n}\n\n// Initialize output structures\nconst all_nodes_summary = [];\nconst llm_run_data = {};\n\n// Process each node\nObject.keys(runData).forEach(nodeName => {\n  const nodeExecutions = runData[nodeName];\n  \n  // Process each execution in the node's array\n  nodeExecutions.forEach((execution, idx) => {\n    // Store node execution summary\n    all_nodes_summary.push({\n      nodeName: nodeName,\n      arrayIndex: idx,\n      startTime: execution.startTime,\n      executionTime: execution.executionTime,\n      executionIndex: execution.executionIndex,\n      executionStatus: execution.executionStatus\n    });\n    \n    // Helper function to find ai_languageModel at level 0 and 1\n    function findAILanguageModel(obj, depth = 0) {\n      const found = [];\n      \n      if (!obj || depth > 1) return found;\n      \n      // Check current level\n      if (obj.ai_languageModel) {\n        found.push({ path: 'level_' + depth, data: obj.ai_languageModel });\n      }\n      \n      // Check next level if at depth 0\n      if (depth === 0) {\n        Object.keys(obj).forEach(key => {\n          if (key !== 'ai_languageModel' && typeof obj[key] === 'object' && obj[key]) {\n            if (obj[key].ai_languageModel) {\n              found.push({ path: key, data: obj[key].ai_languageModel });\n            }\n          }\n        });\n      }\n      \n      return found;\n    }\n    \n    // Look for ai_languageModel in data\n    if (execution.data) {\n      const aiModels = findAILanguageModel(execution.data);\n      \n      aiModels.forEach(model => {\n        // ai_languageModel is list of lists\n        model.data.forEach((outerList, i) => {\n          if (Array.isArray(outerList)) {\n            outerList.forEach((item, ii) => {\n              if (item?.json) {\n                const key = `llm_${nodeName}_${idx}_${i}_${ii}`;\n                // Store the entire json object\n                llm_run_data[key] = item.json;\n              }\n            });\n          }\n        });\n      });\n    }\n    \n    // Look for ai_languageModel in inputOverride\n    if (execution.inputOverride) {\n      const aiModels = findAILanguageModel(execution.inputOverride);\n      \n      aiModels.forEach(model => {\n        // ai_languageModel is list of lists\n        model.data.forEach((outerList, i) => {\n          if (Array.isArray(outerList)) {\n            outerList.forEach((item, ii) => {\n              if (item?.json) {\n                const key = `llm_${nodeName}_${idx}_${i}_${ii}_inputOverride`;\n                \n                // Check if we already have this from data\n                const dataKey = `llm_${nodeName}_${idx}_${i}_${ii}`;\n                if (llm_run_data[dataKey]) {\n                  // Merge options from inputOverride with data\n                  llm_run_data[dataKey] = {\n                    ...llm_run_data[dataKey],\n                    ...item.json\n                  };\n                } else {\n                  // Add as new entry\n                  llm_run_data[key] = item.json;\n                }\n              }\n            });\n          }\n        });\n      });\n    }\n  });\n});\n\n// Calculate tokensByModel\nconst tokensByModel = {};\n\nObject.values(llm_run_data).forEach(item => {\n  // item is the complete json object from ai_languageModel\n  // Get model from options.model or directly from item\n  const model = item.options?.model || item.model || 'unknown';\n  \n  if (!tokensByModel[model]) {\n    tokensByModel[model] = {\n      count: 0,\n      estimatedTokens: 0,\n      actualTokens: 0,\n      completionTokens: 0,\n      promptTokens: 0\n    };\n  }\n  \n  tokensByModel[model].count++;\n  tokensByModel[model].estimatedTokens += item.estimatedTokens || 0;\n  \n  // Read tokenUsage correctly from json.tokenUsage dictionary\n  // Structure: json.tokenUsage.totalTokens, json.tokenUsage.completionTokens, json.tokenUsage.promptTokens\n  if (item.tokenUsage && typeof item.tokenUsage === 'object') {\n    tokensByModel[model].actualTokens += item.tokenUsage.totalTokens || 0;\n    tokensByModel[model].completionTokens += item.tokenUsage.completionTokens || 0;\n    tokensByModel[model].promptTokens += item.tokenUsage.promptTokens || 0;\n  }\n});\n\n// Return the extracted data\nreturn [{\n  json: {\n    all_nodes_summary: all_nodes_summary,\n    llm_run_data: llm_run_data,\n    tokensByModel: tokensByModel\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "86e71222-0e8c-4b07-9b10-63de24aa5335",
      "name": "create models_used list",
      "type": "n8n-nodes-base.code",
      "position": [
        -416,
        144
      ],
      "parameters": {
        "jsCode": "const inputData = $input.first().json;\n\n// Handle array input\nconst data = Array.isArray(inputData) ? inputData[0] : inputData;\n\n// Get model names from tokensByModel\nconst models_used = data.tokensByModel ? Object.keys(data.tokensByModel) : [];\n\nreturn [{\n  json: {\n    models_used: models_used\n  }\n}];"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "f429a378-92e1-46e9-a140-5405f3c49888",
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "Calculate cost",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Test id": {
      "main": [
        [
          {
            "node": "Get an execution",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Exc.": {
      "main": [
        [
          {
            "node": "Get an execution",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "model prices": {
      "main": [
        [
          {
            "node": "Check correctly defined",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If not passed": {
      "main": [
        [
          {
            "node": "Stop and Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get an execution": {
      "main": [
        [
          {
            "node": "Find Nodes with LLM Use",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Standardize names": {
      "main": [
        [
          {
            "node": "model prices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check correctly defined": {
      "main": [
        [
          {
            "node": "If not passed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Nodes with LLM Use": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          },
          {
            "node": "create models_used list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "create models_used list": {
      "main": [
        [
          {
            "node": "Standardize names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

&gt; v2: Now it can read multiple types of LLM usages. Better dynamic approach for reading model usage.

Source: https://n8n.io/workflows/7398/ — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

Prevent concurrent workflow runs using Redis. Uses executeWorkflowTrigger, manualTrigger, stickyNote, executeWorkflow. Event-driven trigger; 43 nodes.

Execute Workflow Trigger, Redis, Stop And Error
General

This workflow sets a small "lock" value in Redis so that only one copy of a long job can run at the same time. If another trigger fires while the job is still busy, the workflow sees the lock, stops e

Execute Workflow Trigger, Redis, Stop And Error
General

Using n8n a lot?

Execute Workflow Trigger, XML, Move Binary Data +1
General

This template facilitates the transfer of a folder, along with all its files and subfolders, within a Nextcloud instance. The Nextcloud user must have access to both the source and destination folders

Execute Workflow Trigger, Next Cloud, Stop And Error
General

If you're in need of a quick and dirty cache that doesn't need anything other than the current version of N8N, boy do I have a dodgy script for you to try!

Execute Workflow Trigger, Data Table, Stop And Error