diff --git a/litellm/llms/openrouter/responses/transformation.py b/litellm/llms/openrouter/responses/transformation.py index 9e32356c2fa..5c3b0a2ab3e 100644 --- a/litellm/llms/openrouter/responses/transformation.py +++ b/litellm/llms/openrouter/responses/transformation.py @@ -8,11 +8,19 @@ encrypted_content for multi-turn stateless workflows. Docs: https://openrouter.ai/docs/api/reference/responses/overview """ -from typing import Final +from typing import Any, 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 ( + ResponseCompletedEvent, + ResponseInputParam, + ResponsesAPIResponse, + ResponsesAPIStreamingResponse, +) from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -75,3 +83,92 @@ 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, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> dict: + """ + Same as the chat completions path: always ask OpenRouter to include + cost data in the response's `usage` object, so `transform_response_api_response` + below has something to extract. Without this, OpenRouter omits `usage.cost` + and every Responses API request gets logged with $0 spend. + """ + request: 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 request: + request["usage"] = {"include": True} + + return request + + def transform_response_api_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: Any, + ) -> ResponsesAPIResponse: + """ + Extracts cost information from the response body, mirroring + `OpenrouterConfig.transform_response` on the chat completions path. + + OpenRouter returns cost information in the `usage` object when + `usage.include=true` is set on the request (see `transform_responses_api_request`). + """ + response: Final = super().transform_response_api_response( + model=model, + raw_response=raw_response, + logging_obj=logging_obj, + ) + + try: + response_json: Final = raw_response.json() + if "usage" in response_json and response_json["usage"]: + response_cost: Final = response_json["usage"].get("cost") + if response_cost is not None: + # Store cost in hidden params for the cost calculator to use + if not hasattr(response, "_hidden_params"): + response._hidden_params = {} + if "additional_headers" not in response._hidden_params: + response._hidden_params["additional_headers"] = {} + response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = float( + response_cost + ) + except Exception: + # If we can't extract cost, continue without it - don't fail the response + pass + + return response + + def transform_streaming_response( + self, + model: str, + parsed_chunk: dict, + logging_obj: Any, + ) -> ResponsesAPIStreamingResponse: + response_event: Final = super().transform_streaming_response( + model=model, + parsed_chunk=parsed_chunk, + logging_obj=logging_obj, + ) + if not isinstance(response_event, ResponseCompletedEvent): + return response_event + + usage: Final = response_event.response.usage + if usage is None or usage.cost is None: + return response_event + + response_event.response._hidden_params["additional_headers"] = { + **response_event.response._hidden_params.get("additional_headers", {}), + "llm_provider-x-litellm-response-cost": float(usage.cost), + } + return response_event 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..43424e9e5f8 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,6 +9,10 @@ reasoning.encrypted_content for multi-turn stateless workflows. Related issue: https://github.com/BerriAI/litellm/issues/22189 """ +import json +from unittest.mock import Mock + +import httpx import pytest import litellm @@ -82,6 +86,137 @@ class TestOpenRouterResponsesAPIConfig: assert "OpenRouter API key is required" in str(e) +class TestOpenRouterResponsesAPICostTracking: + """ + Regression tests: OpenRouter's Responses API must request and extract cost + data the same way the chat completions path already does. Without this, + every request routed through the Responses API (e.g. Codex-style clients) + gets logged with $0 spend despite real token usage. + """ + + def test_transform_request_adds_usage_include(self): + """Request should always ask OpenRouter to include usage.cost in the response.""" + config = OpenRouterResponsesAPIConfig() + from litellm.types.router import GenericLiteLLMParams + + request = config.transform_responses_api_request( + model="openai/gpt-5-mini", + input="Hello", + response_api_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request.get("usage") == {"include": True} + + def test_transform_request_preserves_existing_usage_param(self): + """An explicitly-set usage param should not be clobbered.""" + config = OpenRouterResponsesAPIConfig() + from litellm.types.router import GenericLiteLLMParams + + request = config.transform_responses_api_request( + model="openai/gpt-5-mini", + input="Hello", + response_api_optional_request_params={"usage": {"include": False}}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert request.get("usage") == {"include": False} + + def test_transform_response_extracts_cost(self): + """Response should pull usage.cost into hidden params for the cost calculator.""" + config = OpenRouterResponsesAPIConfig() + + body = { + "id": "resp_abc123", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "openai/gpt-5-mini", + "output": [], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "cost": 0.01234, + }, + } + raw_response = Mock(spec=httpx.Response) + raw_response.text = json.dumps(body) + raw_response.json.return_value = body + raw_response.headers = {} + + result = config.transform_response_api_response( + model="openai/gpt-5-mini", + raw_response=raw_response, + logging_obj=Mock(), + ) + + assert ( + result._hidden_params["additional_headers"][ + "llm_provider-x-litellm-response-cost" + ] + == 0.01234 + ) + + def test_transform_response_without_cost_does_not_error(self): + """Missing usage.cost should not raise or set the header.""" + config = OpenRouterResponsesAPIConfig() + + body = { + "id": "resp_abc123", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "openai/gpt-5-mini", + "output": [], + "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}, + } + raw_response = Mock(spec=httpx.Response) + raw_response.text = json.dumps(body) + raw_response.json.return_value = body + raw_response.headers = {} + + result = config.transform_response_api_response( + model="openai/gpt-5-mini", + raw_response=raw_response, + logging_obj=Mock(), + ) + + assert "llm_provider-x-litellm-response-cost" not in result._hidden_params.get( + "additional_headers", {} + ) + + def test_transform_streaming_response_extracts_completed_response_cost(self): + config = OpenRouterResponsesAPIConfig() + + result = config.transform_streaming_response( + model="openai/gpt-5-mini", + parsed_chunk={ + "type": "response.completed", + "response": { + "id": "resp_abc123", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "openai/gpt-5-mini", + "output": [], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "cost": 0.01234, + }, + }, + }, + logging_obj=Mock(), + ) + + assert ( + result.response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] + == 0.01234 + ) + + class TestOpenRouterResponsesAPIRegistration: """Test that OpenRouter is properly registered as a native Responses API provider."""