diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index bcf6ac74f8..a9c7f40e32 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -109,6 +109,37 @@ def get_adapter_data_with_key(adapter_id: str, key_value: str) -> Any: raise InValidAdapterId() return updated_adapters[0].get(key_value) + @staticmethod + def get_icon(adapter: AdapterInstance) -> str: + """Registry icon for an adapter, or the warning icon if unresolvable.""" + if not adapter.is_available: + return AdapterKeys.UNAVAILABLE_ICON + try: + adapter_class = Adapterkit().get_adapter_class_by_adapter_id( + adapter.adapter_id + ) + except Exception as e: + logger.warning( + "Adapter %s is not in the SDK registry: %s", adapter.adapter_id, e + ) + return AdapterKeys.UNAVAILABLE_ICON + return adapter_class.get_icon() or AdapterKeys.UNAVAILABLE_ICON + + @staticmethod + def get_model_label(adapter: AdapterInstance) -> str: + """Model name from the adapter metadata. + + Adapters without a model use the adapter id's provider prefix. + """ + try: + model = adapter.metadata.get("model") + except Exception as e: + logger.error( + "Could not read metadata for adapter %s: %s", adapter.adapter_id, e + ) + model = None + return model or adapter.adapter_id.split("|")[0] + @staticmethod def test_adapter(adapter_id: str, adapter_metadata: dict[str, Any]) -> bool: try: @@ -214,27 +245,6 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None: else: raise InternalServiceError() - @staticmethod - def get_adapter_instance_by_id(adapter_instance_id: str) -> Adapter: - """Get the adapter instance by its ID. - - Parameters: - - adapter_instance_id (str): The ID of the adapter instance. - - Returns: - - Adapter: The adapter instance with the specified ID. - - Raises: - - Exception: If there is an error while fetching the adapter instance. - """ - try: - adapter = AdapterInstance.objects.get(id=adapter_instance_id) - except Exception as e: - logger.error(f"Unable to fetch adapter: {e}") - if not adapter: - logger.error("Unable to fetch adapter") - return adapter.adapter_name - @staticmethod def get_adapters_by_type( adapter_type: AdapterTypes, user: User diff --git a/backend/adapter_processor_v2/constants.py b/backend/adapter_processor_v2/constants.py index 5aee0f42ff..438ee09079 100644 --- a/backend/adapter_processor_v2/constants.py +++ b/backend/adapter_processor_v2/constants.py @@ -35,6 +35,7 @@ class AdapterKeys: IS_AVAILABLE = "is_available" DEPRECATION_METADATA = "deprecation_metadata" IS_DEPRECATED = "is_deprecated" + UNAVAILABLE_ICON = "⚠️" class AllowedDomains(Enum): diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index c8545223bd..8209dd75c7 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -184,24 +184,7 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: if not instance.is_available and instance.deprecation_metadata: rep[AdapterKeys.DEPRECATION_METADATA] = instance.deprecation_metadata - # Only call SDK for available adapters - if instance.is_available: - try: - rep[common.ICON] = AdapterProcessor.get_adapter_data_with_key( - instance.adapter_id, common.ICON - ) - except Exception as e: - # Log error but don't fail serialization - import logging - - logger = logging.getLogger(__name__) - logger.warning( - f"Failed to retrieve icon for adapter {instance.adapter_id}: {e}" - ) - rep[common.ICON] = "⚠️" # Fallback icon for SDK errors - else: - # Use generic warning icon for deprecated adapters - rep[common.ICON] = "⚠️" + rep[common.ICON] = AdapterProcessor.get_icon(instance) model = instance.metadata.get("model") if model: diff --git a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py index 008fed3850..5fa6d3bf93 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py @@ -1,6 +1,9 @@ import logging +from typing import Any from adapter_processor_v2.adapter_processor import AdapterProcessor +from adapter_processor_v2.models import AdapterInstance +from rest_framework.serializers import ValidationError from backend.serializers import AuditSerializer from prompt_studio.prompt_profile_manager_v2.constants import ProfileManagerKeys @@ -9,33 +12,58 @@ logger = logging.getLogger(__name__) +# Adapter FK -> "conf" response key read by the UI. +ADAPTER_LABELS = ( + (ProfileManagerKeys.LLM, "LLM"), + (ProfileManagerKeys.EMBEDDING_MODEL, "Embedding Model"), + (ProfileManagerKeys.VECTOR_STORE, "Vector Store"), + (ProfileManagerKeys.X2TEXT, "Text Extractor"), +) + class ProfileManagerSerializer(AuditSerializer): class Meta: model = ProfileManager fields = "__all__" - # View owns uniqueness (IntegrityError->DuplicateData on create); drop - # the DRF auto-validator that 400s on re-save / PUT before the view runs. + # Dropped so a duplicate create surfaces the view's DuplicateData. validators = [] + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + """Reject a change to an adapter the requester cannot access. + + An unchanged value passes, so a co-owner can still save a profile + that points at an adapter shared only with the owner. + """ + request = self.context.get("request") + if not request: + return attrs + accessible = AdapterInstance.objects.for_user(request.user) + for field, _ in ADAPTER_LABELS: + adapter = attrs.get(field) + if not adapter or adapter == getattr(self.instance, field, None): + continue + if not accessible.filter(id=adapter.id).exists(): + raise ValidationError({field: "No access to the selected adapter."}) + return attrs + def to_representation(self, instance): # type: ignore - rep: dict[str, str] = super().to_representation(instance) - llm = rep[ProfileManagerKeys.LLM] - embedding = rep[ProfileManagerKeys.EMBEDDING_MODEL] - vector_db = rep[ProfileManagerKeys.VECTOR_STORE] - x2text = rep[ProfileManagerKeys.X2TEXT] - if llm: - rep[ProfileManagerKeys.LLM] = AdapterProcessor.get_adapter_instance_by_id(llm) - if embedding: - rep[ProfileManagerKeys.EMBEDDING_MODEL] = ( - AdapterProcessor.get_adapter_instance_by_id(embedding) - ) - if vector_db: - rep[ProfileManagerKeys.VECTOR_STORE] = ( - AdapterProcessor.get_adapter_instance_by_id(vector_db) - ) - if x2text: - rep[ProfileManagerKeys.X2TEXT] = AdapterProcessor.get_adapter_instance_by_id( - x2text - ) + """Resolve the adapter FKs to the name, model and icon the UI renders. + + Not filtered by adapter access - display data only, no credentials. + """ + rep: dict[str, Any] = super().to_representation(instance) + conf: dict[str, str] = {} + for field, label in ADAPTER_LABELS: + adapter = getattr(instance, field) + if not adapter: + continue + # Keep the id for adapters the viewer cannot access. + rep[f"{field}_id"] = str(rep[field]) + rep[field] = adapter.adapter_name + conf[label] = AdapterProcessor.get_model_label(adapter) + if field == ProfileManagerKeys.LLM: + rep["icon"] = AdapterProcessor.get_icon(adapter) + if conf: + conf["Profile Name"] = instance.profile_name + rep["conf"] = conf return rep diff --git a/backend/prompt_studio/prompt_profile_manager_v2/tests/__init__.py b/backend/prompt_studio/prompt_profile_manager_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py b/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py new file mode 100644 index 0000000000..987bef8243 --- /dev/null +++ b/backend/prompt_studio/prompt_profile_manager_v2/tests/test_profile_display_info.py @@ -0,0 +1,98 @@ +"""Profile serializer resolves adapter FKs to display data without an access check. + +The DRF base is patched out so the assertions cover only that resolution. +""" + +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from backend.serializers import AuditSerializer + +from prompt_studio.prompt_profile_manager_v2.serializers import ProfileManagerSerializer + + +def _adapter(name: str, model: str) -> SimpleNamespace: + return SimpleNamespace(adapter_name=name, model=model) + + +def _represent(instance: SimpleNamespace, base_rep: dict) -> dict: + with ( + patch.object(AuditSerializer, "to_representation", return_value=base_rep), + patch( + "prompt_studio.prompt_profile_manager_v2.serializers." + "AdapterProcessor.get_model_label", + side_effect=lambda adapter: adapter.model, + ), + patch( + "prompt_studio.prompt_profile_manager_v2.serializers." + "AdapterProcessor.get_icon", + return_value="/icons/adapter-icons/OpenAI.png", + ), + ): + return ProfileManagerSerializer().to_representation(instance) + + +class ProfileDisplayInfoTests(unittest.TestCase): + def test_display_info_resolved_without_adapter_access(self) -> None: + instance = SimpleNamespace( + profile_name="Prod", + llm=_adapter("Shared GPT", "gpt-4o"), + embedding_model=_adapter("Shared Embed", "text-embedding-3-small"), + vector_store=_adapter("Shared Qdrant", "qdrant"), + x2text=_adapter("Shared LLMW", "llmwhisperer"), + ) + base_rep = { + field: "some-uuid" + for field in ("llm", "embedding_model", "vector_store", "x2text") + } + + rep = _represent(instance, base_rep) + + self.assertEqual( + rep["conf"], + { + "LLM": "gpt-4o", + "Embedding Model": "text-embedding-3-small", + "Vector Store": "qdrant", + "Text Extractor": "llmwhisperer", + "Profile Name": "Prod", + }, + ) + # Only the LLM contributes the tile icon. + self.assertEqual(rep["icon"], "/icons/adapter-icons/OpenAI.png") + # FK ids are replaced by the adapter names. + self.assertEqual(rep["llm"], "Shared GPT") + + def test_unset_adapters_are_skipped(self) -> None: + instance = SimpleNamespace( + profile_name="Half configured", + llm=_adapter("Shared GPT", "gpt-4o"), + embedding_model=None, + vector_store=None, + x2text=None, + ) + + rep = _represent(instance, {"llm": "some-uuid", "embedding_model": None}) + + self.assertEqual( + rep["conf"], {"LLM": "gpt-4o", "Profile Name": "Half configured"} + ) + self.assertIsNone(rep["embedding_model"]) + + def test_profile_with_no_adapters_has_empty_conf(self) -> None: + instance = SimpleNamespace( + profile_name="Empty", + llm=None, + embedding_model=None, + vector_store=None, + x2text=None, + ) + + rep = _represent(instance, {}) + + # No "Profile Name" either - the tile has nothing to show. + self.assertEqual(rep["conf"], {}) + self.assertNotIn("icon", rep) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/views.py b/backend/prompt_studio/prompt_profile_manager_v2/views.py index d8d9bbb65f..907a137e4f 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/views.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/views.py @@ -36,7 +36,10 @@ def get_permissions(self) -> list[Any]: return [IsOwnerOrSharedUserOrSharedToOrg()] def get_queryset(self) -> QuerySet | None: - queryset = ProfileManager.objects.for_user(self.request.user) + # Serializer reads all four adapters for the display info + queryset = ProfileManager.objects.for_user(self.request.user).select_related( + "llm", "embedding_model", "vector_store", "x2text" + ) filter_args = FilterHelper.build_filter_args( self.request, ProfileManagerKeys.CREATED_BY, diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 5741c7ccd9..b3d42cf5f0 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -375,7 +375,7 @@ def list_profiles(self, request: HttpRequest, pk: Any = None) -> Response: profile_manager_instances = ProfileManager.objects.filter( prompt_studio_tool=prompt_tool - ) + ).select_related("llm", "embedding_model", "vector_store", "x2text") serialized_instances = ProfileManagerSerializer( profile_manager_instances, many=True diff --git a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx index c76e073e1b..e5e120399b 100644 --- a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx +++ b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx @@ -63,6 +63,21 @@ function AddLlmProfile({ const { setPostHogCustomEvent } = usePostHogEvents(); const { getStrategies } = useRetrievalStrategies(); + const editedProfile = llmProfiles?.find( + (item) => item?.profile_id === editLlmProfileId, + ); + + const adapterOptions = (items, field) => { + const id = editedProfile?.[`${field}_id`]; + if (!id || items?.some((item) => item?.value === id)) { + return items; + } + return [ + ...items, + { value: id, label: editedProfile?.[field], disabled: true }, + ]; + }; + useEffect(() => { setAdaptorProfilesDropdown(); }, []); @@ -121,38 +136,18 @@ function AddLlmProfile({ setModalTitle("Edit LLM Profile"); - const llmProfileDetails = [...llmProfiles].find( - (item) => item?.profile_id === editLlmProfileId, - ); - - const llmItem = llmItems.find( - (item) => item?.label === llmProfileDetails?.llm, - ); - - const vectorDbItem = vectorDbItems.find( - (item) => item?.label === llmProfileDetails?.vector_store, - ); - - const embeddingItem = embeddingItems.find( - (item) => item?.label === llmProfileDetails?.embedding_model, - ); - - const x2TextItem = x2TextItems.find( - (item) => item?.label === llmProfileDetails?.x2text, - ); - setResetForm(true); setFormDetails({ - profile_name: llmProfileDetails?.profile_name, - llm: llmItem?.value || null, - chunk_size: llmProfileDetails?.chunk_size, - vector_store: vectorDbItem?.value || null, - chunk_overlap: llmProfileDetails?.chunk_overlap, - embedding_model: embeddingItem?.value || null, - x2text: x2TextItem?.value || null, - retrieval_strategy: llmProfileDetails?.retrieval_strategy, - similarity_top_k: llmProfileDetails?.similarity_top_k, - section: llmProfileDetails?.section, + profile_name: editedProfile?.profile_name, + llm: editedProfile?.llm_id || null, + chunk_size: editedProfile?.chunk_size, + vector_store: editedProfile?.vector_store_id || null, + chunk_overlap: editedProfile?.chunk_overlap, + embedding_model: editedProfile?.embedding_model_id || null, + x2text: editedProfile?.x2text_id || null, + retrieval_strategy: editedProfile?.retrieval_strategy, + similarity_top_k: editedProfile?.similarity_top_k, + section: editedProfile?.section, prompt_studio_tool: details?.tool_id, }); setActiveKey(true); @@ -501,7 +496,7 @@ function AddLlmProfile({ help={getBackendErrorDetail("llm", backendErrors)} > + + +