diff --git a/litellm/llms/openrouter/responses/transformation.py b/litellm/llms/openrouter/responses/transformation.py index 9e32356c2fa..d464e44225b 100644 --- a/litellm/llms/openrouter/responses/transformation.py +++ b/litellm/llms/openrouter/responses/transformation.py @@ -8,14 +8,20 @@ encrypted_content for multi-turn stateless workflows. Docs: https://openrouter.ai/docs/api/reference/responses/overview """ -from typing import Final +from typing import TYPE_CHECKING, Final + +import httpx import litellm from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ResponseAPIUsage, ResponseInputParam, ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging + class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig): """ @@ -27,6 +33,7 @@ class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig): Key difference from direct OpenAI: - Uses https://openrouter.ai/api/v1 as the API base - Uses OPENROUTER_API_KEY for authentication + - Requests OpenRouter's `usage.cost` and surfaces it for spend tracking """ @property @@ -75,3 +82,55 @@ class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig): def supports_native_websocket(self) -> bool: """OpenRouter does not support native WebSocket for Responses API""" return False + + def transform_responses_api_request( + self, + model: str, + input: str | ResponseInputParam, + response_api_optional_request_params: dict[str, object], # mutable-ok: base signature + litellm_params: GenericLiteLLMParams, + headers: dict[str, str], # mutable-ok: base signature + ) -> dict[str, object]: # mutable-ok: request body, the HTTP handler extends it in place + """Ask OpenRouter to report real spend in `usage.cost`. + + Mirrors the chat path (`OpenrouterConfig.transform_request`). Without + `usage.include=true` OpenRouter omits `cost` from the response, so the + Responses API path has nothing to fall back on and every request logs + `spend = 0` for any model not in litellm's bundled pricing JSON. + """ + transformed: Final = super().transform_responses_api_request( + model=model, + input=input, + response_api_optional_request_params=response_api_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + if "usage" not in transformed: + transformed["usage"] = {"include": True} # mutable-ok: extends the request body super() just built + return transformed + + def transform_response_api_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "Logging", + ) -> ResponsesAPIResponse: + """Carry OpenRouter's returned `usage.cost` into hidden params. + + `get_response_cost_from_hidden_params()` reads + `additional_headers["llm_provider-x-litellm-response-cost"]` before any + static price-map lookup, so this keeps cost accounting working for + OpenRouter models that are absent from litellm's bundled pricing JSON. + Same mechanism as the chat path (`OpenrouterConfig.transform_response`). + """ + response: Final = super().transform_response_api_response( + model=model, + raw_response=raw_response, + logging_obj=logging_obj, + ) + usage: Final = response.usage + response_cost: Final = usage.cost if isinstance(usage, ResponseAPIUsage) else None + if response_cost is not None: + hidden: Final = response._hidden_params # pyright: ignore[reportPrivateUsage] # cost-header write, mirrors chat path + hidden["additional_headers"]["llm_provider-x-litellm-response-cost"] = float(response_cost) + return response diff --git a/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py b/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py index d3ea8d5b907..a5d13959ee9 100644 --- a/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py +++ b/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py @@ -9,12 +9,17 @@ reasoning.encrypted_content for multi-turn stateless workflows. Related issue: https://github.com/BerriAI/litellm/issues/22189 """ +from unittest.mock import MagicMock + +import httpx import pytest import litellm +from litellm.cost_calculator import get_response_cost_from_hidden_params from litellm.llms.openrouter.responses.transformation import ( OpenRouterResponsesAPIConfig, ) +from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -57,9 +62,7 @@ class TestOpenRouterResponsesAPIConfig: from litellm.types.router import GenericLiteLLMParams params = GenericLiteLLMParams(api_key="sk-or-test-key") - headers = config.validate_environment( - headers={}, model="openai/o4-mini", litellm_params=params - ) + headers = config.validate_environment(headers={}, model="openai/o4-mini", litellm_params=params) assert headers["Authorization"] == "Bearer sk-or-test-key" def test_validate_environment_raises_without_key(self, monkeypatch): @@ -97,8 +100,7 @@ class TestOpenRouterResponsesAPIRegistration: provider=LlmProviders.OPENROUTER, ) assert config is not None, ( - "OpenRouter must be registered as a native Responses API provider " - "to preserve reasoning.encrypted_content" + "OpenRouter must be registered as a native Responses API provider to preserve reasoning.encrypted_content" ) assert isinstance(config, OpenRouterResponsesAPIConfig) @@ -116,3 +118,120 @@ class TestOpenRouterResponsesAPIRegistration: # The URL should point to OpenRouter's responses endpoint url = config.get_complete_url(api_base=None, litellm_params={}) assert "/responses" in url + + +_RESPONSE_BODY = { + "id": "resp_123", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "model": "openrouter/anthropic/claude-3.5-sonnet", + "output": [], + "parallel_tool_calls": True, + "tool_choice": "auto", + "tools": [], + "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}, +} + + +def _raw_response(body: dict) -> httpx.Response: + return httpx.Response( + status_code=200, + json=body, + request=httpx.Request("POST", "https://openrouter.ai/api/v1/responses"), + ) + + +class TestOpenRouterResponsesAPICostTracking: + """ + Regression for https://github.com/BerriAI/litellm/issues/38507. + + OpenRouter's Responses API path never requested `usage.include=true` and + never read OpenRouter's returned `usage.cost`, so every `aresponses` + request against an OpenRouter model missing from litellm's bundled + pricing JSON logged `spend = 0` despite real token usage. + """ + + def test_transform_request_adds_usage_include(self): + """usage.include=true is added so OpenRouter returns real cost.""" + config = OpenRouterResponsesAPIConfig() + body = config.transform_responses_api_request( + model="anthropic/claude-3.5-sonnet", + input="hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert body["usage"] == {"include": True} + + def test_transform_request_preserves_caller_usage(self): + """A usage value the caller already set is left untouched.""" + config = OpenRouterResponsesAPIConfig() + body = config.transform_responses_api_request( + model="anthropic/claude-3.5-sonnet", + input="hello", + response_api_optional_request_params={"usage": {"include": False}}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert body["usage"] == {"include": False} + + def test_transform_response_extracts_openrouter_cost(self): + """usage.cost from the response body reaches the cost calculator.""" + config = OpenRouterResponsesAPIConfig() + body = {**_RESPONSE_BODY, "usage": {**_RESPONSE_BODY["usage"], "cost": 0.001234}} + + response = config.transform_response_api_response( + model="anthropic/claude-3.5-sonnet", + raw_response=_raw_response(body), + logging_obj=MagicMock(), + ) + + additional_headers = response._hidden_params["additional_headers"] + assert additional_headers["llm_provider-x-litellm-response-cost"] == 0.001234 + assert get_response_cost_from_hidden_params(response._hidden_params) == 0.001234 + + def test_transform_response_no_cost_in_body(self): + """No usage.cost -> no cost header, and nothing raised.""" + config = OpenRouterResponsesAPIConfig() + + response = config.transform_response_api_response( + model="anthropic/claude-3.5-sonnet", + raw_response=_raw_response(_RESPONSE_BODY), + logging_obj=MagicMock(), + ) + + additional_headers = response._hidden_params.get("additional_headers", {}) + assert "llm_provider-x-litellm-response-cost" not in additional_headers + assert get_response_cost_from_hidden_params(response._hidden_params) is None + + def test_transform_response_no_usage_object(self): + """A response with no usage object at all -> no cost header, no crash.""" + config = OpenRouterResponsesAPIConfig() + body = {k: v for k, v in _RESPONSE_BODY.items() if k != "usage"} + + response = config.transform_response_api_response( + model="anthropic/claude-3.5-sonnet", + raw_response=_raw_response(body), + logging_obj=MagicMock(), + ) + + additional_headers = response._hidden_params.get("additional_headers", {}) + assert "llm_provider-x-litellm-response-cost" not in additional_headers + + def test_transform_response_preserves_response_headers(self): + """The cost key is merged in, not written over the parent's headers.""" + config = OpenRouterResponsesAPIConfig() + body = {**_RESPONSE_BODY, "usage": {**_RESPONSE_BODY["usage"], "cost": 0.5}} + raw = _raw_response(body) + raw.headers["x-ratelimit-remaining"] = "42" + + response = config.transform_response_api_response( + model="anthropic/claude-3.5-sonnet", + raw_response=raw, + logging_obj=MagicMock(), + ) + + additional_headers = response._hidden_params["additional_headers"] + assert additional_headers["llm_provider-x-litellm-response-cost"] == 0.5 + assert additional_headers["llm_provider-x-ratelimit-remaining"] == "42"