Skip to content

fix(caching): preserve prompt_tokens_details through embedding cache round-trip - #24806

Closed
michelligabriele wants to merge 3 commits into
BerriAI:mainfrom
michelligabriele:fix/embedding-cache-prompt-tokens-details
Closed

fix(caching): preserve prompt_tokens_details through embedding cache round-trip#24806
michelligabriele wants to merge 3 commits into
BerriAI:mainfrom
michelligabriele:fix/embedding-cache-prompt-tokens-details

Conversation

@michelligabriele

Copy link
Copy Markdown
Collaborator

The embedding caching layer was dropping prompt_tokens_details (including image_count) because CachedEmbedding had no field for usage metadata and the cache retrieval code reconstructed Usage without it. This caused inconsistent responses where the first call returned image_count but cached responses did not, breaking cost tracking for multimodal embeddings.

Add prompt_tokens_details to CachedEmbedding, persist per-item details during cache storage, aggregate them on retrieval, and merge them in combine_usage() for partial cache hits.

Relevant issues

Related to #9029 (image_count support for multimodal embeddings)

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🐛 Bug Fix

Changes

  • litellm/types/caching.py — Added prompt_tokens_details: Optional[dict] field to CachedEmbedding TypedDict so usage metadata survives serialization to any cache backend (Redis, S3, disk)
  • litellm/caching/caching.py — Updated _convert_to_cached_embedding() to accept and store per-item details; added _get_per_item_prompt_tokens_details() helper that extracts details from the response (distributing evenly for multi-item batches); updated add_embedding_response_to_cache() to pass details through
  • litellm/caching/caching_handler.py — Updated _process_async_embedding_cached_response() to aggregate prompt_tokens_details from cached items back into PromptTokensDetailsWrapper; updated combine_usage() to merge details during partial cache hits; added _merge_prompt_tokens_details() helper
  • tests/test_litellm/caching/test_caching_handler.py — 5 new tests: single-item preservation, backward compatibility (old cached entries without the field), multi-item aggregation, combine_usage merge, and None handling

…round-trip

The embedding caching layer was dropping prompt_tokens_details (including
image_count) because CachedEmbedding had no field for usage metadata and
the cache retrieval code reconstructed Usage without it. This caused
inconsistent responses where the first call returned image_count but
cached responses did not, breaking cost tracking for multimodal embeddings.

Add prompt_tokens_details to CachedEmbedding, persist per-item details
during cache storage, aggregate them on retrieval, and merge them in
combine_usage() for partial cache hits.
@vercel

vercel Bot commented Mar 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 30, 2026 7:00pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing michelligabriele:fix/embedding-cache-prompt-tokens-details (f7424ea) with main (5cec43c)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where prompt_tokens_details (including image_count for multimodal embeddings) was silently dropped during cache round-trips, causing inconsistent cost-tracking between first-call and cached responses. The fix threads the details through the entire cache pipeline: storage per item, aggregation on retrieval, and merging during partial cache hits.

Key changes:

  • CachedEmbedding gains a prompt_tokens_details: Optional[dict] field so the data survives serialization to any backend (Redis, S3, disk)
  • _get_per_item_prompt_tokens_details distributes batch-level token details evenly across items using remainder-distributing arithmetic, so that re-aggregation on retrieval reproduces the original totals exactly
  • _process_async_embedding_cached_response now aggregates per-item details back into a PromptTokensDetailsWrapper when building the cached Usage object
  • combine_usage is extended with _merge_prompt_tokens_details to correctly sum details during partial-hit + live-call merges
  • 5 new mock-only unit tests cover the main scenarios: single-item, backward compat, multi-item aggregation, combine_usage, and None handling

Confidence Score: 4/5

Safe to merge after the result.data None guard is addressed; remaining findings are low-risk style issues.

The core fix is correct and well-tested with 5 new mock-only unit tests. A P1 issue exists in _get_per_item_prompt_tokens_details where len(result.data) raises TypeError if result.data is None. A P2 edge case exists in _merge_prompt_tokens_details where zero-valued falsy fields could silently be dropped. The TypedDict total=True concern is a type-checking nit with no runtime impact.

litellm/caching/caching.py — _get_per_item_prompt_tokens_details needs a None guard on result.data

Important Files Changed

Filename Overview
litellm/caching/caching.py Added _get_per_item_prompt_tokens_details helper that distributes batch-level token details per item, and threaded those details through _convert_to_cached_embedding and add_embedding_response_to_cache. Minor defensive gap: result.data is not guarded against None before calling len().
litellm/caching/caching_handler.py Updated _process_async_embedding_cached_response to aggregate prompt_tokens_details from cached items; added _merge_prompt_tokens_details helper and wired it into combine_usage. Logic is sound; minor falsy-zero edge case in the non-numeric/non-dict branch of _merge_prompt_tokens_details.
litellm/types/caching.py Added prompt_tokens_details: Optional[dict] to CachedEmbedding; because TypedDict defaults to total=True this new key is required for mypy but backward-compatible at runtime (all read sites use .get()).
tests/test_litellm/caching/test_caching_handler.py 5 new mock-only tests covering single-item preservation, backward compatibility, multi-item aggregation, combine_usage merge, and None handling. All use MagicMock/AsyncMock, no real network calls. Pre-existing test unchanged.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant CachingHandler
    participant Cache
    participant Backend

    Note over Caller,Backend: Write path
    Caller->>CachingHandler: aembedding(inputs)
    CachingHandler->>Cache: add_embedding_response_to_cache
    Cache->>Cache: _get_per_item_prompt_tokens_details
    Cache->>Backend: store CachedEmbedding with prompt_tokens_details

    Note over Caller,Backend: Full cache hit
    Caller->>CachingHandler: aembedding(inputs)
    CachingHandler->>Backend: fetch cached items
    Backend-->>CachingHandler: items with prompt_tokens_details
    CachingHandler->>CachingHandler: aggregate details per item
    CachingHandler-->>Caller: EmbeddingResponse with usage

    Note over Caller,Backend: Partial cache hit
    CachingHandler->>Backend: some hits, some misses
    CachingHandler->>CachingHandler: live call for misses
    CachingHandler->>CachingHandler: combine_usage merges details
    CachingHandler-->>Caller: merged EmbeddingResponse
Loading

Reviews (3): Last reviewed commit: "fix(caching): address Greptile review — ..." | Re-trigger Greptile

Comment thread litellm/caching/caching_handler.py Outdated
Comment on lines +449 to +452
if aggregated_details:
prompt_tokens_details = PromptTokensDetailsWrapper(
**aggregated_details
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unguarded PromptTokensDetailsWrapper construction may fail on unexpected cached keys

aggregated_details is built from cr.get("prompt_tokens_details") which may come from a cache entry written by a different version of LiteLLM (either older or newer). If that stored dict contains a key that is not a known field of PromptTokensDetailsWrapper and the model is configured with extra = "forbid", PromptTokensDetailsWrapper(**aggregated_details) will raise a ValidationError and crash the entire cache-retrieval path for all inputs in the batch.

A simple try/except is sufficient to keep backward and forward compatibility:

            if aggregated_details:
                try:
                    prompt_tokens_details = PromptTokensDetailsWrapper(
                        **aggregated_details
                    )
                except Exception:
                    verbose_logger.debug(
                        "LiteLLM Cache: Could not reconstruct prompt_tokens_details "
                        f"from cached data: {aggregated_details}"
                    )
                    prompt_tokens_details = None

The same risk applies to PromptTokensDetailsWrapper(**merged) in _merge_prompt_tokens_details (line 543).

Comment on lines +688 to +732
def _get_per_item_prompt_tokens_details(
self,
result: EmbeddingResponse,
idx_in_result_data: int,
) -> Optional[dict]:
"""
Extract per-item prompt_tokens_details from a response for caching.

For single-item responses (common for multimodal providers like Bedrock Titan,
Nova, Vertex AI), returns the full prompt_tokens_details.
For multi-item responses, distributes integer fields evenly across items
so that summing all per-item details reconstructs the original totals.
"""
if (
result.usage is None
or result.usage.prompt_tokens_details is None
):
return None

details = result.usage.prompt_tokens_details
if hasattr(details, "model_dump"):
details_dict = details.model_dump(exclude_none=True)
elif isinstance(details, dict):
details_dict = {k: v for k, v in details.items() if v is not None}
else:
return None

if not details_dict:
return None

num_items = len(result.data)
if num_items <= 1:
return details_dict

# Distribute integer/float fields evenly across items
per_item: dict = {}
for key, value in details_dict.items():
if isinstance(value, int):
quotient, remainder = divmod(value, num_items)
per_item[key] = quotient + (1 if idx_in_result_data < remainder else 0)
elif isinstance(value, float):
per_item[key] = value / num_items
else:
per_item[key] = value
return per_item if per_item else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No direct unit test for the cache-write distribution path

_get_per_item_prompt_tokens_details contains non-trivial remainder-distributing arithmetic (e.g. 3 images across 2 items → [2, 1]), yet all five new tests exercise only the cache-retrieval/aggregation side by pre-populating mock cached_result dicts that already contain per-item values. A regression in the distribution logic (wrong idx_in_result_data, off-by-one in the remainder check) would not be caught.

Consider adding a unit test that:

  1. Constructs a real EmbeddingResponse with usage.prompt_tokens_details.image_count = N and len(data) = K
  2. Calls cache._get_per_item_prompt_tokens_details(result, idx) for every idx in range(K)
  3. Asserts that the per-item values sum back to N

Comment thread litellm/caching/caching_handler.py Outdated
Embedding,
EmbeddingResponse,
ModelResponse,
PromptTokensDetailsWrapper,

Check failure

Code scanning / CodeQL

Module-level cyclic import

'PromptTokensDetailsWrapper' may not be defined if module [litellm.types.utils](1) is imported before module [litellm.caching.caching_handler](2), as the [definition](3) of PromptTokensDetailsWrapper occurs after the cyclic [import](4) of litellm.caching.caching_handler. 'PromptTokensDetailsWrapper' may not be defined if module [litellm.types.utils](1) is imported before module [litellm.caching.caching_handler](2), as the [definition](3) of PromptTokensDetailsWrapper occurs after the cyclic [import](5) of litellm.caching.caching_handler. 'PromptTokensDetailsWrapper' may not be defined if module [litellm.types.utils](1) is imported before module [litellm.caching.caching_handler](2), as the [definition](3) of PromptTokensDetailsWrapper occurs after the cyclic [import](6) of litellm.caching.caching_handler.
…uction, nested dict merge

Move PromptTokensDetailsWrapper to inline import to resolve CodeQL cyclic
import warning. Guard PromptTokensDetailsWrapper construction with
try/except to handle unexpected cached keys. Add recursive dict merging
in _merge_prompt_tokens_details for nested fields like
cache_creation_token_details.
@michelligabriele

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #26653, which is the same change refiled against litellm_internal_staging per the updated internal SDLC (this PR was opened before that policy change). The branch was rebased onto current litellm_internal_staging to pick up a fresh CI run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants