diff --git a/litellm/integrations/arize/arize_phoenix.py b/litellm/integrations/arize/arize_phoenix.py index 00bc24d4188..81fd0e53ea7 100644 --- a/litellm/integrations/arize/arize_phoenix.py +++ b/litellm/integrations/arize/arize_phoenix.py @@ -118,18 +118,32 @@ class ArizePhoenixLogger(OpenTelemetry): # type: ignore """ Retrieve dynamic Phoenix project name from request metadata. - Users can set `metadata.phoenix_project_name` in their request to route - traces to different Phoenix projects dynamically. + Priority order (highest to lowest): + 1. Per-request metadata: ``metadata.phoenix_project_name`` + 2. Team / key metadata via ``metadata.user_api_key_auth_metadata.phoenix_project_name`` + (the proxy merges both team and key metadata into ``user_api_key_auth_metadata`` + inside the standard logging payload — this is where ``phoenix_project_name`` lands + when it is set on a team or API key via the management endpoints) + 3. ``litellm_params.metadata.phoenix_project_name`` (SDK / non-proxy usage) """ standard_logging_payload = kwargs.get("standard_logging_object") if isinstance(standard_logging_payload, dict): metadata = standard_logging_payload.get("metadata") if isinstance(metadata, dict): + # 1. Per-request metadata (highest priority) project_name = metadata.get("phoenix_project_name") if project_name: return str(project_name) - # Also check litellm_params.metadata for SDK usage + # 2. Team / key metadata — the proxy stores merged team+key metadata + # in user_api_key_auth_metadata inside StandardLoggingMetadata. + auth_metadata = metadata.get("user_api_key_auth_metadata") + if isinstance(auth_metadata, dict): + project_name = auth_metadata.get("phoenix_project_name") + if project_name: + return str(project_name) + + # 3. litellm_params.metadata for direct SDK usage (non-proxy) litellm_params = kwargs.get("litellm_params") if isinstance(litellm_params, dict): metadata = litellm_params.get("metadata") or {} diff --git a/tests/test_litellm/integrations/arize/test_arize_phoenix.py b/tests/test_litellm/integrations/arize/test_arize_phoenix.py index 129b35fb06a..ec44137247f 100644 --- a/tests/test_litellm/integrations/arize/test_arize_phoenix.py +++ b/tests/test_litellm/integrations/arize/test_arize_phoenix.py @@ -228,6 +228,139 @@ class TestGetDynamicProjectName: kwargs = {"standard_logging_object": "not-a-dict"} assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None + # ------------------------------------------------------------------ + # Tests for team / key metadata path (the bug reported by Viktor O.) + # ------------------------------------------------------------------ + + def test_extracts_from_user_api_key_auth_metadata(self): + """ + phoenix_project_name set on a team is merged into + user_api_key_auth_metadata by the proxy's pre-call utils. + _get_dynamic_project_name must check that nested dict. + """ + kwargs = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + } + } + } + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project" + + def test_per_request_metadata_takes_priority_over_team_metadata(self): + """ + A per-request phoenix_project_name must win over a team-level one. + """ + kwargs = { + "standard_logging_object": { + "metadata": { + "phoenix_project_name": "request-project", + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + }, + } + } + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "request-project" + + def test_falls_back_to_team_metadata_when_no_top_level_name(self): + """ + When there is no top-level phoenix_project_name but the team metadata + has one, the team value must be returned. + """ + kwargs = { + "standard_logging_object": { + "metadata": { + # no top-level phoenix_project_name + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + }, + } + } + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project" + + def test_team_metadata_takes_priority_over_litellm_params(self): + """ + Team metadata (via standard_logging_object) must win over + litellm_params.metadata (SDK-level). + """ + kwargs = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + } + } + }, + "litellm_params": { + "metadata": {"phoenix_project_name": "sdk-project"}, + }, + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project" + + def test_empty_user_api_key_auth_metadata_does_not_crash(self): + """Empty auth_metadata dict must not raise and must fall through.""" + kwargs = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": {} + } + } + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None + + def test_none_user_api_key_auth_metadata_does_not_crash(self): + """None auth_metadata must not raise and must fall through.""" + kwargs = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": None + } + } + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None + + def test_full_priority_chain_all_sources_present(self): + """ + Full priority chain: + per-request > team (user_api_key_auth_metadata) > litellm_params + """ + # Per-request wins + kwargs_request = { + "standard_logging_object": { + "metadata": { + "phoenix_project_name": "request-project", + "user_api_key_auth_metadata": {"phoenix_project_name": "team-project"}, + } + }, + "litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}}, + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_request) == "request-project" + + # No per-request → team wins + kwargs_team = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": {"phoenix_project_name": "team-project"}, + } + }, + "litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}}, + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_team) == "team-project" + + # No per-request, no team → SDK wins + kwargs_sdk = { + "standard_logging_object": {"metadata": {}}, + "litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}}, + } + assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_sdk) == "sdk-project" + + # Nothing → None + assert ArizePhoenixLogger._get_dynamic_project_name({}) is None + class TestDynamicProjectNameOnSpan: """set_arize_phoenix_attributes sets openinference.project.name on the span.""" @@ -253,6 +386,52 @@ class TestDynamicProjectNameOnSpan: span.set_attribute.assert_called_once_with("openinference.project.name", "env-project") + @patch.dict("os.environ", {"PHOENIX_PROJECT_NAME": "env-fallback"}, clear=False) + @patch("litellm.integrations.arize._utils.set_attributes") + def test_team_metadata_project_name_sets_span_attribute(self, _mock_set_attrs): + """ + Regression test: phoenix_project_name from team metadata (stored in + user_api_key_auth_metadata) must be applied to the span, not the env fallback. + """ + span = MagicMock() + kwargs = { + "standard_logging_object": { + "metadata": { + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + } + } + } + } + ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj=None) + + span.set_attribute.assert_called_once_with("openinference.project.name", "team-project") + + @patch.dict( + "os.environ", + {"PHOENIX_PROJECT_NAME": "env-fallback"}, + clear=False, + ) + @patch("litellm.integrations.arize._utils.set_attributes") + def test_per_request_overrides_team_metadata_on_span(self, _mock_set_attrs): + """ + Per-request phoenix_project_name must override team metadata on the span. + """ + span = MagicMock() + kwargs = { + "standard_logging_object": { + "metadata": { + "phoenix_project_name": "request-project", + "user_api_key_auth_metadata": { + "phoenix_project_name": "team-project" + }, + } + } + } + ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj=None) + + span.set_attribute.assert_called_once_with("openinference.project.name", "request-project") + if __name__ == "__main__": unittest.main()