diff --git a/langfuse/openai.py b/langfuse/openai.py index ea9dcd68c..fa3c75682 100644 --- a/langfuse/openai.py +++ b/langfuse/openai.py @@ -727,8 +727,14 @@ def _parse_cost(usage: Optional[Any] = None) -> Any: # OpenRouter is returning total cost of the invocation # https://openrouter.ai/docs/use-cases/usage-accounting#cost-breakdown - if hasattr(usage, "cost") and isinstance(getattr(usage, "cost"), float): - return {"total": getattr(usage, "cost")} + cost = ( + usage.get("cost") if isinstance(usage, dict) else getattr(usage, "cost", None) + ) + + # bool is a subclass of int, so it has to be excluded explicitly to avoid + # turning a stray `"cost": true` into a cost of 1.0 + if isinstance(cost, (int, float)) and not isinstance(cost, bool): + return {"total": float(cost)} return None diff --git a/tests/unit/test_parse_cost.py b/tests/unit/test_parse_cost.py new file mode 100644 index 000000000..4527b2cb9 --- /dev/null +++ b/tests/unit/test_parse_cost.py @@ -0,0 +1,48 @@ +from langfuse.openai import _parse_cost + + +class _Usage: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def test_none_usage(): + assert _parse_cost(None) is None + + +def test_object_with_float_cost(): + assert _parse_cost(_Usage(cost=0.0021)) == {"total": 0.0021} + + +def test_object_without_cost(): + assert _parse_cost(_Usage(prompt_tokens=10)) is None + + +def test_dict_with_float_cost(): + """OpenRouter usage arrives as a dict on the streaming path. + + _parse_usage already branches on isinstance(usage, dict) and both are called + with the same object, so cost has to handle a dict too. + """ + assert _parse_cost({"cost": 0.0021, "prompt_tokens": 10}) == {"total": 0.0021} + + +def test_dict_without_cost(): + assert _parse_cost({"prompt_tokens": 10}) is None + + +def test_integer_cost_is_not_dropped(): + """A free or fully cached model reports "cost": 0, which is an int, not a float.""" + assert _parse_cost({"cost": 0}) == {"total": 0.0} + assert _parse_cost(_Usage(cost=0)) == {"total": 0.0} + assert _parse_cost(_Usage(cost=2)) == {"total": 2.0} + + +def test_bool_cost_is_rejected(): + assert _parse_cost({"cost": True}) is None + assert _parse_cost(_Usage(cost=False)) is None + + +def test_non_numeric_cost_is_rejected(): + assert _parse_cost({"cost": "0.0021"}) is None + assert _parse_cost({"cost": None}) is None