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:
- Read token counts from the platform (Claude) or estimate from DOM text
- Detect which model is being used
- Apply the model's pricing
- 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 }
}
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'
}
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
}
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]
}
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)}`
}
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
}
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()
}
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)