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.
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.pythe ordering inside the requesttryis:dashscope.pyhas the same shape withresponse_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:
Result:
0media 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 withmax_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
usagedefensively 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-dictresponse_dataas "no usage reported" and still record the call, then let the existing structural checks raiseInvalidImageResponseErroras 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_imageandedit_imageingemini.pyanddashscope.pyare affected.