fix(openrouter): move usage accounting to extra_body for compatibility

This commit is contained in:
liangjie.wanglj 2026-06-16 15:40:59 +08:00
parent cf2db415b8
commit 49f85239b1
2 changed files with 52 additions and 15 deletions

View file

@ -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,

View file

@ -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.