Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
UN-2896 [FIX] Don't re-validate unchanged defaults in set_default_triad
The Default Triad UI submits all four defaults on every save, so validating
every submitted value locked a user out of changing any default once one of
theirs had been deprecated underneath them. Skip unchanged values, matching
ProfileManagerSerializer.validate, and cover it with a regression test.

Also drops the duplicated adapter name from the deprecation message and
promotes the inline logger in serializers to module level.
  • Loading branch information
Deepak-Kesavan committed Sep 7, 2026
commit 1a5d92c1067cce106cd4b3e7c30d44eb70f04bd1
47 changes: 22 additions & 25 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ def _resolve_selectable_adapter(adapter_pk: str) -> AdapterInstance:
raise DeprecatedAdapter(get_deprecation_message(adapter.adapter_id))
return adapter

# Payload key -> UserDefaultAdapter field backing that default.
_DEFAULT_TRIAD_FIELDS = (
(AdapterKeys.LLM_DEFAULT, "default_llm_adapter"),
(AdapterKeys.EMBEDDING_DEFAULT, "default_embedding_adapter"),
(AdapterKeys.VECTOR_DB_DEFAULT, "default_vector_db_adapter"),
(AdapterKeys.X2TEXT_DEFAULT, "default_x2text_adapter"),
)

@staticmethod
def set_default_triad(default_triad: dict[str, str], user: User) -> None:
try:
Expand All @@ -230,31 +238,20 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None:
organization_member=organization_member
)

if default_triad.get(AdapterKeys.LLM_DEFAULT, None):
user_default_adapter.default_llm_adapter = (
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.LLM_DEFAULT]
)
)
if default_triad.get(AdapterKeys.EMBEDDING_DEFAULT, None):
user_default_adapter.default_embedding_adapter = (
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.EMBEDDING_DEFAULT]
)
)

if default_triad.get(AdapterKeys.VECTOR_DB_DEFAULT, None):
user_default_adapter.default_vector_db_adapter = (
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.VECTOR_DB_DEFAULT]
)
)

if default_triad.get(AdapterKeys.X2TEXT_DEFAULT, None):
user_default_adapter.default_x2text_adapter = (
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.X2TEXT_DEFAULT]
)
for payload_key, field in AdapterProcessor._DEFAULT_TRIAD_FIELDS:
adapter_pk = default_triad.get(payload_key)
# The UI submits all four defaults on every save, so an
# unchanged value must not be re-validated -- otherwise a user
# whose stored default was deprecated under them could never
# change any of the other three.
if not adapter_pk or str(adapter_pk) == str(
getattr(user_default_adapter, f"{field}_id", None)
):
continue
setattr(
user_default_adapter,
field,
AdapterProcessor._resolve_selectable_adapter(adapter_pk),
)

user_default_adapter.save()
Expand Down
9 changes: 6 additions & 3 deletions backend/adapter_processor_v2/deprecated_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ def is_adapter_selectable(adapter: Any) -> bool:


def get_deprecation_message(adapter_id: str | None) -> str:
"""User-facing reason ``adapter_id`` can no longer be used."""
"""User-facing reason ``adapter_id`` can no longer be used.

``reason`` is surfaced verbatim, so each entry must phrase it as a
complete sentence naming the adapter and its replacement.
"""
metadata = get_deprecation_metadata(adapter_id)
if not metadata:
return "This adapter has been deprecated and can no longer be used."
name = metadata.get("adapter_name") or "This adapter"
return f"{name} has been deprecated. {metadata['reason']}"
return metadata["reason"]
6 changes: 3 additions & 3 deletions backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging
from typing import Any

from account_v2.serializer import UserSerializer
Expand Down Expand Up @@ -26,6 +27,8 @@

from .models import AdapterInstance, UserDefaultAdapter

logger = logging.getLogger(__name__)


class TestAdapterSerializer(serializers.Serializer):
adapter_id = serializers.CharField(max_length=FLC.ADAPTER_ID_LENGTH)
Expand Down Expand Up @@ -155,9 +158,6 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
)
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}"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""``set_default_triad`` must not re-validate defaults the user did not change.

The Default Triad UI submits all four defaults on every save, so a user whose
stored default was deprecated underneath them would otherwise be locked out of
changing any of the other three. Needs a live DB (integration tier).
"""

from __future__ import annotations

import secrets

import pytest
from account_v2.models import Organization, User
from django.test import TestCase
from tenant_account_v2.models import OrganizationMember
from utils.user_context import UserContext

from adapter_processor_v2.adapter_processor import AdapterProcessor
from adapter_processor_v2.exceptions import DeprecatedAdapter
from adapter_processor_v2.models import AdapterInstance, UserDefaultAdapter

LLM_WHISPERER_V1 = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e"


class SetDefaultTriadDeprecationTest(TestCase):
def setUp(self) -> None:
self.org = Organization.objects.create(
name="org-triad", display_name="Org Triad", organization_id="org-triad"
)
UserContext.set_organization_identifier(self.org.organization_id)
self.user = User.objects.create_user(
username="triad@example.com",
email="triad@example.com",
password=secrets.token_urlsafe(),
)
self.member = OrganizationMember.objects.create(
organization=self.org, user=self.user, role="user"
)

def make(adapter_id: str, name: str, adapter_type: str, available=True):
return AdapterInstance.objects.create(
adapter_id=adapter_id,
adapter_name=name,
adapter_type=adapter_type,
organization=self.org,
created_by=self.user,
is_available=available,
)

self.llm_a = make("openai|llm-a", "llm-a", "LLM")
self.llm_b = make("openai|llm-b", "llm-b", "LLM")
# The user's stored X2TEXT default, deprecated out from under them by
# migration 0007.
self.stale_x2text = make(LLM_WHISPERER_V1, "old-whisperer", "X2TEXT", False)

UserDefaultAdapter.objects.create(
organization_member=self.member,
default_llm_adapter=self.llm_a,
default_x2text_adapter=self.stale_x2text,
)

def test_changing_one_default_tolerates_a_stale_deprecated_default(self) -> None:
"""The regression: resubmitting the unchanged deprecated id must pass."""
AdapterProcessor.set_default_triad(
{
"llm_default": str(self.llm_b.id),
"x2text_default": str(self.stale_x2text.id),
},
self.user,
)

defaults = UserDefaultAdapter.objects.get(organization_member=self.member)
assert defaults.default_llm_adapter_id == self.llm_b.id
# untouched, still pointing at the deprecated adapter
assert defaults.default_x2text_adapter_id == self.stale_x2text.id

def test_newly_selecting_a_deprecated_adapter_is_still_rejected(self) -> None:
"""Skipping unchanged values must not weaken the guard itself."""
other = AdapterInstance.objects.create(
adapter_id=LLM_WHISPERER_V1,
adapter_name="another-whisperer",
adapter_type="X2TEXT",
organization=self.org,
created_by=self.user,
is_available=False,
)

with pytest.raises(DeprecatedAdapter):
AdapterProcessor.set_default_triad(
{"x2text_default": str(other.id)}, self.user
)