Skip to content

Image metering: a 200 whose JSON body is not a dict is billed but never recorded #1584

Description

@OliverBryant

Problem

Image providers deliberately record usage before validating a 200's body, because the provider has already billed the call. But the metering call itself sits after the first few reads of the parsed body, so a 200 whose top-level JSON is not a dict is billed by the provider and recorded nowhere.

In gemini.py the ordering inside the request try is:

response_data = response.json()
...
usage_metadata = response_data.get("usageMetadata", {})   # <-- AttributeError if not a dict
token_usage = {...}
record_image_usage(...)                                    # <-- never reached

dashscope.py has the same shape with response_data.get("usage", {}).

Reproduction

A 200 whose body is a JSON array (or string, or number) — legal JSON, and something a proxy or gateway can return:

class _Resp:
    status_code = 200
    def json(self): return ["not", "a", "dict"]
    def raise_for_status(self): return None

Result: 0 media rows recorded for a call the provider charged for.

Scope note

This is pre-existing and was not introduced by #1424, which improved the same case substantially: because the failure is now classified as a non-retryable invalid response, one such body costs 1 provider call instead of max_retries (measured: 4 attempts → 1 attempt with max_retries=4). The row loss itself is unchanged at 0 either way.

Fixing it properly means restructuring where the metering call sits relative to the first body reads — reading usage defensively so a non-dict body still produces a zero-token row rather than no row. That is a change to the metering placement contract in both providers, so it was left out of #1424's review scope rather than folded in silently.

Suggested fix

Guard the usage extraction so it cannot raise before record_image_usage, e.g. treat a non-dict response_data as "no usage reported" and still record the call, then let the existing structural checks raise InvalidImageResponseError as they do now. The shared boundary already records a zero-quantity/zero-token row as its "billed but unmeasured" convention, so the row would carry the right meaning.

Both generate_image and edit_image in gemini.py and dashscope.py are affected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions