Skip to content

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

Merged
krrish-berri-2 merged 3 commits into
litellm_internal_stagingfrom
litellm_fix_embedding_cache_prompt_tokens_details
Apr 28, 2026
Merged

fix(caching): preserve prompt_tokens_details through embedding cache round-trip#26653
krrish-berri-2 merged 3 commits into
litellm_internal_stagingfrom
litellm_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.

Refiled from #24806 onto litellm_internal_staging per the new internal SDLC. Branch rebased onto current litellm_internal_staging; no behavioural changes vs the original PR.

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

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.
…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.
@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where prompt_tokens_details (including image_count for multimodal embeddings) was dropped during cache round-trips, causing inconsistent usage metadata between first-call and cached responses. The fix adds the field to CachedEmbedding, distributes it per-item during storage, aggregates on retrieval, and merges it in combine_usage() for partial cache hits. All changes are well-scoped and include backward-compatible handling of old cached entries without the field.

Confidence Score: 4/5

Safe to merge; no correctness-breaking issues found, only P2 observability and edge-case concerns.

All findings are P2 (style/best-practice level). The core logic is sound, backward compatibility is preserved via .get() on cached dicts, and the five new tests cover the critical paths. The two bare except Exception: return None blocks and the mixed-batch distribution limitation are worth addressing but do not block correctness.

litellm/caching/caching_handler.py (silent exception swallowing in two places) and litellm/caching/caching.py (batch distribution assumption).

Important Files Changed

Filename Overview
litellm/types/caching.py Adds prompt_tokens_details: Optional[dict] to CachedEmbedding TypedDict; follows the same convention as existing optional fields, and .get() is used everywhere so old cached entries without this key are handled correctly.
litellm/caching/caching.py Adds _get_per_item_prompt_tokens_details() and threads details through _convert_to_cached_embedding() and add_embedding_response_to_cache(). The integer-remainder distribution for multi-item batches is correct for uniform batches but can mis-assign counts in mixed text/image batches.
litellm/caching/caching_handler.py Aggregates per-item prompt_tokens_details on cache retrieval and merges them in combine_usage via the new _merge_prompt_tokens_details helper. Main concern is the bare except Exception: return None in two places which silently drops details without any log output.
tests/test_litellm/caching/test_caching_handler.py Adds 5 well-scoped unit tests covering single-item preservation, backward-compat (old cached entries), multi-item aggregation, combine_usage merge, and None-handling. All tests use mocks and make no real network calls.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant CachingHandler
    participant Cache
    participant Provider

    Note over Caller,Provider: Cache MISS (first call)
    Caller->>CachingHandler: aembedding(input=[img1, img2])
    CachingHandler->>Cache: get(k1), get(k2) - both miss
    CachingHandler->>Provider: embedding(input=[img1, img2])
    Provider-->>CachingHandler: EmbeddingResponse with prompt_tokens_details image_count 2
    CachingHandler->>Cache: set k1 with CachedEmbedding prompt_tokens_details image_count 1
    CachingHandler->>Cache: set k2 with CachedEmbedding prompt_tokens_details image_count 1
    CachingHandler-->>Caller: EmbeddingResponse usage image_count 2

    Note over Caller,Provider: Full Cache HIT (second call)
    Caller->>CachingHandler: aembedding(input=[img1, img2])
    CachingHandler->>Cache: get(k1), get(k2) - both hit
    CachingHandler->>CachingHandler: aggregate details, image_count 1 plus 1
    CachingHandler-->>Caller: EmbeddingResponse usage image_count 2

    Note over Caller,Provider: Partial Cache HIT
    Caller->>CachingHandler: aembedding(input=[img1, img3])
    CachingHandler->>Cache: get(k1) hit, get(k3) miss
    CachingHandler->>Provider: embedding(input=[img3])
    Provider-->>CachingHandler: EmbeddingResponse image_count 1
    CachingHandler->>CachingHandler: combine_usage, merge prompt_tokens_details
    CachingHandler-->>Caller: EmbeddingResponse usage image_count 2
Loading

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

Comment on lines +451 to +459
if aggregated_details:
from litellm.types.utils import PromptTokensDetailsWrapper

try:
prompt_tokens_details = PromptTokensDetailsWrapper(
**aggregated_details
)
except Exception:
prompt_tokens_details = 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 Silent failure drops prompt_tokens_details without any diagnostic

PromptTokensDetailsWrapper(**aggregated_details) is swallowed silently on exception — if aggregated_details contains a key that Pydantic rejects (e.g. a field from a provider that isn't yet modelled in PromptTokensDetailsWrapper), the entire details object is discarded and callers see None with no log entry. The same pattern is repeated in _merge_prompt_tokens_details (line 566-569). Adding a verbose_logger.warning before the return None would make diagnosing this far easier.

Suggested change
if aggregated_details:
from litellm.types.utils import PromptTokensDetailsWrapper
try:
prompt_tokens_details = PromptTokensDetailsWrapper(
**aggregated_details
)
except Exception:
prompt_tokens_details = None
try:
prompt_tokens_details = PromptTokensDetailsWrapper(
**aggregated_details
)
except Exception as e:
verbose_logger.warning(
f"LiteLLM Cache: failed to reconstruct prompt_tokens_details from cached embedding: {e}. Dropping details."
)
prompt_tokens_details = None

Comment on lines +718 to +731
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

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 Even-distribution assumption breaks for mixed text/image batches

When a batch contains a mix of text and image inputs (e.g. ["hello", "<base64_img>", "world"]) and the provider returns image_count=1, the distribution assigns image_count=1 to item 0 (the text input) instead of item 1 (the image), because the remainder goes to the lowest indices. Upon retrieval, the aggregated image_count is still correct, but the per-item association is wrong — a cached text-only re-run of item 0 would incorrectly carry an image_count. This is an inherent limitation of distributing aggregate counts without per-item provenance data; a code comment clarifying this caveat would help future maintainers.

@krrish-berri-2
krrish-berri-2 merged commit 0dd64ba into litellm_internal_staging Apr 28, 2026
113 of 114 checks passed
@krrish-berri-2
krrish-berri-2 deleted the litellm_fix_embedding_cache_prompt_tokens_details branch April 28, 2026 15:25
yugborana pushed a commit to yugborana/litellm that referenced this pull request Jun 2, 2026
…round-trip (BerriAI#26653)

* fix(caching): preserve prompt_tokens_details through embedding cache 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.

* style: apply Black formatting to caching files

* fix(caching): address Greptile review — cyclic import, guarded construction, 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.
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…round-trip (BerriAI#26653)

* fix(caching): preserve prompt_tokens_details through embedding cache 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.

* style: apply Black formatting to caching files

* fix(caching): address Greptile review — cyclic import, guarded construction, 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.
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