n4nAI

How to count tokens before sending an API request

Learn how to count tokens locally before making LLM API calls to control costs, avoid truncation, and optimize context windows.

n4n Team4 min read780 words

Audio narration

Coming soon — every post will get a voice note here.

Counting tokens before you hit an LLM API saves money, prevents silent truncation, and lets you fit the maximum useful context into every request. Most providers bill by the token and enforce hard context limits, but they only tell you the count after the fact — or not at all. This guide shows you how to count accurately on the client side, handle model-specific tokenizers, and verify your counts match what the API actually sees.

Step 1: Pick the right tokenizer for your model

Tokenization is model-specific. GPT-4o uses o200k_base, Claude uses a different vocabulary, and open models like Llama 3 use their own. Using the wrong tokenizer gives you counts that are directionally correct but numerically wrong — sometimes by 15-20%.

For OpenAI-compatible models, use tiktoken. For Anthropic, use their anthropic-tokenizer package. For open models, load the tokenizer from Hugging Face transformers.

# tiktoken for OpenAI and compatible models
import tiktoken

def get_openai_encoding(model: str) -> tiktoken.Encoding:
    """Return the correct tiktoken encoding for a given model name."""
    try:
        return tiktoken.encoding_for_model(model)
    except KeyError:
        # Fallback for newer models not yet in tiktoken's registry
        # gpt-4o, gpt-4o-mini, o1 series all use o200k_base
        if any(m in model for m in ("gpt-4o", "o1", "o3")):
            return tiktoken.get_encoding("o200k_base")
        # Older default
        return tiktoken.get_encoding("cl100k_base")

For Anthropic models, the tokenizer isn’t publicly exposed with the same fidelity, but their SDK provides a counting endpoint. For local open models:

# transformers for Llama, Mistral, Qwen, etc.
from transformers import AutoTokenizer

def get_hf_tokenizer(model_id: str) -> AutoTokenizer:
    """Load a tokenizer from Hugging Face Hub."""
    return AutoTokenizer.from_pretrained(model_id, use_fast=True)

Verification tip: Send a known string to the API and compare the returned usage.prompt_tokens against your local count. They should match exactly for OpenAI models. For Anthropic, expect ±1-2 token variance due to internal formatting.

Step 2: Count tokens in a simple string

The baseline operation is counting a single string. This is useful for estimating costs before you build a full request.

def count_tokens(text: str, encoding: tiktoken.Encoding) -> int:
    """Count tokens in a plain text string."""
    return len(encoding.encode(text))

# Example
enc = get_openai_encoding("gpt-4o")
prompt = "Summarize the following document in three bullet points."
print(f"Token count: {count_tokens(prompt, enc)}")  # ~12 tokens

Verification: Run the same string through the OpenAI tokenizer web tool (platform.openai.com/tokenizer) and confirm the count matches.

Step 3: Count tokens in a chat completion request

Real requests use the chat format with roles, message boundaries, and special tokens. OpenAI’s chat format adds overhead: each message has a header (<|im_start|>role), content, and footer (<|im_end|>), plus a final assistant primer. The exact formula varies by model family.

def count_chat_tokens(
    messages: list[dict[str, str]],
    encoding: tiktoken.Encoding,
    model: str = "gpt-4o"
) -> int:
    """
    Count tokens for a chat completion request.
    Based on OpenAI's published counting rules for o200k_base models.
    """
    # Every reply is primed with <|im_start|>assistant
    token_count = 3  # <|im_start|>assistant<|im_sep|>
    
    for msg in messages:
        role = msg["role"]
        content = msg.get("content", "")
        
        # Message header: <|im_start|>{role}<|im_sep|>
        token_count += 4
        # Content tokens
        token_count += len(encoding.encode(content))
        # Message footer: <|im_end|>
        token_count += 1
    
    return token_count

# Example
messages = [
    {"role": "system", "content": "You are a concise summarizer."},
    {"role": "user", "content": "Summarize this: " + "x" * 5000},
]
enc = get_openai_encoding("gpt-4o")
print(f"Estimated prompt tokens: {count_chat_tokens(messages, enc)}")

Verification: Make a real API call with max_tokens=1 and check usage.prompt_tokens in the response. Adjust the overhead constants if your model family differs (e.g., gpt-3.5-turbo uses slightly different framing).

Step 4: Handle tool/function definitions

If you use function calling, the schema definitions consume tokens too. OpenAI injects a JSON schema representation into the system prompt. Count it by serializing your tool definitions the same way the API does.

import json

def count_tool_tokens(tools: list[dict], encoding: tiktoken.Encoding) -> int:
    """
    Approximate token count for tool definitions.
    OpenAI serializes the function schema as a JSON string in the system prompt.
    """
    # The API injects something like:
    # "namespace functions { type FunctionName = (_: { ... }) => any; }"
    # We approximate by encoding the JSON schema plus overhead.
    tool_json = json.dumps({"tools": tools}, separators=(",", ":"))
    # Rough overhead for the namespace wrapper and type definitions
    overhead = 50  
    return len(encoding.encode(tool_json)) + overhead

tools = [{
    "type": "function",
    "function": {
        "name": "search_web",
        "description": "Search the web for current information",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"}
            },
            "required": ["query"]
        }
    }
}]
print(f"Tool tokens: {count_tool_tokens(tools, enc)}")

Verification: Compare against a real request with tools attached. The variance is typically under 5% for simple schemas, higher for deeply nested ones.

Step 5: Account for images (vision models)

Vision tokens depend on image resolution and the model’s patch size. GPT-4o uses a variable token count: a base cost plus per-tile tokens. The formula is documented but easier to implement as a lookup.

def count_image_tokens(width: int, height: int, model: str = "gpt-4o") -> int:
    """
    Estimate vision tokens for GPT-4o family.
    Based on OpenAI's documented pricing: 85 tokens base + 170 per 512x512 tile.
    """
    if "gpt-4o" not in model:
        raise ValueError("Formula validated for gpt-4o family only")
    
    # Resize logic: max dimension 2048, min 768, then tile to 512
    max_dim = max(width, height)
    min_dim = min(width, height)
    
    # Scale down if needed
    if max_dim > 2048:
        scale = 2048 / max_dim
        width = int(width * scale)
        height = int(height * scale)
    elif min_dim < 768:
        scale = 768 / min_dim
        width = int(width * scale)
        height = int(height * scale)
    
    # Tile count
    tiles_x = (width + 511) // 512
    tiles_y = (height + 511) // 512
    tiles = tiles_x * tiles_y
    
    return 85 + 170 * tiles

# 1024x1024 image = 4 tiles = 85 + 680 = 765 tokens
print(f"1024x1024 image tokens: {count_image_tokens(1024, 1024)}")

Verification: Send a single image with a minimal prompt ("What is this?") and check usage.prompt_tokens. Subtract the text token count; the remainder should match your calculation.

Step 6: Build a pre-flight check function

Combine the pieces into a single function you call before every request. This is where you enforce context limits, estimate cost, and decide whether to truncate.

from dataclasses import dataclass
from typing import Optional

@dataclass
class TokenEstimate:
    prompt_tokens: int
    max_completion_tokens: int
    estimated_total: int
    context_limit: int
    will_fit: bool
    estimated_cost_usd: float

MODEL_LIMITS = {
    "gpt-4o": 128_000,
    "gpt-4o-mini": 128_000,
    "o1-preview": 128_000,
    "o1-mini": 128_000,
    "gpt-4-turbo": 128_000,
    "gpt-3.5-turbo": 16_384,
}

# Rough pricing per 1M tokens (input, output) — update from provider pricing page
MODEL_PRICING = {
    "gpt-4o": (2.50, 10.00),
    "gpt-4o-mini": (0.15, 0.60),
    "o1-preview": (15.00, 60.00),
    "o1-mini": (3.00, 12.00),
}

def estimate_request(
    messages: list[dict],
    model: str,
    max_completion_tokens: int,
    tools: Optional[list[dict]] = None,
    images: Optional[list[tuple[int, int]]] = None,  # (width, height)
) -> TokenEstimate:
    """Full pre-flight token and cost estimate."""
    encoding = get_openai_encoding(model)
    context_limit = MODEL_LIMITS.get(model, 128_000)
    
    # Count messages
    prompt_tokens = count_chat_tokens(messages, encoding, model)
    
    # Add tools
    if tools:
        prompt_tokens += count_tool_tokens(tools, encoding)
    
    # Add images
    if images:
        for w, h in images:
            prompt_tokens += count_image_tokens(w, h, model)
    
    estimated_total = prompt_tokens + max_completion_tokens
    will_fit = estimated_total <= context_limit
    
    # Cost estimate
    input_price, output_price = MODEL_PRICING.get(model, (0, 0))
    estimated_cost = (prompt_tokens * input_price + max_completion_tokens * output_price) / 1_000_000
    
    return TokenEstimate(
        prompt_tokens=prompt_tokens,
        max_completion_tokens=max_completion_tokens,
        estimated_total=estimated_total,
        context_limit=context_limit,
        will_fit=will_fit,
        estimated_cost_usd=estimated_cost,
    )

# Usage
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Explain quantum computing in simple terms."},
]
estimate = estimate_request(
    messages=messages,
    model="gpt-4o-mini",
    max_completion_tokens=500,
)
print(f"Prompt: {estimate.prompt_tokens} tokens")
print(f"Total: {estimate.estimated_total} / {estimate.context_limit}")
print(f"Fits: {estimate.will_fit}")
print(f"Est. cost: ${estimate.estimated_cost_usd:.6f}")

Verification: Log the estimate alongside the actual usage object from every API response. Build a dashboard or alert on divergence >2%.

Step 7: Truncate intelligently when you exceed the limit

Counting is only useful if you act on it. When will_fit is false, truncate the conversation history — not the system prompt, not the current user message. Preserve the most recent context first.

def truncate_messages(
    messages: list[dict],
    encoding: tiktoken.Encoding,
    model: str,
    max_prompt_tokens: int,
    reserve_tokens: int = 100,  # buffer for tools, images, formatting
) -> list[dict]:
    """
    Truncate message history to fit within max_prompt_tokens.
    Keeps system prompt and most recent messages.
    """
    system_msgs = [m for m in messages if m["role"] == "system"]
    other_msgs = [m for m in messages if m["role"] != "system"]
    
    # Count system tokens (usually small, keep all)
    system_tokens = sum(
        len(encoding.encode(m.get("content", ""))) + 4  # header + footer
        for m in system_msgs
    )
    
    available = max_prompt_tokens - system_tokens - reserve_tokens
    if available <= 0:
        raise ValueError("System prompt alone exceeds budget")
    
    # Add messages from most recent backwards
    kept = []
    used = 0
    for msg in reversed(other_msgs):
        msg_tokens = len(encoding.encode(msg.get("content", ""))) + 5  # header + footer + role
        if used + msg_tokens > available:
            break
        kept.insert(0, msg)
        used += msg_tokens
    
    return system_msgs + kept

# Example: force a truncation
long_history = [
    {"role": "system", "content": "You are a helpful assistant."},
] + [
    {"role": "user" if i % 2 == 0 else "assistant", "content": f"Message {i} " + "x" * 2000}
    for i in range(20)
]

enc = get_openai_encoding("gpt-4o-mini")
truncated = truncate_messages(long_history, enc, "gpt-4o-mini", max_prompt_tokens=8000)
print(f"Original: {len(long_history)} messages, Truncated: {len(truncated)} messages")

Verification: After truncation, run count_chat_tokens on the result and confirm it’s under your budget. Run the actual API call and verify no 400 context length exceeded errors.

Step 8: Handle streaming and reasoning models

Streaming responses don’t change prompt token counting, but reasoning models (o1, o3) add a hidden reasoning_tokens bucket in usage.completion_tokens_details. You can’t predict this locally, but you must budget for it in max_completion_tokens.

def estimate_with_reasoning_budget(
    messages: list[dict],
    model: str,
    max_completion_tokens: int,
    reasoning_budget_ratio: float = 0.3,
) -> TokenEstimate:
    """
    For reasoning models, reserve a portion of max_completion_tokens for hidden reasoning.
    """
    estimate = estimate_request(messages, model, max_completion_tokens)
    
    if "o1" not in model and "o3" not in model
    else int(max_completion_tokens * (1 - reasoning_budget_ratio))
    
    # Adjust: the visible output tokens will be lower
    visible_output = int(max_completion_tokens * (1 - reasoning_budget_ratio))
    reasoning_tokens = max_completion_tokens - visible_output
    
    return TokenEstimate(
        prompt_tokens=estimate.prompt_tokens,
        max_completion_tokens=visible_output,
        estimated_total=estimate.prompt_tokens + max_completion_tokens,
        context_limit=estimate.context_limit,
        will_fit=estimate.will_fit,
        estimated_cost_usd=estimate.estimated_cost_usd,  # pricing includes reasoning
    )

Verification: After an o1/o3 call, check usage.completion_tokens_details.reasoning_tokens. Track the ratio over time and adjust reasoning_budget_ratio per use case.

Step 9: Integrate into your request pipeline

Wrap the estimate in a middleware or decorator so every outbound request gets counted automatically. This prevents accidental overspend and gives you centralized logging.

import functools
from typing import Callable, Any

def with_token_accounting(model: str, max_completion_tokens: int):
    """Decorator that logs token estimates and enforces limits."""
    def decorator(func: Callable[..., Any]):
        @functools.wraps(func)
        def wrapper(messages: list[dict], **kwargs):
            estimate = estimate_request(
                messages=messages,
                model=model,
                max_completion_tokens=max_completion_tokens,
                tools=kwargs.get("tools"),
                images=kwargs.get("images"),
            )
            
            # Log for observability
            print(f"[token-accounting] model={model} prompt={estimate.prompt_tokens} "
                  f"max_completion={estimate.max_completion_tokens} "
                  f"total={estimate.estimated_total}/{estimate.context_limit} "
                  f"cost≈${estimate.estimated_cost_usd:.6f}")
            
            if not estimate.will_fit:
                # Auto-truncate or raise
                encoding = get_openai_encoding(model)
                messages = truncate_messages(
                    messages, encoding, model, 
                    max_prompt_tokens=estimate.context_limit - max_completion_tokens - 100
                )
                # Re-estimate after truncation
                estimate = estimate_request(messages, model, max_completion_tokens, 
                                            kwargs.get("tools"), kwargs.get("images"))
                print(f"[token-accounting] Truncated to {estimate.prompt_tokens} prompt tokens")
            
            return func(messages, **kwargs)
        return wrapper
    return decorator

# Usage
@with_token_accounting(model="gpt-4o-mini", max_completion_tokens=1000)
def call_llm(messages: list[dict], **kwargs):
    # Your actual API call here
    pass

Verification: Ship this to staging. Tail logs for a day and confirm:

  • No context length exceeded errors
  • Estimated vs actual prompt tokens match within 1-2 tokens
  • Cost estimates align with billing reports at month end

Step 10: Test edge cases systematically

Token counting breaks in predictable ways. Add these to your test suite.

import pytest

class TestTokenCounting:
    def test_empty_message(self):
        enc = get_openai_encoding("gpt-4o")
        assert count_chat_tokens([{"role": "user", "content": ""}], enc) > 0
    
    def test_unicode_emoji(self):
        enc = get_openai_encoding("gpt-4o")
        # Emoji often tokenize to multiple tokens
        count = count_tokens("🎉🚀💯", enc)
        assert count >= 3  # at least one per emoji
    
    def test_very_long_single_token_word(self):
        enc = get_openai_encoding("gpt-4o")
        # "antidisestablishmentarianism" is one token in cl100k_base
        count = count_tokens("antidisestablishmentarianism", enc)
        assert count == 1
    
    def test_truncation_preserves_system(self):
        enc = get_openai_encoding("gpt-4o-mini")
        msgs = [
            {"role": "system", "content": "x" * 5000},
            {"role": "user", "content": "hello"},
        ]
        truncated = truncate_messages(msgs, enc, "gpt-4o-mini", max_prompt_tokens=100)
        assert truncated[0]["role"] == "system"
    
    def test_tool_counting_matches_api(self):
        """Requires network — run in CI nightly."""
        pass  # Implement with real API call and compare

if __name__ == "__main__":
    pytest.main([__file__, "-v"])

Run these on every PR. The unicode and truncation tests catch the most common production bugs.


Counting tokens locally is table stakes for any production LLM integration. The code above covers the 95% case: OpenAI-compatible chat completions with optional tools and vision. For Anthropic, Bedrock, or Vertex, the principle is identical — swap the tokenizer and the framing constants. The key discipline is verifying your counts against actual API responses continuously, not once at launch.

Tagstoken-countapi-usagetokenizationdeveloper-guide

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All tokens & tokenization posts →