Skip to content
Open
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
49 changes: 28 additions & 21 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from unstract.sdk1.adapters.base import Adapter
from unstract.sdk1.adapters.x2text.constants import X2TextConstants
from unstract.sdk1.constants import AdapterTypes
from unstract.sdk1.constants import Common as common
from unstract.sdk1.embedding import EmbeddingCompat
from unstract.sdk1.exceptions import SdkError
from unstract.sdk1.llm import LLM
Expand All @@ -28,6 +29,8 @@

logger = logging.getLogger(__name__)

UNAVAILABLE_ADAPTER_ICON = "⚠️"

try:
from plugins.subscription.time_trials.subscription_adapter import add_unstract_key
except ImportError:
Expand Down Expand Up @@ -109,6 +112,31 @@ 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_display_info(adapter: AdapterInstance) -> tuple[str, str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (type design) — tuple[str, str] is the weakest available encoding for (icon, model), and splitting the function removes the hazard for free.

Both elements are str, so model, icon = AdapterProcessor.get_display_info(adapter) type-checks and silently ships an emoji as the model name. Only the docstring says which slot is which.

Normally I'd weigh that against a NamedTuple and conclude it isn't worth it — this repo has 34 bare -> tuple[...] returns against exactly one production NamedTuple, and adapter_processor_v2/ has zero dataclasses, so a 2-tuple is locally idiomatic and there's only one call site. But here a split is smaller than a NamedTuple and also fixes the wasted-work problem flagged on serializers.py:39:

@staticmethod
def get_model_label(adapter: AdapterInstance) -> str: ...  # metadata["model"] or provider prefix

@staticmethod
def get_icon(adapter: AdapterInstance) -> str: ...         # registry icon or UNAVAILABLE_ADAPTER_ICON

Two named single-value returns can't be swapped, need no new type, and let the caller resolve the icon only for the LLM — cutting registry walks from 4 per profile to 1.

Two smaller accuracy notes on the annotation itself:

  • str isn't actually enforced on either side. get_adapter_data_with_key returns Any (line 87) and .metadata returns Any (models.py:191), so both values are unchecked. It holds in practice (model is "type": "string" in all 37 adapter schemas), but mypy can't help — [tool.mypy] strict = true is set in pyproject.toml, yet the mypy pre-commit hook is commented out and no workflow invokes it.
  • The "always non-empty" invariant has one reachable hole: adapter_id is CharField(default="") (models.py:75-79), so "".split("|")[0] is "". An adapter with no model in metadata and an empty adapter_id returns ("⚠️", "") — satisfies str, renders as a blank tile, logs nothing.

"""Icon and model label for an adapter, as (icon, model).

Display data only, no credentials. Never raises - falls back to the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — three claims in this docstring aren't quite what the code does. (Separate from the icon-fallback thread above — this is about the contract text itself.)

"Never raises" — true today, but only by accident. adapter.is_available (line 123) and the whole return expression (line 138) sit outside both try blocks. The final adapter.adapter_id.split("|")[0] can't IndexError only because adapter_id is a non-null CharField(default="") (models.py:75-79) — nothing enforces that. This matters because the caller (serializers.py:39) runs inside to_representation with many=True and no guard, so any future raise here becomes a 500 on the whole profile list. Either weaken the claim to "best-effort" or move the return inside the guarded region so it's structurally true.

"Display data only, no credentials" — accurate about the return value, but it reads as "this function doesn't touch credentials", which is the opposite of what line 134 does: adapter.metadata Fernet-decrypts the full blob, API keys included, and only model escapes. Suggest making the mechanism explicit, e.g. "Reads (and therefore decrypts) adapter.metadata to pull the model name; only the model string is returned — never credentials."

"falls back to … the adapter id's provider prefix" — frames the normal path as an error path. The prefix is returned whenever metadata has no model key, which is the expected case for vector DBs and text extractors (Qdrant metadata is url/api_key; there is no model). A reader seeing qdrant on a tile will hunt for a bug that isn't there. Suggest: "Returns the metadata model where the adapter has one (LLM/embedding); vector DB and x2text adapters have none, so the adapter id's provider prefix is used."

Related, on the log message at line 132: "No icon for adapter {id}" fires when get_adapter_data_with_key raises, which means InValidAdapterIdadapter not in the SDK registry, not adapter has no icon. The genuine no-icon case is handled by the or on line 129 and logs nothing. Since is_available was already checked, reaching that except means is_available=True while the SDK no longer ships the adapter — i.e. the flag is stale (it's only written by migration 0003 and the manage_deprecated_adapters command, so it goes stale on every SDK upgrade). That's a real drift signal currently described as a missing icon. get_adapter_data_with_key also already logs the same event at line 108, so this line is a duplicate as well as a misnomer.

warning icon and the adapter id's provider prefix.
"""
icon = UNAVAILABLE_ADAPTER_ICON
if adapter.is_available:
try:
icon = (
AdapterProcessor.get_adapter_data_with_key(
adapter.adapter_id, common.ICON
)
or UNAVAILABLE_ADAPTER_ICON
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
except Exception as e:
logger.warning(f"No icon for adapter {adapter.adapter_id}: {e}")
try:
model = adapter.metadata.get("model")
except Exception as e:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this except Exception swallows InvalidEncryptionKey, which the codebase treats as a call-the-admin outage.

adapter.metadata is not a plain attribute — it's a decrypting property (adapter_processor_v2/models.py:190-201) that converts a Fernet InvalidToken into InvalidEncryptionKey. That exception is a 403 APIException whose detail reads "Platform encryption key for storing adapter credentials has changed! All adapters are inaccessible. Please inform the platform admin immediately." (backend/utils/exceptions.py:6-12).

Caught here, it becomes a logger.warning and a tile that renders a plausible-looking "openai". Note the asymmetry: AdapterListSerializer does the same instance.metadata.get("model") unguarded at adapter_processor_v2/serializers.py:206, so the adapters page surfaces the actionable 403 while the profile payload silently degrades — and for a shared-project viewer the profile payload is the only path, so they get no signal at all.

The same handler also absorbs several unrelated failures, all reachable, none of which "No metadata for adapter" describes:

  • TypeError: cannot convert 'NoneType' object to bytesadapter_metadata_b is BinaryField(null=True) (models.py:89) and is only populated when metadata is truthy (serializers.py:73-80).
  • ValueError from Fernet(...) on a malformed ENCRYPTION_KEY.
  • json.JSONDecodeError on corrupt plaintext.

Suggested fix — let the outage propagate, narrow the rest, and log at error:

try:
    model = adapter.metadata.get("model")
except InvalidEncryptionKey:
    raise  # platform-wide credential outage; must not degrade to a log line
except (TypeError, ValueError, AttributeError) as e:
    logger.error(
        "Unreadable metadata for adapter %s (%s): %s",
        adapter.id, adapter.adapter_id, e,
    )
    model = None

If a 403 on the profile list is considered too disruptive for a display path, that's a defensible call — but then it should be a deliberate one, with the log at error and a message that names the real condition rather than "No metadata".

logger.warning(f"No metadata for adapter {adapter.adapter_id}: {e}")
model = None
return icon, 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 +242,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
Expand Down
46 changes: 26 additions & 20 deletions backend/prompt_studio/prompt_profile_manager_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,39 @@

logger = logging.getLogger(__name__)

# Adapter FK -> label shown on the Prompt Studio output tiles.
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.
# Uniqueness is enforced by the view; the auto-validator 400s on re-save.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this rewrite dropped the load-bearing half of the comment, and the shorter version is now inaccurate.

Before: View owns uniqueness (IntegrityError->DuplicateData on create); drop the DRF auto-validator that 400s on re-save / PUT before the view runs.
After: Uniqueness is enforced by the view; the auto-validator 400s on re-save.

Three things were lost, and one inaccuracy was introduced:

  1. "on create" scoping is gone, and the replacement over-claims. Only the create paths catch IntegrityErrorviews.py:58-61 and prompt_studio_core_v2/views.py:971-984. ProfileManagerView inherits DRF's default update/partial_update with no IntegrityError handling, so on a PUT/PATCH name collision nothing in "the view" enforces uniqueness — it surfaces as an uncaught DB error. "Uniqueness is enforced by the view" is simply not true for updates.
  2. "before the view runs" is gone — and that clause is the justification. Without it, "the auto-validator 400s on re-save" reads as a description of desirable behaviour, leaving the next reader to wonder why validators = [] is there at all.
  3. The DRF-version dependency is now unrecoverable: this only matters on DRF 3.15+, which auto-generates a UniqueTogetherValidator from Meta.constraints (models.py:153-158; DRF pinned at 3.17.1 in backend/pyproject.toml:22).

validators = [] is the kind of line a future maintainer deletes as dead config. The original comment was what stopped that. Suggest restoring it — this is a case where shorter is strictly worse.

validators = []

def to_representation(self, instance): # type: ignore
"""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, 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
)
conf: dict[str, str] = {}
for field, label in ADAPTER_LABELS:
adapter = getattr(instance, field, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — getattr(instance, field, None) silently swallows a broken FK, and the continue branch is otherwise unreachable.

All four adapter FKs are null=False, blank=False, on_delete=models.PROTECT (prompt_profile_manager_v2/models.py:61-91). A None is therefore impossible under the schema — so if not adapter: continue can only fire in the one case that must not be silent.

Django's forward FK descriptor raises RelatedObjectDoesNotExist, which is constructed as a subclass of both model.DoesNotExist and AttributeError (django/db/models/fields/related_descriptors.py). Because it inherits AttributeError, the three-argument getattr catches it and returns the default. A dangling FK — raw SQL delete, partial restore, cross-schema drift — therefore produces no exception, no log, and no user-visible error: just a row quietly missing from the tooltip, and a blank icon and title if it's the llm FK.

Suggested fix — be explicit about the impossible case:

from django.core.exceptions import ObjectDoesNotExist

for field, label in ADAPTER_LABELS:
    try:
        adapter = getattr(instance, field)
    except ObjectDoesNotExist:
        logger.error(
            "Profile %s references a missing %s adapter (%s)",
            instance.profile_id, field, getattr(instance, f"{field}_id", None),
        )
        continue

ObjectDoesNotExist catches the real condition without the AttributeError blanket, so a genuine typo in ADAPTER_LABELS still fails loudly.

Related: test_unset_adapters_are_skipped and test_profile_with_no_adapters_has_empty_conf both assert on FK states the schema forbids, so neither covers this.

if not adapter:
continue
icon, model = AdapterProcessor.get_display_info(adapter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — the icon is resolved for all four adapters and kept for one, and each resolution rescans the entire SDK registry from disk.

rep["icon"] is only set in the llm branch below, but get_display_info resolves the icon unconditionally. The discarded work is not cheap:

get_display_infoget_adapter_data_with_key__fetch_adapters_by_key_value (adapter_processor.py:196-199) → Adapterkit().get_adapters_list().

Adapterkit is a @singleton, but get_adapters_list() is not memoised (unstract/sdk1/src/unstract/sdk1/adapters/adapterkit.py:64-86) — it loops every registered adapter and calls m.get_json_schema(), which is a file read:

with open(schema_path) as f:
    return f.read()

There are 37 adapter schema files in the registry. So each icon lookup is a full registry scan with ~37 open() calls, plus an INFO log line (adapter_processor.py:196). For a 4-profile project that is 16 lookups ≈ 590 file opens and 16 INFO lines per profile-list request — and 12 of those 16 lookups have their result thrown away.

Two independent fixes, both small:

  1. Only resolve the icon where it's used:
for field, label in ADAPTER_LABELS:
    adapter = getattr(instance, field)
    conf[label] = AdapterProcessor.get_model_label(adapter)
    rep[field] = adapter.adapter_name
if instance.llm_id:
    rep["icon"] = AdapterProcessor.get_icon(instance.llm)
  1. Make the icon lookup O(1) — Adapterkit already exposes a direct dict route that avoids get_adapters_list() entirely:
icon = Adapterkit().get_adapter_class_by_adapter_id(adapter.adapter_id).get_icon()

Same underlying get_icon() value, no registry scan, no file I/O. It raises RuntimeError instead of InValidAdapterId for an unknown id, which the existing except Exception already covers.

rep[field] = adapter.adapter_name
conf[label] = model
if field == ProfileManagerKeys.LLM:
rep["icon"] = icon
if conf:
conf["Profile Name"] = instance.profile_name
rep["conf"] = conf

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — conf is now a load-bearing wire contract with no schema and a client that can't tolerate its absence.

Two things worth pinning down:

1. The frontend dereferences conf unguarded. PromptOutput.jsx:132-140 does Object.entries(adapterConf)?.map(...), called with profile?.conf at line 381. Object.entries(undefined) throws — the ?. after the call is useless. Before this PR the client guaranteed conf existed because getModelOrAdapterId always returned { conf: {} } locally; now its provenance is entirely remote. The backend does always set it, so this isn't broken today, but the guard was removed at the same moment the value became remote, which is the wrong direction. Object.entries(adapterConf || {}) costs nothing.

2. Display labels are being used as payload keys. "LLM", "Embedding Model", "Vector Store", "Text Extractor" are human-readable strings, and "LLM" is indexed literally by the client at PromptOutput.jsx:374,376 and PromptOutputsModal.jsx:86 (profile?.conf?.LLM). The other three only reach a generic Object.entries tooltip, so they really are display text — but nothing distinguishes them. The comment on ADAPTER_LABELS at line 12 ("label shown on the Prompt Studio output tiles") actively invites someone to rename "LLM""Model" as a copy change, which would silently blank the tile title.

Also mixing "Profile Name" — a profile attribute, not an adapter — into the same flat namespace means the tooltip and the adapter set share one dict, so a future adapter type called "Profile Name" would collide.

Minimum: tighten the comment to say these are response keys and that "LLM" is read by key. Better, if you're willing: key conf by the FK name (llm, embedding_model, …) and let the frontend own the display strings, which is where they belong.

Small accuracy note: rep: dict[str, str] on line 33 is no longer true — conf is a nested dict, and super().to_representation() returns plenty of non-str values (UUIDs, ints, bools). dict[str, Any] is honest.

return rep
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""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_display_info",
side_effect=lambda adapter: ("openai.png", adapter.model),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — the test mocks out the method it's named after, so none of the new logic is covered.

_represent patches AdapterProcessor.get_display_info wholesale, so the assertions only exercise the four-line loop in to_representation. Every genuinely new and failure-prone branch in get_display_info (adapter_processor.py:115-138) has zero coverage:

Branch Silent failure if it regresses
is_available gate deprecated adapter raises InValidAdapterId into the list endpoint
or UNAVAILABLE_ADAPTER_ICON this is commit b30385d's entire fixicon: None
except around get_adapter_data_with_key exception escapes → 500 on profile list
except around adapter.metadata see the encryption-key comment above
`model or adapter_id.split(" ")[0]`

The mock also diverges from the real contract: the fake adapter exposes adapter.model, but production reads adapter.metadata.get("model") — a decrypting property. Mocking at the get_display_info seam is a defensible boundary for a serializer test, but it means no test anywhere touches the real attribute path.

get_display_info needs no Django — a SimpleNamespace plus one patch of get_adapter_data_with_key covers it. Suggested backend/adapter_processor_v2/tests/test_adapter_display_info.py:

@pytest.mark.parametrize("is_available,registry_icon,metadata,expected", [
    (True,  "/icons/adapter-icons/OpenAI.png", {"model": "gpt-4o"}, ("/icons/adapter-icons/OpenAI.png", "gpt-4o")),
    (True,  None,                              {"model": "gpt-4o"}, ("⚠️", "gpt-4o")),        # b30385de
    (False, "/icons/adapter-icons/OpenAI.png", {"model": "gpt-4o"}, ("⚠️", "gpt-4o")),        # deprecated
    (True,  "/icons/adapter-icons/Qdrant.png", {},                  ("/icons/adapter-icons/Qdrant.png", "qdrant")),
])

plus assert_not_called() on get_adapter_data_with_key when is_available is False — that assertion is the only thing that pins the gate.

One more worth adding, given this PR's whole premise is exposing this payload to users without adapter access: seed the fake metadata with {"model": "gpt-4o", "api_key": "sk-SECRET"} and assert "sk-SECRET" not in json.dumps(rep). One line, and it permanently pins the security claim in the docstring. There's precedent at backend/mcp_server/tests/test_no_credential_leak.py.

Two smaller notes:

  • patch(...) here has no autospec=True. A rename is caught, but a signature change (get_display_info(adapter, *, want_icon=True)) would sail through green while production callers break.
  • The icon fixture is "openai.png", which isImageUrl classifies as not a URL (no leading /). Real registry icons are /icons/adapter-icons/*.png. Harmless here since the value is never rendered, but it's a misleading shape to copy.

),
):
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"], "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 per profile for the display info
queryset = ProfileManager.objects.for_user(self.request.user).select_related(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — the select_related landed on a queryset that never serializes more than one profile.

ProfileManagerView has no list route. prompt_profile_manager_v2/urls.py wires only profile-manager/<uuid:pk>/retrieve/update/partial_update/destroy, so this queryset only ever yields a single profile and the join saves at most 3 queries.

The endpoint that actually populates llmProfiles — the one this feature exists for — is GET prompt-studio/prompt-studio-profile/<pk>/ (frontend/src/components/helpers/custom-tools/CustomToolsHelper.js:102prompt_studio_core_v2/urls.py:96), which lands on PromptStudioCoreView.list_profiles at backend/prompt_studio/prompt_studio_core_v2/views.py:376:

profile_manager_instances = ProfileManager.objects.filter(
    prompt_studio_tool=prompt_tool
)
serialized_instances = ProfileManagerSerializer(
    profile_manager_instances, many=True
).data

No select_related, same serializer, many=True. Since to_representation now dereferences all four FKs per row, that path does 4 lazy FK queries per profile — the exact N+1 this change was meant to prevent, on the hot path, unmitigated.

Suggested fix — add the same join in list_profiles:

profile_manager_instances = ProfileManager.objects.filter(
    prompt_studio_tool=prompt_tool
).select_related("llm", "embedding_model", "vector_store", "x2text")

Worth keeping the join here too (it still helps retrieve), but the comment above it implies it protects a list read that this view doesn't serve.

"llm", "embedding_model", "vector_store", "x2text"
)
filter_args = FilterHelper.build_filter_args(
self.request,
ProfileManagerKeys.CREATED_BY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ function PromptCardItems({
indexDocs,
isSimplePromptStudio,
isPublicSource,
adapters,
selectedHighlight,
details,
singlePassExtractMode,
Expand Down Expand Up @@ -114,32 +113,6 @@ function PromptCardItems({
);
}, [allTableSettings]);

const getModelOrAdapterId = (profile, adapters) => {
const result = { conf: {} };
const keys = [
{ key: "llm", label: "LLM" },
{ key: "embedding_model", label: "Embedding Model" },
{ key: "vector_store", label: "Vector Store" },
{ key: "x2text", label: "Text Extractor" },
];

keys.forEach((key) => {
const adapterName = profile[key.key];
const adapter = adapters?.find(
(adapter) => adapter?.adapter_name === adapterName,
);
if (adapter) {
result.conf[key.label] =
adapter?.model || adapter?.adapter_id?.split("|")[0];
if (adapter?.adapter_type === "LLM") {
result.icon = adapter?.icon;
}
result.conf["Profile Name"] = profile?.profile_name;
}
});
return result;
};

const getUpdatedCoverage = (promptId, singlePass, promptOutputs) => {
let updatedCoverage = null;
Object.keys(promptOutputs).forEach((key) => {
Expand All @@ -166,18 +139,18 @@ function PromptCardItems({
getUpdatedCoverage(promptId, singlePassExtractMode, promptOutputs) ||
coverageCountData;

const getAdapterInfo = async (adapterData) => {
// If simple prompt studio, return early
useEffect(() => {
setExpandCard(true);
}, [isSinglePassExtractLoading]);

useEffect(() => {
if (isSimplePromptStudio) {
return;
}

// Update llmProfiles with additional fields
const updatedProfiles = llmProfiles?.map((profile) => {
return { ...getModelOrAdapterId(profile, adapterData), ...profile };
});
// conf/icon come off the profile payload; the viewer may not have
// access to the project's adapters.
setLlmProfileDetails(
updatedProfiles
(llmProfiles || [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — the fix is partial; two other output surfaces still resolve model names from the viewer's own adapter list.

Removing the client-side lookup here is right, but the identical pattern survives in two places, both routed through getLLMModelNamesForProfiles (frontend/src/helpers/GetStaticData.js:499-514), which maps profile.llmadapter.model using the caller's adapter list:

  • frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx:145-151
  • frontend/src/components/custom-tools/output-for-doc-modal/OutputForDocModal.jsx:178-186

Both fetch /api/v1/unstract/{org}/adapter/?adapter_type=LLM, which returns only the adapters the viewer owns — empty for a shared-project user. So the Combined Output view and the per-document output modal still show a blank model name for exactly the users this PR targets.

Now that the backend ships conf.LLM on every profile, both call sites can read profile?.conf?.LLM directly, which also deletes two adapter API round-trips and lets getLLMModelNamesForProfiles go away.

If that's intentionally out of scope, worth saying so in the PR description — the "Can this PR break any existing features" section currently reads as though the fix is complete.

.map((profile) => ({
...profile,
isDefault: profile?.profile_id === selectedLlmProfileId,
Expand All @@ -192,15 +165,7 @@ function PromptCardItems({
return 0;
}),
);
};

useEffect(() => {
setExpandCard(true);
}, [isSinglePassExtractLoading]);

useEffect(() => {
getAdapterInfo(adapters);
}, [llmProfiles, selectedLlmProfileId]);
}, [llmProfiles, selectedLlmProfileId, isSimplePromptStudio]);

return (
<Card
Expand Down
21 changes: 14 additions & 7 deletions frontend/src/components/custom-tools/prompt-card/PromptOutput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useState } from "react";
import {
displayPromptResult,
generateApiRunStatusId,
isImageUrl,
PROMPT_RUN_API_STATUSES,
PROMPT_RUN_TYPES,
} from "../../../helpers/GetStaticData";
Expand Down Expand Up @@ -355,13 +356,19 @@ function PromptOutput({
>
<div className="llm-info">
<div className="llm-info-left">
<Image
src={profile?.icon}
width={15}
height={15}
preview={false}
className="prompt-card-llm-icon"
/>
{isImageUrl(profile?.icon) ? (
<Image
src={profile?.icon}
width={15}
height={15}
preview={false}
className="prompt-card-llm-icon"
/>
) : (
<span className="prompt-card-llm-icon">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — when icon is absent this renders a zero-width empty span with no placeholder and nothing in the console.

isImageUrl(undefined) is false, so an undefined icon takes the <span> branch and renders nothing at all. Combined with {profile?.conf?.LLM} two lines down also being empty, the tile renders completely blank rather than indicating anything is wrong.

The backend always sets rep["icon"] when the llm FK resolves, so this isn't reachable today — but it becomes reachable via the dangling-FK path flagged on serializers.py:36, and via any stale cached profile payload predating this shape.

One-character fix, and it reuses the fallback the backend already picked:

<span className="prompt-card-llm-icon">{profile?.icon || "⚠️"}</span>

{profile?.icon}
</span>
)}
<Typography.Text
className="prompt-card-llm-title"
ellipsis={{ tooltip: profile?.conf?.LLM }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Col, Image, Modal, Row, Typography } from "antd";
import PropTypes from "prop-types";
import { isImageUrl } from "../../../helpers/GetStaticData";
import usePromptOutput from "../../../hooks/usePromptOutput";
import { useCustomToolStore } from "../../../store/custom-tool-store";
import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper";
Expand Down Expand Up @@ -68,13 +69,19 @@ function PromptOutputsModal({
<div>
{displayLlmProfile && (
<div className="prompt-output-llm-bg">
<Image
src={profile?.icon}
width={15}
height={15}
preview={false}
className="prompt-card-llm-icon"
/>
{isImageUrl(profile?.icon) ? (
<Image
src={profile?.icon}
width={15}
height={15}
preview={false}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this 13-line block is byte-identical to PromptOutput.jsx:359-371 apart from indentation.

Worth extracting while it's only two copies — the width={15} height={15} preview={false} triple has to stay in sync with .prompt-card-llm-icon in PromptCard.css:150, which is exactly the kind of thing that drifts.

frontend/src/components/widgets/ isn't the right home (everything there is generic — space-wrapper, spinner-loader, empty-state); this is coupled to a prompt-card CSS class. But prompt-card/ already holds eight single-purpose leaf components (CopyPromptOutputBtn.jsx, ExpandCardBtn.jsx, PromptRunCost.jsx, …), so a new file there is conventional.

// prompt-card/ProfileIcon.jsx
function ProfileIcon({ icon }) {
  if (!isImageUrl(icon)) {
    return <span className="prompt-card-llm-icon">{icon || "⚠️"}</span>;
  }
  return (
    <Image src={icon} width={15} height={15} preview={false}
           className="prompt-card-llm-icon" />
  );
}

Both call sites collapse to <ProfileIcon icon={profile?.icon} />, both files drop their now-unused Image and isImageUrl imports, and the fallback above only has to be fixed once.

className="prompt-card-llm-icon"
/>
) : (
<span className="prompt-card-llm-icon">
{profile?.icon}
</span>
)}
<Typography.Text className="prompt-card-llm-title">
{profile?.conf?.LLM}
</Typography.Text>
Expand Down
Loading
Loading