diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index 227e18f3663..41d13b4bff7 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -236,8 +236,9 @@ class TenantTracerCache: service_name: Final = tenant_service_name(auth_metadata) if not credential_headers and not project_headers and service_name is None: return TenantRoute(tracer=default, detached=False) - # A fixed per-integration region endpoint (New Relic us/eu), never a - # caller-supplied host; ``None`` keeps the preset's own endpoint. + # A fixed per-integration region endpoint (New Relic us/eu), or the host + # the key or team was configured with, which its own credentials come + # with; never a host off the request. ``None`` keeps the preset's endpoint. endpoint: Final = dynamic_otlp_endpoint(self._callback_name, dynamic_params) cache_key: Final = ( tuple(sorted(credential_headers.items())), diff --git a/litellm/integrations/otel/presets/langfuse.py b/litellm/integrations/otel/presets/langfuse.py index e600e99b81e..043853769fe 100644 --- a/litellm/integrations/otel/presets/langfuse.py +++ b/litellm/integrations/otel/presets/langfuse.py @@ -54,8 +54,14 @@ def langfuse_dynamic_endpoint(params: StandardCallbackDynamicParams) -> str | No ``None`` means the request does not move the destination, so the preset's env-resolved endpoint stands (V1 parity: ``construct_dynamic_otel_config`` falls back to the env host when the dynamic params carry no ``langfuse_host``). + + A host only counts alongside the key pair it belongs to, which is the same + precondition ``langfuse_dynamic_headers`` applies and the same one V1 applies. + A host on its own would move the endpoint while the headers builder returned + nothing, so the exporter would keep the operator's env-derived Authorization + header and post it to the caller's host. """ host: Final = params.get("langfuse_host") - if not host: + if not host or not params.get("langfuse_public_key") or not params.get("langfuse_secret_key"): return None return _V1Langfuse.get_langfuse_otel_endpoint(host) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py index 84bac3039da..e37d4b3ff1f 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_dynamic.py @@ -713,6 +713,51 @@ def test_langfuse_dynamic_endpoint_is_none_without_a_host(): assert dynamic_otlp_endpoint("langfuse_otel", LANGFUSE_CREDS) is None +@pytest.mark.parametrize( + "partial", + [ + {}, + {"langfuse_public_key": "pk"}, + {"langfuse_secret_key": "sk"}, + ], +) +def test_langfuse_dynamic_endpoint_is_none_without_the_key_pair(partial): + """A host without both keys must not move the destination. + + The headers builder needs both keys, so a host on its own would leave the + exporter carrying the operator's env-derived Authorization header while + pointing it at the caller's host. V1 returns None in the same state. + """ + assert dynamic_otlp_endpoint("langfuse_otel", {**partial, "langfuse_host": "attacker.example.com"}) is None + + +def test_operator_credentials_stay_on_the_operator_endpoint(): + """A team that sets only a host, and reaches routing via its service name, + must not redirect the exporter that still carries the operator's header.""" + cache = _cache( + "langfuse_otel", + exporters=[ + ExporterSpec( + kind="otlp_http", + endpoint="http://env-host:3100/api/public/otel", + headers="Authorization=Basic operator-env", + owner="langfuse_otel", + ) + ], + ) + params = {"langfuse_host": "attacker.example.com"} + new_cfg = cache._routed_config( + cache._credential_headers(params), + {}, + dynamic_otlp_endpoint("langfuse_otel", params), + "team-a", + ) + exporter = new_cfg.exporters[0] + assert exporter.endpoint == "http://env-host:3100/api/public/otel" + assert exporter.headers == "Authorization=Basic operator-env" + assert new_cfg.service_name == "team-a" + + def test_langfuse_host_stamped_onto_owned_exporter_only(): """A Langfuse key's host must never repoint a co-configured exporter owned by a different backend."""