mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* fix(langfuse): log token usage for /v1/responses calls ResponseAPIUsage exposes input_tokens/output_tokens, so the Langfuse generation logger read prompt_tokens/completion_tokens as 0 while cost was still correct. Normalize the usage with ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage before extracting usage_details. Resolves LIT-8238 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(langfuse): adapt responses-api usage regression test to langfuse v4 spans The v4 migration exports usage_details as an OTEL span attribute via an in-memory exporter instead of a mocked generation() call. Assert on the exported span's langfuse.observation.usage_details with the same token counts. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(langfuse): log token usage for streamed /v1/responses calls An assembled /v1/responses stream reaches the logger with its usage stored as a plain dict of chat fields, which the attribute reads saw as zero. Route both ResponseAPIUsage and that dict through one normalizer. * fix(langfuse): keep generations whose usage dict fails validation; cover streaming in the e2e A usage dict whose token counts are not integers falls back to the raw object, so the generation is still logged with zero usage instead of being dropped. The live e2e now runs streamed and non-streamed /v1/responses. * ci: re-run the e2e gate * test(langfuse): run the real prompt helper and label the responses usage e2e cases Drop the _add_prompt_to_generation_params pass-through patch from the Responses usage unit tests so the real helper runs with the empty metadata they already pass. Declare a Subject on both e2e parametrizations (observability, /v1/responses, openai, stream and nonstream). --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
158 lines
5.7 KiB
Python
158 lines
5.7 KiB
Python
"""Live e2e: Langfuse token usage on the /v1/responses path.
|
|
|
|
Covers logging.langfuse.success.logs_usage: a key-scoped ``langfuse``
|
|
(classic SDK) callback must log non-zero input/output tokens to the Langfuse
|
|
generation for a /v1/responses call, streamed or not. A ResponsesAPIResponse
|
|
carries ResponseAPIUsage (input_tokens/output_tokens), and an assembled stream
|
|
carries a plain dict of chat usage fields; the logger must normalize both
|
|
before building usage_details, or Langfuse shows 0 input / 0 output while
|
|
cost is still right.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from e2e_config import CHEAP_OPENAI_MODEL, unique_marker
|
|
from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
|
|
from lifecycle import ResourceManager
|
|
from logging_client import (
|
|
LangfuseCreds,
|
|
LoggingClient,
|
|
first_ok,
|
|
load_langfuse_creds,
|
|
)
|
|
from models import (
|
|
KeyLoggingCallback,
|
|
KeyLoggingCallbackVars,
|
|
KeyMetadata,
|
|
ResponsesApiResponse,
|
|
ResponsesStreamEvent,
|
|
ResponsesUsage,
|
|
)
|
|
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
|
|
class _LangfuseUsageDetails(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
input: int | None = None
|
|
output: int | None = None
|
|
total: int | None = None
|
|
cache_read_input_tokens: int | None = None
|
|
|
|
|
|
_USAGE_DETAILS_ADAPTER: TypeAdapter[_LangfuseUsageDetails] = TypeAdapter(_LangfuseUsageDetails)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def langfuse_creds() -> LangfuseCreds:
|
|
return load_langfuse_creds()
|
|
|
|
|
|
def _completed_usage(stream_events: list[str]) -> ResponsesUsage | None:
|
|
"""Usage the proxy relayed on the stream's response.completed event."""
|
|
for raw in stream_events:
|
|
event = ResponsesStreamEvent.model_validate_json(raw)
|
|
if event.type == "response.completed" and event.response is not None:
|
|
return event.response.usage
|
|
return None
|
|
|
|
|
|
class TestResponsesLangfuseUsage:
|
|
@pytest.mark.covers("logging.langfuse.success.logs_usage", exercised_on=["responses"])
|
|
@pytest.mark.parametrize(
|
|
"stream",
|
|
[
|
|
pytest.param(
|
|
False,
|
|
marks=meta(
|
|
Subject(
|
|
domain=Domain.OBSERVABILITY,
|
|
route=Route.RESPONSES,
|
|
providers=(Provider.OPENAI,),
|
|
models=(CHEAP_OPENAI_MODEL,),
|
|
mode=Mode.NONSTREAM,
|
|
)
|
|
),
|
|
id="non_streaming",
|
|
),
|
|
pytest.param(
|
|
True,
|
|
marks=meta(
|
|
Subject(
|
|
domain=Domain.OBSERVABILITY,
|
|
route=Route.RESPONSES,
|
|
providers=(Provider.OPENAI,),
|
|
models=(CHEAP_OPENAI_MODEL,),
|
|
mode=Mode.STREAM,
|
|
)
|
|
),
|
|
id="streaming",
|
|
),
|
|
],
|
|
)
|
|
def test_responses_call_logs_nonzero_token_usage(
|
|
self, client: LoggingClient, langfuse_creds: LangfuseCreds, resources: ResourceManager, stream: bool
|
|
) -> None:
|
|
key_alias = f"lf-resp-key-{unique_marker()}"
|
|
key = client.key_with_alias(
|
|
key_alias,
|
|
models=[CHEAP_OPENAI_MODEL],
|
|
metadata=KeyMetadata(
|
|
logging=[
|
|
KeyLoggingCallback(
|
|
callback_name="langfuse",
|
|
callback_type="success",
|
|
callback_vars=KeyLoggingCallbackVars(
|
|
langfuse_public_key=langfuse_creds.public_key,
|
|
langfuse_secret_key=langfuse_creds.secret_key,
|
|
langfuse_host=langfuse_creds.host,
|
|
),
|
|
)
|
|
]
|
|
),
|
|
)
|
|
resources.defer(lambda: client.delete_key(key))
|
|
|
|
marker = unique_marker()
|
|
outcome = first_ok(
|
|
client,
|
|
lambda: client.responses_raw(
|
|
key, CHEAP_OPENAI_MODEL, f"reply with one word {marker}", max_output_tokens=64, stream=stream
|
|
),
|
|
)
|
|
usage = (
|
|
_completed_usage(outcome.stream_events)
|
|
if stream
|
|
else ResponsesApiResponse.model_validate_json(outcome.body).usage
|
|
)
|
|
assert usage is not None and usage.input_tokens > 0, (
|
|
f"the /v1/responses reply must report input_tokens, got {outcome.body[:300]} "
|
|
f"with {len(outcome.stream_events)} stream events"
|
|
)
|
|
|
|
observation = client.poll_langfuse_observation(
|
|
langfuse_creds,
|
|
key_alias=key_alias,
|
|
prompt_marker=marker,
|
|
)
|
|
assert observation is not None, (
|
|
f"the key's /v1/responses call (marker {marker}) never reached Langfuse within the deadline"
|
|
)
|
|
assert observation.usage_details is not None, (
|
|
f"the Langfuse generation must carry usageDetails, got {observation.model_dump_json()}"
|
|
)
|
|
details = _USAGE_DETAILS_ADAPTER.validate_python(observation.usage_details)
|
|
cache_read = details.cache_read_input_tokens or 0
|
|
assert details.input is not None and details.input > 0, (
|
|
f"usageDetails.input must be non-zero, got {details.model_dump()}"
|
|
)
|
|
assert details.input + cache_read == usage.input_tokens, (
|
|
f"usageDetails.input {details.input} + cache_read {cache_read} must equal the proxy's "
|
|
f"input_tokens {usage.input_tokens}"
|
|
)
|
|
assert details.output == usage.output_tokens, (
|
|
f"usageDetails.output {details.output} must equal the proxy's output_tokens {usage.output_tokens}"
|
|
)
|