chopratejas commited on
Commit
3cbe3cb
·
1 Parent(s): 503de28

Count Strands reasoningContent, image, document, video tokens (#111 follow-up)

Browse files

reasoningContent: exact counting via count_text() — pure text, no estimation
image: decode with Pillow for (w*h)/750 formula, fallback by byte size
document: ~1500 tokens/page heuristic (3KB/page of PDF)
video: ~1000 tokens/frame heuristic (30KB/frame)

Text content (reasoning, text, toolResult) uses exact tokenization.
Binary content (image, document, video) uses provider formula or
size-based estimates — accurate counting requires content extraction
that only the provider can do.

14 tests covering all Strands content block types.

headroom/tokenizers/base.py CHANGED
@@ -175,6 +175,47 @@ class BaseTokenizer(ABC):
175
  total += self._count_content_parts(tr_content)
176
  else:
177
  total += self.count_text(json.dumps(tr_content))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  else:
179
  # Unknown type - estimate from JSON
180
  total += self.count_text(json.dumps(part))
@@ -183,6 +224,45 @@ class BaseTokenizer(ABC):
183
 
184
  return total
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  def _count_tool_calls(self, tool_calls: list[dict[str, Any]]) -> int:
187
  """Count tokens in tool calls."""
188
  total = 0
 
175
  total += self._count_content_parts(tr_content)
176
  else:
177
  total += self.count_text(json.dumps(tr_content))
178
+ elif not part_type and "reasoningContent" in part:
179
+ # Strands SDK reasoning: {"reasoningContent": {"reasoningText": {"text": "..."}}}
180
+ # This is actual text — count it precisely.
181
+ reasoning = part["reasoningContent"]
182
+ reasoning_text = reasoning.get("reasoningText", {})
183
+ if isinstance(reasoning_text, dict):
184
+ total += self.count_text(reasoning_text.get("text", ""))
185
+ elif isinstance(reasoning_text, str):
186
+ total += self.count_text(reasoning_text)
187
+ elif not part_type and "document" in part:
188
+ # Strands SDK document: {"document": {"source": {"bytes": ...}}}
189
+ # Provider internally extracts text from PDF/DOCX then tokenizes.
190
+ # Accurate counting would require a PDF parser — instead we use
191
+ # the Anthropic documented estimate of ~1500 tokens per page,
192
+ # with ~3KB of PDF per page as a rough heuristic.
193
+ doc = part["document"]
194
+ source = doc.get("source", {})
195
+ doc_bytes = source.get("bytes", b"")
196
+ if isinstance(doc_bytes, bytes | bytearray):
197
+ estimated_pages = max(1, len(doc_bytes) // 3000)
198
+ total += estimated_pages * 1500
199
+ else:
200
+ total += self.count_text(str(doc_bytes))
201
+ elif not part_type and "image" in part:
202
+ # Strands SDK image: {"image": {"source": {"bytes": ...}}}
203
+ # Anthropic formula: tokens = (width * height) / 750.
204
+ # Decode with Pillow for exact count; fall back to estimate.
205
+ total += self._estimate_image_tokens(part["image"])
206
+ elif not part_type and "video" in part:
207
+ # Strands SDK video: provider samples ~1 fps, each frame costs
208
+ # image tokens. We can't decode frames without heavy deps, so
209
+ # estimate from byte size assuming ~30KB per frame, ~1000 tokens
210
+ # per frame (average image).
211
+ vid = part["video"]
212
+ source = vid.get("source", {})
213
+ vid_bytes = source.get("bytes", b"")
214
+ if isinstance(vid_bytes, bytes | bytearray):
215
+ frames = max(1, len(vid_bytes) // 30000)
216
+ total += frames * 1000
217
+ else:
218
+ total += 3200
219
  else:
220
  # Unknown type - estimate from JSON
221
  total += self.count_text(json.dumps(part))
 
224
 
225
  return total
226
 
227
+ @staticmethod
228
+ def _estimate_image_tokens(image_data: dict[str, Any]) -> int:
229
+ """Estimate tokens for an image using Anthropic's formula: (w*h)/750.
230
+
231
+ Tries to decode dimensions with Pillow. Falls back to a conservative
232
+ estimate based on byte size.
233
+ """
234
+ source = image_data.get("source", {})
235
+ img_bytes = source.get("bytes", b"")
236
+
237
+ if isinstance(img_bytes, bytes | bytearray) and len(img_bytes) > 0:
238
+ try:
239
+ import io
240
+
241
+ from PIL import Image
242
+
243
+ img = Image.open(io.BytesIO(img_bytes))
244
+ w, h = img.size
245
+ # Anthropic resizes to fit 1568x1568 max
246
+ max_dim = 1568
247
+ if w > max_dim or h > max_dim:
248
+ scale = max_dim / max(w, h)
249
+ w, h = int(w * scale), int(h * scale)
250
+ return max(100, (w * h) // 750)
251
+ except Exception:
252
+ pass
253
+
254
+ # Fallback: estimate from byte size.
255
+ # Typical screenshot: ~200KB ≈ 1200x800 ≈ 1280 tokens
256
+ if isinstance(img_bytes, bytes | bytearray):
257
+ size_kb = len(img_bytes) / 1024
258
+ if size_kb < 50:
259
+ return 400 # Small icon/thumbnail
260
+ if size_kb < 500:
261
+ return 1200 # Typical screenshot
262
+ return 1600 # Large/high-res image
263
+
264
+ return 1200 # Default estimate
265
+
266
  def _count_tool_calls(self, tool_calls: list[dict[str, Any]]) -> int:
267
  """Count tokens in tool calls."""
268
  total = 0
tests/test_strands_tokenizer.py CHANGED
@@ -190,3 +190,192 @@ class TestMixedFormats:
190
  count = t.count_messages(messages)
191
  # Should be substantial — the tool result alone is ~700 tokens
192
  assert count > 500, f"Mixed conversation count too low: {count}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  count = t.count_messages(messages)
191
  # Should be substantial — the tool result alone is ~700 tokens
192
  assert count > 500, f"Mixed conversation count too low: {count}"
193
+
194
+
195
+ class TestStrandsReasoningContent:
196
+ """Strands reasoning blocks: {"reasoningContent": {"reasoningText": {"text": "..."}}}."""
197
+
198
+ def test_reasoning_text_counted_as_text(self):
199
+ """reasoningContent text should be counted with count_text, not estimated."""
200
+ t = _get_counter()
201
+ reasoning = "Let me think step by step about this problem. " * 100
202
+
203
+ # Strands format
204
+ msg_strands = [
205
+ {
206
+ "role": "assistant",
207
+ "content": [{"reasoningContent": {"reasoningText": {"text": reasoning}}}],
208
+ }
209
+ ]
210
+
211
+ # Equivalent plain text for comparison
212
+ msg_plain = [{"role": "assistant", "content": reasoning}]
213
+
214
+ s = t.count_messages(msg_strands)
215
+ p = t.count_messages(msg_plain)
216
+ assert s == p, f"Reasoning={s} should equal plain text={p}"
217
+
218
+ def test_reasoning_plus_text_both_counted(self):
219
+ """Message with both reasoning and text blocks."""
220
+ t = _get_counter()
221
+ msg = [
222
+ {
223
+ "role": "assistant",
224
+ "content": [
225
+ {"reasoningContent": {"reasoningText": {"text": "thinking " * 200}}},
226
+ {"text": "Here is my answer " * 50},
227
+ ],
228
+ }
229
+ ]
230
+ count = t.count_messages(msg)
231
+ # Should be substantial — both blocks counted
232
+ assert count > 200, f"Combined reasoning+text too low: {count}"
233
+
234
+
235
+ class TestStrandsMediaContent:
236
+ """Strands image, document, video blocks."""
237
+
238
+ def test_image_not_zero(self):
239
+ """Image block should have nonzero token count."""
240
+ t = _get_counter()
241
+ msg = [
242
+ {
243
+ "role": "user",
244
+ "content": [{"image": {"format": "png", "source": {"bytes": b"x" * 50000}}}],
245
+ }
246
+ ]
247
+ count = t.count_messages(msg)
248
+ assert count > 100, f"Image count too low: {count}"
249
+
250
+ def test_document_not_zero(self):
251
+ """Document block should have nonzero token count."""
252
+ t = _get_counter()
253
+ msg = [
254
+ {
255
+ "role": "user",
256
+ "content": [
257
+ {
258
+ "document": {
259
+ "format": "pdf",
260
+ "name": "report.pdf",
261
+ "source": {"bytes": b"x" * 30000},
262
+ }
263
+ }
264
+ ],
265
+ }
266
+ ]
267
+ count = t.count_messages(msg)
268
+ assert count > 1000, f"Document count too low: {count}"
269
+
270
+ def test_video_not_zero(self):
271
+ """Video block should have nonzero token count."""
272
+ t = _get_counter()
273
+ msg = [
274
+ {
275
+ "role": "user",
276
+ "content": [{"video": {"format": "mp4", "source": {"bytes": b"x" * 300000}}}],
277
+ }
278
+ ]
279
+ count = t.count_messages(msg)
280
+ assert count > 1000, f"Video count too low: {count}"
281
+
282
+
283
+ class TestStrandsFullConversation:
284
+ """End-to-end conversation with all Strands content types."""
285
+
286
+ def test_agent_conversation_with_reasoning_and_tools(self):
287
+ """Realistic Strands agent conversation."""
288
+ t = _get_counter()
289
+ messages = [
290
+ {"role": "user", "content": [{"text": "Analyze this code and fix the bug"}]},
291
+ {
292
+ "role": "assistant",
293
+ "content": [
294
+ {
295
+ "reasoningContent": {
296
+ "reasoningText": {"text": "Let me examine the code carefully. " * 50}
297
+ }
298
+ },
299
+ {
300
+ "toolUse": {
301
+ "toolUseId": "t1",
302
+ "name": "read_file",
303
+ "input": {"path": "main.py"},
304
+ }
305
+ },
306
+ ],
307
+ },
308
+ {
309
+ "role": "user",
310
+ "content": [
311
+ {
312
+ "toolResult": {
313
+ "toolUseId": "t1",
314
+ "content": [
315
+ {
316
+ "text": "def process():\n data = fetch()\n return transform(data)\n"
317
+ * 50
318
+ }
319
+ ],
320
+ }
321
+ },
322
+ ],
323
+ },
324
+ {
325
+ "role": "assistant",
326
+ "content": [
327
+ {
328
+ "reasoningContent": {
329
+ "reasoningText": {"text": "The bug is in the transform function. " * 30}
330
+ }
331
+ },
332
+ {"text": "I found the issue. The transform function doesn't handle None."},
333
+ ],
334
+ },
335
+ ]
336
+ count = t.count_messages(messages)
337
+ # Reasoning + tool result + text = should be substantial
338
+ assert count > 500, f"Full conversation too low: {count}"
339
+
340
+ # Verify reasoning contributes meaningfully
341
+ no_reasoning = [
342
+ {"role": "user", "content": [{"text": "Analyze this code"}]},
343
+ {
344
+ "role": "assistant",
345
+ "content": [
346
+ {
347
+ "toolUse": {
348
+ "toolUseId": "t1",
349
+ "name": "read_file",
350
+ "input": {"path": "main.py"},
351
+ }
352
+ },
353
+ ],
354
+ },
355
+ {
356
+ "role": "user",
357
+ "content": [
358
+ {
359
+ "toolResult": {
360
+ "toolUseId": "t1",
361
+ "content": [
362
+ {
363
+ "text": "def process():\n data = fetch()\n return transform(data)\n"
364
+ * 50
365
+ }
366
+ ],
367
+ }
368
+ },
369
+ ],
370
+ },
371
+ {
372
+ "role": "assistant",
373
+ "content": [
374
+ {"text": "I found the issue."},
375
+ ],
376
+ },
377
+ ]
378
+ count_no_reasoning = t.count_messages(no_reasoning)
379
+ assert count > count_no_reasoning + 100, (
380
+ f"Reasoning should add significant tokens: with={count}, without={count_no_reasoning}"
381
+ )