mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge remote-tracking branch 'origin/main' into litellm_spend_pricing_e2e
This commit is contained in:
commit
ee274c158d
18 changed files with 385 additions and 127 deletions
|
|
@ -6,10 +6,10 @@ from litellm.integrations.otel.logger import OpenTelemetryV2
|
|||
from litellm.integrations.otel.mappers.langfuse import (
|
||||
LANGFUSE_OBSERVATION_INPUT,
|
||||
LANGFUSE_OBSERVATION_OUTPUT,
|
||||
LANGFUSE_TRACE_NAME,
|
||||
LangfuseMapper,
|
||||
)
|
||||
from litellm.integrations.otel.model.metadata import caller_trace_name
|
||||
from litellm.integrations.otel.model.request_io import request_input, response_output, stream_output
|
||||
from litellm.integrations.otel.model.trace_controls import caller_trace_controls
|
||||
from litellm.integrations.otel.plumbing.context import request_root_span
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -18,14 +18,13 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
class LangfuseOpenTelemetryV2(OpenTelemetryV2):
|
||||
"""Names the trace from the request. Langfuse reads ``langfuse.trace.name`` off the root observation,
|
||||
and the proxy's root span is still recording when the LLM call starts."""
|
||||
"""Stamps the caller's trace controls (name, user, session, tags) on the request. Langfuse reads them off
|
||||
the root observation, and the proxy's root span is still recording when the LLM call starts."""
|
||||
|
||||
def log_pre_api_call(self, model: str, messages: object, kwargs: Mapping[str, object]) -> None:
|
||||
root: Final = request_root_span()
|
||||
name: Final = caller_trace_name(kwargs)
|
||||
if root is not None and root.is_recording() and name is not None:
|
||||
root.set_attribute(LANGFUSE_TRACE_NAME, name)
|
||||
if root is not None and root.is_recording():
|
||||
root.set_attributes(LangfuseMapper.trace_attributes(caller_trace_controls(kwargs)))
|
||||
super().log_pre_api_call(model, messages, kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -555,7 +555,7 @@ class OpenTelemetryV2(CustomLogger):
|
|||
capture_content=self.config.capture_span_content,
|
||||
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
|
||||
request_route=request_root_http_route(),
|
||||
trace_name=call.trace_name,
|
||||
trace=call.trace,
|
||||
)
|
||||
end_time_ns: Final = to_ns(end_time)
|
||||
if carrier is not None and carrier.span is not None:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ Langfuse ingests OTLP spans and reads from its own vendor namespace
|
|||
|
||||
Every attribute is declared as a ``key -> extractor`` table entry (one callable
|
||||
per mapping operation): ``_LLM_CALL_ATTRS`` for scalars and ``_BLOB_ATTRS`` for
|
||||
the JSON-serialized payloads. ``_llm_call`` just applies both tables.
|
||||
the JSON-serialized payloads. ``trace_attributes`` maps the caller's trace controls
|
||||
(shared with the root observation); ``_llm_call`` applies both tables plus it.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -16,6 +17,7 @@ from typing import Final
|
|||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import (
|
||||
collect,
|
||||
drop_none_pairs,
|
||||
json_if,
|
||||
output_messages,
|
||||
serialize_messages,
|
||||
|
|
@ -25,10 +27,14 @@ from litellm.integrations.otel.model.payloads import (
|
|||
LLMRequestParams,
|
||||
LLMUsage,
|
||||
)
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls
|
||||
|
||||
LANGFUSE_OBSERVATION_INPUT: Final = "langfuse.observation.input"
|
||||
LANGFUSE_OBSERVATION_OUTPUT: Final = "langfuse.observation.output"
|
||||
LANGFUSE_TRACE_NAME: Final = "langfuse.trace.name"
|
||||
LANGFUSE_TRACE_USER_ID: Final = "user.id"
|
||||
LANGFUSE_TRACE_SESSION_ID: Final = "session.id"
|
||||
LANGFUSE_TRACE_TAGS: Final = "langfuse.trace.tags"
|
||||
|
||||
|
||||
class LangfuseMapper:
|
||||
|
|
@ -37,7 +43,6 @@ class LangfuseMapper:
|
|||
"langfuse.observation.model.name": lambda d: d.request_model or None,
|
||||
"langfuse.observation.metadata.provider": lambda d: d.provider or None,
|
||||
"langfuse.observation.id": lambda d: d.identity.call_id or None,
|
||||
LANGFUSE_TRACE_NAME: lambda d: d.trace_name or None,
|
||||
"langfuse.trace.metadata.team_id": lambda d: d.identity.team_id or None,
|
||||
"langfuse.trace.metadata.team_alias": lambda d: d.identity.team_alias or None,
|
||||
}
|
||||
|
|
@ -77,9 +82,21 @@ class LangfuseMapper:
|
|||
case _:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def trace_attributes(trace: TraceControls) -> AttributeMap:
|
||||
return drop_none_pairs(
|
||||
(
|
||||
(LANGFUSE_TRACE_NAME, trace.name or None),
|
||||
(LANGFUSE_TRACE_USER_ID, trace.user_id or None),
|
||||
(LANGFUSE_TRACE_SESSION_ID, trace.session_id or None),
|
||||
(LANGFUSE_TRACE_TAGS, trace.tags or None),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return {
|
||||
**collect(cls._LLM_CALL_ATTRS, data),
|
||||
**cls.trace_attributes(data.trace),
|
||||
**collect(cls._BLOB_ATTRS, data),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ they live in one place.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue
|
||||
|
|
@ -39,7 +39,12 @@ def tool_attr_budget(vocabularies: int) -> int:
|
|||
|
||||
def drop_none(values: Mapping[str, AttrValue | None]) -> AttributeMap:
|
||||
"""Return ``values`` with ``None``-valued entries removed."""
|
||||
return {k: v for k, v in values.items() if v is not None}
|
||||
return drop_none_pairs(values.items())
|
||||
|
||||
|
||||
def drop_none_pairs(pairs: Iterable[tuple[str, AttrValue | None]]) -> AttributeMap:
|
||||
"""Return ``pairs`` as a map with ``None``-valued entries removed."""
|
||||
return {k: v for k, v in pairs if v is not None}
|
||||
|
||||
|
||||
def tool_definition_attrs(
|
||||
|
|
|
|||
|
|
@ -43,12 +43,12 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
|
||||
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
|
||||
from litellm.integrations.otel.model.semconv import resolve_operation
|
||||
from litellm.integrations.otel.model.utils import as_str, to_seconds
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls
|
||||
from litellm.integrations.otel.model.utils import as_str, as_str_mapping, to_seconds
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
LANGFUSE_TRACE_NAME_HEADER: Final = "langfuse_trace_name"
|
||||
REQUESTER_METADATA_KEY: Final = "requester_metadata"
|
||||
REQUESTER_METADATA_PATH: Final = f"{REQUESTER_METADATA_KEY}."
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ class LLMCallEvent:
|
|||
# needs to be reasonable for a span that never gets closed (a leak).
|
||||
provisional_span_name: str
|
||||
time_to_first_chunk_seconds: float | None
|
||||
trace_name: str | None
|
||||
trace: TraceControls
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, kwargs: Mapping[str, Any]) -> LLMCallEvent:
|
||||
|
|
@ -242,30 +242,10 @@ class LLMCallEvent:
|
|||
upstream_started=kwargs.get("api_call_start_time") is not None,
|
||||
provisional_span_name=f"{operation.value} {model}".strip(),
|
||||
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
|
||||
trace_name=caller_trace_name(kwargs),
|
||||
trace=caller_trace_controls(kwargs),
|
||||
)
|
||||
|
||||
|
||||
def caller_trace_name(kwargs: Mapping[str, object]) -> str | None:
|
||||
request: Final = _as_str_mapping(kwargs.get("litellm_params"))
|
||||
if request is None:
|
||||
return None
|
||||
proxy_request: Final = _as_str_mapping(request.get("proxy_server_request"))
|
||||
headers: Final = _as_str_mapping(proxy_request.get("headers")) if proxy_request is not None else None
|
||||
from_header: Final = as_str(headers.get(LANGFUSE_TRACE_NAME_HEADER)) if headers is not None else None
|
||||
if from_header:
|
||||
return from_header
|
||||
return next(
|
||||
(
|
||||
name
|
||||
for key in ("metadata", "litellm_metadata")
|
||||
if (metadata := _as_str_mapping(request.get(key))) is not None
|
||||
and (name := as_str(metadata.get("trace_name")))
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None:
|
||||
"""Seconds from the upstream request being issued (``api_call_start_time``)
|
||||
to the first streamed chunk (``completion_start_time``); ``None`` for
|
||||
|
|
@ -300,15 +280,8 @@ def auth_metadata(payload: StandardLoggingPayload | None, kwargs: Mapping[str, o
|
|||
)
|
||||
|
||||
|
||||
def _as_str_mapping(value: object) -> Mapping[str, object] | None:
|
||||
"""A read-only view of ``value`` when it is a mapping, else ``None``."""
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
return cast("Mapping[str, object]", value) # cast-ok: isinstance-guarded, JSON metadata has str keys
|
||||
|
||||
|
||||
def _string_entries(value: object) -> Mapping[str, str] | None:
|
||||
entries: Final = _as_str_mapping(value)
|
||||
entries: Final = as_str_mapping(value)
|
||||
if entries is None:
|
||||
return None
|
||||
typed: Final = MappingProxyType({key: item for key, item in entries.items() if isinstance(item, str)})
|
||||
|
|
@ -324,18 +297,18 @@ def _metadata_dicts(
|
|||
litellm copies it onto ``metadata``, but both are yielded so a route that
|
||||
populates only one is still covered.
|
||||
"""
|
||||
payload_view: Final = _as_str_mapping(payload)
|
||||
payload_view: Final = as_str_mapping(payload)
|
||||
if payload_view is not None:
|
||||
payload_metadata: Final = _as_str_mapping(payload_view.get("metadata"))
|
||||
payload_metadata: Final = as_str_mapping(payload_view.get("metadata"))
|
||||
if payload_metadata is not None:
|
||||
yield payload_metadata
|
||||
params: Final = _as_str_mapping(kwargs.get("litellm_params"))
|
||||
params: Final = as_str_mapping(kwargs.get("litellm_params"))
|
||||
if params is None:
|
||||
return
|
||||
yield from (
|
||||
metadata
|
||||
for key in ("metadata", "litellm_metadata")
|
||||
if (metadata := _as_str_mapping(params.get(key))) is not None
|
||||
if (metadata := as_str_mapping(params.get(key))) is not None
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -365,14 +338,14 @@ def metadata_from_request_data(data: object) -> Mapping[str, object] | None:
|
|||
The proxy stores it under ``metadata`` or ``litellm_metadata`` depending on the route;
|
||||
the proxy-owned siblings (``user_api_key_*``, ``requester_ip_address``) are not read.
|
||||
"""
|
||||
top: Final = _as_str_mapping(data)
|
||||
top: Final = as_str_mapping(data)
|
||||
if top is None:
|
||||
return None
|
||||
snapshots: Final = tuple(
|
||||
snapshot
|
||||
for name in ("metadata", "litellm_metadata")
|
||||
if (nested := _as_str_mapping(top.get(name))) is not None
|
||||
and (snapshot := _as_str_mapping(nested.get(REQUESTER_METADATA_KEY))) is not None
|
||||
if (nested := as_str_mapping(top.get(name))) is not None
|
||||
and (snapshot := as_str_mapping(nested.get(REQUESTER_METADATA_KEY))) is not None
|
||||
)
|
||||
return MappingProxyType({REQUESTER_METADATA_KEY: snapshots[0]}) if snapshots else None
|
||||
|
||||
|
|
@ -382,7 +355,7 @@ def flatten_metadata(raw: Mapping[str, object]) -> Iterator[tuple[str, str]]:
|
|||
stack: Final = list(tuple(raw.items())[::-1]) # mutable-ok: iterative worklist keeps the walk off the call stack
|
||||
while stack:
|
||||
key, value = stack.pop()
|
||||
if (nested := _as_str_mapping(value)) is not None:
|
||||
if (nested := as_str_mapping(value)) is not None:
|
||||
stack.extend(tuple((f"{key}.{sub_key}", sub_value) for sub_key, sub_value in nested.items())[::-1])
|
||||
elif isinstance(value, (str, bool, int, float)):
|
||||
yield key, str(value)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, ClassVar, Final, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from litellm.integrations.otel.model.metadata import (
|
||||
RequestContext,
|
||||
RequestIdentity,
|
||||
)
|
||||
from litellm.integrations.otel.model.metadata import RequestContext, RequestIdentity
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
GenAIOperation,
|
||||
GenAIOutputType,
|
||||
|
|
@ -22,6 +19,7 @@ from litellm.integrations.otel.model.semconv import (
|
|||
resolve_output_type,
|
||||
resolve_provider,
|
||||
)
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls
|
||||
from litellm.integrations.otel.model.utils import (
|
||||
as_bool,
|
||||
as_float,
|
||||
|
|
@ -387,7 +385,7 @@ class LLMCallSpanData:
|
|||
output_type: GenAIOutputType | None = None
|
||||
call_type: str | None = None
|
||||
request_route: str | None = None
|
||||
trace_name: str | None = None
|
||||
trace: TraceControls = field(default_factory=TraceControls)
|
||||
|
||||
@classmethod
|
||||
def from_standard_logging_payload(
|
||||
|
|
@ -396,7 +394,7 @@ class LLMCallSpanData:
|
|||
capture_content: bool = False,
|
||||
time_to_first_chunk_seconds: float | None = None,
|
||||
request_route: str | None = None,
|
||||
trace_name: str | None = None,
|
||||
trace: TraceControls | None = None,
|
||||
) -> LLMCallSpanData:
|
||||
params: Final = cast(Mapping[str, object], payload.get("model_parameters") or {})
|
||||
# The single parse of the request's metadata — the request-vs-provider
|
||||
|
|
@ -438,7 +436,7 @@ class LLMCallSpanData:
|
|||
output_type=resolve_output_type(call_type),
|
||||
call_type=call_type or None,
|
||||
request_route=request_route or context.identity.request_route,
|
||||
trace_name=trace_name,
|
||||
trace=trace or TraceControls(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
61
litellm/integrations/otel/model/trace_controls.py
Normal file
61
litellm/integrations/otel/model/trace_controls.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""The caller's Langfuse trace controls, parsed from the live callback kwargs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.integrations.otel.model.utils import as_str, as_str_mapping
|
||||
|
||||
LANGFUSE_HEADER_PREFIX: Final = "langfuse_"
|
||||
_ITEMS: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TraceControls:
|
||||
"""The caller's trace-level Langfuse controls: ``metadata.trace_name`` / ``trace_user_id`` / ``session_id`` /
|
||||
``tags`` on the request (SDK or proxy body), with the proxy's ``langfuse_<control>`` headers winning over the
|
||||
body for the scalar ones. Mutation controls (``trace_id``, ``existing_trace_id``, ``update_trace_keys``) are
|
||||
deliberately not carried."""
|
||||
|
||||
name: str | None = None
|
||||
user_id: str | None = None
|
||||
session_id: str | None = None
|
||||
tags: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def caller_trace_controls(kwargs: Mapping[str, object]) -> TraceControls:
|
||||
request: Final = as_str_mapping(kwargs.get("litellm_params"))
|
||||
if request is None:
|
||||
return TraceControls()
|
||||
proxy_request: Final = as_str_mapping(request.get("proxy_server_request"))
|
||||
headers: Final = as_str_mapping(proxy_request.get("headers")) if proxy_request is not None else None
|
||||
bodies: Final = tuple(
|
||||
metadata
|
||||
for key in ("metadata", "litellm_metadata")
|
||||
if (metadata := as_str_mapping(request.get(key))) is not None
|
||||
)
|
||||
|
||||
def scalar(control: str) -> str | None:
|
||||
from_header: Final = as_str(headers.get(f"{LANGFUSE_HEADER_PREFIX}{control}")) if headers is not None else None
|
||||
if from_header:
|
||||
return from_header
|
||||
return next((value for body in bodies if (value := as_str(body.get(control)))), None)
|
||||
|
||||
return TraceControls(
|
||||
name=scalar("trace_name"),
|
||||
user_id=scalar("trace_user_id"),
|
||||
session_id=scalar("session_id"),
|
||||
tags=next((tags for body in bodies if (tags := _str_items(body.get("tags")))), ()),
|
||||
)
|
||||
|
||||
|
||||
def _str_items(value: object) -> tuple[str, ...]:
|
||||
try:
|
||||
items: Final = _ITEMS.validate_python(value)
|
||||
except ValidationError:
|
||||
return ()
|
||||
return tuple(item for item in items if isinstance(item, str) and item)
|
||||
|
|
@ -8,7 +8,13 @@ parsing lives in :mod:`litellm.integrations.otel.plumbing.providers` instead,
|
|||
because it delegates to the OTel SDK's own W3C Baggage parser.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
_STR_MAPPING: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def as_str(value: object) -> str | None:
|
||||
|
|
@ -55,6 +61,13 @@ def as_bool(value: object) -> bool | None:
|
|||
return bool(value)
|
||||
|
||||
|
||||
def as_str_mapping(value: object) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _STR_MAPPING.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def as_str_tuple(value: object) -> tuple[str, ...] | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ test.describe("Auto Router template select anchoring", () => {
|
|||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("opens the options below the trigger when there is room below it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const viewport = { width: 1280, height: 900 };
|
||||
await page.setViewportSize(viewport);
|
||||
const trigger = await openTemplateSelect(page);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
await trigger.evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||
await expect.poll(async () => (await trigger.boundingBox())?.y).toBeLessThan(viewport.height / 2);
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -139,8 +139,10 @@ class Scenario:
|
|||
|
||||
def delete_key(self, token: str) -> None:
|
||||
self.gateway.post("/key/delete", {"keys": [token]})
|
||||
response: Final = self.gateway.request("GET", "/key/info", params={"key": sha256(token.encode()).hexdigest()})
|
||||
assert response.status_code == 404, f"Deleted key remains readable: {response.status_code}"
|
||||
hashed: Final = sha256(token.encode()).hexdigest()
|
||||
assert read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token = %s', (hashed,)) == []
|
||||
info: Final = object_value(self.gateway.get("/key/info", {"key": hashed})["info"])
|
||||
assert info["status"] == "deleted", f"Deleted key still served as live: {info['status']}"
|
||||
|
||||
def delete_model(self, identity: str) -> None:
|
||||
self.gateway.post("/model/delete", {"id": identity})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from contextlib import ExitStack
|
||||
from collections.abc import Iterator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from hashlib import sha256
|
||||
from typing import Final
|
||||
import os
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
|
||||
|
||||
|
|
@ -134,37 +136,58 @@ def test_scim_deactivation_blocks_null_and_false_keys_but_preserves_other_owners
|
|||
assert_serving(gateway, model, token, 200)
|
||||
|
||||
|
||||
def _set_team_admin_editable_fields(gateway: Gateway, fields: list[JsonValue]) -> None:
|
||||
response: Final = gateway.request("PATCH", "/update/ui_settings", {"team_admin_editable_team_fields": fields})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _team_admins_may_edit(gateway: Gateway, fields: list[JsonValue]) -> Iterator[None]:
|
||||
original: Final = object_value(gateway.get("/get/ui_settings")["values"]).get("team_admin_editable_team_fields")
|
||||
_set_team_admin_editable_fields(gateway, fields)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_set_team_admin_editable_fields(gateway, original if isinstance(original, list) else [])
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.team.member_update.demoted_role_cannot_write")
|
||||
def test_warmed_team_role_demotion_prevents_later_management_writes(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
with gateway.scenario() as scenario, _team_admins_may_edit(gateway, ["tpm_limit"]):
|
||||
model: Final = scenario.model()
|
||||
user: Final = scenario.user(user_role="internal_user")
|
||||
team: Final = scenario.team(models=[model], members_with_roles=[{"user_id": user, "role": "admin"}])
|
||||
control_team: Final = scenario.team(models=[model])
|
||||
team: Final = scenario.team(
|
||||
models=[model], tpm_limit=1000, members_with_roles=[{"user_id": user, "role": "admin"}]
|
||||
)
|
||||
control_team: Final = scenario.team(models=[model], tpm_limit=1000)
|
||||
caller: Final = scenario.key(
|
||||
user_id=user, team_id=team, models=[model], allowed_routes=["/team/update", "/v1/chat/completions"]
|
||||
)
|
||||
gateway.chat(model, key=caller)
|
||||
changed: Final = gateway.request("POST", "/team/update", {"team_id": team, "team_alias": "permitted"}, key=caller)
|
||||
changed: Final = gateway.request("POST", "/team/update", {"team_id": team, "tpm_limit": 5000}, key=caller)
|
||||
assert changed.status_code == 200, changed.text
|
||||
assert read_rows('SELECT tpm_limit FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team,)) == [
|
||||
{"tpm_limit": 5000}
|
||||
]
|
||||
unrelated_before: Final = read_rows(
|
||||
'SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = %s', (control_team,)
|
||||
'SELECT tpm_limit FROM "LiteLLM_TeamTable" WHERE team_id = %s', (control_team,)
|
||||
)
|
||||
unrelated: Final = gateway.request(
|
||||
"POST", "/team/update", {"team_id": control_team, "team_alias": "must-not-persist"}, key=caller
|
||||
"POST", "/team/update", {"team_id": control_team, "tpm_limit": 7000}, key=caller
|
||||
)
|
||||
assert unrelated.status_code == 403, unrelated.text
|
||||
assert read_rows(
|
||||
'SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = %s', (control_team,)
|
||||
'SELECT tpm_limit FROM "LiteLLM_TeamTable" WHERE team_id = %s', (control_team,)
|
||||
) == unrelated_before
|
||||
gateway.post("/team/member_update", {"team_id": team, "user_id": user, "role": "user"})
|
||||
for target in (team, control_team):
|
||||
before: Final = read_rows('SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = %s', (target,))
|
||||
before: Final = read_rows('SELECT tpm_limit FROM "LiteLLM_TeamTable" WHERE team_id = %s', (target,))
|
||||
denied: Final = gateway.request(
|
||||
"POST", "/team/update", {"team_id": target, "team_alias": "must-not-persist"}, key=caller
|
||||
"POST", "/team/update", {"team_id": target, "tpm_limit": 9000}, key=caller
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
assert read_rows('SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = %s', (target,)) == before
|
||||
after: Final = read_rows('SELECT tpm_limit FROM "LiteLLM_TeamTable" WHERE team_id = %s', (target,))
|
||||
assert after == before
|
||||
roster: Final = read_rows('SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team,))
|
||||
members: Final = roster[0]["members_with_roles"]
|
||||
assert isinstance(members, list)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfi
|
|||
from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import (
|
||||
ResponseAPIUsage,
|
||||
ResponseCompletedEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseIncompleteEvent,
|
||||
|
|
@ -69,6 +70,7 @@ class TestBaseResponsesAPIStreamingIterator:
|
|||
|
||||
mock_responses_api_response = Mock(spec=ResponsesAPIResponse)
|
||||
mock_responses_api_response.id = "resp_u2028"
|
||||
mock_responses_api_response.usage = ResponseAPIUsage(input_tokens=3, output_tokens=2, total_tokens=5)
|
||||
mock_completed_event = Mock(spec=ResponseCompletedEvent)
|
||||
mock_completed_event.type = ResponsesAPIStreamEvents.RESPONSE_COMPLETED
|
||||
mock_completed_event.response = mock_responses_api_response
|
||||
|
|
@ -123,6 +125,7 @@ class TestBaseResponsesAPIStreamingIterator:
|
|||
# Mock the _update_responses_api_response_id_with_model_id method
|
||||
updated_response = Mock(spec=ResponsesAPIResponse)
|
||||
updated_response.id = "updated_response_id"
|
||||
updated_response.usage = ResponseAPIUsage(input_tokens=3, output_tokens=2, total_tokens=5)
|
||||
|
||||
# Create the iterator instance
|
||||
iterator = BaseResponsesAPIStreamingIterator(
|
||||
|
|
@ -524,7 +527,7 @@ class TestBaseResponsesAPIStreamingIterator:
|
|||
"type": "server_error",
|
||||
"message": "The model encountered an error",
|
||||
}
|
||||
mock_responses_api_response.usage = None
|
||||
mock_responses_api_response.usage = ResponseAPIUsage(input_tokens=3, output_tokens=2, total_tokens=5)
|
||||
|
||||
mock_failed_event = Mock(spec=ResponseFailedEvent)
|
||||
mock_failed_event.type = ResponsesAPIStreamEvents.RESPONSE_FAILED
|
||||
|
|
@ -604,7 +607,7 @@ class TestBaseResponsesAPIStreamingIterator:
|
|||
mock_responses_api_response = Mock(spec=ResponsesAPIResponse)
|
||||
mock_responses_api_response.id = "resp_incomplete_123"
|
||||
mock_responses_api_response.incomplete_details = {"reason": "max_output_tokens"}
|
||||
mock_responses_api_response.usage = None
|
||||
mock_responses_api_response.usage = ResponseAPIUsage(input_tokens=3, output_tokens=2, total_tokens=5)
|
||||
|
||||
mock_incomplete_event = Mock(spec=ResponseIncompleteEvent)
|
||||
mock_incomplete_event.type = ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE
|
||||
|
|
|
|||
|
|
@ -833,45 +833,38 @@ def test_router_fallbacks_with_cooldowns_and_model_id():
|
|||
@pytest.mark.asyncio()
|
||||
async def test_router_fallbacks_with_cooldowns_and_dynamic_credentials():
|
||||
"""
|
||||
Ensure cooldown on credential 1 does not affect credential 2
|
||||
A 429 answered to a caller-supplied credential cools down none of the shared deployments,
|
||||
so the next credential still reaches them, while a 429 owned by a shared deployment does
|
||||
"""
|
||||
from litellm.router_utils.cooldown_handlers import _async_get_cooldown_deployments
|
||||
|
||||
litellm._turn_on_debug()
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {"model": "gpt-3.5-turbo", "rpm": 1},
|
||||
"model_info": {
|
||||
"id": "123",
|
||||
},
|
||||
"litellm_params": {"model": "gpt-3.5-turbo"},
|
||||
"model_info": {"id": deployment_id},
|
||||
}
|
||||
]
|
||||
for deployment_id in ("123", "456")
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
## trigger ratelimit
|
||||
try:
|
||||
with pytest.raises(litellm.RateLimitError):
|
||||
await router.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_key="my-bad-key-1",
|
||||
mock_response="litellm.RateLimitError",
|
||||
model="gpt-3.5-turbo", messages=messages, api_key="my-bad-key-1", mock_response="litellm.RateLimitError"
|
||||
)
|
||||
pytest.fail("Expected RateLimitError")
|
||||
except litellm.RateLimitError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(1)
|
||||
assert await _async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None) == []
|
||||
|
||||
cooldown_list = await _async_get_cooldown_deployments(
|
||||
litellm_router_instance=router, parent_otel_span=None
|
||||
response = await router.acompletion(
|
||||
model="gpt-3.5-turbo", messages=messages, api_key="my-good-key-2", mock_response="served with credential 2"
|
||||
)
|
||||
print("cooldown_list: ", cooldown_list)
|
||||
assert len(cooldown_list) == 1
|
||||
assert response.choices[0].message.content == "served with credential 2"
|
||||
|
||||
await router.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
with pytest.raises(litellm.RateLimitError):
|
||||
await router.acompletion(model="gpt-3.5-turbo", messages=messages, mock_response="litellm.RateLimitError")
|
||||
await asyncio.sleep(1)
|
||||
cooled_down = await _async_get_cooldown_deployments(litellm_router_instance=router, parent_otel_span=None)
|
||||
assert len(cooled_down) == 1 and cooled_down[0] in {"123", "456"}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from tests._live_test_helpers import cheapest_together_chat_model
|
||||
from litellm import (
|
||||
RateLimitError,
|
||||
TextCompletionResponse,
|
||||
|
|
@ -4023,27 +4022,27 @@ def test_async_text_completion():
|
|||
asyncio.run(test_get_response())
|
||||
|
||||
|
||||
@pytest.mark.flaky(retries=6, delay=1)
|
||||
def test_async_text_completion_together_ai():
|
||||
litellm.set_verbose = True
|
||||
print("test_async_text_completion")
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
async def test_get_response():
|
||||
try:
|
||||
client = AsyncOpenAI(api_key="my-fake-key")
|
||||
|
||||
async def run_call():
|
||||
with patch.object(client.completions.with_raw_response, "create", side_effect=mock_post) as mock_call:
|
||||
response = await litellm.atext_completion(
|
||||
model=cheapest_together_chat_model(),
|
||||
model="together_ai/Qwen/Qwen2-1.5B-Instruct",
|
||||
prompt="good morning",
|
||||
max_tokens=10,
|
||||
client=client,
|
||||
)
|
||||
print(f"response: {response}")
|
||||
except litellm.RateLimitError as e:
|
||||
print(e)
|
||||
except litellm.Timeout as e:
|
||||
print(e)
|
||||
except Exception as e:
|
||||
pytest.fail("An unexpected error occurred")
|
||||
return response, mock_call.call_args.kwargs
|
||||
|
||||
asyncio.run(test_get_response())
|
||||
response, sent = asyncio.run(run_call())
|
||||
assert sent["model"] == "Qwen/Qwen2-1.5B-Instruct"
|
||||
assert sent["prompt"] == "good morning"
|
||||
assert sent["max_tokens"] == 10
|
||||
assert response.choices[0].text == ") might be faster than then answering, and the added time it takes for the"
|
||||
assert response.usage.total_tokens == 18
|
||||
|
||||
|
||||
# test_async_text_completion()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from litellm.types.utils import ( # noqa: E402
|
|||
INPUT_ATTR: Final = "langfuse.observation.input"
|
||||
OUTPUT_ATTR: Final = "langfuse.observation.output"
|
||||
TRACE_NAME_ATTR: Final = "langfuse.trace.name"
|
||||
TRACE_CONTROL_ATTRS: Final = (TRACE_NAME_ATTR, "user.id", "session.id", "langfuse.trace.tags")
|
||||
CHAT_DATA: Final = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "ping"}]}
|
||||
|
||||
|
||||
|
|
@ -374,6 +375,99 @@ def test_unnamed_request_leaves_the_trace_name_off_both_spans():
|
|||
assert TRACE_NAME_ATTR not in root_attrs and TRACE_NAME_ATTR not in generation_attrs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("capture", ["span_only", "no_content"])
|
||||
def test_body_metadata_user_session_and_tags_land_on_the_root_and_the_generation(capture):
|
||||
logger, exporter = _logger(capture=capture)
|
||||
|
||||
root_attrs, generation_attrs = _run_named_request(
|
||||
logger,
|
||||
exporter,
|
||||
{
|
||||
"metadata": {
|
||||
"trace_user_id": "user-42",
|
||||
"session_id": "session-7",
|
||||
"tags": ["prod", "eval", "nightly"],
|
||||
"user_api_key_team_id": "team-from-proxy",
|
||||
},
|
||||
"proxy_server_request": {"headers": {}},
|
||||
},
|
||||
)
|
||||
|
||||
for attrs in (root_attrs, generation_attrs):
|
||||
assert attrs["user.id"] == "user-42"
|
||||
assert attrs["session.id"] == "session-7"
|
||||
assert tuple(attrs["langfuse.trace.tags"]) == ("prod", "eval", "nightly")
|
||||
assert TRACE_NAME_ATTR not in attrs
|
||||
|
||||
|
||||
def test_langfuse_user_and_session_headers_beat_body_metadata_on_both_spans():
|
||||
logger, exporter = _logger()
|
||||
|
||||
root_attrs, generation_attrs = _run_named_request(
|
||||
logger,
|
||||
exporter,
|
||||
{
|
||||
"metadata": {"trace_user_id": "from-body", "session_id": "from-body"},
|
||||
"proxy_server_request": {
|
||||
"headers": {"langfuse_trace_user_id": "from-header", "langfuse_session_id": "from-header-s"}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
for attrs in (root_attrs, generation_attrs):
|
||||
assert attrs["user.id"] == "from-header"
|
||||
assert attrs["session.id"] == "from-header-s"
|
||||
|
||||
|
||||
def test_caller_metadata_cannot_override_the_proxy_team_identity():
|
||||
logger, exporter = _logger()
|
||||
response: Final = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
|
||||
litellm_params: Final = {
|
||||
"metadata": {"trace_user_id": "u", "trace_metadata": {"team_id": "spoofed"}, "team_id": "spoofed"}
|
||||
}
|
||||
logger.log_pre_api_call(
|
||||
model="gpt-5.4-mini", messages=[], kwargs={"litellm_call_id": "call_1", "litellm_params": litellm_params}
|
||||
)
|
||||
payload: Final = {
|
||||
"call_type": "acompletion",
|
||||
"custom_llm_provider": "openai",
|
||||
"model": "gpt-5.4-mini",
|
||||
"messages": CHAT_DATA["messages"],
|
||||
"response": response.model_dump(),
|
||||
"status": "success",
|
||||
"litellm_call_id": "call_1",
|
||||
"metadata": {
|
||||
"user_api_key_team_id": "real-team",
|
||||
"user_api_key_team_alias": "real-alias",
|
||||
"team_id": "spoofed",
|
||||
"team_alias": "spoofed",
|
||||
},
|
||||
"hidden_params": {},
|
||||
}
|
||||
asyncio.run(
|
||||
logger.async_log_success_event(
|
||||
{"standard_logging_object": payload, "litellm_params": litellm_params}, response, None, None
|
||||
)
|
||||
)
|
||||
|
||||
attrs: Final = dict(exporter.get_finished_spans()[0].attributes or {})
|
||||
assert attrs["user.id"] == "u"
|
||||
assert attrs["langfuse.trace.metadata.team_id"] == "real-team"
|
||||
assert attrs["langfuse.trace.metadata.team_alias"] == "real-alias"
|
||||
assert "langfuse.trace.metadata" not in attrs and "langfuse.trace.id" not in attrs
|
||||
|
||||
|
||||
def test_a_request_without_trace_controls_stamps_none_of_them():
|
||||
logger, exporter = _logger()
|
||||
|
||||
root_attrs, generation_attrs = _run_named_request(
|
||||
logger, exporter, {"metadata": {"user_api_key_team_id": "t1", "tags": []}, "proxy_server_request": {"headers": {}}}
|
||||
)
|
||||
|
||||
assert set(TRACE_CONTROL_ATTRS).isdisjoint(root_attrs)
|
||||
assert set(TRACE_CONTROL_ATTRS).isdisjoint(generation_attrs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capture", "mappers"),
|
||||
[("no_content", ("genai", "langfuse")), ("span_only", ("genai",))],
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ from litellm.integrations.otel import (
|
|||
)
|
||||
from litellm.integrations.otel.mappers.genai import GenAIMapper
|
||||
from litellm.integrations.otel.model import spans as spans_mod
|
||||
from litellm.integrations.otel.model.metadata import LLMCallEvent, caller_trace_name
|
||||
from litellm.integrations.otel.model.metadata import LLMCallEvent
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
LLMCallSpanData,
|
||||
RequestIdentity,
|
||||
|
|
@ -743,15 +744,62 @@ def test_request_identity_falls_back_to_legacy_team_keys():
|
|||
ids=["header", "body", "anthropic-body", "header-beats-body", "blank-header-falls-through", "neither", "empty"],
|
||||
)
|
||||
def test_caller_trace_name_prefers_the_langfuse_header_over_body_metadata(request_data, expected):
|
||||
assert caller_trace_name({"litellm_params": request_data}) == expected
|
||||
assert LLMCallEvent.from_dict({"litellm_params": request_data}).trace_name == expected
|
||||
assert caller_trace_controls({"litellm_params": request_data}).name == expected
|
||||
assert LLMCallEvent.from_dict({"litellm_params": request_data}).trace.name == expected
|
||||
|
||||
|
||||
def test_llm_span_data_carries_the_caller_trace_name():
|
||||
data: Final = LLMCallSpanData.from_standard_logging_payload(_sample_payload(), trace_name="nightly-eval")
|
||||
@pytest.mark.parametrize(
|
||||
("request_data", "expected"),
|
||||
[
|
||||
(
|
||||
{"metadata": {"trace_user_id": "u-body", "session_id": "s-body", "tags": ["a", "b", "c"]}},
|
||||
TraceControls(user_id="u-body", session_id="s-body", tags=("a", "b", "c")),
|
||||
),
|
||||
(
|
||||
{
|
||||
"proxy_server_request": {
|
||||
"headers": {"langfuse_trace_user_id": "u-header", "langfuse_session_id": "s-header"}
|
||||
},
|
||||
"metadata": {"trace_user_id": "u-body", "session_id": "s-body"},
|
||||
},
|
||||
TraceControls(user_id="u-header", session_id="s-header"),
|
||||
),
|
||||
(
|
||||
{"litellm_metadata": {"trace_user_id": "u-anthropic", "session_id": "s-anthropic", "tags": ["x"]}},
|
||||
TraceControls(user_id="u-anthropic", session_id="s-anthropic", tags=("x",)),
|
||||
),
|
||||
(
|
||||
{"metadata": {"tags": ["kept", 7, "", None, "also-kept"]}},
|
||||
TraceControls(tags=("kept", "also-kept")),
|
||||
),
|
||||
({"metadata": {"tags": "not-a-list", "trace_user_id": "", "session_id": 12}}, TraceControls(session_id="12")),
|
||||
(
|
||||
{
|
||||
"metadata": {
|
||||
"trace_id": "forced",
|
||||
"existing_trace_id": "forced",
|
||||
"update_trace_keys": ["name"],
|
||||
"trace_metadata": {"team_id": "spoofed"},
|
||||
"user_api_key_team_id": "t1",
|
||||
}
|
||||
},
|
||||
TraceControls(),
|
||||
),
|
||||
({}, TraceControls()),
|
||||
],
|
||||
ids=["body", "headers-beat-body", "anthropic-body", "non-string-tags-dropped", "scalar-coercion", "mutation-controls-ignored", "empty"],
|
||||
)
|
||||
def test_caller_trace_controls_carry_user_session_and_tags(request_data, expected):
|
||||
assert caller_trace_controls({"litellm_params": request_data}) == expected
|
||||
assert LLMCallEvent.from_dict({"litellm_params": request_data}).trace == expected
|
||||
|
||||
assert data.trace_name == "nightly-eval"
|
||||
assert LLMCallSpanData.from_standard_logging_payload(_sample_payload()).trace_name is None
|
||||
|
||||
def test_llm_span_data_carries_the_caller_trace_controls():
|
||||
controls: Final = TraceControls(name="nightly-eval", user_id="u1", session_id="s1", tags=("a", "b"))
|
||||
data: Final = LLMCallSpanData.from_standard_logging_payload(_sample_payload(), trace=controls)
|
||||
|
||||
assert data.trace == controls
|
||||
assert LLMCallSpanData.from_standard_logging_payload(_sample_payload()).trace == TraceControls()
|
||||
|
||||
|
||||
def test_llm_span_carries_proxy_request_route():
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.integrations.otel.mappers import (
|
|||
WeaveMapper,
|
||||
resolve_mappers,
|
||||
)
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
LLMCallSpanData,
|
||||
LLMRequestParams,
|
||||
|
|
@ -135,8 +136,35 @@ def test_langfuse_mapper_observation_attrs():
|
|||
|
||||
|
||||
def test_langfuse_mapper_names_the_trace_from_the_caller():
|
||||
assert LangfuseMapper().map(_llm_call(trace_name="nightly-eval"))["langfuse.trace.name"] == "nightly-eval"
|
||||
assert "langfuse.trace.name" not in LangfuseMapper().map(_llm_call(trace_name=None))
|
||||
named = LangfuseMapper().map(_llm_call(trace=TraceControls(name="nightly-eval")))
|
||||
assert named["langfuse.trace.name"] == "nightly-eval"
|
||||
assert "langfuse.trace.name" not in LangfuseMapper().map(_llm_call(trace=TraceControls()))
|
||||
|
||||
|
||||
def test_langfuse_mapper_carries_the_caller_user_session_and_tags():
|
||||
controls = TraceControls(user_id="u-42", session_id="s-7", tags=("prod", "eval", "nightly"))
|
||||
attrs = LangfuseMapper().map(_llm_call(trace=controls))
|
||||
|
||||
assert attrs["user.id"] == "u-42"
|
||||
assert attrs["session.id"] == "s-7"
|
||||
assert attrs["langfuse.trace.tags"] == ("prod", "eval", "nightly")
|
||||
assert attrs["langfuse.trace.metadata.team_id"] == "t1"
|
||||
assert attrs["langfuse.trace.metadata.team_alias"] == "team one"
|
||||
|
||||
|
||||
def test_langfuse_mapper_omits_unset_trace_controls():
|
||||
attrs = LangfuseMapper().map(_llm_call(trace=TraceControls(user_id="", session_id=None, tags=())))
|
||||
|
||||
assert {"user.id", "session.id", "langfuse.trace.tags", "langfuse.trace.name"}.isdisjoint(attrs)
|
||||
|
||||
|
||||
def test_langfuse_trace_attributes_match_between_root_and_generation():
|
||||
controls = TraceControls(name="n", user_id="u", session_id="s", tags=("t",))
|
||||
generation = LangfuseMapper().map(_llm_call(trace=controls))
|
||||
|
||||
root = LangfuseMapper.trace_attributes(controls)
|
||||
assert root == {"langfuse.trace.name": "n", "user.id": "u", "session.id": "s", "langfuse.trace.tags": ("t",)}
|
||||
assert all(generation[key] == value for key, value in root.items())
|
||||
|
||||
|
||||
def test_langfuse_mapper_skips_when_no_messages():
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ async def test_chat_completion():
|
|||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
assert "is not available for this API key" in str(e)
|
||||
assert "is not available for this API key" in str(e.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue