Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,37 @@
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:
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
logger.error(

Check failure on line 137 in backend/adapter_processor_v2/adapter_processor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaAQLYZ3B9QRCRdnzM4I&open=AaAQLYZ3B9QRCRdnzM4I&pullRequest=2240
"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:
Expand Down Expand Up @@ -214,27 +245,6 @@
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
Expand Down
1 change: 1 addition & 0 deletions backend/adapter_processor_v2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class AdapterKeys:
IS_AVAILABLE = "is_available"
DEPRECATION_METADATA = "deprecation_metadata"
IS_DEPRECATED = "is_deprecated"
UNAVAILABLE_ICON = "⚠️"


class AllowedDomains(Enum):
Expand Down
19 changes: 1 addition & 18 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
70 changes: 49 additions & 21 deletions backend/prompt_studio/prompt_profile_manager_v2/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
return rep
Empty file.
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion backend/prompt_studio/prompt_profile_manager_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
"llm", "embedding_model", "vector_store", "x2text"
)
filter_args = FilterHelper.build_filter_args(
self.request,
ProfileManagerKeys.CREATED_BY,
Expand Down
2 changes: 1 addition & 1 deletion backend/prompt_studio/prompt_studio_core_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading