mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(openrouter): track responses API cost from usage.cost
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. Chat completions through the same model were costed fine. Add the two overrides the chat path already has: request usage.include=true in the body, and carry usage.cost into _hidden_params additional_headers['llm_provider-x-litellm-response-cost'], which get_response_cost_from_hidden_params() reads before any static price-map lookup. Fixes #38507
This commit is contained in:
parent
cd63c7e5a7
commit
b5ce08cdac
2 changed files with 184 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue