headroomlabs-ai/headroom · warning · NotImplementedError

{self.__class__.__name__} does not support decoding

Error message

{self.__class__.__name__} does not support decoding

What it means

BaseTokenizer.decode() is optional: the base-class default raises NotImplementedError naming the subclass, because not every backend can map token IDs back to text. Estimating/heuristic counters only approximate counts and carry no vocabulary, so decode is impossible.

Source

Thrown at headroom/tokenizers/base.py:458

        """
        raise NotImplementedError(f"{self.__class__.__name__} does not support encoding")

    def decode(self, tokens: list[int]) -> str:
        """Decode token IDs to text.

        Optional method - not all backends support decoding.
        Default implementation raises NotImplementedError.

        Args:
            tokens: List of token IDs.

        Returns:
            Decoded text.

        Raises:
            NotImplementedError: If decoding is not supported.
        """
        raise NotImplementedError(f"{self.__class__.__name__} does not support decoding")


class _DelegatingBlockCounter(BaseTokenizer):
    """Adapter exposing :meth:`BaseTokenizer._count_content_parts` to non-subclasses.

    The provider token counters in ``headroom/providers/`` are not
    ``BaseTokenizer`` subclasses, and each grew its own shortened content-block
    walker that handled only the shapes its provider was expected to send. The
    result was that every one of them priced most modern blocks at ~0: measured
    on a 6,800-char block, ``OpenAITokenCounter`` returned 8 tokens for
    ``tool_result``/``thinking``/``document``/``mcp_tool_result`` and — its own
    Responses shapes — ``output_text``/``refusal``; ``AnthropicTokenCounter``
    returned 7 for ``thinking``/``document``, which are Anthropic's own.

    Rather than add a fifth partial walker, this lets them borrow the audited one.
    It is image-safe (base64 blobs get a pixel-based estimate instead of being
    serialized and priced as text) and bounds oversized blobs, which a naive
    ``count_text(str(block))`` catch-all does not.

View on GitHub (pinned to 322425c43b)

Solutions

  1. Install the backend dependency (tiktoken / transformers) and request a known model so the registry returns a real tokenizer.
  2. Branch on capability: guard decode calls with isinstance checks against a decoding-capable class or a try/except NotImplementedError.
  3. If decode is essential, pin a specific tokenizer explicitly (e.g. TiktokenTokenCounter) instead of relying on auto-detection.

Example fix

# before
text = tokenizer.decode([9450, 1917])  # NotImplementedError on estimator

# after
try:
    text = tokenizer.decode([9450, 1917])
except NotImplementedError:
    text = None  # estimation-only backend; skip round-trip logic
Defensive patterns

Strategy: try-catch

Validate before calling

decodable = hasattr(tokenizer, "decode") and type(tokenizer).decode is not BaseTokenizer.decode

Type guard

def supports_decoding(t) -> bool:
    try:
        t.decode([0])
        return True
    except NotImplementedError:
        return False

Try / catch

try:
    text = tokenizer.decode(ids)
except NotImplementedError:
    text = None

Prevention

When it happens

Trigger: Calling decode() on an EstimatingTokenCounter or any fallback tokenizer obtained from the registry when the real backend was unavailable (missing dependency, unknown model, failed load).

Common situations: Environments without tiktoken/transformers installed; fallback kicked in after a tokenizer load failure (see HuggingFace fallback errors) and code still tries to decode; tests written against tiktoken running on minimal CI images.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/529ab869136885a1. Report an issue: GitHub.