litellm/tests/integration/_support/pdf_document.py
devin-ai-integration[bot] 50ab9a3211
fix(token_counter): price a base64 PDF document per page instead of as one image (#45301)
* fix(token_counter): price a base64 PDF document per page instead of as one image

A `document` or `file` block carrying inline PDF bytes was priced like a single
image (85 tokens), so when a provider's count-tokens endpoint rejected the
model (Bedrock Opus) the local fallback answered 116 for a 12-page PDF the
provider then billed at 35941 input tokens. The counter now reads the PDF with
pypdf and prices each page as its extracted text plus the image Anthropic
renders it to (1568 px long edge, 1.15 MP, 750 pixels per token), falling back
to the old image pricing when pypdf is missing or the bytes are not a readable PDF.

* refactor(token_counter): count PDF pages as they are read

Sum each page's text and rendered-image tokens straight from the pypdf
reader instead of materializing a page list first, keep the fallback
to image pricing atomic when a page cannot be read, and annotate the
new tests' locals as Final

* test(integration): audit cells for page-priced PDF documents in count_tokens, pre-call checks and spend

* test(integration): release the held peer when the concurrent budget wait times out

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-08 21:20:46 -07:00

149 lines
5.3 KiB
Python

import base64
import json
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from itertools import accumulate
from types import MappingProxyType
from typing import Final
from integration._support.wire import Reply
from pydantic import JsonValue
PDF_MEDIA_TYPE: Final = "application/pdf"
COUNT_TOKENS_TARGET: Final = "/v1/messages/count_tokens"
COUNT_REFUSED: Final = Reply(
status=400,
body=json.dumps(
{"type": "error", "error": {"type": "invalid_request_error", "message": "count_tokens is not supported"}}
).encode(),
)
@dataclass(frozen=True, slots=True)
class Page:
width: int = 612
height: int = 792
text: str | None = None
LETTER: Final = Page()
NARROW: Final = Page(width=100, height=1000)
_RENDERED_TOKENS: Final = MappingProxyType({(612, 792): 1534, (100, 1000): 328})
def rendered_tokens(pages: Sequence[Page]) -> int:
return sum(_RENDERED_TOKENS[(page.width, page.height)] for page in pages)
def _escaped(text: str) -> str:
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _content_stream(page: Page) -> bytes:
stream: Final = f"BT /F1 12 Tf 72 {page.height - 72} Td ({_escaped(page.text or '')}) Tj ET".encode()
return b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream"
def _page_object(page: Page, contents_id: int | None) -> bytes:
contents: Final = f" /Contents {contents_id} 0 R" if contents_id is not None else ""
return (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page.width} {page.height}]"
f" /Resources << /Font << /F1 1 0 R >> >>{contents} >>"
).encode()
def _page_bodies(pages: Sequence[Page], starts: Sequence[int]) -> Iterator[bytes]:
for page, start in zip(pages, starts):
if page.text is None:
yield _page_object(page, None)
else:
yield _content_stream(page)
yield _page_object(page, start)
def pdf_bytes(pages: Sequence[Page]) -> bytes:
starts: Final = tuple(accumulate((1 if page.text is None else 2 for page in pages), initial=3))
page_ids: Final = tuple(start + (0 if page.text is None else 1) for start, page in zip(starts, pages))
kids: Final = " ".join(f"{identity} 0 R" for identity in page_ids)
bodies: Final = (
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode(),
*_page_bodies(pages, starts),
b"<< /Type /Catalog /Pages 2 0 R >>",
)
header: Final = b"%PDF-1.4\n"
objects: Final = tuple(
f"{number} 0 obj\n".encode() + body + b"\nendobj\n" for number, body in enumerate(bodies, start=1)
)
offsets: Final = tuple(accumulate((len(chunk) for chunk in objects), initial=len(header)))
entries: Final = b"".join(f"{offset:010d} 00000 n \n".encode() for offset in offsets[:-1])
trailer: Final = (
f"xref\n0 {len(bodies) + 1}\n".encode()
+ b"0000000000 65535 f \n"
+ entries
+ f"trailer\n<< /Size {len(bodies) + 1} /Root {starts[-1]} 0 R >>\nstartxref\n{offsets[-1]}\n%%EOF\n".encode()
)
return header + b"".join(objects) + trailer
def encoded(raw: bytes) -> str:
return base64.b64encode(raw).decode()
def data_url(media_type: str, raw: bytes) -> str:
return f"data:{media_type};base64,{encoded(raw)}"
def pdf_data_url(pages: Sequence[Page]) -> str:
return data_url(PDF_MEDIA_TYPE, pdf_bytes(pages))
def base64_source(data: JsonValue, media_type: JsonValue = PDF_MEDIA_TYPE) -> dict[str, JsonValue]:
return {"type": "base64", "media_type": media_type, "data": data}
def document(source: Mapping[str, JsonValue], **fields: JsonValue) -> dict[str, JsonValue]:
return {"type": "document", "source": dict(source), **fields}
def pdf_document(pages: Sequence[Page], **fields: JsonValue) -> dict[str, JsonValue]:
return document(base64_source(encoded(pdf_bytes(pages))), **fields)
def text_document(text: str) -> dict[str, JsonValue]:
return document({"type": "text", "media_type": "text/plain", "data": text})
def chat_file(file_data: JsonValue, filename: JsonValue = "document.pdf") -> dict[str, JsonValue]:
return {"type": "file", "file": {"filename": filename, "file_data": file_data}}
def responses_input_file(file_data: JsonValue, filename: JsonValue = "document.pdf") -> dict[str, JsonValue]:
return {"type": "input_file", "filename": filename, "file_data": file_data}
def messages_body(model: str, blocks: Sequence[JsonValue], text: str, **fields: JsonValue) -> dict[str, JsonValue]:
return {
"model": model,
"max_tokens": 64,
"messages": [{"role": "user", "content": [*blocks, {"type": "text", "text": text}]}],
**fields,
}
def chat_body(model: str, parts: Sequence[JsonValue], text: str, **fields: JsonValue) -> dict[str, JsonValue]:
return {
"model": model,
"max_tokens": 64,
"messages": [{"role": "user", "content": [*parts, {"type": "text", "text": text}]}],
**fields,
}
def responses_body(model: str, items: Sequence[JsonValue], text: str, **fields: JsonValue) -> dict[str, JsonValue]:
return {
"model": model,
"max_output_tokens": 64,
"input": [{"role": "user", "content": [*items, {"type": "input_text", "text": text}]}],
**fields,
}