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] Self-review: close the remaining deprecated-adapter paths
- platform-service: the adapter_instance route's blanket `except Exception`
  re-wrapped every APIError as a 500, so both the new deprecation error and
  the pre-existing "not found" reported as server errors and logged a
  traceback. Re-raise APIError untouched, as the neighbouring route does.
- Move the adapter_id check into AdapterInstanceSerializer.validate:
  adapter_id is writable, so update/partial_update could set a deprecated id
  that create rejected.
- Default profile creation and the project-import warning gated on is_usable
  alone, letting a deprecated default land in a new profile without passing
  through the serializer.
- set_default_triad accepted a deprecated adapter as a user default straight
  from the API.
- New is_adapter_selectable() states the rule once: usable, available, and
  not deprecated.
- DefaultTriad: disable deprecated options instead of dropping them. Filtering
  emptied adapterList for an org whose adapters are all deprecated, which
  gated the effect that loads the current defaults, and left the current
  default rendering as a bare UUID.

Claude-Session: https://claude.ai/code/session_01DXuiGyUwXyU1EVQBeMHppe
  • Loading branch information
Deepak-Kesavan committed Aug 31, 2026
commit 4cecc1a60fe669c29c0479bb0b8c887dabec391b
31 changes: 22 additions & 9 deletions backend/adapter_processor_v2/adapter_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from adapter_processor_v2.deprecated_adapters import (
get_deprecation_message,
is_adapter_deprecated,
is_adapter_selectable,
)
from adapter_processor_v2.exceptions import (
AdapterNotFound,
Expand Down Expand Up @@ -210,6 +211,14 @@ def __fetch_adapters_by_key_value(key: str, value: Any) -> Adapter:
adapters = adapter_kit.get_adapters_list()
return [iterate for iterate in adapters if iterate[key] == value]

@staticmethod
def _resolve_selectable_adapter(adapter_pk: str) -> AdapterInstance:
"""Adapter for ``adapter_pk``, refusing one that can no longer be chosen."""
adapter = AdapterInstance.objects.get(pk=adapter_pk)
if not is_adapter_selectable(adapter):
raise DeprecatedAdapter(get_deprecation_message(adapter.adapter_id))
return adapter

@staticmethod
def set_default_triad(default_triad: dict[str, str], user: User) -> None:
try:
Expand All @@ -222,34 +231,38 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None:
)

if default_triad.get(AdapterKeys.LLM_DEFAULT, None):
user_default_adapter.default_llm_adapter = AdapterInstance.objects.get(
pk=default_triad[AdapterKeys.LLM_DEFAULT]
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 = (
AdapterInstance.objects.get(
pk=default_triad[AdapterKeys.EMBEDDING_DEFAULT]
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 = (
AdapterInstance.objects.get(
pk=default_triad[AdapterKeys.VECTOR_DB_DEFAULT]
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.VECTOR_DB_DEFAULT]
)
)

if default_triad.get(AdapterKeys.X2TEXT_DEFAULT, None):
user_default_adapter.default_x2text_adapter = AdapterInstance.objects.get(
pk=default_triad[AdapterKeys.X2TEXT_DEFAULT]
user_default_adapter.default_x2text_adapter = (
AdapterProcessor._resolve_selectable_adapter(
default_triad[AdapterKeys.X2TEXT_DEFAULT]
)
)

user_default_adapter.save()

logger.info("Changed defaults successfully")
except Exception as e:
logger.error(f"Unable to save defaults because: {e}")
if isinstance(e, InValidAdapterId):
if isinstance(e, (InValidAdapterId, DeprecatedAdapter)):
raise e
else:
raise InternalServiceError()
Expand Down
16 changes: 16 additions & 0 deletions backend/adapter_processor_v2/deprecated_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ def get_deprecation_metadata(adapter_id: str | None) -> dict[str, Any] | None:
return dict(metadata) if metadata else None


def is_adapter_selectable(adapter: Any) -> bool:
"""Whether an ``AdapterInstance`` may back a new profile, default or config.

Covers the three ways an adapter stops being a valid choice: usage
exhausted (``is_usable``), withdrawn from the SDK (``is_available``), and
deprecated here. Existing selections are not re-validated against this —
they stay readable so users can see what to migrate off.
"""
return bool(
adapter is not None
and adapter.is_usable
and adapter.is_available
and not is_adapter_deprecated(adapter.adapter_id)
)


def get_deprecation_message(adapter_id: str | None) -> str:
"""User-facing reason ``adapter_id`` can no longer be used."""
metadata = get_deprecation_metadata(adapter_id)
Expand Down
16 changes: 15 additions & 1 deletion backend/adapter_processor_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from cryptography.fernet import Fernet
from django.conf import settings
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import ModelSerializer, ValidationError
from tenant_account_v2.sharing_helpers import (
serialize_group_refs,
serialize_owner_refs,
Expand All @@ -15,6 +15,7 @@
from adapter_processor_v2.adapter_processor import AdapterProcessor
from adapter_processor_v2.constants import AdapterKeys
from adapter_processor_v2.deprecated_adapters import (
get_deprecation_message,
get_deprecation_metadata,
is_adapter_deprecated,
)
Expand Down Expand Up @@ -95,6 +96,19 @@ class AdapterInstanceSerializer(BaseAdapterSerializer):
Used for CRUD other than listing
"""

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Reject a deprecated adapter_id.

Sits on the serializer rather than the create view because
``adapter_id`` is writable, so update/partial_update reach it too.
"""
adapter_id = attrs.get(AdapterKeys.ADAPTER_ID)
if is_adapter_deprecated(adapter_id):
raise ValidationError(
{AdapterKeys.ADAPTER_ID: get_deprecation_message(adapter_id)}
)
return attrs

def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]:
if data.get(AdapterKeys.ADAPTER_METADATA, None):
encryption_secret: str = settings.ENCRYPTION_KEY
Expand Down
47 changes: 47 additions & 0 deletions backend/adapter_processor_v2/tests/test_deprecated_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from __future__ import annotations

import pytest
from rest_framework.serializers import ValidationError

from adapter_processor_v2.adapter_processor import AdapterProcessor
from adapter_processor_v2.deprecated_adapters import (
DEPRECATED_ADAPTERS,
get_deprecation_message,
is_adapter_deprecated,
is_adapter_selectable,
)
from adapter_processor_v2.exceptions import DeprecatedAdapter
from unstract.sdk1.adapters.adapterkit import Adapterkit
Expand Down Expand Up @@ -62,3 +64,48 @@ def test_deprecation_message_names_the_replacement():
def test_unknown_adapter_is_not_deprecated():
assert not is_adapter_deprecated("openai|some-uuid")
assert not is_adapter_deprecated(None)


class _FakeAdapter:
"""Stand-in for AdapterInstance; is_adapter_selectable reads 4 fields."""

def __init__(self, adapter_id, is_usable=True, is_available=True):
self.adapter_id = adapter_id
self.is_usable = is_usable
self.is_available = is_available


def test_selectable_adapter_passes():
assert is_adapter_selectable(_FakeAdapter("openai|some-uuid"))


@pytest.mark.parametrize(
"adapter",
[
None,
_FakeAdapter(LLM_WHISPERER_V1),
_FakeAdapter("openai|some-uuid", is_usable=False),
_FakeAdapter("openai|some-uuid", is_available=False),
],
ids=["none", "deprecated", "usage-exhausted", "withdrawn-from-sdk"],
)
def test_unselectable_adapters_are_refused(adapter):
"""Guards default-profile creation and set_default_triad."""
assert not is_adapter_selectable(adapter)


def test_serializer_rejects_deprecated_adapter_id():
"""Covers create AND update/partial_update, since adapter_id is writable."""
from adapter_processor_v2.serializers import AdapterInstanceSerializer

serializer = AdapterInstanceSerializer()
with pytest.raises(ValidationError) as exc:
serializer.validate({"adapter_id": LLM_WHISPERER_V1})
assert "adapter_id" in exc.value.detail


def test_serializer_allows_supported_adapter_id():
from adapter_processor_v2.serializers import AdapterInstanceSerializer

attrs = {"adapter_id": "llmwhisperer|a5e6b8af-3e1f-4a80-b006-d017e8e67f93"}
assert AdapterInstanceSerializer().validate(attrs) == attrs
5 changes: 2 additions & 3 deletions backend/adapter_processor_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,9 @@ def create(self, request: Any) -> Response:
):
use_platform_unstract_key = True

# Deprecated adapter_ids are rejected in AdapterInstanceSerializer.validate,
# which also covers update/partial_update.
serializer.is_valid(raise_exception=True)
adapter_id = serializer.validated_data.get(AdapterKeys.ADAPTER_ID)
if is_adapter_deprecated(adapter_id):
raise DeprecatedAdapter(get_deprecation_message(adapter_id))
adapter_type = serializer.validated_data.get(AdapterKeys.ADAPTER_TYPE)
self._enforce_llm_creation_restriction(request, adapter_type)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from account_v2.constants import Common
from account_v2.models import User
from adapter_processor_v2.deprecated_adapters import is_adapter_selectable
from adapter_processor_v2.models import AdapterInstance, UserDefaultAdapter
from django.conf import settings
from django.db import transaction
Expand Down Expand Up @@ -143,8 +144,8 @@ def create_default_profile_manager(user: User, tool_id: uuid) -> None:
"vector_store": default_adapter.default_vector_db_adapter,
"x2text": default_adapter.default_x2text_adapter,
}
# A valid profile needs a usable default for every adapter type
if not all(adapter and adapter.is_usable for adapter in adapters.values()):
# A valid profile needs a selectable default for every adapter type
if not all(is_adapter_selectable(adapter) for adapter in adapters.values()):
logger.info(
"Skipping default profile creation: "
"incomplete or unusable default adapters"
Expand Down Expand Up @@ -3137,7 +3138,7 @@ def validate_adapter_configuration(
]

for adapter in adapters_to_check:
if not adapter or not adapter.is_usable:
if not is_adapter_selectable(adapter):
warning_message = (
"Some adapters may need to be configured before you can use "
"this project. Please check the profile settings."
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/settings/default-triad/DefaultTriad.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Button, Select, Typography } from "antd";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

import { usableAdapters } from "../../../helpers/GetStaticData";
import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
Expand Down Expand Up @@ -67,7 +66,7 @@ function DefaultTriad() {
url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
})
.then((adapters) => {
setAdapterList(usableAdapters(adapters));
setAdapterList(adapters);
})
.catch((err) => {
setAlertDetails(
Expand Down Expand Up @@ -203,8 +202,14 @@ function DefaultTriad() {
{dropdownData
.filter((data) => data?.adapter_type === type)
.map((data) => (
<Option key={data?.id} value={data?.id}>
{data?.adapter_name}
<Option
key={data?.id}
value={data?.id}
disabled={data?.is_deprecated}
>
{data?.is_deprecated
? `${data?.adapter_name} (deprecated)`
: data?.adapter_name}
</Option>
))}
</Select>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,11 @@ def adapter_instance() -> Any:
f"{adapter_instance_id}, Error: {msg}"
)
raise APIError(message=msg, code=403)
except APIError:
# Already carries its own status and user-facing message (adapter not
# found, adapter deprecated); re-wrapping would report it as a 500 and
# log a traceback for what is a client-side condition.
raise
except Exception as e:
msg = f"Error while getting db adapter settings for {adapter_instance_id}: {e}"
raise APIError(message=msg)
Expand Down
Loading