Problem/Motivation

estimated_cost_usd is overstated by exactly cached_tokens * input_per_token on any provider whose API reports input tokens inclusive of cache reads.

Affected: OpenAI and OpenAI-compatible providers — OpenAiBasedProviderClientBase.php:633,637 sets input from prompt_tokens and cached from prompt_tokens_details.cached_tokens, where cached is a subset of prompt_tokens. Gemini behaves the same way (GeminiProvider.php:441,446, where promptTokenCount includes cachedContentTokenCount).

Not affected: Anthropic, which reports input_tokens exclusive of cache reads (AnthropicProvider.php:1803,1822; streaming path identical in AnthropicStreamedChatMessageIterator.php:154,160). Anthropic is the module's default, so many sites will not see this.

AiPostGenerateSubscriber::extractTokens() (:234-242) passes both values through unmodified. TokenEstimator::calculateCost() (:139-146) then computes input_tokens * in_rate and cached_tokens * cache_rate and sums both, so cached tokens are charged at the full input rate and again at the cache rate.

Worked example with the rates this module ships for openai:gpt-4o (config/install/ai_metering.settings.yml:22-25), for a call with prompt_tokens 1000 of which 800 cached, completion_tokens 200:

  • Recorded: 1000*0.0000025 + 800*0.00000125 + 200*0.00001 = $0.0055
  • Actual: 200*0.0000025 + 800*0.00000125 + 200*0.00001 = $0.0035
  • Overstated by 57%. The error scales with cache hit rate, so the dashboard makes prompt caching appear to increase cost.

Root cause is a contract ambiguity upstream: TokenUsageDto (ai/src/Dto/TokenUsageDto.php:16-31) documents no convention for whether input includes cached, and the two provider families implement opposite conventions. This module's own docblock (TokenEstimator.php:87-89, "Number of input (non-cached) tokens") specifies the exclusive form — so the contract here is correct and the caller violates it for OpenAI/Gemini.

Scope: affects estimated_cost_usd (QuotaManager.php:162) and everything derived from it — dashboard, personal usage blocks, provider breakdowns, rollups, CSV/JSON exports. Quota enforcement and threshold alerts are token-based (QuotaManager.php:190,211) and are not affected, so nothing breaks functionally; this is silent inaccuracy in the module's primary output.

Steps to reproduce

  1. Configure an OpenAI (or Gemini) provider and issue repeated chat calls sharing a large stable system prompt, so the provider begins reporting a non-zero cached token count.
  2. Compare the module's recorded estimated_cost_usd for those calls against the provider's own billed cost. Recorded cost exceeds actual by cached_tokens * input_per_token, growing as cache hit rate rises.

Environment

  • Drupal version: n/a — not reproduced on a running site, see AI assistance note
  • ai_metering version: 1.0.x at commit a39fea9
  • AI provider module + version: affects ai_provider_openai and gemini_provider (and any OpenAI-compatible provider); ai_provider_anthropic is NOT affected
  • AI feature module tested: none — found by code inspection

Proposed resolution

Normalise in extractTokens() according to each provider's convention, so calculateCost() always receives the cached-exclusive input its contract already documents.

A blind max(0, input - cached) must be avoided — it would corrupt Anthropic, which is currently correct.

The durable fix belongs upstream: TokenUsageDto should document and enforce a single convention across providers. Worth raising with the ai maintainers — see also the related cache-write gap in #3615562, which is blocked on the same DTO.

Remaining tasks

  • Decide the normalisation point and how provider convention is detected.
  • Add test coverage passing a non-zero $cached_tokens — no existing test passes the 5th argument to calculateCost(), which is why this path was never exercised.
  • Decide whether historical rows should be back-corrected or left with a documented caveat.
  • Confirm empirically against a live OpenAI account before fixing.

User interface changes

None directly. Recorded and displayed costs will drop on affected providers once fixed, which may surprise operators comparing against historical figures.

API changes

None expected. TokenEstimator::calculateCost() keeps its signature and its documented contract; the caller is what changes.

Data model changes

None required. Historical ai_metering_usage.estimated_cost_usd rows for affected providers remain overstated unless a back-correction is chosen (see Remaining tasks).

AI assistance

AI-Generated: Yes (Claude Code — multi-agent code analysis located this defect and traced the token path line by line with concrete values; this issue text was drafted with AI assistance). No module code was generated. Every claim is code-cited and was reviewed before filing; the citations can be checked against the referenced lines.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

camoa created an issue. See original summary.

camoa’s picture

Issue summary: View changes

codeitwisely made their first commit to this issue’s fork.

  • codeitwisely committed 07f4cb83 on 1.0.x
    Issue #3615553: Stop double-charging cached tokens on OpenAI and Gemini
    
codeitwisely’s picture

Assigned: Unassigned » codeitwisely
Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

codeitwisely’s picture

Status: Fixed » Closed (fixed)