fix(openrouter): bill responses, decisions and pass-through from usage.cost and always allow reasoning params (#45640) [partial: decisions and pass-through billing only]

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
(cherry picked from commit 465f20f274)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-09 13:28:31 -07:00 • committed by Devin AI
parent 645ba717e5
commit ecc5a8a3d1
9 changed files with 98 additions and 33 deletions

View file

@ -7,6 +7,7 @@ from pydantic import TypeAdapter, ValidationError
from typing_extensions import assert_never
import litellm
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.decisions.transformation import (
@ -205,11 +206,13 @@ def _prepare_call(
def _format_response(response: DecisionsIRResponse, call: _DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse:
formatted: Final = _formatted_response(response, call)
provider_cost: Final = call.provider_config.provider_reported_cost(response)
formatted.set_hidden_params(
{
"model": f"{call.custom_llm_provider}/{call.model}",
"custom_llm_provider": call.custom_llm_provider,
"provider_response_model": f"{call.custom_llm_provider}/{call.model}",
**({"additional_headers": {RESPONSE_COST_HEADER: provider_cost}} if provider_cost is not None else {}),
}
)
return formatted

View file

@ -308,6 +308,9 @@ class BaseDecisionsConfig(ABC):
def get_default_api_base(self) -> str | None:
return None
def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None:
return None
def missing_api_base_message(self, custom_llm_provider: str) -> str:
return f"api_base is required for Decisions provider '{custom_llm_provider}'"

View file

@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Final, cast
import httpx
import litellm
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
@ -40,18 +39,10 @@ class CacheControlSupportedModels(str, Enum):
class OpenrouterConfig(OpenAIGPTConfig):
def get_supported_openai_params(self, model: str) -> list:
"""
Allow reasoning parameters for models flagged as reasoning-capable.
Include reasoning parameters regardless of model-map capabilities.
"""
supported_params: Final = super().get_supported_openai_params(model=model)
try:
if litellm.supports_reasoning(model=model, custom_llm_provider="openrouter") or litellm.supports_reasoning(
model=model
):
supported_params.append("reasoning_effort")
supported_params.append("thinking")
except Exception:
pass
return list(dict.fromkeys(supported_params))
return list(dict.fromkeys([*supported_params, "reasoning_effort", "thinking"]))
def map_openai_params(
self,

View file

@ -1,4 +1,7 @@
from typing import Final
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
from litellm.types.decisions import DecisionsIRResponse
class OpenRouterDecisionsConfig(BaseDecisionsConfig):
@ -8,3 +11,9 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig):
def get_default_api_base(self) -> str | None:
return "https://openrouter.ai/api"
def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None:
cost: Final = response.usage.extra.get("cost")
if not isinstance(cost, (int, float)):
return None
return float(cost)

View file

@ -17,6 +17,7 @@ from litellm.types.utils import ModelResponse, StandardPassThroughResponseObject
class _TypeSafeUsage(BaseModel):
input_tokens: int = 0
output_tokens: int = 0
cost: float | None = None
class _TypeSafeResponse(BaseModel):
@ -82,7 +83,9 @@ class TypeSafePassthroughLoggingHandler:
)
pricing: Final = _pricing_for(candidate_model_keys)
response_cost: Final = (
input_tokens * pricing.input_cost_per_token + output_tokens * pricing.output_cost_per_token
usage.cost
if custom_llm_provider == "openrouter" and usage.cost is not None
else input_tokens * pricing.input_cost_per_token + output_tokens * pricing.output_cost_per_token
)
usage_object: Final = Usage(
prompt_tokens=input_tokens,

View file

@ -69,6 +69,7 @@ class _Provider:
api_key: str | None
wraps_result: bool
cost_map_key: str | None
provider_reported_cost: float | None = None
_PROVIDERS: Final = (
@ -89,7 +90,8 @@ _PROVIDERS: Final = (
"typesafe/jev-1.13",
_API_KEY,
False,
"openrouter/typesafe/jev-1.13",
None,
1.5834e-5,
),
_Provider(
"strands_decider", "strands_decider/systemone-decider", "/v1/systemone", "systemone-decider", None, False, None
@ -133,17 +135,32 @@ def _number(value: JsonValue) -> float:
return float(value)
def _expected_spend(cost_map_key: str | None) -> float:
if cost_map_key is None:
def _expected_spend(provider: _Provider) -> float:
if provider.provider_reported_cost is not None:
return provider.provider_reported_cost
if provider.cost_map_key is None:
return 0.0
prices: Final = object_value(json.loads(Path("model_prices_and_context_window.json").read_text())[cost_map_key])
prices: Final = object_value(
json.loads(Path("model_prices_and_context_window.json").read_text())[provider.cost_map_key]
)
return _number(_USAGE["input_tokens"]) * _number(prices["input_cost_per_token"]) + _number(
_USAGE["output_tokens"]
) * _number(prices["output_cost_per_token"])
def _usage(provider: _Provider) -> dict[str, JsonValue]:
return {
**_USAGE,
**({"cost": provider.provider_reported_cost} if provider.provider_reported_cost is not None else {}),
}
def _answer_body(provider: _Provider) -> dict[str, JsonValue]:
answer: Final[dict[str, JsonValue]] = {"model": provider.body_model, "answers": _ANSWERS, "usage": _USAGE}
answer: Final[dict[str, JsonValue]] = {
"model": provider.body_model,
"answers": _ANSWERS,
"usage": _usage(provider),
}
return {"result": answer, "success": True} if provider.wraps_result else answer
@ -226,16 +243,14 @@ def _pass_through_config(directory: Path, pass_through_target: str, native_api_b
@pytest.mark.parametrize("provider", _PROVIDERS, ids=lambda provider: provider.name)
def test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map(
gateway: Gateway, provider: _Provider
) -> None:
expected_spend: Final = _expected_spend(provider.cost_map_key)
def test_each_provider_gets_its_own_path_key_and_body_and_is_billed(gateway: Gateway, provider: _Provider) -> None:
expected_spend: Final = _expected_spend(provider)
with gateway.scenario() as scenario:
handle: Final = _register(scenario, _answer_body(provider))
model: Final = _deployment(scenario, handle, provider)
response: Final = _decide(gateway, model)
assert response.status_code == 200, response.text
assert response.json() == {"model": provider.body_model, "answers": _ANSWERS, "usage": _USAGE}
assert response.json() == {"model": provider.body_model, "answers": _ANSWERS, "usage": _usage(provider)}
assert response.headers["x-litellm-model-group"] == model
assert math.isclose(float(response.headers.get("x-litellm-response-cost", "0")), expected_spend, rel_tol=1e-9)
(call,) = _upstream_calls(gateway, handle)

View file

@ -1,4 +1,5 @@
from datetime import datetime
from typing import Final
from unittest.mock import MagicMock
import httpx
@ -12,7 +13,7 @@ from litellm.proxy.pass_through_endpoints.success_handler import PassThroughEndp
@pytest.fixture(autouse=True)
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch):
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
@ -137,14 +138,17 @@ def test_success_handler_dispatches_to_typesafe_handler():
assert normalized["kwargs"]["model"] == "typesafe/jev-1.13.0"
def test_openrouter_decisions_response_is_priced_from_request_model_registry_row():
def test_openrouter_decisions_response_uses_provider_cost_without_cost_map_row(
monkeypatch: pytest.MonkeyPatch,
):
logging_obj = _logging_obj()
model_cost = litellm.model_cost["openrouter/typesafe/jev-1.13"]
cost: Final = 1.5834e-5
monkeypatch.delitem(litellm.model_cost, "openrouter/typesafe/jev-1.13", raising=False)
response = TypeSafePassthroughLoggingHandler.typesafe_passthrough_handler(
httpx_response=_response(),
response_body={
"model": "typesafe/jev-1.13-20260917",
"usage": {"input_tokens": 282, "output_tokens": 20},
"usage": {"input_tokens": 282, "output_tokens": 20, "cost": cost},
},
logging_obj=logging_obj,
url_route="https://openrouter.ai/api/alpha/decisions",
@ -156,10 +160,9 @@ def test_openrouter_decisions_response_is_priced_from_request_model_registry_row
custom_llm_provider="openrouter",
)
expected_cost = 282 * model_cost["input_cost_per_token"] + 20 * model_cost["output_cost_per_token"]
assert response["kwargs"]["model"] == "openrouter/typesafe/jev-1.13-20260917"
assert response["kwargs"]["custom_llm_provider"] == "openrouter"
assert response["kwargs"]["response_cost"] == pytest.approx(expected_cost)
assert response["kwargs"]["response_cost"] == cost
assert response["kwargs"]["combined_usage_object"].prompt_tokens == 282
assert response["kwargs"]["combined_usage_object"].completion_tokens == 20
assert response["kwargs"]["combined_usage_object"].total_tokens == 302

View file

@ -11,6 +11,7 @@ import pytest
import respx
import litellm
from litellm.cost_calculator import get_response_cost_from_hidden_params
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.types.decisions import (
@ -1055,3 +1056,33 @@ async def test_requests_a_provider_cannot_serve_are_rejected_before_http(
await litellm.adecisions(model=model, api_key="caller-key", **request_kwargs)
assert len(respx_mock.calls) == 0
@pytest.mark.asyncio
async def test_openrouter_decisions_uses_provider_reported_cost_without_cost_map(
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delitem(litellm.model_cost, "openrouter/typesafe/jev-1.13", raising=False)
cost: Final = 1.1802e-5
route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(
json={
"model": "typesafe/jev-1.13",
"answers": _RESPONSE["answers"],
"usage": {
"input_tokens": _INPUT_TOKENS,
"output_tokens": _OUTPUT_TOKENS,
"cost": cost,
},
}
)
response: Final = await litellm.adecisions(
model="openrouter/typesafe/jev-1.13",
state={"source": "unit-test"},
questions=_QUESTIONS,
api_key="caller-key",
)
assert route.called
assert get_response_cost_from_hidden_params(response.hidden_params) == cost

View file

@ -96,6 +96,15 @@ def test_openrouter_extra_body_transformation():
]
def test_openrouter_supported_params_include_reasoning_for_unmapped_model():
supported_params = OpenrouterConfig().get_supported_openai_params(
"openrouter/unmapped-model"
)
assert "reasoning_effort" in supported_params
assert "thinking" in supported_params
def test_openrouter_cache_control_flag_removal():
transformed_request = OpenrouterConfig().transform_request(
model="openrouter/deepseek/deepseek-chat",
@ -537,17 +546,15 @@ def test_openrouter_reasoning_models_allow_reasoning_effort_param():
assert supported_params.count("reasoning_effort") == 1
def test_openrouter_non_reasoning_models_do_not_add_reasoning_effort():
"""
Models without reasoning support should not gain reasoning-specific params.
"""
def test_openrouter_non_reasoning_models_support_reasoning_params():
config = OpenrouterConfig()
supported_params = config.get_supported_openai_params(
model="openrouter/anthropic/claude-3-5-haiku"
)
assert "reasoning_effort" not in supported_params
assert "reasoning_effort" in supported_params
assert "thinking" in supported_params
def test_openrouter_reasoning_effort_max_maps_to_xhigh():