litellm/tests/benchmarks/test_token_counter_pdf_benchmarks.py
Mateo Wang 23ad8870b4
fix(token_counter): price PDF pages for OpenAI and Azure models the way they bill (#47554)
* test(token_counter): price PDF pages for OpenAI and Azure models the way they bill

* fix(token_counter): price PDF pages for OpenAI and Azure models the way they bill

* fix(token_counter): count azure, data-zone and dated GPT names with their family's encoding

* fix(token_counter): price only drawn PDF images and keep other gateways' GPT pricing

Detect raster images from the page content stream, including inline
images on pages with empty resources, and walk each form once. Strip
only openai/, azure/ and Azure data-zone prefixes, bound the model
family scan, read Azure-only chat models from the cost map, and keep
cl100k_base for listed non-chat OpenAI models.

* fix(token_counter): price GPT names behind any prefix like the bare name and walk shared forms per resources

Prices a PDF by the model name's last path segment, the same name the proxy counts with,
keeps listed non-chat models on Claude pricing, walks each form once per inherited resources
dictionary so a shared child form drawing an image is not missed, and resolves PDF pricing
only when a message carries a PDF

* fix(token_counter): keep the rendered-page count for image pages, parse each form once, and match chat models only

A PDF page that draws a raster image keeps the rendered-page estimate, since newer GPT models bill such a page near
it. The image walk parses each form once and stops at a fixed number of drawn XObjects, so a form shared under many
resources dictionaries no longer slows the count. The family prefix match skips non-chat entries, and ft: fine-tunes
count like their base model

* fix(token_counter): count Azure-only chat models with o200k like other OpenAI chat models

* fix(token_counter): count reservation and project ITPM with the serving deployment's model

Budget reservation and project ITPM counted with the requested group name, so a
GPT-named group served by Claude reserved a PDF at OpenAI's price. They now count
with each deployment's model minus its provider prefix, the name count_tokens uses,
and take the largest. A custom OpenAI tokenizer with no model counts with cl100k
again instead of raising, and listed Azure gpt- chat entries price as OpenAI.

* perf(token_counter): find drawn PDF images with a byte scan instead of parsing every operator

pypdf parsed every operator of a page's content stream to find the images it draws, so a
compressed page of a million operators took about 30 seconds to price for an OpenAI model.
A linear scan of the decoded stream for name Do and inline images finds the same draws.

* fix(token-counter): count each team deployment by its PDF pricing and honor base_model

Budget reservation and the TPM limiter now look up the deployments that serve the
caller's team, by base_model when set. A deployment that reads PDF pages and prices
them unlike the group name counts with its own model, any other with the group name.
A comment between an XObject name and Do now reads as whitespace, and the timing
tests moved to CodSpeed benchmarks.

* fix(benchmarks): install pypdf in the benchmark environment

The PDF token-count benchmarks parse pages with pypdf, which only the proxy-runtime extra installed, so CodSpeed took the no-parser path.

* fix(proxy): count PDF pages for deployments served under multi-segment model names

* fix(proxy): count a team's wildcard route the way routing serves it and reserve OpenAI chat's per-PDF charge

* fix(proxy): count routing-group and alias members with the caller's team, as routing serves them
2026-10-29 18:24:04 -07:00

126 lines
5.2 KiB
Python

"""PDF pages and model names that once took seconds to minutes to count, kept here so a slowdown shows on CodSpeed.
Each input is smaller than the one that exposed the slowdown, since the old cost grew with the same input size."""
import base64
from itertools import accumulate, chain
from typing import Final
import pytest
from litellm.litellm_core_utils.token_counter import token_counter
TEXT_LINE: Final = "Revenue grew eleven percent while churn fell to two percent."
def _pdf_base64(text: str, page_resources: str, drawing: bytes, xobjects: tuple[bytes, ...] = ()) -> str:
escaped: Final = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
stream: Final = f"BT /F1 12 Tf 72 720 Td ({escaped}) Tj ET".encode("latin-1") + drawing
bodies: Final = (
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Count 1 /Kids [ 5 0 R ] >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"\nendstream",
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {page_resources} /Contents 4 0 R >>".encode(),
*xobjects,
)
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 = accumulate((len(header), *(len(obj) for obj in objects[:-1])))
xref: Final = f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode() + b"".join(
f"{offset:010d} 00000 n \n".encode() for offset in offsets
)
trailer: Final = (
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{len(header) + sum(len(obj) for obj in objects)}\n%%EOF\n"
).encode()
return base64.b64encode(header + b"".join(objects) + xref + trailer).decode()
def _form(resources_number: int, drawing: bytes) -> bytes:
return (
f"<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources {resources_number} 0 R "
f"/Length {len(drawing)} >>\nstream\n".encode()
+ drawing
+ b"\nendstream"
)
def _count_pdf(pdf_base64: str) -> int:
document: Final = {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": pdf_base64}}
return token_counter(model="gpt-4o", messages=[{"role": "user", "content": [document]}])
def _page_of_drawing_operators() -> str:
return _pdf_base64("", "<< >>", b" q Q" * 50_000)
def _page_of_forms_sharing_resources() -> str:
form_count: Final = 300
shared_resources: Final = (
"<< /XObject << " + " ".join(f"/F{index} {8 + index} 0 R" for index in range(form_count)) + " >> >>"
).encode()
forms: Final = tuple(_form(7, b"/F0 Do") for _ in range(form_count))
return _pdf_base64(
TEXT_LINE,
"<< /Font << /F1 3 0 R >> /XObject << /X0 6 0 R >> >>",
b" q 10 0 0 10 0 0 cm /X0 Do Q",
(forms[0], shared_resources, *forms),
)
def _page_of_parents_sharing_a_child_form() -> str:
parent_count: Final = 100
first: Final = 6
child_number: Final = first + 2 * parent_count
child_drawing: Final = b" ".join((b"/D Do",) * 4000)
child: Final = (
f"<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Length {len(child_drawing)} >>\nstream\n".encode()
+ child_drawing
+ b"\nendstream"
)
parents: Final = tuple(
chain.from_iterable(
(_form(first + 2 * index + 1, b"/C Do"), f"<< /XObject << /C {child_number} 0 R >> >>".encode())
for index in range(parent_count)
)
)
page_resources: Final = (
"<< /Font << /F1 3 0 R >> /XObject << "
+ " ".join(f"/P{index} {first + 2 * index} 0 R" for index in range(parent_count))
+ " >> >>"
)
page_drawing: Final = b" " + b" ".join(f"/P{index} Do".encode() for index in range(parent_count))
return _pdf_base64(TEXT_LINE, page_resources, page_drawing, (*parents, child))
PAGE_OF_DRAWING_OPERATORS: Final = _page_of_drawing_operators()
PAGE_OF_FORMS_SHARING_RESOURCES: Final = _page_of_forms_sharing_resources()
PAGE_OF_PARENTS_SHARING_A_CHILD_FORM: Final = _page_of_parents_sharing_a_child_form()
BLANK_PAGE_COUNT: Final = _count_pdf(_pdf_base64("", "<< >>", b""))
TEXT_PAGE_COUNT: Final = _count_pdf(_pdf_base64(TEXT_LINE, "<< /Font << /F1 3 0 R >> >>", b""))
LONG_UNKNOWN_MODEL_NAME: Final = "unknown-" + "a-" * 5_000
NAME_SAMPLE: Final = "Ceci est un texte en français: éèàç. " * 8
UNKNOWN_MODEL_NAME_COUNT: Final = token_counter(model="my-private-model", text=NAME_SAMPLE)
@pytest.mark.benchmark
def test_pdf_page_of_drawing_operators_and_no_image() -> None:
assert _count_pdf(PAGE_OF_DRAWING_OPERATORS) == BLANK_PAGE_COUNT
@pytest.mark.benchmark
def test_pdf_page_of_forms_sharing_one_resources_dictionary() -> None:
assert _count_pdf(PAGE_OF_FORMS_SHARING_RESOURCES) == TEXT_PAGE_COUNT
@pytest.mark.benchmark
def test_pdf_page_of_parent_forms_sharing_one_child_form() -> None:
assert _count_pdf(PAGE_OF_PARENTS_SHARING_A_CHILD_FORM) > TEXT_PAGE_COUNT
@pytest.mark.benchmark
def test_token_counter_long_unknown_model_name() -> None:
assert token_counter(model=LONG_UNKNOWN_MODEL_NAME, text=NAME_SAMPLE) == UNKNOWN_MODEL_NAME_COUNT