Module 5: Structured Logging for AI Systems
4. Token and Cost Tracking
Description
For an AI Engineer, not knowing how much each request costs is like a backend engineer who doesn't monitor their database. The OpenAI bill can surprise you at the end of the month if you don't track in real time. This capsule implements a complete tracking system: how to get usage from the API, how to calculate cost by model, how to log and accumulate costs per request, and how to detect outliers — those 5% of requests that consume 60% of the budget.
Why cost tracking is the "wow" feature
Real scenario without cost tracking:
→ You launch your AI app
→ Week 1: $5 on OpenAI, normal
→ Week 2: $8, expected
→ Week 3: $47, what happened?
Without cost logs, debugging is:
"Let me review the code... did someone change the model?
is there a loop that calls the LLM multiple times?
did someone forget to set max_tokens?"
→ Hours of debugging
With cost tracking in logs:
jq -s 'sort_by(-.cost_usd) | .[0:5]' logs.json
→ The 5 most expensive requests are all from the /summarize endpoint
→ Each one uses ~15,000 input tokens
→ A user is sending 100-page documents
→ Fix: truncate the input in the sanitizer
→ 5 minutes of debugging
Getting usage from the OpenAI API
# The OpenAI API returns usage in every response
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Analyze this text..."}]
)
# Structure of the usage object:
usage = response.usage
print(usage.prompt_tokens) # Tokens in the input (including the system prompt)
print(usage.completion_tokens) # Tokens in the generated output
print(usage.total_tokens) # prompt_tokens + completion_tokens
# Example:
# usage.prompt_tokens = 245
# usage.completion_tokens = 87
# usage.total_tokens = 332
# When is it NOT available?
# - Some models or configurations may not return usage
# - stream=True can behave differently
# Solution: use tiktoken to count if usage isn't available
Calculate cost precisely
# src/cost_calculator.py
from dataclasses import dataclass
from typing import Optional
@dataclass
class CostCalculation:
model: str
input_tokens: int
output_tokens: int
total_tokens: int
input_cost_usd: float
output_cost_usd: float
total_cost_usd: float
# Prices per 1M tokens (update periodically from openai.com/pricing)
# Do NOT hardcode in business code — centralize here for easy updates
MODEL_PRICING = {
# GPT-4o-mini: the high-efficiency model
"gpt-4o-mini": {
"input": 0.150, # $0.15 per 1M input tokens
"output": 0.600, # $0.60 per 1M output tokens
},
# GPT-4o: the balanced model
"gpt-4o": {
"input": 2.50,
"output": 10.00,
},
# GPT-4o-2024-11-20 (alias for GPT-4o latest)
"gpt-4o-2024-11-20": {
"input": 2.50,
"output": 10.00,
},
# GPT-4 Turbo
"gpt-4-turbo": {
"input": 10.00,
"output": 30.00,
},
# GPT-4 (base, legacy)
"gpt-4": {
"input": 30.00,
"output": 60.00,
},
# GPT-3.5 Turbo (legacy, very cheap)
"gpt-3.5-turbo": {
"input": 0.50,
"output": 1.50,
},
}
# Fallback for unlisted models (conservative best estimate)
DEFAULT_PRICING = {"input": 0.150, "output": 0.600}
def calculate_cost(
model: str,
input_tokens: int,
output_tokens: int
) -> CostCalculation:
"""
Calculates the exact cost of an LLM call.
Formula: (tokens / 1_000_000) * price_per_million
"""
# Normalize the model name (it may have version suffixes)
pricing = _get_pricing(model)
input_cost = (input_tokens / 1_000_000) * pricing["input"]
output_cost = (output_tokens / 1_000_000) * pricing["output"]
return CostCalculation(
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
input_cost_usd=round(input_cost, 8),
output_cost_usd=round(output_cost, 8),
total_cost_usd=round(input_cost + output_cost, 8),
)
def _get_pricing(model: str) -> dict:
"""
Gets the pricing for a model, with intelligent fallback.
Handles models with version suffixes like "gpt-4o-mini-2024-07-18".
"""
if model in MODEL_PRICING:
return MODEL_PRICING[model]
# Try to match by prefix (for timestamped versions)
for key in MODEL_PRICING:
if model.startswith(key):
return MODEL_PRICING[key]
# Fall back to the default
return DEFAULT_PRICING
Log the cost in every request
# src/llm_wrapper.py (updated with complete cost tracking)
import time
import structlog
from src.cost_calculator import calculate_cost, CostCalculation
from src.tracing import get_request_id
log = structlog.get_logger()
# Thresholds for alerts
HIGH_COST_THRESHOLD_USD = 0.05 # $0.05 per request is high for most apps
VERY_HIGH_COST_THRESHOLD_USD = 0.20
HIGH_TOKENS_THRESHOLD = 10_000 # 10K input tokens is suspicious
HIGH_LATENCY_THRESHOLD_MS = 10_000
def call_llm_with_cost_tracking(
client,
model: str,
messages: list,
**kwargs
) -> tuple:
"""
Makes an LLM call and tracks tokens and cost.
Returns (response, cost_calculation).
"""
start_time = time.time()
request_id = get_request_id() or "unknown"
# Warn if the input looks excessively long
total_input_chars = sum(len(m.get("content", "")) for m in messages)
if total_input_chars > 40_000: # Approx 10K tokens
log.warning(
"large_input_detected",
input_chars=total_input_chars,
estimated_tokens=total_input_chars // 4, # Rough estimate
model=model
)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
**kwargs
)
duration_ms = (time.time() - start_time) * 1000
# Calculate cost
cost = calculate_cost(
model=model,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens
)
# INFO log: cost and tokens (always)
log.info(
"llm_request_completed",
model=model,
input_tokens=cost.input_tokens,
output_tokens=cost.output_tokens,
total_tokens=cost.total_tokens,
input_cost_usd=cost.input_cost_usd,
output_cost_usd=cost.output_cost_usd,
total_cost_usd=cost.total_cost_usd,
duration_ms=round(duration_ms, 1)
)
# Alerts for anomalous cost
if cost.total_cost_usd > VERY_HIGH_COST_THRESHOLD_USD:
log.warning(
"very_high_cost_request",
total_cost_usd=cost.total_cost_usd,
total_tokens=cost.total_tokens,
model=model,
note="Investigate whether the input is being truncated correctly"
)
elif cost.total_cost_usd > HIGH_COST_THRESHOLD_USD:
log.warning(
"high_cost_request",
total_cost_usd=cost.total_cost_usd,
model=model
)
# Alert for excessively long input
if cost.input_tokens > HIGH_TOKENS_THRESHOLD:
log.warning(
"excessive_input_tokens",
input_tokens=cost.input_tokens,
model=model,
note="Is the sanitizer truncating correctly?"
)
return response, cost
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
log.error(
"llm_request_failed",
error_type=type(e).__name__,
duration_ms=round(duration_ms, 1),
model=model
)
raise
Accumulate cost in requests with multiple LLM calls
# Some pipelines make multiple LLM calls per request
# (e.g., guardrail judge + main analysis)
# It's important to track the request's total cost, not just each call's
from dataclasses import dataclass, field
from typing import List
from src.cost_calculator import CostCalculation
@dataclass
class RequestCostAccumulator:
"""Accumulates the cost of multiple LLM calls in a single request."""
request_id: str
calls: List[CostCalculation] = field(default_factory=list)
def add(self, cost: CostCalculation):
self.calls.append(cost)
@property
def total_cost_usd(self) -> float:
return round(sum(c.total_cost_usd for c in self.calls), 8)
@property
def total_tokens(self) -> int:
return sum(c.total_tokens for c in self.calls)
@property
def num_calls(self) -> int:
return len(self.calls)
def to_log_dict(self) -> dict:
return {
"total_cost_usd": self.total_cost_usd,
"total_tokens": self.total_tokens,
"llm_calls_count": self.num_calls,
"cost_breakdown": [
{"model": c.model, "tokens": c.total_tokens, "cost": c.total_cost_usd}
for c in self.calls
]
}
# Usage in the endpoint:
# accumulator = RequestCostAccumulator(request_id=request_id)
#
# # Call to the guardrail LLM judge
# _, cost1 = call_llm_with_cost_tracking(client, model, guard_messages)
# accumulator.add(cost1)
#
# # Main call
# _, cost2 = call_llm_with_cost_tracking(client, model, main_messages)
# accumulator.add(cost2)
#
# log.info("request_total_cost", **accumulator.to_log_dict())
Estimate tokens WITHOUT calling the API: tiktoken
# Useful for:
# 1. Validating that the input doesn't exceed the limit before calling
# 2. Estimating cost before the call
# 3. Counting tokens when the API doesn't return usage
import tiktoken
def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:
"""
Counts tokens exactly as the model does.
Requires: pip install tiktoken
"""
try:
enc = tiktoken.encoding_for_model(model)
except KeyError:
# Fallback for unknown models
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text))
def count_messages_tokens(messages: list, model: str = "gpt-4o-mini") -> int:
"""
Counts tokens for a list of messages (as the API sends them).
Includes the overhead tokens from the message structure.
"""
try:
enc = tiktoken.encoding_for_model(model)
except KeyError:
enc = tiktoken.get_encoding("cl100k_base")
# Overhead per message: 3 tokens (role, content, separators)
overhead_per_message = 3
total = 0
for message in messages:
total += overhead_per_message
for key, value in message.items():
total += len(enc.encode(str(value)))
total += 3 # Overhead from the response format
return total
def estimate_cost(
messages: list,
model: str = "gpt-4o-mini",
expected_output_tokens: int = 500
) -> dict:
"""
Estimates the cost of a call BEFORE making it.
Useful for validating that the input isn't excessively expensive.
"""
from src.cost_calculator import calculate_cost
input_tokens = count_messages_tokens(messages, model)
cost = calculate_cost(model, input_tokens, expected_output_tokens)
return {
"estimated_input_tokens": input_tokens,
"estimated_output_tokens": expected_output_tokens,
"estimated_cost_usd": cost.total_cost_usd,
"model": model
}
# Example of usage to reject very expensive requests:
def pre_validate_cost(messages, model, max_cost_usd=0.20):
estimate = estimate_cost(messages, model)
if estimate["estimated_cost_usd"] > max_cost_usd:
raise ValueError(
f"Request estimated too expensive: ${estimate['estimated_cost_usd']:.4f} "
f"(maximum: ${max_cost_usd}). "
f"Input tokens: {estimate['estimated_input_tokens']}"
)
Cost analysis from the logs
# scripts/analyze_costs.py
# Script to analyze costs from the JSON logs
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
def load_logs(log_file: str) -> list:
"""Loads logs from a JSON Lines file."""
logs = []
with open(log_file) as f:
for line in f:
line = line.strip()
if line:
try:
logs.append(json.loads(line))
except json.JSONDecodeError:
pass
return logs
def analyze_costs(logs: list, date_filter: str = None) -> dict:
"""Complete cost analysis from the logs."""
# Filter by date if specified
if date_filter:
logs = [l for l in logs if l.get("timestamp", "").startswith(date_filter)]
# Only completed-request logs with cost
cost_logs = [
l for l in logs
if l.get("event") == "llm_request_completed" and "total_cost_usd" in l
]
if not cost_logs:
return {"error": "No cost logs found"}
total_cost = sum(l["total_cost_usd"] for l in cost_logs)
total_tokens = sum(l.get("total_tokens", 0) for l in cost_logs)
# By model
by_model = defaultdict(lambda: {"cost": 0, "tokens": 0, "count": 0})
for l in cost_logs:
model = l.get("model", "unknown")
by_model[model]["cost"] += l["total_cost_usd"]
by_model[model]["tokens"] += l.get("total_tokens", 0)
by_model[model]["count"] += 1
# Top 10 most expensive requests
top_expensive = sorted(cost_logs, key=lambda l: l["total_cost_usd"], reverse=True)[:10]
# Cost distribution
buckets = {"<$0.001": 0, "$0.001-$0.01": 0, "$0.01-$0.05": 0, "$0.05-$0.20": 0, ">$0.20": 0}
for l in cost_logs:
cost = l["total_cost_usd"]
if cost < 0.001: buckets["<$0.001"] += 1
elif cost < 0.01: buckets["$0.001-$0.01"] += 1
elif cost < 0.05: buckets["$0.01-$0.05"] += 1
elif cost < 0.20: buckets["$0.05-$0.20"] += 1
else: buckets[">$0.20"] += 1
return {
"summary": {
"total_cost_usd": round(total_cost, 6),
"total_tokens": total_tokens,
"total_requests": len(cost_logs),
"avg_cost_per_request": round(total_cost / len(cost_logs), 8),
"avg_tokens_per_request": total_tokens // len(cost_logs),
},
"by_model": {
model: {
"cost_usd": round(data["cost"], 6),
"tokens": data["tokens"],
"requests": data["count"],
"pct_of_total": round(data["cost"] / total_cost * 100, 1)
}
for model, data in by_model.items()
},
"top_expensive_requests": [
{
"request_id": l.get("request_id"),
"cost_usd": l["total_cost_usd"],
"tokens": l.get("total_tokens"),
"model": l.get("model")
}
for l in top_expensive
],
"cost_distribution": buckets
}
if __name__ == "__main__":
log_file = sys.argv[1] if len(sys.argv) > 1 else "logs/app.json"
date_filter = sys.argv[2] if len(sys.argv) > 2 else None
logs = load_logs(log_file)
analysis = analyze_costs(logs, date_filter)
print(json.dumps(analysis, indent=2))
Cost comparison between models
# Helps make decisions about which model to use:
COST_COMPARISON = {
"Sentiment analysis (300 input, 100 output tokens)": {
"gpt-4o-mini": calculate_cost("gpt-4o-mini", 300, 100).total_cost_usd,
"gpt-4o": calculate_cost("gpt-4o", 300, 100).total_cost_usd,
"gpt-4-turbo": calculate_cost("gpt-4-turbo", 300, 100).total_cost_usd,
}
}
# gpt-4o-mini: $0.0001050 per request
# gpt-4o: $0.0017500 per request (16.7x more expensive)
# gpt-4-turbo: $0.0060000 per request (57.1x more expensive)
# For 10,000 requests/month:
# gpt-4o-mini: $1.05/month
# gpt-4o: $17.50/month
# gpt-4-turbo: $60.00/month
# Conclusion: for simple sentiment analysis, gpt-4o-mini is the obvious choice
# Only use gpt-4o for cases that genuinely require more capacity
Cost tracker tests
# tests/unit/test_cost_calculator.py
import pytest
from src.cost_calculator import calculate_cost
def test_gpt4o_mini_cost():
"""Verifies cost calculation for gpt-4o-mini."""
cost = calculate_cost("gpt-4o-mini", input_tokens=500, output_tokens=200)
# Input: 500 / 1,000,000 * 0.15 = 0.000075
# Output: 200 / 1,000,000 * 0.60 = 0.000120
# Total: 0.000195
assert abs(cost.total_cost_usd - 0.000195) < 0.000001
def test_unknown_model_uses_default():
"""Unknown models use the default pricing without crashing."""
cost = calculate_cost("gpt-unknown-model-2099", 100, 100)
assert cost.total_cost_usd > 0 # Doesn't crash, returns something reasonable
@pytest.mark.parametrize("model,inp,out,expected_total", [
("gpt-4o-mini", 1_000_000, 0, 0.15), # 1M input tokens = $0.15
("gpt-4o-mini", 0, 1_000_000, 0.60), # 1M output tokens = $0.60
("gpt-4o", 1_000_000, 0, 2.50), # 1M input GPT-4o = $2.50
])
def test_pricing_table(model, inp, out, expected_total):
"""Verifies the pricing table for the most important models."""
cost = calculate_cost(model, inp, out)
assert abs(cost.total_cost_usd - expected_total) < 0.01
Exercises
Exercise 1: Calculate costs manually
For each scenario, calculate the cost before running the code:
- Analyzing a tweet (50 input tokens, 30 output tokens) with
gpt-4o-mini - Summarizing an article (2,000 input tokens, 500 output tokens) with
gpt-4o-mini - The same summary with
gpt-4o
See solution
Scenario 1: Tweet with gpt-4o-mini
- Input: 50 / 1,000,000 × $0.15 = $0.0000075
- Output: 30 / 1,000,000 × $0.60 = $0.000018
- Total: $0.0000255 (~$0.000026)
Scenario 2: Article with gpt-4o-mini
- Input: 2,000 / 1,000,000 × $0.15 = $0.0003
- Output: 500 / 1,000,000 × $0.60 = $0.0003
- Total: $0.0006
Scenario 3: Article with gpt-4o
- Input: 2,000 / 1,000,000 × $2.50 = $0.005
- Output: 500 / 1,000,000 × $10.00 = $0.005
- Total: $0.010 (16.7x more expensive than gpt-4o-mini for this case)
Exercise 2: Detect the outlier request
You have these cost logs. Which one is the outlier and what could be the cause?
{"cost_usd": 0.00019, "input_tokens": 300, "output_tokens": 87}
{"cost_usd": 0.00021, "input_tokens": 320, "output_tokens": 95}
{"cost_usd": 0.00018, "input_tokens": 280, "output_tokens": 80}
{"cost_usd": 0.04250, "input_tokens": 65000, "output_tokens": 1200}
{"cost_usd": 0.00020, "input_tokens": 310, "output_tokens": 88}
See solution
The 4th log has 65,000 input tokens vs an average of ~300 for the others. That's an outlier of more than 200x.
Possible causes:
- The sanitizer didn't truncate the input correctly
- The user uploaded an entire document instead of a fragment
- There's a bug in the prompt construction (accumulated context, loop without reset)
Cost of the outlier: ~225× the average, which means a single request of this type costs the same as 225 normal requests.
Exercise 3: Alert policy
Define alert thresholds for an app with these characteristics:
- Average cost per request: $0.0002
- Average requests/hour: 100
- Monthly budget: $100
See guide
Expected budget: $0.0002 × 100 × 24 × 30 = $14.40/month (much less than $100)
Reasonable thresholds:
high_cost_per_request: > $0.02 (100× the average) → WARNINGvery_high_cost_per_request: > $0.10 (500× the average) → ERRORhourly_cost_alert: > $5/hour (vs $0.02/hour normal) → immediate ERRORdaily_cost_alert: > $20/day (vs $0.48/day normal) → WARNINGmonthly_budget_alert: > $80 (80% of the budget) → ERROR, notify the team
Summary
response.usageis the source of truth for tokens — use it whenever it's available- Centralize the prices in an updatable table, don't hardcode them in business code
- Log the cost in every request with
input_cost_usd,output_cost_usd,total_cost_usd - Cost alerts let you detect outliers before they impact the bill
- tiktoken lets you estimate tokens before calling the API — useful for validating inputs
- Analysis scripts over the JSON logs reveal cost patterns that are invisible without logging
Additional resources
- OpenAI Pricing — Updated prices (check periodically)
- tiktoken GitHub — Counting tokens before API calls
- OpenAI Usage Dashboard — Official usage dashboard
- LangSmith — AI observability platform with built-in cost tracking
- Helicone — Proxy that adds observability (including cost tracking) without changing code