From 4ba841c4d96cb3ef8d5526fba404bee668e578e2 Mon Sep 17 00:00:00 2001 From: tinysolver Date: Sat, 3 Oct 2026 12:23:09 +0900 Subject: [PATCH] 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 --- .../image_generation/transformation.py | 12 ++++---- ...est_openrouter_image_gen_transformation.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/litellm/llms/openrouter/image_generation/transformation.py b/litellm/llms/openrouter/image_generation/transformation.py index 96e49248711..d2dc190ff17 100644 --- a/litellm/llms/openrouter/image_generation/transformation.py +++ b/litellm/llms/openrouter/image_generation/transformation.py @@ -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, ) diff --git a/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py b/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py index 5933393014e..024e87d0007 100644 --- a/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py +++ b/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py @@ -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"bad gateway"))