feat(sdk): add per-call prompt token composition metrics - #4623
feat(sdk): add per-call prompt token composition metrics#4623georgeglarson wants to merge 7 commits into
Conversation
|
📁 PR Artifacts Notice This PR contains a |
…tion payloads
Address draft-PR review on prompt composition:
- Async prepare paths now compute prompt composition via asyncio.to_thread
(the pattern aformat_messages_for_responses uses for image inlining)
instead of running up to 5 prompt-sized synchronous tokenization passes
on the shared event loop; sync paths stay synchronous.
- Subscription-mode Responses payloads normalize message items to
{"role", "content"} without a "type" key; the payload converter now
accepts that shape, so Codex subscription calls record compositions
instead of silently skipping every call.
- Nits: is_estimate documented as reserved for a future provider-reported
mode; PromptComposition docstring notes divergence from the
chat-template budget counter; probe comment corrected (messages=[] works
in litellm 1.84.1); inline test imports moved to module top; tautological
delta assertion annotated; input_image converter branch covered; async
aresponses composition test added.
Push-guard bypass: updating open draft PR OpenHands#4623 with review fixes,
approved by George.
Co-authored-by: openhands <openhands@all-hands.dev>
Mirrors the SDK docstring caveat (OpenHands/software-agent-sdk#4623): subscription mode folds the system prompt into the first user message, so those tokens count as history/latest on that transport. Co-authored-by: openhands <openhands@all-hands.dev>
|
Really nice work! Thanks for validating it against real agent runs. The decomposition is exactly the kind of measurement we need for tool-loading and context-budget studies. I'm supportive of getting this into the SDK. One concern on the current shape: counting is always on for every LLM call, for every user. The measured cost is small (~10-20ms typical, ~31ms at 100K tokens), but it's a global tax on a metric most users won't consume. Could we put this behind an opt-in flag (something like enable_prompt_composition on the LLM, defaulting off) so it's available when you want to troubleshoot a prompt or run a tool-loading study, without imposing the extra tokenization pass on every default user? Drafted with help from an AI agent (OpenHands) on behalf of @rajshah4. |
Record a per-call decomposition of estimated prompt tokens (system prompt, tool schemas, conversation history, latest message) for every LLM completion. Computed at the LLM completion/responses boundary where messages and tools are final, counted with litellm's token_counter, and recorded into Metrics via Telemetry so consumers can attribute prompt size to components. Counts are client-side estimates (flagged via is_estimate); provider-reported usage remains authoritative. Co-authored-by: openhands <openhands@all-hands.dev>
…ords Address review findings on prompt composition recording: - Responses path now counts the finalized payload (instructions + input items converted to chat format) instead of the unprepared messages, so the record reflects what the provider received; tool serialization or conversion failures skip the record instead of breaking the call. - An all-zero counting result (e.g. litellm.disable_token_counter) now skips the record instead of storing a bogus all-zero estimate. - Docstrings: components do not necessarily sum to provider prompt_tokens (fixes the inverted direction claim), tool schema counts follow litellm's token_counter serialization convention rather than wire-format JSON, and counting cost is documented as linear in prompt size (~31 ms at ~100K tokens, ~61 ms at ~190K tokens measured). - Tests: mock-tools double-count guard, controlled tool-token delta, all-zero skip, Responses payload converter, tool serialization failure. Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
…tion payloads
Address draft-PR review on prompt composition:
- Async prepare paths now compute prompt composition via asyncio.to_thread
(the pattern aformat_messages_for_responses uses for image inlining)
instead of running up to 5 prompt-sized synchronous tokenization passes
on the shared event loop; sync paths stay synchronous.
- Subscription-mode Responses payloads normalize message items to
{"role", "content"} without a "type" key; the payload converter now
accepts that shape, so Codex subscription calls record compositions
instead of silently skipping every call.
- Nits: is_estimate documented as reserved for a future provider-reported
mode; PromptComposition docstring notes divergence from the
chat-template budget counter; probe comment corrected (messages=[] works
in litellm 1.84.1); inline test imports moved to module top; tautological
delta assertion annotated; input_image converter branch covered; async
aresponses composition test added.
Push-guard bypass: updating open draft PR OpenHands#4623 with review fixes,
approved by George.
Co-authored-by: openhands <openhands@all-hands.dev>
…converter Review-panel disposition (accepted tradeoff): in subscription mode the auth-layer transform folds the system prompt into the first user message, so those tokens are counted in history/latest rather than system_prompt_tokens. Buckets follow the wire payload, which is the documented contract for the Responses path; reclassifying would require guessing at a lossy fold. Co-authored-by: openhands <openhands@all-hands.dev>
a209d86 to
208bba2
Compare
Mirrors the SDK docstring caveat (OpenHands/software-agent-sdk#4623): subscription mode folds the system prompt into the first user message, so those tokens count as history/latest on that transport. Co-authored-by: openhands <openhands@all-hands.dev>
…sition Address rajshah4's review on draft PR OpenHands#4623: put composition counting behind an opt-in flag (default off) so it is available for prompt troubleshooting and tool-loading studies without imposing the extra tokenization pass on every default user. When off, no composition is computed on any path (chat/responses, sync/async), no records are appended, and token_counter is never called; when on, behavior is unchanged from before. Push-guard bypass: updating open draft PR OpenHands#4623 with review fixes, approved by George. Co-authored-by: openhands <openhands@all-hands.dev>
|
Done in |
|
Thanks for the PR. I looked a little inside. it is actually adding a little of confusion and repetition. Lets take an example "composition": {
"model": "gpt-4o-mini",
"system_prompt_tokens": 3340,
"tool_tokens": 5702,
"history_tokens": 0,
"latest_message_tokens": 38,
"is_estimate": true,
"response_id": "chatcmpl-EGVEG8NSZIEywDIyNQnqt99X5R4tJ"}
|
|
Thanks for taking a look, @VascoSch92 — these are fair questions to raise on a new metrics surface. A few of them are worth revisiting against the evidence traces attached to the PR (
On (5) why it's an estimate — this is the crux. The provider returns one aggregate number, not a breakdown. There's no provider API that returns the 3340/5702/0/38 split; that decomposition only exists client-side. Producing that breakdown is the entire reason for the feature — if the provider gave it to us, we wouldn't need this PR. The On (3) tool_tokens — on native-function-calling models the traces show On (4) history derivable by diffing — not quite cleanly. seq 0 → seq 1: history goes 0 → 73, but On (2) system constant per turn — true in these traces (3,340 across all 9 calls of task-01), and a reasonable thing to flag. The per-call shape mirrors the existing On (1) and (6) model / response_id — both match the existing pattern: The one genuinely actionable nit I see is the Drafted with help from an AI agent (OpenHands) on behalf of @rajshah4. |
If old code is not optimal doesn't mean that we should continue to create not optimal code.
It is an estimate if you can not compute that correctly. But in this case we can. So there is no motivation to have estimate here. I think all that can be resume in a script that you can run after an agent conversation to extract the information. but at run time we don't need that. |
|
Fair point on the architecture — I think you're right that starting with a script is the better first step. Get the decomposition into people's hands as an offline tool, validate it's useful on real runs, and earn the runtime API surface before committing to it. No disagreement from me on that. @georgeglarson — the decomposition logic you've built here ( If the script proves the value and there's later appetite for live observability (context-window UI, real-time tool-loading studies), the runtime path is already designed and can be revisited then. But starting with the script is the lower-risk way in. Happy to run the script against the harness-benchmark suite and report back on what it surfaces. Drafted with help from an AI agent (OpenHands) on behalf of @rajshah4. |
HUMAN:
Validated. Human thinks this is correct also.
AGENT:
Verification beyond unit tests: the default agent (
get_default_agent, 19 tools) ran live ontasks from the published harness-benchmark short suite
(https://github.com/rajshah4/harness-benchmark) on two model lanes: MiniMax-M3 (three tasks, 41
calls; litellm has no tokenizer mapping for this model, so estimates use the fallback
tokenizer) and gpt-4o-mini (two tasks, 37 calls; litellm maps this model to its real o200k
tokenizer). Every call on both lanes recorded both a
PromptCompositionand a provider usagerecord, with zero counting failures. The table under "Why" comes from those runs; per-call data
is attached under
.pr/evidence/(PR-only reviewer context, removed after approval per repopolicy).
Re-verified after rebasing onto current upstream/main: p09-task-01 on gpt-4o-mini, 20 calls, verifier PASS, est/provider ratio 0.99-1.01.
Estimator validation: the per-call ratio of the estimated component sum to provider-reported
prompt_tokensruns 0.97-1.00 on the mapped-tokenizer lane (median 1.00 on the trivial task)and 0.85-0.97 on the unmapped lane. The residual gap is consistent with request framing and
tokenizer mapping differences; the component split is the finding, not the absolute sum.
Commands run on the branch:
uv run pytest tests/sdk/llm/test_prompt_composition.py -q→ 20 passeduv run pytest tests/sdk/llm -q→ 999 passeduv run pytest tests/sdk -q→ 5970 passed, 17 failed (pre-existing on clean upstream/main in this environment; the failure list is byte-identical with and without this branch), 7 skipped, 10 xfaileduv run pre-commit run --files <each changed file>→ all hooks passWhy
The SDK cannot say what a prompt is made of.
Metricsrecords provider totals, but nothingdecomposes a call into system prompt, tool schemas, history, and the latest message. Two
consumers need this decomposition: the context-window UI work (the usage panel renders totals,
with no breakdown available to display), and tool-loading studies such as #4083, where the
per-call cost of the tool surface is the quantity under discussion.
Measured on the default agent (live runs, MiniMax-M3 lane, litellm
token_counter):The gpt-4o-mini lane (mapped tokenizer) reproduces the same shape: system ≈ 3,340 and tool
schemas ≈ 5,702 estimated tokens per call, constant across 37 calls.
The preamble (system + tool schemas ≈ 9,080 estimated tokens) is constant per call: ~79% of the
average call on the trivial task (average provider-reported input 11,478 tokens), and ~84% of
first calls. On the 26-call task, re-sent tool schemas came to 27% of the run's total
provider-reported input tokens.
Summary
PromptCompositionper-call record onMetrics: system / tool / history / latest-messagetokens, an
is_estimateflag, and aresponse_idjoining to the authoritativeTokenUsage.token_counter, opt-invia
enable_prompt_composition=True(default off: no tokenization pass runs, no records areappended). When enabled, cost scales linearly with prompt size (~10-20 ms on typical agent
payloads, ~31 ms measured at ~100K tokens); best-effort (counting failure records nothing,
the call is never affected).
Metricspayloads still load. Exported asopenhands.sdk.PromptComposition. Agent steps that send tools as native function-callingschemas have
tool_tokens > 0; auxiliary calls that pass no tools (condenser, titlegeneration) have
tool_tokens == 0.Issue Number
Related to #4083 (this PR provides the measurement; it does not implement deferred loading).
How to Test
For a live check: build an LLM, make any completion, then inspect
llm.metrics.latest_prompt_composition.Video/Screenshots
Type
Notes
Companion docs PR: OpenHands/docs#757