From 2f833f93e66cfc5eef39260240e425a851527c93 Mon Sep 17 00:00:00 2001 From: Naineel Soyantar Date: Thu, 27 Aug 2026 17:44:49 +0530 Subject: [PATCH 1/2] fix(openrouter): track cost for Responses API requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter's Responses API config (OpenRouterResponsesAPIConfig) never requested or extracted cost data, unlike the chat completions path (OpenrouterConfig), which sets usage.include=true on the request and pulls usage.cost from the response into _hidden_params for the cost calculator to pick up. Because of this, every request routed through OpenRouter's Responses API (aresponses call_type — e.g. Codex CLI and other OpenAI Responses API clients) got logged with $0 spend, even with real, sometimes large, token usage recorded. Mirrors the existing chat/transformation.py behavior: - transform_responses_api_request: sets usage={"include": true} on the outgoing request when not already set. - transform_response_api_response: extracts usage.cost from the raw response body into _hidden_params["additional_headers"] ["llm_provider-x-litellm-response-cost"], which response_cost_calculator() already reads via get_response_cost_from_hidden_params(). Fixes #38507 Co-Authored-By: Claude Sonnet 5 --- .../openrouter/responses/transformation.py | 70 +++++++++++- ...est_openrouter_responses_transformation.py | 105 ++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openrouter/responses/transformation.py b/litellm/llms/openrouter/responses/transformation.py index 9e32356c2fa..30303ac0e4c 100644 --- a/litellm/llms/openrouter/responses/transformation.py +++ b/litellm/llms/openrouter/responses/transformation.py @@ -8,11 +8,14 @@ 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 ResponseInputParam, ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -75,3 +78,68 @@ 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 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..cb577686a09 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,107 @@ 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", {} + ) + + class TestOpenRouterResponsesAPIRegistration: """Test that OpenRouter is properly registered as a native Responses API provider.""" From 8a051733cbd46133343a5bf40869c0044fcf4420 Mon Sep 17 00:00:00 2001 From: Naineel Soyantar Date: Mon, 7 Sep 2026 10:04:37 +0530 Subject: [PATCH 2/2] fix(openrouter): track Responses API stream cost --- .../openrouter/responses/transformation.py | 31 ++++++++++++++++++- ...est_openrouter_responses_transformation.py | 30 ++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openrouter/responses/transformation.py b/litellm/llms/openrouter/responses/transformation.py index 30303ac0e4c..5c3b0a2ab3e 100644 --- a/litellm/llms/openrouter/responses/transformation.py +++ b/litellm/llms/openrouter/responses/transformation.py @@ -15,7 +15,12 @@ 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 ResponseInputParam, ResponsesAPIResponse +from litellm.types.llms.openai import ( + ResponseCompletedEvent, + ResponseInputParam, + ResponsesAPIResponse, + ResponsesAPIStreamingResponse, +) from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -143,3 +148,27 @@ class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig): 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 cb577686a09..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 @@ -186,6 +186,36 @@ class TestOpenRouterResponsesAPICostTracking: "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."""