Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 49 additions & 31 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2965,19 +2965,48 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
return count * cost_per_image


def _estimate_message_chars(msg: Dict[str, Any]) -> int:
"""Char count for token estimation, excluding base64 image data.

Base64 images are counted via `_count_image_tokens` instead; including
their raw chars here would massively overestimate token usage.
def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]:
"""Shadow of a message holding only what the provider actually receives.

Two adjustments to the raw persisted dict:

* ``api_content`` is a SUBSTITUTE for ``content``, not an addition to it.
``turn_context.substitute_api_content()`` pops the sidecar and overwrites
``content`` at every API-bound build site, so exactly one of the two is
ever sent. Counting both double-counts any message whose sidecar differs
from its clean stored content (2.00x on a 40KB sidecar).

The substitution mirrors that helper's guard exactly: only a non-empty
STRING sidecar on a ``user``/``assistant`` row displaces ``content``.
Any other sidecar shape is popped and discarded on the wire without
touching ``content``, so a shadow that substituted unconditionally
would UNDERcount those rows — the dangerous direction, since it makes
compaction fire too late and the turn dies on a hard context error.
* Base64 image payloads are replaced with a placeholder; they are charged
separately at a flat rate by ``_count_image_tokens``, and counting their
raw chars here would massively overestimate usage.
"""
if not isinstance(msg, dict):
return len(str(msg))
sidecar = msg.get("api_content")
sidecar_wins = (
isinstance(sidecar, str)
and bool(sidecar)
and msg.get("role") in ("user", "assistant")
)
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "api_content":
# Always popped before the request is built; only counted when it
# actually replaces ``content``.
if sidecar_wins:
shadow["content"] = v
continue
if k == "content":
if sidecar_wins:
# The sidecar wins on the wire; skip the clean copy so the
# same logical content is not counted twice.
continue
if isinstance(v, list):
cleaned = []
for part in v:
Expand All @@ -2995,36 +3024,25 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int:
shadow[k] = v
else:
shadow[k] = v
return len(str(shadow))
return shadow


def _estimate_message_chars(msg: Dict[str, Any]) -> int:
"""Char count for token estimation, excluding base64 image data.

Base64 images are counted via `_count_image_tokens` instead; including
their raw chars here would massively overestimate token usage.
"""
if not isinstance(msg, dict):
return len(str(msg))
return len(str(_wire_message_shadow(msg)))


def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
"""Token estimate for a message shadow with image payloads stripped."""
if not isinstance(msg, dict):
return estimate_tokens_rough(str(msg))
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "content":
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return estimate_tokens_rough(str(shadow))
return estimate_tokens_rough(str(_wire_message_shadow(msg)))


def estimate_request_tokens_rough(
Expand Down
65 changes: 65 additions & 0 deletions tests/agent/test_model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,71 @@ def test_message_with_list_content(self):
# string representation.
assert 1500 <= result < 2000

def test_api_content_substitutes_for_content_not_added_to_it(self):
"""``api_content`` replaces ``content`` on the wire, so count one.

``turn_context.substitute_api_content()`` pops the sidecar and
overwrites ``content`` at every API-bound build site. Counting both
doubled the estimate for any message carrying a sidecar.
"""
body = "cached prompt bytes " * 2000
wire_shape = {"role": "user", "content": body}
persisted_shape = {"role": "user", "content": body, "api_content": body}

assert estimate_messages_tokens_rough([persisted_shape]) == \
estimate_messages_tokens_rough([wire_shape])

def test_api_content_is_counted_when_it_differs_from_content(self):
"""The sidecar is what's sent, so its size is the one that matters."""
big_sidecar = "cached prompt bytes " * 2000
msg = {"role": "user", "content": "short", "api_content": big_sidecar}

result = estimate_messages_tokens_rough([msg])

# Lower bound: fails if the sidecar were dropped rather than
# substituted (which would undercount the real request).
assert result >= (len(big_sidecar) // 4) * 0.9

def test_non_string_api_content_does_not_displace_content(self):
"""Only a sidecar shape the wire actually substitutes may displace content.

``substitute_api_content()`` overwrites ``content`` only for a
non-empty STRING sidecar on a user/assistant row; every other shape
is popped and discarded, leaving the clean ``content`` on the wire.
The shadow must mirror that guard — substituting unconditionally
would drop the real content from the estimate and UNDERcount, which
is the dangerous direction (compaction fires too late and the turn
dies on a hard context error).
"""
body = "clean stored content " * 2000
baseline = estimate_messages_tokens_rough([{"role": "user", "content": body}])

for bad_sidecar in (None, "", 42, ["not", "a", "string"]):
msg = {"role": "user", "content": body, "api_content": bad_sidecar}
assert estimate_messages_tokens_rough([msg]) >= baseline, bad_sidecar

# Same for a role the substitution never applies to.
tool_row = {"role": "tool", "content": body, "api_content": "ignored"}
assert estimate_messages_tokens_rough([tool_row]) >= baseline

def test_image_stripping_survives_shadow_extraction(self):
"""Non-regression for the ``_wire_message_shadow()`` extraction.

Both estimator helpers now share one shadow builder; this pins the
flat per-image accounting that the extraction moved, independent of
the ``api_content`` fix (a valid sidecar is a string, so it cannot
carry an image list).
"""
import base64
import os

payload = "data:image/png;base64," + base64.b64encode(os.urandom(300_000)).decode()
msg = {"role": "user",
"content": [{"type": "image_url", "image_url": {"url": payload}}]}

# Raw base64 would be ~100K tokens; the flat per-image model is ~1.5K.
assert estimate_messages_tokens_rough([msg]) < 5_000



class TestEstimateRequestTokensRough:
Expand Down
Loading