From 49f85239b1b5c9cb854ad906ad5e5f7e290dbc07 Mon Sep 17 00:00:00 2001 From: "liangjie.wanglj" <122603020@qq.com> Date: Tue, 16 Jun 2026 15:40:59 +0800 Subject: [PATCH] fix(openrouter): move usage accounting to extra_body for compatibility --- .../llms/openrouter/chat/transformation.py | 16 +++--- .../test_openrouter_chat_transformation.py | 51 ++++++++++++++++--- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index 107d5c25e6d..56eefec2e15 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -167,17 +167,19 @@ class OpenrouterConfig(OpenAIGPTConfig): messages = self._move_cache_control_to_content(messages) extra_body = optional_params.pop("extra_body", {}) - response = super().transform_request( + request = super().transform_request( model, messages, optional_params, litellm_params, headers ) - response.update(extra_body) + request.update(extra_body) - # ALWAYS add usage parameter to get cost data from OpenRouter - # This ensures cost tracking works for all OpenRouter models - if "usage" not in response: - response["usage"] = {"include": True} + # Usage accounting is an OpenRouter-specific flag, not a valid top-level + # OpenAI chat param. Sending it at the top level makes OpenAI-compatible + # clients reject the request ("unexpected keyword argument 'usage'"), so + # it must travel inside `extra_body` instead. + extra_body = request.setdefault("extra_body", {}) + extra_body.setdefault("usage", {"include": True}) - return response + return request def transform_response( self, diff --git a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py index 8d1129cc5da..9d802893b4e 100644 --- a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py +++ b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py @@ -372,7 +372,7 @@ def test_openrouter_cost_tracking_non_streaming(): Test OpenRouter cost tracking for non-streaming completions. Verifies: - 1. Request includes usage.include=true to get cost data + 1. Request asks for usage accounting via extra_body, not a top-level usage field 2. Response extracts cost from usage.cost and stores in _hidden_params """ from unittest.mock import Mock, patch @@ -380,7 +380,6 @@ def test_openrouter_cost_tracking_non_streaming(): config = OpenrouterConfig() - # Test request adds usage parameter transformed_request = config.transform_request( model="openrouter/anthropic/claude-sonnet-4.5", messages=[{"role": "user", "content": "Hello"}], @@ -388,8 +387,8 @@ def test_openrouter_cost_tracking_non_streaming(): litellm_params={}, headers={}, ) - assert "usage" in transformed_request - assert transformed_request["usage"] == {"include": True} + assert "usage" not in transformed_request + assert transformed_request["extra_body"]["usage"] == {"include": True} # Test response extracts cost mock_response = Mock(spec=httpx.Response) @@ -460,13 +459,12 @@ def test_openrouter_cost_tracking_streaming(): Test OpenRouter cost tracking for streaming completions. Verifies: - 1. Request includes usage.include=true (same as non-streaming) + 1. Request asks for usage accounting via extra_body (same as non-streaming) 2. Streaming chunks preserve usage/cost data in the final chunk 3. Cost field is accessible in the usage object """ config = OpenrouterConfig() - # Test request adds usage parameter for streaming transformed_request = config.transform_request( model="openrouter/anthropic/claude-sonnet-4.5", messages=[{"role": "user", "content": "Hello"}], @@ -474,8 +472,8 @@ def test_openrouter_cost_tracking_streaming(): litellm_params={}, headers={}, ) - assert "usage" in transformed_request - assert transformed_request["usage"] == {"include": True} + assert "usage" not in transformed_request + assert transformed_request["extra_body"]["usage"] == {"include": True} # Test streaming chunks preserve cost data handler = OpenRouterChatCompletionStreamingHandler( @@ -528,6 +526,43 @@ def test_openrouter_cost_tracking_streaming(): assert result2.usage.cost == 0.0001 +def test_openrouter_usage_accounting_not_top_level(): + """ + Regression: usage accounting must be requested via extra_body, never as a + top-level field. A top-level `usage` is rejected by OpenAI-compatible clients + with "unexpected keyword argument 'usage'" (e.g. for OpenAI models served + through OpenRouter). + """ + transformed_request = OpenrouterConfig().transform_request( + model="openrouter/openai/gpt-4o", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert "usage" not in transformed_request + assert transformed_request["extra_body"]["usage"] == {"include": True} + + +def test_openrouter_usage_accounting_preserves_user_extra_body(): + """ + User-provided OpenRouter params still flatten to the top level (issue #8425) + while usage accounting is added under extra_body without clobbering them. + """ + transformed_request = OpenrouterConfig().transform_request( + model="openrouter/openai/gpt-4o", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"extra_body": {"provider": {"order": ["OpenAI"]}}}, + litellm_params={}, + headers={}, + ) + + assert "usage" not in transformed_request + assert transformed_request["provider"]["order"] == ["OpenAI"] + assert transformed_request["extra_body"]["usage"] == {"include": True} + + def test_openrouter_reasoning_models_allow_reasoning_effort_param(): """ OpenRouter reasoning-capable models should accept the reasoning_effort param.