{
  "name": "Ollama usage monitor (single account)",
  "nodes": [
    {
      "id": "43f83ccc-4812-48e0-83a9-8c0d99e87eac",
      "name": "Every 5 hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -900,
        -120
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 5
            }
          ]
        }
      }
    },
    {
      "id": "e8e04a3b-66d8-4fd8-b83f-4eda197b5925",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -900,
        60
      ],
      "webhookId": "a3379610-bdb6-4e83-a3ad-de54162de24c",
      "parameters": {
        "httpMethod": "POST",
        "path": "ollama-usage-monitor",
        "responseMode": "onReceived",
        "options": {}
      }
    },
    {
      "id": "f9e2820d-5de6-4e47-a593-93976ede4ab9",
      "name": "GET usage 24h",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -660,
        -30
      ],
      "parameters": {
        "method": "GET",
        "url": "https://ollama.com/api/usage?range=24h",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": false,
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "Ollama API key"
        }
      }
    },
    {
      "id": "7fb4ea5b-6f16-4265-985d-c308637a37ff",
      "name": "Wait 12 s (1)",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        -440,
        -30
      ],
      "webhookId": "d4e747ef-9121-41ed-997d-a78a8b8de324",
      "parameters": {
        "resume": "timeInterval",
        "amount": 12,
        "unit": "seconds"
      }
    },
    {
      "id": "81c9fefe-6a6b-4781-a1c4-16eab54db1cb",
      "name": "GET usage 30d",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -220,
        -30
      ],
      "parameters": {
        "method": "GET",
        "url": "https://ollama.com/api/usage?range=30d",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": false,
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "Ollama API key"
        }
      }
    },
    {
      "id": "4fc106de-69b8-4cf6-81b8-7f28d3cda58c",
      "name": "Wait 12 s (2)",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        0,
        -30
      ],
      "webhookId": "8ef50207-d553-4116-91d8-15012b0bbd88",
      "parameters": {
        "resume": "timeInterval",
        "amount": 12,
        "unit": "seconds"
      }
    },
    {
      "id": "4d9e0b6e-7cd4-46cf-9ce1-339b8d185d52",
      "name": "POST /api/me",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        -30
      ],
      "parameters": {
        "method": "POST",
        "url": "https://ollama.com/api/me",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "Ollama API key"
        }
      }
    },
    {
      "id": "0261924b-6550-4d38-a0be-0bdf03f4cc9d",
      "name": "Build message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        -30
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Ollama usage monitor, single account. Body of an n8n Code node (mode: run once for all items).\n// Inputs: $('GET usage 24h'), $('GET usage 30d'), $('POST /api/me'). Output: [{ json: { message } }].\n// Plain text. Times are UTC. Old-plan quota percentages are estimates, because Ollama stopped reporting them\n// on about 7 Oct 2026. New-plan included credits are exact dollars.\n\nconst HOUR = 3600e3;\nconst DAY = 24 * HOUR;\n\n// Old-plan windows: fixed five-hour blocks that end at minute :55, and fixed seven-day weeks.\nconst SESSION_EPOCH = Date.UTC(2026, 7, 12, 11, 55, 0);\nconst SESSION_LEN = 5 * HOUR;\nconst WEEK_EPOCH = Date.UTC(2026, 7, 16, 11, 16, 0);\nconst WEEK_LEN = 7 * DAY;\n\n// Calibrated estimates (old plans): about 0.10% of a session per request; 64.9% weekly at 3,672 requests (8 Oct 2026).\nconst SESSION_PCT_PER_REQ = 0.1;\nconst WEEKLY_PCT_PER_REQ = 64.9 / 3672;\nconst PACE_MIN_FRAC = 0.07; // no projection until this share of a window has passed\n\n// Included credits per month by plan (new plans). Unknown plans fall back to Pro.\nconst INCLUDED_USD = { pro: 60, max: 300, team: 1000 };\nconst DEFAULT_INCLUDED_USD = 60;\nconst CAPPED_PCT = 99.9;\n\n// History kept in workflow static data. The workflow runs every five hours, so nine days of hours is plenty.\nconst HOURLY_DAYS = 9;\nconst DAILY_DAYS = 70;\n\nconst usage24 = $('GET usage 24h').first().json;\nconst usage30 = $('GET usage 30d').first().json;\nconst me = $('POST /api/me').first().json;\n\n// The API's own \"until\" is the read time, so every run does the same arithmetic on the same data.\nconst NOW = new Date(usage24.until).getTime();\nif (!Number.isFinite(NOW)) throw new Error('GET usage 24h has no valid \"until\" timestamp');\n\n// ---- persistent history ----\nconst store = $getWorkflowStaticData('global');\nstore.hourly = store.hourly || {};\nstore.hourlyUsd = store.hourlyUsd || {};\nstore.daily = store.daily || {};\n\nfor (const b of usage24.buckets || []) {\n  const k = new Date(b.from).toISOString();\n  store.hourly[k] = b.request_count || 0;\n  store.hourlyUsd[k] = Number(b.usage_usd || 0);\n}\nfor (const b of usage30.buckets || []) {\n  store.daily[new Date(b.from).toISOString()] = { reqs: b.request_count || 0, usd: Number(b.usage_usd || 0) };\n}\nconst pruneBefore = (obj, cutoff) => {\n  for (const k of Object.keys(obj)) if (Date.parse(k) < cutoff) delete obj[k];\n};\npruneBefore(store.hourly, NOW - HOURLY_DAYS * DAY);\npruneBefore(store.hourlyUsd, NOW - HOURLY_DAYS * DAY);\npruneBefore(store.daily, NOW - DAILY_DAYS * DAY);\n\n// ---- formatting ----\nconst WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];\nconst MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\nconst pad2 = (n) => String(n).padStart(2, '0');\nconst iso = (ms) => new Date(ms).toISOString();\nconst has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);\nconst n0 = (x) => Math.round(Number(x) || 0).toLocaleString('en-GB');\nconst usd = (x) => '$' + (Number(x) || 0).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 });\nconst utcStamp = (ms) => {\n  const d = new Date(ms);\n  return WD[d.getUTCDay()] + ' ' + d.getUTCDate() + ' ' + MON[d.getUTCMonth()] + ' ' + pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes()) + ' UTC';\n};\nconst dayLabel = (ms) => {\n  const d = new Date(ms);\n  return WD[d.getUTCDay()] + ' ' + pad2(d.getUTCDate()) + ' ' + MON[d.getUTCMonth()];\n};\nconst inWords = (ms) => {\n  const mins = Math.max(0, Math.floor(ms / 60000));\n  const d = Math.floor(mins / 1440);\n  const h = Math.floor((mins % 1440) / 60);\n  const m = mins % 60;\n  if (d > 0) return 'in ' + d + 'd ' + h + 'h';\n  if (h > 0) return 'in ' + h + 'h ' + m + 'm';\n  return 'in ' + m + 'm';\n};\nconst pct = (x) => {\n  const r = Math.round(x * 10) / 10;\n  return (r < 10 ? r.toFixed(1) : String(Math.round(x))) + '%';\n};\nconst daysWord = (d) => {\n  const r = d >= 10 ? Math.round(d) : Math.round(d * 10) / 10;\n  return '' + r + (r === 1 ? ' day' : ' days');\n};\nconst bar = (p) => {\n  let f = Math.round(p / 10);\n  if (p > 0 && f < 1) f = 1;\n  f = Math.min(10, Math.max(0, f));\n  return '▰'.repeat(f) + '▱'.repeat(10 - f);\n};\nconst spark = (vals) => {\n  const b = '▁▂▃▄▅▆▇█';\n  const max = Math.max(...vals, 1);\n  return vals.map((v) => b[Math.min(7, Math.floor((v / max) * 7.999))]).join('');\n};\nconst level = (p) => (p < 60 ? 'LOW' : p < 85 ? 'MEDIUM' : 'HIGH');\nconst sum = (rows, f) => rows.reduce((s, r) => s + r[f], 0);\n\n// ---- account facts ----\nconst email = (me && me.Email) || 'unknown account';\nconst planRaw = me && me.Plan ? String(me.Plan) : 'unknown';\nconst planKey = planRaw.toLowerCase();\nconst totals30 = usage30.totals || {};\nconst isNewPlan = totals30.usage_usd != null;\n\n// Complete UTC days only (today's partial bucket is left out of trends).\nconst days = (usage30.buckets || [])\n  .filter((b) => !b.partial)\n  .map((b) => ({ t: new Date(b.from).getTime(), reqs: b.request_count || 0, usd: Number(b.usage_usd || 0) }));\nconst last7 = days.slice(-7);\nconst prev7 = days.length >= 14 ? days.slice(-14, -7) : [];\n\n// Requests (and dollars) from startMs to NOW. Hours in history are exact. A missing hour takes its day's total,\n// less the hours already covered, spread evenly over that day's missing hours. approx is true when that happened.\nfunction countSince(startMs) {\n  let reqs = 0;\n  let usdSum = 0;\n  let approx = false;\n  for (let day = Math.floor(startMs / DAY) * DAY; day < NOW; day += DAY) {\n    const dayTotal = store.daily[iso(day)] || { reqs: 0, usd: 0 };\n    const hours = [];\n    for (let t = day; t < day + DAY && t < NOW; t += HOUR) hours.push(t);\n    let covered = 0;\n    let coveredUsd = 0;\n    const missing = [];\n    for (const t of hours) {\n      const k = iso(t);\n      if (has(store.hourly, k)) {\n        covered += store.hourly[k];\n        coveredUsd += store.hourlyUsd[k] || 0;\n        if (t + HOUR > startMs) {\n          reqs += store.hourly[k];\n          usdSum += store.hourlyUsd[k] || 0;\n        }\n      } else {\n        missing.push(t);\n      }\n    }\n    if (missing.length) {\n      const perReq = Math.max(0, dayTotal.reqs - covered) / missing.length;\n      const perUsd = Math.max(0, dayTotal.usd - coveredUsd) / missing.length;\n      for (const t of missing) {\n        const share = (Math.min(t + HOUR, NOW) - Math.max(t, startMs)) / HOUR;\n        if (share > 0) {\n          approx = true;\n          reqs += perReq * share;\n          usdSum += perUsd * share;\n        }\n      }\n    }\n  }\n  return { reqs, usd: usdSum, approx };\n}\n\nconst lines = [];\nlines.push('OLLAMA USAGE, ' + utcStamp(NOW));\nlines.push(email + ' (' + planRaw.toUpperCase() + ')');\nlines.push('');\n\nif (isNewPlan) {\n  // ---- new plan: exact dollars against the included credits ----\n  const limit = has(INCLUDED_USD, planKey) ? INCLUDED_USD[planKey] : DEFAULT_INCLUDED_USD;\n  const created = me && me.CreatedAt ? Date.parse(me.CreatedAt) : NaN;\n  const nowD = new Date(NOW);\n  const anchor = Number.isFinite(created) ? created : Date.UTC(nowD.getUTCFullYear(), nowD.getUTCMonth(), 1);\n  const anchorDay = new Date(anchor).getUTCDate();\n  const anchorTime = anchor % DAY;\n  const monthLen = (y, m) => new Date(Date.UTC(y, m + 1, 0)).getUTCDate();\n  const anniversary = (y, m) => Date.UTC(y, m, Math.min(anchorDay, monthLen(y, m))) + anchorTime;\n  let mStart = anniversary(nowD.getUTCFullYear(), nowD.getUTCMonth());\n  if (mStart > NOW) mStart = anniversary(nowD.getUTCFullYear(), nowD.getUTCMonth() - 1);\n  const startD = new Date(mStart);\n  const mEnd = anniversary(startD.getUTCFullYear(), startD.getUTCMonth() + 1);\n\n  // Dollars from the start day to today, from the daily totals.\n  const startDay = Math.floor(mStart / DAY) * DAY;\n  let mUsd = 0;\n  let approx = !Number.isFinite(created);\n  for (let d = startDay; d <= Math.floor(NOW / DAY) * DAY; d += DAY) {\n    const dayRow = store.daily[iso(d)];\n    if (dayRow) mUsd += dayRow.usd;\n    else approx = true;\n  }\n  // An anniversary that is not at midnight: take out the start day's spend before the anniversary hour.\n  if (mStart > startDay && ((store.daily[iso(startDay)] || {}).usd || 0) > 0) {\n    let pre = 0;\n    let covered = true;\n    for (let h = startDay; h < Math.floor(mStart / HOUR) * HOUR; h += HOUR) {\n      if (!has(store.hourlyUsd, iso(h))) {\n        covered = false;\n        break;\n      }\n      pre += store.hourlyUsd[iso(h)];\n    }\n    if (covered) mUsd -= pre;\n    else approx = true;\n  }\n  mUsd = Math.max(0, mUsd);\n  const mPct = (mUsd / limit) * 100;\n  const capped = mPct >= CAPPED_PCT;\n  const elapsed = (NOW - mStart) / (mEnd - mStart);\n  const daysIn = (NOW - mStart) / DAY;\n\n  let pace = null;\n  if (!capped) {\n    if (elapsed < PACE_MIN_FRAC) pace = 'too early in the window to project';\n    else if (mPct / elapsed > 100) pace = 'at this rate the included credits run out in about ' + daysWord((limit - mUsd) / (mUsd / daysIn));\n    else pace = 'on pace for about ' + pct(mPct / elapsed) + ' of the included credits by reset';\n  }\n\n  lines.push('Included credits (' + planRaw.toUpperCase() + ')  ' + (capped ? 'CAPPED' : level(mPct)) + '  ' + bar(mPct) + ' ' + pct(mPct));\n  lines.push('  ' + usd(mUsd) + ' of ' + usd(limit) + ' included credits used' + (approx ? ' (approximate, some history missing)' : ''));\n  lines.push(capped ? '  Included credits are spent. Purchased credits, if any, come next.' : '  ' + usd(limit - mUsd) + ' left');\n  lines.push('  Resets ' + utcStamp(mEnd) + ' (' + inWords(mEnd - NOW) + '), on the subscription day of the month (est.)');\n  if (pace) lines.push('  ' + pace);\n} else {\n  // ---- old plan: estimated five-hour and weekly windows from request counts ----\n  const sessStart = SESSION_EPOCH + Math.floor((NOW - SESSION_EPOCH) / SESSION_LEN) * SESSION_LEN;\n  const sessEnd = sessStart + SESSION_LEN;\n  const weekStart = WEEK_EPOCH + Math.floor((NOW - WEEK_EPOCH) / WEEK_LEN) * WEEK_LEN;\n  const weekEnd = weekStart + WEEK_LEN;\n  const sess = countSince(sessStart);\n  const week = countSince(weekStart);\n  const sessN = Math.round(sess.reqs);\n  const weekN = Math.round(week.reqs);\n  const sessPct = Math.min(sessN * SESSION_PCT_PER_REQ, 100);\n  const weekPct = Math.min(weekN * WEEKLY_PCT_PER_REQ, 100);\n  const weekElapsed = (NOW - weekStart) / WEEK_LEN;\n\n  lines.push('5h session (EST.)  ' + level(sessPct) + '  ' + bar(sessPct) + ' ' + pct(sessPct));\n  lines.push('  ' + n0(sessN) + ' requests this block' + (sess.approx ? ' (approximate)' : '') + '. Resets ' + utcStamp(sessEnd) + ' (' + inWords(sessEnd - NOW) + ')');\n  lines.push('');\n  lines.push('Weekly (EST.)  ' + level(weekPct) + '  ' + bar(weekPct) + ' ' + pct(weekPct));\n  lines.push('  ' + n0(weekN) + ' requests since ' + utcStamp(weekStart) + (week.approx ? ' (approximate)' : '') + '. Resets ' + utcStamp(weekEnd) + ' (' + inWords(weekEnd - NOW) + ')');\n  if (weekElapsed >= PACE_MIN_FRAC) lines.push('  on pace for about ' + pct(weekPct / weekElapsed) + ' by reset');\n  else lines.push('  too early in the week to project');\n}\n\n// ---- shared lines: last five hours and seven days, the 30-day totals, and the trend ----\nconst b5 = countSince(NOW - 5 * HOUR);\nconst b7 = countSince(NOW - 7 * DAY);\nconst money = (r) => (isNewPlan ? ' and ' + usd(r.usd) : '');\nlines.push('');\nlines.push('Last 5 hours: ' + n0(b5.reqs) + ' requests' + money(b5) + (b5.approx ? ' (approximate)' : ''));\nlines.push('Last 7 days: ' + n0(b7.reqs) + ' requests' + money(b7) + (b7.approx ? ' (approximate)' : ''));\nlines.push('30 days: ' + n0(totals30.request_count) + ' requests' + (isNewPlan ? ', ' + usd(totals30.usage_usd) : ''));\nif (isNewPlan && totals30.input_tokens) {\n  const cachedPct = Math.round(((totals30.cached_input_tokens || 0) / totals30.input_tokens) * 100);\n  const tok = (n) => (n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : (n / 1e6).toFixed(1) + 'M');\n  lines.push('Tokens (30 days): ' + tok(totals30.input_tokens) + ' in (' + cachedPct + '% cached), ' + tok(totals30.output_tokens || 0) + ' out');\n}\nif (last7.length) {\n  const prevReqs = sum(prev7, 'reqs');\n  let trend = 'Trend: 7-day requests';\n  if (prev7.length && prevReqs > 0) {\n    const change = ((sum(last7, 'reqs') - prevReqs) / prevReqs) * 100;\n    trend += ' ' + (change >= 0 ? 'up ' : 'down ') + Math.abs(Math.round(change)) + '% on the 7 days before';\n  }\n  lines.push(trend);\n  lines.push('  ' + spark(last7.map((r) => r.reqs)) + '  ' + dayLabel(last7[0].t) + ' to ' + dayLabel(last7[last7.length - 1].t));\n}\n\nlines.push('');\nlines.push(\n  'Quota percentages on old plans are estimates. Ollama stopped reporting them on about 7 Oct 2026, and the figures are calibrated on one dashboard reading (64.9% weekly at 3,672 requests, 8 Oct 2026). Included credits on new plans are exact dollars.',\n);\n\nreturn [{ json: { message: lines.join('\\n') } }];\n"
      }
    },
    {
      "id": "e3b271ef-be86-41a4-a235-417dbeb61710",
      "name": "Post to webhook",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        -30
      ],
      "parameters": {
        "method": "POST",
        "url": "https://example.com/your-webhook",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.message }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Every 5 hours": {
      "main": [
        [
          {
            "node": "GET usage 24h",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "GET usage 24h",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GET usage 24h": {
      "main": [
        [
          {
            "node": "Wait 12 s (1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 12 s (1)": {
      "main": [
        [
          {
            "node": "GET usage 30d",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GET usage 30d": {
      "main": [
        [
          {
            "node": "Wait 12 s (2)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 12 s (2)": {
      "main": [
        [
          {
            "node": "POST /api/me",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "POST /api/me": {
      "main": [
        [
          {
            "node": "Build message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build message": {
      "main": [
        [
          {
            "node": "Post to webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}
