Problem/Motivation
AiPostGenerateSubscriber::getSubscribedEvents() (:61-66) binds the same handler, onPostGenerate, to both PostGenerateResponseEvent and PostStreamingResponseEvent.
For a streamed chat response both events fire, so onPostGenerate runs twice for a single AI call and QuotaManager::logUsage() inserts two rows into ai_metering_usage for that one call.
Nothing deduplicates them. ai_metering_usage declares only a primary key on id plus non-unique indexes (ai_metering.install:91-100), the insert at QuotaManager.php:175-177 is unconditional, and the module's only State-based dedup (AiPreGenerateSubscriber.php:165-167) gates hook_ai_metering_quota_exceeded invocation rather than the insert.
Consequence: for any site whose AI features stream — the default path for chat UIs and agent workflows — recorded tokens and cost are roughly doubled, and quota is consumed at twice the true rate. Unlike the cost-only defect in #3615553, this one also causes users to hit their quota early and be blocked from legitimate work.
Steps to reproduce
- Configure any provider and trigger a streamed chat completion (for example through a chat UI that consumes the response as a stream, rather than a single blocking call).
- Inspect
ai_metering_usagefor that call, or the usage log at/admin/reports/ai-metering. Two rows appear for the single call. Compare against a non-streamed call, which produces one.
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: provider-independent — the double-binding is in this module, not in any provider
- AI feature module tested: none — found by code inspection; any streaming-capable feature module should exhibit it
Proposed resolution
Decide which event is authoritative for a streamed call and record once. Options worth weighing:
- Handle only
PostStreamingResponseEventfor streamed calls, since token counts are not final until the stream is consumed. - Persist an idempotency key so a repeat insert for the same call is recognised. The event already carries
requestThreadId(available atAiPostGenerateSubscriber.php:80), butai_metering_usagehas no column for it.
A unique key over the natural columns is not a valid fix: ai_metering_usage is an append-only event log, and two genuine identical calls by the same user in the same second are legitimate and must both persist.
Remaining tasks
- Confirm empirically which events fire, and in what order, for a streamed call.
- Choose between event-selection and an idempotency key; the latter also guards future replay paths.
- Add test coverage for the streaming path — the existing suite does not assert that a streamed call produces exactly one usage row.
- Decide how to treat historical double-counted rows and any quota already consumed against them.
User interface changes
None directly. Recorded usage and cost will roughly halve for streaming sites once fixed.
API changes
None expected, unless an idempotency key is added to the public insert path.
Data model changes
Only if the idempotency-key option is chosen — that would add a column to ai_metering_usage. Historical duplicate rows remain unless a cleanup is written.
AI assistance
AI-Generated: Yes (Claude Code — multi-agent code analysis located this defect while paper-testing a different, incorrect claim about duplicate rows; 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.
Issue fork ai_metering-3615557
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
Comment #2
camoa commentedComment #3
codeitwisely commentedComment #6
codeitwisely commentedComment #8
codeitwisely commented