fix(openrouter): read /images usage when image token details are missing or null

The /images usage schema allows null for completion_tokens_details and
image_tokens, and per-image priced models report only completion_tokens. A
null completion_tokens_details raised AttributeError and a null image_tokens
failed ImageUsage validation after the image was already generated and billed,
so the caller got an error and no spend was logged

Those fields are now read null-safe, and output_tokens falls back to
completion_tokens when image_tokens is absent. usage.cost is read as before
This commit is contained in:
tinysolver 2026-10-03 12:23:09 +09:00
parent 89bae53233
commit 4ba841c4d9
2 changed files with 36 additions and 5 deletions

View file

@ -106,11 +106,13 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
"""
usage_data: Final = response_json.get("usage", {})
if usage_data:
prompt_tokens: Final = usage_data.get("prompt_tokens", 0)
total_tokens: Final = usage_data.get("total_tokens", 0)
# The /images usage schema allows null for completion_tokens_details and image_tokens, and
# per-image priced models report only completion_tokens
prompt_tokens: Final = usage_data.get("prompt_tokens") or 0
total_tokens: Final = usage_data.get("total_tokens") or 0
completion_tokens_details: Final = usage_data.get("completion_tokens_details", {})
image_tokens: Final = completion_tokens_details.get("image_tokens", 0)
completion_tokens_details: Final = usage_data.get("completion_tokens_details") or {}
image_tokens: Final = completion_tokens_details.get("image_tokens")
model_response.usage = ImageUsage(
input_tokens=prompt_tokens,
@ -118,7 +120,7 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
image_tokens=0, # Input doesn't contain images for generation
text_tokens=prompt_tokens,
),
output_tokens=image_tokens,
output_tokens=image_tokens if image_tokens is not None else usage_data.get("completion_tokens") or 0,
total_tokens=total_tokens,
)

View file

@ -297,6 +297,35 @@ def test_transform_response_reports_openrouter_usage_and_cost():
assert response._hidden_params["model"] == IMAGE_ONLY_MODEL
# The ImageGenerationUsage schema in https://openrouter.ai/openapi.json (2026-10-03) requires only
# prompt_tokens, completion_tokens and total_tokens, allows null for completion_tokens_details and
# image_tokens, and its example for a per-image priced model has no completion_tokens_details
PER_IMAGE_USAGE: Final = {"prompt_tokens": 0, "completion_tokens": 4175, "total_tokens": 4175, "cost": 0.04}
@pytest.mark.parametrize(
"usage",
[
PER_IMAGE_USAGE,
{**PER_IMAGE_USAGE, "completion_tokens_details": None},
{**PER_IMAGE_USAGE, "completion_tokens_details": {"image_tokens": None}},
],
ids=["no-details", "null-details", "null-image-tokens"],
)
def test_transform_response_without_image_tokens_reports_completion_tokens_and_cost(usage: dict[str, object]):
response = _transform_response(
httpx.Response(200, json={"created": 1790994427, "data": [{"b64_json": "aW1hZ2Ux"}], "usage": usage})
)
assert response.usage == ImageUsage(
input_tokens=0,
input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0),
output_tokens=usage["completion_tokens"],
total_tokens=usage["total_tokens"],
)
assert response._hidden_params["additional_headers"] == {"llm_provider-x-litellm-response-cost": usage["cost"]}
def test_transform_response_with_non_json_body_raises_openrouter_exception():
with pytest.raises(OpenRouterException, match="Error parsing OpenRouter response") as exc_info:
_transform_response(httpx.Response(502, content=b"<html>bad gateway</html>"))