Guide · LLM Observability & Evaluation
MCP Server LLM cost tracking — token usage headers, budget guardrails, per-tool attribution, spend alerts
Three LLM cost-tracking behaviours catch MCP server authors off-guard: in streaming responses, the usage object is only present on the final chunk when you pass stream_options: { include_usage: true } — omitting this option means streaming tools report zero tokens and your cost ledger silently under-counts spend; different providers report usage in incompatible fields — OpenAI uses prompt_tokens + completion_tokens while Anthropic uses input_tokens + output_tokens, and some models bill cache reads and cache writes at different per-token rates; and cost calculations must use the model ID from the response, not the request — when using a proxy (LiteLLM, Helicone) or a provider with automatic fallback, the model that served the response may differ from what the tool requested, and the wrong model ID gives you the wrong per-token price.
TL;DR
Extract usage from non-streaming completions via completion.usage. For streaming, pass stream_options: { include_usage: true } and read chunk.usage on the final chunk. Normalize to a { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, modelId } shape in a provider adapter. Write to a llm_cost_log SQLite table with (tool_name, model_id, input_tokens, output_tokens, cost_usd, created_at). Use SELECT sum(cost_usd) FROM llm_cost_log WHERE created_at > datetime('now', '-1 hour') to check hourly spend before each tool call and enforce soft limits.
Token usage extraction across providers
Build a provider-agnostic adapter that normalizes token counts from OpenAI and Anthropic responses into a single shape. Anthropic Claude models support prompt caching — cache reads cost ~10% of base input price and cache writes cost ~25% more, so they need separate tracking to calculate accurate cost.
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import Database from 'better-sqlite3';
const openai = new OpenAI();
const anthropic = new Anthropic();
// Pricing table (USD per 1M tokens) — update when providers change rates
const MODEL_PRICING: Record<string, {
input: number;
output: number;
cacheRead?: number;
cacheWrite?: number;
}> = {
'gpt-4o': { input: 2.50, output: 10.00 },
'gpt-4o-mini': { input: 0.15, output: 0.60 },
'gpt-4.1': { input: 2.00, output: 8.00 },
'claude-opus-4-7': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
'claude-sonnet-4-6': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00, cacheRead: 0.08, cacheWrite: 1.00 },
};
interface UsageRecord {
modelId: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
costUsd: number;
}
function calcCost(modelId: string, usage: Omit<UsageRecord, 'costUsd' | 'modelId'>): number {
// Match by prefix for versioned model IDs (e.g. 'gpt-4o-mini-2024-07-18' → 'gpt-4o-mini')
const priceKey = Object.keys(MODEL_PRICING).find(k =>
modelId === k || modelId.startsWith(k)
) ?? modelId;
const prices = MODEL_PRICING[priceKey];
if (!prices) return 0; // unknown model — log but don't block
const perMillion = 1_000_000;
return (
(usage.inputTokens * (prices.input ?? 0)) / perMillion +
(usage.outputTokens * (prices.output ?? 0)) / perMillion +
(usage.cacheReadTokens * (prices.cacheRead ?? 0)) / perMillion +
(usage.cacheWriteTokens * (prices.cacheWrite ?? 0)) / perMillion
);
}
// OpenAI usage extractor — handles both cached (prompt_tokens_details) and uncached
function extractOpenAIUsage(completion: OpenAI.Chat.ChatCompletion): Omit<UsageRecord, 'costUsd'> {
const usage = completion.usage;
const modelId = completion.model; // use response.model, NOT the request model param
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0;
return {
modelId,
inputTokens: (usage?.prompt_tokens ?? 0) - cacheReadTokens,
outputTokens: usage?.completion_tokens ?? 0,
cacheReadTokens,
cacheWriteTokens: 0, // OpenAI doesn't distinguish cache writes separately
};
}
// Anthropic usage extractor — cache read and write billed separately
function extractAnthropicUsage(message: Anthropic.Message): Omit<UsageRecord, 'costUsd'> {
const usage = message.usage;
const modelId = message.model; // from response, not request
return {
modelId,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
};
}
Streaming token extraction
Streaming responses accumulate usage on the final chunk when stream_options: { include_usage: true } is set. For Anthropic streaming, usage appears in the message_delta event with type 'message_delta'. Never estimate tokens by counting words — always use the provider's reported usage for billing accuracy.
// OpenAI streaming with accurate token extraction
async function openaiStreamWithUsage(
params: OpenAI.Chat.ChatCompletionCreateParamsStreaming
): Promise<{ text: string; usage: Omit<UsageRecord, 'costUsd'> }> {
const stream = await openai.chat.completions.create({
...params,
stream: true,
stream_options: { include_usage: true }, // required — omit and usage is null
});
let text = '';
let finalModelId = params.model; // fallback — overwritten by response model
let usage: OpenAI.CompletionUsage | null = null;
for await (const chunk of stream) {
text += chunk.choices[0]?.delta?.content ?? '';
// Model ID from response — may differ from requested model (e.g. proxy routing)
if (chunk.model) finalModelId = chunk.model;
// usage only present on the final chunk when include_usage: true
if (chunk.usage) usage = chunk.usage;
}
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0;
return {
text,
usage: {
modelId: finalModelId,
inputTokens: (usage?.prompt_tokens ?? 0) - cacheReadTokens,
outputTokens: usage?.completion_tokens ?? 0,
cacheReadTokens,
cacheWriteTokens: 0,
},
};
}
// Anthropic streaming — usage in message_delta event
async function anthropicStreamWithUsage(
params: Anthropic.MessageCreateParamsStreaming
): Promise<{ text: string; usage: Omit<UsageRecord, 'costUsd'> }> {
const stream = anthropic.messages.stream(params);
let text = '';
let finalModelId = params.model;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
text += event.delta.text;
}
// message_start contains input token counts (including cache tokens)
if (event.type === 'message_start') {
const u = event.message.usage;
inputTokens = u.input_tokens;
cacheReadTokens = u.cache_read_input_tokens ?? 0;
cacheWriteTokens = u.cache_creation_input_tokens ?? 0;
finalModelId = event.message.model;
}
// message_delta contains output token count
if (event.type === 'message_delta' && event.usage) {
outputTokens = event.usage.output_tokens;
}
}
return {
text,
usage: {
modelId: finalModelId,
inputTokens: inputTokens - cacheReadTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
},
};
}
Per-tool cost ledger and budget guardrails
Store every LLM call in a SQLite cost ledger. Before each call, query the hourly spend and reject the call if it would push over the soft limit. Log after each call with the actual model ID from the response — not the requested model — so cost attribution is accurate when providers route to different models.
import Database from 'better-sqlite3';
import { z } from 'zod';
const db = new Database('./data.db');
// One-time schema setup
db.exec(`
CREATE TABLE IF NOT EXISTS llm_cost_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_name TEXT NOT NULL,
model_id TEXT NOT NULL,
input_tok INTEGER NOT NULL DEFAULT 0,
output_tok INTEGER NOT NULL DEFAULT 0,
cache_read INTEGER NOT NULL DEFAULT 0,
cache_write INTEGER NOT NULL DEFAULT 0,
cost_usd REAL NOT NULL DEFAULT 0,
session_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_llm_cost_log_created ON llm_cost_log(created_at);
CREATE INDEX IF NOT EXISTS idx_llm_cost_log_tool ON llm_cost_log(tool_name, created_at);
`);
const insertCostLog = db.prepare(`
INSERT INTO llm_cost_log (tool_name, model_id, input_tok, output_tok, cache_read, cache_write, cost_usd, session_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const getHourlySpend = db.prepare(`
SELECT COALESCE(SUM(cost_usd), 0) AS total
FROM llm_cost_log
WHERE created_at > datetime('now', '-1 hour')
`);
const getToolHourlySpend = db.prepare(`
SELECT COALESCE(SUM(cost_usd), 0) AS total
FROM llm_cost_log
WHERE tool_name = ? AND created_at > datetime('now', '-1 hour')
`);
// Soft limits (USD) — adjust per your budget
const HOURLY_LIMIT_USD = parseFloat(process.env.LLM_HOURLY_LIMIT_USD ?? '5.00');
const PER_TOOL_HOURLY_LIMIT = parseFloat(process.env.LLM_PER_TOOL_LIMIT_USD ?? '2.00');
function checkBudget(toolName: string): void {
const hourlyTotal = (getHourlySpend.get() as { total: number }).total;
const toolHourly = (getToolHourlySpend.get(toolName) as { total: number }).total;
if (hourlyTotal >= HOURLY_LIMIT_USD) {
throw new Error(
`Hourly LLM budget exceeded ($${hourlyTotal.toFixed(4)} / $${HOURLY_LIMIT_USD}). ` +
`Reset in ${60 - new Date().getMinutes()} minutes.`
);
}
if (toolHourly >= PER_TOOL_HOURLY_LIMIT) {
throw new Error(
`Per-tool hourly budget exceeded for '${toolName}' ($${toolHourly.toFixed(4)} / $${PER_TOOL_HOURLY_LIMIT}).`
);
}
}
function logCost(toolName: string, usageRecord: UsageRecord, sessionId?: string): void {
insertCostLog.run(
toolName,
usageRecord.modelId,
usageRecord.inputTokens,
usageRecord.outputTokens,
usageRecord.cacheReadTokens,
usageRecord.cacheWriteTokens,
usageRecord.costUsd,
sessionId ?? null
);
}
// MCP tool with full cost tracking + guardrail
server.tool(
'generate_report',
{
topic: z.string().min(1).max(500),
session_id: z.string().optional(),
},
async ({ topic, session_id }) => {
// Check budget before making the call — prevent runaway spend
checkBudget('generate_report');
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: `Write a short report on: ${topic}` }],
max_tokens: 512,
});
const rawUsage = extractOpenAIUsage(completion);
const costUsd = calcCost(rawUsage.modelId, rawUsage);
logCost('generate_report', { ...rawUsage, costUsd }, session_id);
const text = completion.choices[0]?.message?.content ?? '';
return {
content: [{
type: 'text',
text: JSON.stringify({
report: text,
cost_usd: costUsd,
model_used: rawUsage.modelId,
input_tokens: rawUsage.inputTokens + rawUsage.cacheReadTokens,
output_tokens: rawUsage.outputTokens,
}),
}],
};
}
);
Spend reporting and anomaly detection
Expose a cost summary resource so agents can query spend before initiating expensive workflows. Add an anomaly check that triggers when per-tool cost spikes suddenly — a 10× increase over the rolling average is a reliable signal of prompt injection, runaway loops, or an accidental switch to a premium model.
// MCP resource — spending report by tool and model for the last 24h
server.resource('llm_spend_report', 'llm://spend/24h', async () => {
const byTool = db.prepare(`
SELECT
tool_name,
model_id,
COUNT(*) AS calls,
SUM(input_tok) AS total_input_tokens,
SUM(output_tok) AS total_output_tokens,
SUM(cost_usd) AS total_cost_usd,
AVG(cost_usd) AS avg_cost_per_call,
MAX(cost_usd) AS max_cost_per_call
FROM llm_cost_log
WHERE created_at > datetime('now', '-24 hours')
GROUP BY tool_name, model_id
ORDER BY total_cost_usd DESC
`).all();
const totals = db.prepare(`
SELECT
SUM(cost_usd) AS total_usd,
COUNT(*) AS total_calls,
SUM(input_tok + cache_read) AS total_input_tokens,
SUM(output_tok) AS total_output_tokens
FROM llm_cost_log
WHERE created_at > datetime('now', '-24 hours')
`).get() as { total_usd: number; total_calls: number; total_input_tokens: number; total_output_tokens: number };
return {
contents: [{
uri: 'llm://spend/24h',
text: JSON.stringify({
period: 'last_24h',
total_usd: totals?.total_usd ?? 0,
total_calls: totals?.total_calls ?? 0,
by_tool: byTool,
generated_at: new Date().toISOString(),
}),
}],
};
});
// Background anomaly check — call from a periodic health job
async function checkSpendAnomaly(toolName: string): Promise<void> {
const recent = db.prepare(`
SELECT AVG(cost_usd) AS avg_cost
FROM llm_cost_log
WHERE tool_name = ? AND created_at > datetime('now', '-1 hour')
`).get(toolName) as { avg_cost: number | null };
const baseline = db.prepare(`
SELECT AVG(cost_usd) AS avg_cost
FROM llm_cost_log
WHERE tool_name = ?
AND created_at > datetime('now', '-7 days')
AND created_at < datetime('now', '-1 hour')
`).get(toolName) as { avg_cost: number | null };
const recentAvg = recent.avg_cost ?? 0;
const baselineAvg = baseline.avg_cost ?? 0;
if (baselineAvg > 0 && recentAvg > baselineAvg * 10) {
console.error(
`[cost-anomaly] Tool '${toolName}' average cost spiked ` +
`${(recentAvg / baselineAvg).toFixed(1)}× above 7-day baseline ` +
`($${recentAvg.toFixed(6)} vs $${baselineAvg.toFixed(6)} avg per call). ` +
`Check for model changes, prompt injection, or runaway loops.`
);
// In production: send alert to Slack, PagerDuty, or your on-call channel
}
}
Run checkSpendAnomaly from your MCP server's startup or from a setInterval every 15 minutes. A 10× spike in average call cost is a strong signal — normal prompt variation rarely changes cost by more than 2–3×. The anomaly check catches accidental model upgrades, prompt injections that dramatically increase output length, and infinite retry loops before they exhaust your budget.