DEV Community

Cover image for How to Track Costs Across Multiple AI APIs Without a Backend
Anoop Kumar
Anoop Kumar

Posted on

How to Track Costs Across Multiple AI APIs Without a Backend

Most AI cost tracking tools require you to route your API calls through their backend so they can intercept and log usage. TokenPulse does it entirely client-side — no backend, no API key, all data stays in your browser. Here is the full implementation.

The approach

Instead of intercepting API calls, we estimate costs from what is visible in the browser:

  1. Read token counts from the platform (Claude) or estimate from DOM text
  2. Detect which model is being used
  3. Apply the model's pricing
  4. Aggregate by conversation, day, and week in local storage Accuracy is ±8% — sufficient for understanding spend patterns, not for billing.

Token counting

For Claude, the real token count comes from the internal API response. For other platforms, we estimate:

function estimateTokens(text) {
  if (!text) return 0
  // ~4 characters per token for English text
  // ~3 characters per token for code
  // Using 4 as a reasonable average
  return Math.ceil(text.length / 4)
}

function getConversationTokens() {
  const messages = document.querySelectorAll(MESSAGE_SELECTORS[platform])
  let inputTokens = 0
  let outputTokens = 0

  messages.forEach(msg => {
    const isUser = msg.getAttribute('data-message-author-role') === 'user'
      || msg.closest('[data-testid*="user"]')

    const text = msg.textContent || ''
    const tokens = estimateTokens(text)

    if (isUser) {
      inputTokens += tokens
    } else {
      outputTokens += tokens
    }
  })

  return { inputTokens, outputTokens }
}
Enter fullscreen mode Exit fullscreen mode

Separating input and output tokens matters because they have different pricing — output tokens typically cost 3-5x more than input tokens.

Model detection

Each platform exposes the current model somewhere in the DOM or URL:

const MODEL_DETECTORS = {
  claude: () => {
    // Claude shows model in a selector dropdown
    const selector = document.querySelector('[data-testid="model-selector-dropdown"]')
    if (selector) return selector.textContent.trim().toLowerCase()

    // Fallback: check URL params
    const params = new URLSearchParams(window.location.search)
    return params.get('model') || 'claude-sonnet-4-5'
  },

  chatgpt: () => {
    // ChatGPT shows model in the header
    const header = document.querySelector('[class*="model-switcher"]')
    if (header) {
      const text = header.textContent.toLowerCase()
      if (text.includes('4o')) return 'gpt-4o'
      if (text.includes('o1')) return 'o1'
      if (text.includes('4o mini')) return 'gpt-4o-mini'
    }
    return 'gpt-4o' // default
  },

  gemini: () => {
    const header = document.querySelector('[class*="model-label"]')
    if (header) {
      const text = header.textContent.toLowerCase()
      if (text.includes('2.0 flash')) return 'gemini-2.0-flash'
      if (text.includes('1.5 pro')) return 'gemini-1.5-pro'
    }
    return 'gemini-2.0-flash'
  },
}

function detectModel() {
  const detector = MODEL_DETECTORS[platform]
  return detector ? detector() : 'unknown'
}
Enter fullscreen mode Exit fullscreen mode

Pricing table

Keep pricing in a single constant — update it when providers change rates:

// Prices in USD per 1,000,000 tokens
const PRICING = {
  // Claude models
  'claude-opus-4':       { input: 15.00, output: 75.00 },
  'claude-sonnet-4':     { input:  3.00, output: 15.00 },
  'claude-sonnet-4-5':   { input:  3.00, output: 15.00 },
  'claude-haiku-4-5':    { input:  0.80, output:  4.00 },

  // OpenAI models
  'gpt-4o':              { input:  2.50, output: 10.00 },
  'gpt-4o-mini':         { input:  0.15, output:  0.60 },
  'o1':                  { input: 15.00, output: 60.00 },
  'o1-mini':             { input:  3.00, output: 12.00 },

  // Google models
  'gemini-2.0-flash':    { input:  0.10, output:  0.40 },
  'gemini-1.5-pro':      { input:  1.25, output:  5.00 },

  // DeepSeek models
  'deepseek-v3':         { input:  0.27, output:  1.10 },
  'deepseek-r1':         { input:  0.55, output:  2.19 },

  // Grok models
  'grok-3':              { input:  3.00, output: 15.00 },
  'grok-3-mini':         { input:  0.30, output:  0.50 },
}

function estimateCost(inputTokens, outputTokens, model) {
  // Normalize model name for lookup
  const key = normalizeModelName(model)
  const price = PRICING[key]

  if (!price) {
    // Unknown model — use a reasonable default
    console.warn(`Unknown model: ${model}, using default pricing`)
    return (inputTokens + outputTokens) / 1_000_000 * 3.00
  }

  const inputCost  = (inputTokens  / 1_000_000) * price.input
  const outputCost = (outputTokens / 1_000_000) * price.output
  return inputCost + outputCost
}

function normalizeModelName(raw) {
  const lower = raw.toLowerCase()
  if (lower.includes('opus'))        return 'claude-opus-4'
  if (lower.includes('sonnet'))      return 'claude-sonnet-4-5'
  if (lower.includes('haiku'))       return 'claude-haiku-4-5'
  if (lower.includes('4o-mini'))     return 'gpt-4o-mini'
  if (lower.includes('4o'))          return 'gpt-4o'
  if (lower.includes('o1-mini'))     return 'o1-mini'
  if (lower.includes('o1'))          return 'o1'
  if (lower.includes('2.0 flash'))   return 'gemini-2.0-flash'
  if (lower.includes('1.5 pro'))     return 'gemini-1.5-pro'
  if (lower.includes('deepseek-r1')) return 'deepseek-r1'
  if (lower.includes('deepseek'))    return 'deepseek-v3'
  if (lower.includes('grok-3-mini')) return 'grok-3-mini'
  if (lower.includes('grok'))        return 'grok-3'
  return lower
}
Enter fullscreen mode Exit fullscreen mode

Aggregating costs in local storage

Store costs at three granularities — conversation, day, and week:

const STORAGE_KEY = 'tp_cost_data'

async function recordConversationCost(platform, model, inputTokens, outputTokens) {
  const cost = estimateCost(inputTokens, outputTokens, model)
  const now = new Date()
  const dayKey = now.toISOString().split('T')[0] // YYYY-MM-DD
  const weekKey = getWeekKey(now)

  const stored = await chrome.storage.local.get(STORAGE_KEY)
  const data = stored[STORAGE_KEY] || {
    conversations: [],
    byDay: {},
    byWeek: {},
  }

  // Record conversation
  data.conversations.push({
    platform,
    model,
    inputTokens,
    outputTokens,
    cost,
    timestamp: now.toISOString(),
  })

  // Trim to last 90 days of conversations
  const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000
  data.conversations = data.conversations.filter(
    c => new Date(c.timestamp).getTime() > cutoff
  )

  // Aggregate by day
  if (!data.byDay[dayKey]) data.byDay[dayKey] = { cost: 0, tokens: 0, conversations: 0 }
  data.byDay[dayKey].cost += cost
  data.byDay[dayKey].tokens += inputTokens + outputTokens
  data.byDay[dayKey].conversations += 1

  // Aggregate by week
  if (!data.byWeek[weekKey]) data.byWeek[weekKey] = { cost: 0, tokens: 0 }
  data.byWeek[weekKey].cost += cost
  data.byWeek[weekKey].tokens += inputTokens + outputTokens

  await chrome.storage.local.set({ [STORAGE_KEY]: data })
  return cost
}

function getWeekKey(date) {
  const d = new Date(date)
  d.setHours(0, 0, 0, 0)
  d.setDate(d.getDate() - d.getDay()) // Start of week (Sunday)
  return d.toISOString().split('T')[0]
}
Enter fullscreen mode Exit fullscreen mode

Reading aggregated costs for display

async function getCostSummary() {
  const stored = await chrome.storage.local.get(STORAGE_KEY)
  const data = stored[STORAGE_KEY] || { byDay: {}, byWeek: {}, conversations: [] }

  const now = new Date()
  const todayKey = now.toISOString().split('T')[0]
  const weekKey = getWeekKey(now)

  // This conversation (most recent in storage)
  const recent = data.conversations[data.conversations.length - 1]
  const thisConversation = recent?.cost || 0

  // Today
  const today = data.byDay[todayKey]?.cost || 0

  // This week
  const thisWeek = data.byWeek[weekKey]?.cost || 0

  // This month
  const thisMonth = Object.entries(data.byDay)
    .filter(([key]) => key.startsWith(now.toISOString().slice(0, 7)))
    .reduce((sum, [, val]) => sum + val.cost, 0)

  return {
    thisConversation: formatCost(thisConversation),
    today: formatCost(today),
    thisWeek: formatCost(thisWeek),
    thisMonth: formatCost(thisMonth),
  }
}

function formatCost(usd) {
  if (usd < 0.001) return '$0.000'
  if (usd < 0.01)  return `$${usd.toFixed(4)}`
  if (usd < 1)     return `$${usd.toFixed(3)}`
  return `$${usd.toFixed(2)}`
}
Enter fullscreen mode Exit fullscreen mode

Displaying in the popup

// popup.js
async function renderCostSection() {
  const summary = await getCostSummary()

  document.getElementById('cost-conversation').textContent = summary.thisConversation
  document.getElementById('cost-today').textContent = summary.today
  document.getElementById('cost-week').textContent = summary.thisWeek
}
Enter fullscreen mode Exit fullscreen mode

Keeping pricing current

Model prices change. The pricing table needs occasional updates. Two strategies:

Option 1: Hardcode and update with each extension version. Simple, no network requests.

Option 2: Fetch pricing from a remote JSON file:

async function fetchLatestPricing() {
  try {
    const res = await fetch('https://token-pulse.in/api/pricing.json')
    const data = await res.json()
    await chrome.storage.local.set({ pricing: data, pricingFetchedAt: Date.now() })
    return data
  } catch {
    // Fall back to bundled pricing
    return PRICING
  }
}

async function getPricing() {
  const stored = await chrome.storage.local.get(['pricing', 'pricingFetchedAt'])
  const age = Date.now() - (stored.pricingFetchedAt || 0)
  const ONE_DAY = 24 * 60 * 60 * 1000

  if (stored.pricing && age < ONE_DAY) return stored.pricing
  return fetchLatestPricing()
}
Enter fullscreen mode Exit fullscreen mode

TokenPulse currently uses option 1 — the pricing table is bundled and updated with each release. Option 2 would be better long-term but requires a backend endpoint.


Full implementation at github.com/anu-ship-it/TokenPulse.
TokenPulse — free AI cost tracker, no backend required.

Top comments (0)