From 96ce86f908b56b2cf7ccfb9498d3bbf298ac6421 Mon Sep 17 00:00:00 2001 From: Chun Shi Date: Sun, 30 Aug 2026 20:41:54 -0400 Subject: [PATCH] fix(langfuse): parse comma-separated langfuse_tags header into a list add_metadata_from_header copied the raw langfuse_tags header string into the shared litellm_params metadata dict in place. The router reuses that dict across retry attempts, and _update_kwargs_with_deployment calls existing_tags.append(...) on it: any retry or fallback onto a deployment with litellm_credential_name then fails the whole request with a 400 "'str' object has no attribute 'append'" instead of failing over. The DEPLOYMENT-LEVEL TAGS branch has the sibling problem: list(existing_tags) explodes the string into single characters. Parse the header value into a list at ingestion, mirroring how other langfuse_* steering headers were hardened in #36740. No router changes, so this stays clear of open PR #30042. Fixes #38927 Co-Authored-By: Claude Fable 5 --- litellm/integrations/langfuse/langfuse.py | 9 ++- .../integrations/test_langfuse.py | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 296c2b5714e..1ae11b258b2 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -288,7 +288,14 @@ class LangFuseLogger: verbose_logger.warning("Overwriting Langfuse `%s` from request header", trace_param_key) else: verbose_logger.debug("Found Langfuse `%s` in request header", trace_param_key) - metadata[trace_param_key] = proxy_headers.get(metadata_param_key) + header_value = proxy_headers.get(metadata_param_key) + if trace_param_key == "tags" and isinstance(header_value, str): + # Langfuse tags are a list. Parse the comma-separated header string + # here, so the shared litellm_params metadata never holds a raw + # string: the router's tag handling reuses that dict across retry + # attempts and crashes on a string during retry/fallback (#38927). + header_value = [tag.strip() for tag in header_value.split(",") if tag.strip()] + metadata[trace_param_key] = header_value return metadata diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index d36878e455f..b75ba65fc61 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1554,3 +1554,63 @@ def test_langfuse_deployment_environment_fallback_never_raises(monkeypatch, env_ langfuse_host="https://test.langfuse.com", ) assert logger.langfuse_environment == expected + + +def test_tags_header_is_parsed_into_a_list(): + """A ``langfuse_tags`` header arrives as one comma-separated string.""" + metadata: dict = {} + litellm_params = { + "metadata": metadata, + "proxy_server_request": {"headers": {"langfuse_tags": "org:acme, agent:support"}}, + } + + returned = LangFuseLogger.add_metadata_from_header(litellm_params, metadata) + + assert returned["tags"] == ["org:acme", "agent:support"] + + +def test_tags_header_never_leaves_a_string_in_shared_metadata(): + """``add_metadata_from_header`` mutates the caller's metadata dict in place, and the + router reuses that dict across retry attempts: a raw header string in ``tags`` + crashes ``Router._update_kwargs_with_deployment`` with ``'str' object has no + attribute 'append'`` on any retry/fallback deployment that has + ``litellm_credential_name`` (https://github.com/BerriAI/litellm/issues/38927).""" + metadata: dict = {} + litellm_params = { + "metadata": metadata, + "proxy_server_request": {"headers": {"langfuse_tags": "org:acme,agent:support"}}, + } + + returned = LangFuseLogger.add_metadata_from_header(litellm_params, metadata) + + assert returned is metadata + assert metadata["tags"] == ["org:acme", "agent:support"] + # What the router's "CREDENTIAL NAME AS TAG" branch does on the next attempt: + metadata["tags"].append("Credential: my-credential") + assert "Credential: my-credential" in metadata["tags"] + + +def test_tags_already_in_metadata_pass_through_unchanged(): + """Body-supplied tags are already a list; only header ingestion needs parsing.""" + metadata: dict = {"tags": ["pre:set"]} + litellm_params = { + "metadata": metadata, + "proxy_server_request": {"headers": {}}, + } + + returned = LangFuseLogger.add_metadata_from_header(litellm_params, metadata) + + assert returned["tags"] == ["pre:set"] + + +def test_other_langfuse_headers_stay_raw_strings(): + """Only ``tags`` needs list parsing; other steering headers keep their raw value.""" + metadata: dict = {} + litellm_params = { + "metadata": metadata, + "proxy_server_request": {"headers": {"langfuse_existing_trace_id": "trace-123"}}, + } + + LangFuseLogger.add_metadata_from_header(litellm_params, metadata) + + assert metadata["existing_trace_id"] == "trace-123"