From fee6da2de99e74257acf4c6e6fa9cefd369e00db Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:26:44 +0530 Subject: [PATCH 1/3] UN-2896 [MISC] Deprecate and remove the LLMWhisperer V1 adapter Adds a single DEPRECATED_ADAPTERS registry that drives every guard, seeded with LLMWhisperer V1, and deletes the V1 adapter package it retires. Guards (adapter-type agnostic, so future deprecations are a one-line entry): - excluded from supported_adapters, so it cannot be picked for creation - POST /adapter/ and /test_adapters/ reject a deprecated adapter_id - profile manager rejects pointing a profile at one; existing profiles on a deprecated adapter stay editable in their other fields - platform-service rejects execution off the is_available column, which is every SDK adapter lookup's single choke point Backfill migration marks existing V1 instances unavailable across all orgs (0003 used .first(), which marked only one row per adapter). Removes the V1 package, its icon, its dead env vars (POLL_INTERVAL, MAX_POLLS, STATUS_RETRIES -- V2 uses WAIT_TIMEOUT/MAX_RETRIES/RETRY_*) and the workflow-execution plumbing that forwarded them into tool containers. Claude-Session: https://claude.ai/code/session_01DXuiGyUwXyU1EVQBeMHppe --- .../adapter_processor_v2/adapter_processor.py | 11 +- .../deprecated_adapters.py | 49 ++ backend/adapter_processor_v2/exceptions.py | 5 + .../0007_deprecate_llmwhisperer_v1.py | 55 +++ backend/adapter_processor_v2/serializers.py | 38 +- .../tests/test_deprecated_adapters.py | 64 +++ backend/adapter_processor_v2/views.py | 10 + .../prompt_profile_manager_v2/serializers.py | 13 +- backend/sample.env | 9 - .../icons/adapter-icons/LLMWhisperer.png | Bin 15562 -> 0 bytes .../AdapterSelectionModal.jsx | 8 +- .../add-llm-profile/AddLlmProfile.jsx | 7 +- .../settings/default-triad/DefaultTriad.jsx | 3 +- frontend/src/helpers/GetStaticData.js | 6 + frontend/src/hooks/useSessionValid.js | 3 +- .../helper/adapter_instance.py | 14 +- .../adapters/x2text/llm_whisperer/README.md | 10 - .../x2text/llm_whisperer/pyproject.toml | 18 - .../x2text/llm_whisperer/src/__init__.py | 9 - .../x2text/llm_whisperer/src/constants.py | 106 ---- .../x2text/llm_whisperer/src/llm_whisperer.py | 451 ------------------ .../llm_whisperer/src/static/json_schema.json | 137 ------ .../unstract/workflow_execution/constants.py | 2 - .../workflow_execution/tools_utils.py | 10 - workers/executor/executors/legacy_executor.py | 5 +- workers/sample.env | 2 - workers/tests/test_legacy_executor_extract.py | 86 +--- 27 files changed, 286 insertions(+), 845 deletions(-) create mode 100644 backend/adapter_processor_v2/deprecated_adapters.py create mode 100644 backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py create mode 100644 backend/adapter_processor_v2/tests/test_deprecated_adapters.py delete mode 100644 frontend/public/icons/adapter-icons/LLMWhisperer.png delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py delete mode 100644 unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index a9c7f40e32..c249f68648 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -10,8 +10,13 @@ from tenant_account_v2.organization_member_service import OrganizationMemberService from adapter_processor_v2.constants import AdapterKeys, AllowedDomains +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + is_adapter_deprecated, +) from adapter_processor_v2.exceptions import ( AdapterNotFound, + DeprecatedAdapter, InternalServiceError, InValidAdapterId, TestAdapterError, @@ -39,6 +44,8 @@ class AdapterProcessor: def get_json_schema(adapter_id: str) -> dict[str, Any]: """Function to return JSON Schema for Adapters.""" schema_details: dict[str, Any] = {} + if is_adapter_deprecated(adapter_id): + raise DeprecatedAdapter(get_deprecation_message(adapter_id)) updated_adapters = AdapterProcessor.__fetch_adapters_by_key_value( AdapterKeys.ID, adapter_id ) @@ -67,6 +74,8 @@ def get_all_supported_adapters(user_email: str, type: str) -> list[dict[Any, Any adapter_id = each_adapter.get(AdapterKeys.ID) if not is_special_user and adapter_id.startswith("noOp"): continue + if is_adapter_deprecated(adapter_id): + continue supported_adapters.append( { @@ -112,7 +121,7 @@ def get_adapter_data_with_key(adapter_id: str, key_value: str) -> Any: @staticmethod def get_icon(adapter: AdapterInstance) -> str: """Registry icon for an adapter, or the warning icon if unresolvable.""" - if not adapter.is_available: + if not adapter.is_available or is_adapter_deprecated(adapter.adapter_id): return AdapterKeys.UNAVAILABLE_ICON try: adapter_class = Adapterkit().get_adapter_class_by_adapter_id( diff --git a/backend/adapter_processor_v2/deprecated_adapters.py b/backend/adapter_processor_v2/deprecated_adapters.py new file mode 100644 index 0000000000..d0e3bb2359 --- /dev/null +++ b/backend/adapter_processor_v2/deprecated_adapters.py @@ -0,0 +1,49 @@ +"""Registry of adapters that are no longer offered. + +Adding an entry here is the whole deprecation: the adapter drops out of the +supported-adapter listing, creation of new instances is rejected, and profiles +can no longer be pointed at it. Existing instances keep rendering so users can +see what to migrate off. + +``platform-service`` gates execution on the ``is_available`` column instead of +this registry (it is a separate service with no access to Django app code), so +a new entry needs a data migration that marks the matching rows unavailable. +""" + +from typing import Any + +# adapter_id ("name|uuid", as stored on AdapterInstance) -> deprecation metadata +DEPRECATED_ADAPTERS: dict[str, dict[str, Any]] = { + "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e": { + "reason": ( + "LLMWhisperer V1 is retired. Please switch to the LLMWhisperer V2 " + "text extractor." + ), + "deprecated_date": "2026-08-31", + "replacement_adapter": "LLMWhisperer V2", + "adapter_name": "LLMWhisperer", + "adapter_type": "X2TEXT", + }, +} + + +def is_adapter_deprecated(adapter_id: str | None) -> bool: + """Whether ``adapter_id`` is a deprecated adapter.""" + return bool(adapter_id) and adapter_id in DEPRECATED_ADAPTERS + + +def get_deprecation_metadata(adapter_id: str | None) -> dict[str, Any] | None: + """Deprecation metadata for ``adapter_id``, or None if it is not deprecated.""" + if not adapter_id: + return None + metadata = DEPRECATED_ADAPTERS.get(adapter_id) + return dict(metadata) if metadata else None + + +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) + 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']}" diff --git a/backend/adapter_processor_v2/exceptions.py b/backend/adapter_processor_v2/exceptions.py index 7aa2e586f4..05faa9a5fe 100644 --- a/backend/adapter_processor_v2/exceptions.py +++ b/backend/adapter_processor_v2/exceptions.py @@ -23,6 +23,11 @@ class InValidAdapterId(APIException): default_detail = "Adapter ID is not Valid." +class DeprecatedAdapter(APIException): + status_code = 400 + default_detail = "This adapter has been deprecated and can no longer be used." + + class InternalServiceError(APIException): status_code = 500 default_detail = "Internal Service error" diff --git a/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py new file mode 100644 index 0000000000..b9e57b5110 --- /dev/null +++ b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.30 on 2026-08-31 06:48 + +import logging + +from django.db import migrations + +logger = logging.getLogger(__name__) + +ADAPTER_ID = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" + +# Frozen copy of the adapter_processor_v2.deprecated_adapters entry. Runtime +# behaviour reads the registry, so wording drifting from it changes nothing. +DEPRECATION_METADATA = { + "reason": ( + "LLMWhisperer V1 is retired. Please switch to the LLMWhisperer V2 " + "text extractor." + ), + "deprecated_date": "2026-08-31", + "replacement_adapter": "LLMWhisperer V2", + "adapter_name": "LLMWhisperer", + "adapter_type": "X2TEXT", +} + + +def mark_llmwhisperer_v1_deprecated(apps, schema_editor): + """Mark every LLMWhisperer V1 instance unavailable, across all orgs. + + platform-service reads this column to reject execution, so a row missed + here would fail deep in the SDK instead of with the deprecation message. + """ + AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance") + + updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( + is_available=False, deprecation_metadata=DEPRECATION_METADATA + ) + logger.info("Marked %s LLMWhisperer V1 adapter instance(s) as deprecated.", updated) + + +def reverse_deprecation(apps, schema_editor): + AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance") + + updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( + is_available=True, deprecation_metadata=None + ) + logger.info("Reversed deprecation for %s LLMWhisperer V1 instance(s).", updated) + + +class Migration(migrations.Migration): + dependencies = [ + ("adapter_processor_v2", "0006_adapterinstance_adapter_org_modified_idx"), + ] + + operations = [ + migrations.RunPython(mark_llmwhisperer_v1_deprecated, reverse_deprecation), + ] diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 8209dd75c7..8df95acc8d 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -14,6 +14,10 @@ from adapter_processor_v2.adapter_processor import AdapterProcessor from adapter_processor_v2.constants import AdapterKeys +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_metadata, + is_adapter_deprecated, +) from backend.constants import FieldLengthConstants as FLC from backend.serializers import AuditSerializer from unstract.sdk1.constants import AdapterTypes @@ -28,6 +32,26 @@ class TestAdapterSerializer(serializers.Serializer): adapter_type = serializers.JSONField() +def _add_deprecation_info(rep: dict[str, Any], instance: AdapterInstance) -> bool: + """Stamp availability keys onto ``rep``; returns whether the adapter is usable. + + The registry is consulted alongside the stored flag so a newly deprecated + adapter reads as deprecated before its backfill migration has run. + """ + is_available = instance.is_available and not is_adapter_deprecated( + instance.adapter_id + ) + rep[AdapterKeys.IS_AVAILABLE] = is_available + rep[AdapterKeys.IS_DEPRECATED] = not is_available + if not is_available: + metadata = ( + get_deprecation_metadata(instance.adapter_id) or instance.deprecation_metadata + ) + if metadata: + rep[AdapterKeys.DEPRECATION_METADATA] = metadata + return is_available + + class BaseAdapterSerializer(AuditSerializer): # ``shared_groups`` is no longer an M2M on AdapterInstance — declare it # explicitly so ``fields = "__all__"`` continues to expose it. Share @@ -99,15 +123,11 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]: rep[AdapterKeys.ADAPTER_METADATA] = adapter_metadata - # Add deprecation information - rep[AdapterKeys.IS_AVAILABLE] = instance.is_available - rep[AdapterKeys.IS_DEPRECATED] = not instance.is_available - if not instance.is_available and instance.deprecation_metadata: - rep[AdapterKeys.DEPRECATION_METADATA] = instance.deprecation_metadata + is_available = _add_deprecation_info(rep, instance) # Only retrieve context window and icon for available adapters # Avoid SDK calls for deprecated adapters - if instance.is_available: + if is_available: # Retrieve context window if adapter is a LLM # For other adapter types, context_window is not relevant. if instance.adapter_type == AdapterTypes.LLM.value: @@ -178,11 +198,7 @@ class Meta(BaseAdapterSerializer.Meta): def to_representation(self, instance: AdapterInstance) -> dict[str, str]: rep: dict[str, str] = super().to_representation(instance) - # Add deprecation information - rep[AdapterKeys.IS_AVAILABLE] = instance.is_available - rep[AdapterKeys.IS_DEPRECATED] = not instance.is_available - if not instance.is_available and instance.deprecation_metadata: - rep[AdapterKeys.DEPRECATION_METADATA] = instance.deprecation_metadata + _add_deprecation_info(rep, instance) rep[common.ICON] = AdapterProcessor.get_icon(instance) diff --git a/backend/adapter_processor_v2/tests/test_deprecated_adapters.py b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py new file mode 100644 index 0000000000..621b516592 --- /dev/null +++ b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py @@ -0,0 +1,64 @@ +"""Guards for the adapter deprecation registry (UN-2896). + +Every entry in ``DEPRECATED_ADAPTERS`` must be absent from the SDK registry and +absent from the supported-adapter listing, so a deprecated adapter cannot be +re-registered or offered for creation without this failing. +""" + +from __future__ import annotations + +import pytest + +from adapter_processor_v2.adapter_processor import AdapterProcessor +from adapter_processor_v2.deprecated_adapters import ( + DEPRECATED_ADAPTERS, + get_deprecation_message, + is_adapter_deprecated, +) +from adapter_processor_v2.exceptions import DeprecatedAdapter +from unstract.sdk1.adapters.adapterkit import Adapterkit + +LLM_WHISPERER_V1 = "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" + +REQUIRED_METADATA_KEYS = {"reason", "deprecated_date", "adapter_name", "adapter_type"} + + +def test_llm_whisperer_v1_is_registered_as_deprecated(): + assert is_adapter_deprecated(LLM_WHISPERER_V1) + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_is_not_in_sdk_registry(adapter_id): + """A deprecated adapter must not be registered in the SDK.""" + assert adapter_id not in Adapterkit().adapters + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_metadata_is_complete(adapter_id): + assert REQUIRED_METADATA_KEYS <= set(DEPRECATED_ADAPTERS[adapter_id]) + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_deprecated_adapter_is_not_offered_for_creation(adapter_id): + adapter_type = DEPRECATED_ADAPTERS[adapter_id]["adapter_type"] + offered = AdapterProcessor.get_all_supported_adapters( + user_email="someone@example.com", type=adapter_type + ) + assert adapter_id not in {adapter["id"] for adapter in offered} + + +@pytest.mark.parametrize("adapter_id", sorted(DEPRECATED_ADAPTERS)) +def test_json_schema_is_refused_for_deprecated_adapter(adapter_id): + with pytest.raises(DeprecatedAdapter): + AdapterProcessor.get_json_schema(adapter_id) + + +def test_deprecation_message_names_the_replacement(): + message = get_deprecation_message(LLM_WHISPERER_V1) + assert "LLMWhisperer" in message + assert "V2" in message + + +def test_unknown_adapter_is_not_deprecated(): + assert not is_adapter_deprecated("openai|some-uuid") + assert not is_adapter_deprecated(None) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f2aef82b0c..f350a9204f 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -33,9 +33,14 @@ 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, + is_adapter_deprecated, +) from adapter_processor_v2.exceptions import ( CannotDeleteDefaultAdapter, DeleteAdapterInUseError, + DeprecatedAdapter, DuplicateAdapterNameError, IdIsMandatory, InValidType, @@ -130,6 +135,8 @@ def test(self, request: Request) -> Response: serializer: AdapterInstanceSerializer = self.get_serializer(data=request.data) 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_metadata = serializer.validated_data.get(AdapterKeys.ADAPTER_METADATA) adapter_metadata[AdapterKeys.ADAPTER_TYPE] = serializer.validated_data.get( AdapterKeys.ADAPTER_TYPE @@ -247,6 +254,9 @@ def create(self, request: Any) -> Response: use_platform_unstract_key = True 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) diff --git a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py index 5fa6d3bf93..7b3825fec4 100644 --- a/backend/prompt_studio/prompt_profile_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_profile_manager_v2/serializers.py @@ -2,6 +2,10 @@ from typing import Any from adapter_processor_v2.adapter_processor import AdapterProcessor +from adapter_processor_v2.deprecated_adapters import ( + get_deprecation_message, + is_adapter_deprecated, +) from adapter_processor_v2.models import AdapterInstance from rest_framework.serializers import ValidationError @@ -29,10 +33,11 @@ class Meta: validators = [] def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - """Reject a change to an adapter the requester cannot access. + """Reject a change to an adapter the requester cannot access or use. An unchanged value passes, so a co-owner can still save a profile - that points at an adapter shared only with the owner. + that points at an adapter shared only with the owner, and a profile + already on a deprecated adapter stays editable in its other fields. """ request = self.context.get("request") if not request: @@ -44,6 +49,10 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: continue if not accessible.filter(id=adapter.id).exists(): raise ValidationError({field: "No access to the selected adapter."}) + if not adapter.is_available or is_adapter_deprecated(adapter.adapter_id): + raise ValidationError( + {field: get_deprecation_message(adapter.adapter_id)} + ) return attrs def to_representation(self, instance): # type: ignore diff --git a/backend/sample.env b/backend/sample.env index 700422d1a4..0ad5903053 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -150,15 +150,6 @@ SYSTEM_ADMIN_EMAIL="admin@abc.com" # Set Django Session Expiry Time (in seconds) SESSION_COOKIE_AGE=86400 -# Control async extraction of LLMWhisperer -# Time in seconds to wait before polling LLMWhisperer's status API -ADAPTER_LLMW_POLL_INTERVAL=30 -# Total number of times to poll the status API. -# 500 mins to allow 1500 (max pages limit) * 20 (approx time in sec to process a page) -ADAPTER_LLMW_MAX_POLLS=1000 -# Number of times to retry the /whisper-status API before failing the extraction -ADAPTER_LLMW_STATUS_RETRIES=5 - # Enable logging of workflow history. ENABLE_LOG_HISTORY=True # Interval in seconds for periodic consumer operations. diff --git a/frontend/public/icons/adapter-icons/LLMWhisperer.png b/frontend/public/icons/adapter-icons/LLMWhisperer.png deleted file mode 100644 index 417301d4684193ce580b8f9fa4308035c9686cd6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15562 zcmd73by!qU_dj}OU_iPg%ck#6a3 z=AQBW{hoWj&%N*c^FH^lnRCwGYp?ZLx%XKoOzVj<5k4J0002a)DsUYDfP#Oaz%3l` z=g4d90sxo+Rrn)4uhi`s&(!Bnn{W09Pg9d0-YPgEm%9b^foTV3hiZTLfS>*7mSSP> z{mjgXHxh)V_o;8WXv5(jl2M%OJ#6pk6eU96Fq3MN-r^OaxK$uz8yFa<0mY8v7x&Gm z^zfc7rQvfC^!;YbVj*ptSp9Q`jEpSy1y?XJGYbpLaXu9M(UIc@zzbOJ{|_&c_kT`_ z6rL+hm;7G(X1$u;`heML0rv7LE~wa}zU4*q=cNNZsN23mXsF7CwcoxOuP8h7p)3-^)Dvp-FE?$d zAcHALZa1aUQBOhKdhom}@tpj_)I5gKpQ2OIQPxhd8qHVz;TEb#{B2?q&KZGA-Fu`a~6V)=(q8x8uGpe|I0Neg5~GXhN~s14O&cmiIOo|DuxQ=>)2 zqleRI^6Rm4MUpVwA2K=kiaI@tK>wysStb9_tA%0M=<1TKnw|M|5C)Q}Gv^O_2}r$Q z3>Kq{X7OY3$T95yTrjB@s>yN7V^Fc%*Mz@PliLH$9z%d4KAb!gV^f>Iq$WJv)KB7D zzL#%}oELpymxIZ{;o}9~CELp`1Z9ucdFq$wDJrnP0->SZM*f>P(}od&Q7|3|#)A$x z<{H{OoLqQsUN6wMuMf-_-C6N@QiLIFZ_s?!SowOL2j(Uwo!0to?Hy)9#e26Oszz6D z#rl7*>zDTE0dP1RUj?&bF7m%!20KV1{}VY}^Mj$w@;n7aMO8)R=ki!Hd{P-#RCuyjnoc8E^04 zo5s~hNmfqoti!Z?L~Cg1(bEP3xl&%vr%uoC3}U8gU@ee{)(v7ave@iCc?b}XeC_MF zKd~DHjx>CR4>H!24ASJvR8zDS`1m0Y3UEV8hZp%+Di(NjybajxVSpaA>I^h>2SXk} zYneQ=!we25|L_Y6DDpszt`y;HcMTr^WewQ$erh)v{uAI-7Q9{`2r&63)GrahAP`q3 zMF}8(x*J1RCq=|+qqQVB@$8X~eQ_1}s_E8hS) zMl~b`fP!)6HXf|wwmOBux z1u*~o_b?P&>k|~P})%@f89SaqpqKj8IP?PrvgWNmqDkxB-j#}p1V&rMFk3dhq z=`X$G2by1LC=4)n=bWL;#eQcBiVWv;f*?u%Lp2v3O8|^jV!zx611BYy4+J?&t~Kq8 zkwE2r(o&XCv8Lgv0J}UabHYaWK87nc%aIlebM_S&`+YsY^rPQ@Wa7$$mVH_NCv5CP zwd1KMA03ES<qpH1-(Rdp4{i=sb{9)!K_7wTL(4pjkqmmr!$f|BlLGZ(VWY=F zWU+5_A*5uni!N!9=Z|5TNk_PH@Z9<^R)||yT`yb;nU0|76k0iyzLPN2$ zg#g7|P;I$&M{})D|C?n0i3GV~2#TYk|28IEfe*_k9{)I;lZhykp9-qVH75gWju_0kB?TTS4GthK?Z>;rN>Wb0tiKH}`JQSWh^}*lua~ znG4l|Ye%D9K=o%}0{#C|KL!{Op4fHrb#87z@zWXzJe$abzx;n>_)irT$7QW-+;i_= z+7Qfx#{VzXBS?^;#maw0p@pPR{fqOjY>RR*YShkm0dZ)Lx1>Z?-D%dJ8COcfShWUM z$^!`7BMg@ndH1?(fxrKfLIwK(JF;qSPD>eFKKHQ<+)7$%yKzN$oluS(gA$u=gZ?@r zk`2SNN)33I<=NIG(jI6F+{iWBZDk{NM$`9@suF^0|Yd9DEKlD6oCw42%;RRjepG(qz}dH|61aE2tlAkKo8g=4g3^}d4>7(ZF;l(T%}D#&XB z;Vj)VcL!mA)=`J8HIk2>7`}~U8L>pNR4^v&Tpwfy>4r*z9YQfno)bH%hC;ug>t!1ZS zXZnxtq<_B`Ovw1^)xp;bGQZm8k>$4-@N+Zv<$i^n$UwjP0<&^3^XJ8%8gNpRC~JJl zVvoa8af@WUv?m_ODY)4k{KpbA{|W(O64$NN|Dg5B+o81u)EaJDMgEmCIO=no$c&et z*9k1?TYX=sy1`+Y)FU?{ax+-WEVo~DN|)ofoTzcCmHg9Gp`!LN4E4ZR3Q8Mqp87`% zWA%FOYdeJ`PFn$F6CmRMC>#miP;Pq70Ci6m@Z}U*5p!H%0&Tnf$LP3xl*TEz1~9$%&%efuHvT+2 zZqwd)3&PAmg)|zH0c~2jRHZ@Mb=6>}^kO|=hUwAfwDE|u7Fk}_Ohjn$^si`r^doM!w3}TljMb=8 zxtD~AA{mmjKAHT>lVki|H(4Ejw2$|{Jx9YFGEB04?29l?vKhBF0Y=Z_5SX9d$(Gwo2KhVDU=kqQ$ zCXDXLt&E#eAu8l+E;}ZGEY^p=u6hFq%>BE>_xvHHTgA1r`}G$c$k*U1S=ju;_tDHO z6-L;t(ZX`*^(FIbig1eY+)SpE&I574qIvtH*9*-I{gW80!3GB{gWam^{7B1+s9r)-0u3ut7I z<9J-G_dy6~riP`r)3^K8 zR?&s0kro8Vl^Vb*2#lpoVTO+Bq=mGPL5d>1s2)(BtPzUW8RfGSB|RDVl7U$pCIG$E8=<903-ft1 zO->z&`iWya;MQ}1uJ4=&NDuM_06JPKjNLxTP6Ybv4~olgY7O32AvADilQptGRp_+g zqiobO?#4_*?o4^TK}`xHu*p<1&Dpb?`L=t-ZD3 zthsW`<6_yTon{yNZ9OMG#1tijto8^X^fHt8*6BsBbVU_kj-O?DLa}u&*yIZIuJg}| zO`tAd!12biy<~eYV4%S@>y9q+lME8j8DP96U{8lHC$o#y4YfSQ7>D#{+bNZhHq}U~ zNA9CteveWGwt7okQ8KE_s`ah!lODogC`N*`Oq9oq=wlKFdH6`y2#Q!W(vrXR{^C`V zC2ed*GCY0kFiBMP8J8cVY&~m;48J%iG)*CdG#0@hCw@(mD(IM68Y=w#Xh8GBr9HYR zp1=nuGk;+>i8rXVu(h&$fP7}{d|I(zR`0>)6U_L!yrdA3(EGhi zf@I$JsY?OU{hVETQfFmJdtV=B%GFU>ucSM`T_PBO>Y1=c7E9qw+7Avv;B3N$4*(p^ zW}U^YNjx7M|6rEpq95U=0RPM_+JWgLiJ^iAFJ8pwB&kR4mz z-~?9B8?ZkJcV1!~3&nEs^`INp=PM??b2MJ@l4O3pZpkX#)xLXUR2!(-3LrF~ljx-T zQ@Jmn!B&TlR2i<{FX4`nT)3oYxB8?o2_8Ll$C;0VKe?{;V8l9Em|U`oiq``GmsV-SJLq(1UK1{#5PAm2SRw(7TmIu~_WB1+FRNB)q zS@5u87n;(+c+dT6a28G}_{axe4$G7DrD$$-xI2j)m!hn)*@lu_?BW5@wENur#ADox zt-4X8zVFR0ml}UuD+(x&aO6~UA$1p5#Ph>L0{9NQ8093j&;V;@eZ78MJMTndaQXvO zhn=pBdBazOqd%lWf%$boIkB8%>*-)n#N)NuS`q+fW|e;b?e%ruoHopgg6Xdq!OCdYjTCZXfw>N}3N{97S=RbN~KZ2yRF_Xg**wNQb=rjT7+$mV_n z;6&WOU2#i{mM-YluVgREsFQ>a`^!y8J!P<1xu4%6jFf!lG1WhW$Lt8pyiQd{24`6w zRV+GSAAP>AWHGf$T^in2pm5fRGj!`;B5n|)b#GxXzzzwg(eWfXvt?a-rP^n3Uma>Q#6(q9zzZa zEJMRz@b6;beA`SU3YhalW+Zl-489I9-etI(oh5i55&COHAF#Rdx_j1~Wl5Q^Z@T(> zZ%6>1`=GOUZGzGC+3Fb^NBKq?6Y5-v+|v4ww2bxWntX@>uRrKnsy)_7dG68nfGmYj z6^2O(0u;fjhRSjSnkp@2-sFk4=#{th;h5h=NEJ13ziak|;8%w^EP!7eOdAE?Rt-gN zP1^FdRtbiSRc*)xvc!g|c`%k)vRGO_kEsog-t*bu0ep!XNxWnbk%hMn1K;g2|1L%i zu7^{~VOi2&wBD!_I}PmHMjgs>2n1In=%E1!a?2GIL%?#ko}*{R0CZ@K(xRUT1OcZq z)&oY_BgEHy{@zTeWg3x*775$`W8RCn9MYcrRoTfC;>M;6*qZ6)>enwEnl^zKkd#!CrhBpF2NqwLr0ZA_p`vQe zkh;Hb^pP%}8lN5Yj?2kP*^<~kwGl3(hVH_1aU#P?uUR#XlHY#j@L^#xaqw9S^Ae09 zS$*&O7gY3T0st7NbJ_XpS;W1P4InoJkt&Sh^w{QmES*7*nGRv9k@mb1Cn#~zuyVKD zx1D4KepS*lFmu$NxSVa~lxP9CsY|5u^sv)6?)eqXc7$T0{Hmwyx8UH14;=9rUigog0YDjPYuGOur0ID*;e0Ca zMRuO$fZKlCOOpCI!8rO`Yh&mZjf{1Qb(U_irU*VN(#{ilI^jJYRc;Vr>M>`%r_}kN ze^nvNGE_yvU~@6nz>b5T2a)jUFULVf2=V^23T*)R!&i71X-#}~kXaaW!hFg*7VZ85 zG&%D0YH4ZG2HW`--F2RVmDyEg!axZ`WNlm;RErJ)YaaYIu8H0ud&*=lm0}+E@Qls< z@ug1$Ws&>dPdUKdo)pG3oyh0xUdWXfe5=D2OngN|&Bzq>{ zgq4@gOmb|k#d(&2O$hcOS_yR}sYgqDWB+Ct0wpr7SZ7ZeY<`EYu8OrP$8aRDJ;xdO zNh1%|Kg))F+5T-MSUB$n;_lj7Z;#4aRfBICT|~yNM4Mt8%|Z`VPnp8lRWs0Eeu@x- z#Z{c;w+NX?#ckSOyKjudZp4gjcELIIK!5tlLxb(W(Y~nZPO*#FA8xsWE;w!)pozy3 z+HcU24AaYX1Sp8vp40lscYRgz0G(;7l|=8Aq^jO^z(m3(6zH_;uY3D*57G7gKU!(09xZAQ=pLvoLBqF3#J$wO4d( zG*{>+RFrXZcN2{8YI8XW(^$p@7f}9a+pnhtk$-=R>w{5^z{GO^%Y0k-&YWN3UlI>oMs*%_iS2%!83N`w;qeh~ZWl6d1Khu*@&&2q z*II4|fqr>mMve_jk)8c7=rd0HfM#3BZ;c6JMM2bWPGkEn80{MH3tjuIyPxksljz!b zoz`_F2j~|=^U;12rt{~=4RqCuTskxZ%o-o5N>|I^p`}0 zvMuL;3W3`KefEMLV~1u>nT!{2us`Nb-R>`I17+|2ZP=&YZZPJJwca`7)zm}6)WnYn zXIst(sxr`QXXnDVB`ZL8NNy}xc^tkW5Qo#uD#sR8Y(30b>~?1_YsgS=$D)Q_yGwLr z{8<1#AXmT8D5t!*WE2VIAD0XYQU2Fw z>{$w9@-hU-M$0^kpdIY4;uc_QWkxHh3!*QXaak;YxD}gSmJpr8bgC*-lD+B&U)!A`l*G7LR}(63<_+eTVUC@(c1Hz zw!ZNZuT;tjZ!I$b&MVgnE;CYQfUNKyKDdOq{N2y5@y67Ts8qhE0DdOVDVwns^77i~O7iSjniXURp zQzz-5QyzSOkUebF_+`OEn4b-iZo+*&aozpcy*0@Ah5$gG2V(JcFm=n4x+6E2Y`as* z2Ra(=YO%>Qx-GoK@w~ZxwLE0#)>ogdtUP98eEx|`2~xMy1e^bf7y0mWj~S!k@vvz( z6xe%^z7z=Rqo;6I|I8y@DGwn_YO6_Z*lJsVU|M-B?9=SmO9?syp;rK1r@w%*!1|5( zFJo1OZA-i4q2k*a1%Fp-wO0=-*5ghvZ~7lt&ZgUIV=46;cQbQYd#ocx)yxL=Xw zz(~ZiF2NA-x$PokK$nzHE9_*CRaxeagi15jCLwU-MDb_HooCPQm!+Pc!>_I<2Z30J zMKUs%INGZ$`~(bew#UWQ_Dt#9O{J&0t<-3VF#ZgqA015L1^`gfvIi?0eCFSTNi|R3 zBz4)&C@1~21p!yZYINAYWxpXsa~1PaH^b-BOxEGY<1B@DI!)?8sp$D9Gw3}%*JW}a z+O{pb`-PutXHK$1Q=s2A*nPb2;d<|wmUE5Jj4LJYL=Re?FqpptQG1y3e&M6P+JeVR z|EB_jKyghrnnr-JXt6u$<^1*2LCLmVtndILzgt8mS^3c1&|>Qa(<8)e^py9@eUb&H zhm&J0d^cK0Q2x^`b~wGFa4FFVttc6k*0DT#FoZ-P^a~hU{&pBqM^%hR&%Ma=9eOX5 z)r5@iy!>RLIP-DkX~zQq5Xzq+db*`!JABXbXA!jQ_wA~#mV9}(U&SkV7N8s88XVo4 zBttsAA+luuM10)-UWQCUg^u!)5jm0KcaUeEF`H@0y&`Rgo-!*+ubg4T@&HfquDoLL zefg(o-Vw9Ju{DM&iR?&FAMOGv!2_(R>mekqVu;YHq1(t`I1d9D>jK|K+kN$8SuK_z z7_!&xHEENwzXfUt566c0c}ThHT22xg%6r3QJ~y%0%0Y$vRdTT**t?>Dj3yHMJ7nN4 zq2D1PW7b*rjtSfplZONu+E1_z6p)eL5)G$JjQR_jl>ALx9+_sfbqfi-8ck$A%R4+B8a1VJ&u|@^anq+(P!% zA$!)x+^7MdX*lxG_M6XxTIb#$&A0a|47%1L7EI(Dn^_U0u`OQ56zGBHr}I@=9kPzA z3a8k-H}>EwR*_xC0P2BA8UIq>rb{4F{p+g0kE`Fe>OS9bR9R<7OEIKe>bqHiexr}d< zbDZs~62An`ZQ8SE>}Wldhv!Jr^~W#1uX-)rozFngVtrDujhmDR^-C=|*VnWKL_RfE zPV9Yb3#?2q^C!X0ptroo@0nUzB-=t7oij6_;wG0NkxMX{mL6^UEpJoMek1jTBcD_1 zGQCdVc!O6ixXX4Nm9&v>A5B6tbq2iLAAGykCR5lU%a{iFI-V7hVA#^Jf?9Asi#Ox9 z{C(j}YXWBa*tN5JnJKg5KV73OvhOuSRaAR05C?D#k=SzCOe_t*HfS{rIGYbv9boSf z;{U?;`)xN#=JH3E6D20MgRXNDy_3^|lmQY3_DwxtQS4|)u&>qumT}Nf`XKM_@93hz zxevAYC3XpE4X-N63~TXS*eLvb;@%qBcPY1(Kt#M}5?RYG%h#1yoUA58js5BG$EE5Q zS~`|GsGar-e41pkg_gZu*U(YuYz^zZyd1lW#%EwB0auc!s%PqxEWzPux)5FqOcMWg zp?hBGK-&2c)qMtW|8{4MLgOe|mH3tZ13yu1JCU+;i56=-ev-Q6ZfEOFveQdipSXTz zd)vx%h97;`GRP?i3W5sEvOS7u+vaIpG#qzkXD5E*Q!8ihZKtd@Z1TtRZ+mSk^v`&soyPey?)y8V(_L{E ziLF&wQNy!hySQ_Ayl67-J;LuQ$+KOFy>O4`p+e>!rQ_Ani%|kg6egnEe+#=(?AtdM zROLi-nyd3@muPpxoS-khMJUUk?gsgb?hWDZk#dD#jB|Iy>Atz((K&VH7By4w|RDDdjgG? zC&a1S!yA6yk40VLm}IUN!4=eC%yu@N%O3+H-Ra^F(yP#0rB7por$FJKYnJtT?StbeAAFQ<}trxlX_?Oqh(_nuHW5Q zjgCDQ_D;cpT}>bJlv!cO=`(Ye5zEqc!FknGwD}dT0rD~;NjhSR;7HFjGq9D$zn9~e zvutzF7Wj_s_Dj~UN0BH8F`M@Nm)Mh>4!`8*E^ie*nNfe;f1|2too%{i?8^;QBZdkx zvhr7@9GeE2w!!XJ+pEq$`4XtFK zZ+0$6aMSX&Ud#;KmxEb$7owjVEoB%U5Axrlg~-r5i2a5uzv^8bp4n5C^>I0l(N@nW z0LFf2;NE=F;Ek&9b%UqUqN2RrIs9HVh=CjFX}>Fpcq z86IO`ZQP6AxlVwEH>y2 zMlE5+-d-cOndYjd{gY38O+j>h0yur`IAf`|Hi5VAkz8mi+%QO}X>X9rB~(qvqE^5e zpO#YF;H5pul=e9qnrpQ|&Y8-8;N!Mrt6k?7tR;91m?oHt+$puKj%Hfl_tBvLl)*AA z`AU1DlspvIF1e;x{Qt-sSI)8R0qu`}ek{#dj+<7|LQO~MB zE3jcPW>Ye3XV9G|4n4AZ+7}nCa`0ku&rB!=l6H7{s^&)w{DbAsSUeQ zCS+(yq(&n~ZX=67JDPnzw_B$bFM}=_bo$!!-YXw3QJtp&rpMhFV?TM39#UMFk$DCa zvuuKi*-$w25ZM|W9gB7C8lc{o26g+s&WqD5&altFV*>+8SSphdtiC0tOUOJJqSmxdToxbR?6^V(iePTxSqQr-Wz9N3r}g)hfgHSLNjmGA0i zSX!esvq~-fdalRIu$yDGw%F77n=u&~0VU8B6d1%ZEVI!ij31}i z32=qJUJ3uvgozY(rLs6L*v(HrBy8=^2M^Yj;u&f2W?P_=jgLm zgA#R(hS6;bms0-j6B~W$aF(n&l`DjxEg+&oHq{TmFMETF+?H4FDZjPmm#|Ou<*e7| z@hkn7BN1+GidE&zW~N*bYRKr`fw;_TmFEKpRWWYB`(c-i+K;I9@(Ls05BSQ0Q%qmi za>oxA2Mo>;^P?!F&j4@zuP}kAl|1+Bq%2`?^)IBnHceJVmhtX6`*A~%{oV*n=Jk4pd`K+VkakcqLyYz6qR`m; zBMjA}u~(!3E1P@pCm2VPx|U*iB`Dh%dA?#!b7s+=fzO~cq5#Xh_1lro#ciE7c&R_T zf9>{~W=QyuuXm;eH?=>p!=RRU?v4+#z78GNNmg0q9a9V%8(Ed1ERF!lq$J`K)Cl`w zC5)yl(KCeb#kaJ^C5^VThh_fVJUT*X)VAouldH~rhYOQ+{x~PFUrS)R26awQX1&e{ z6J`>7i#JOx5n8Ry@%K+mI78TZS5OpKj2Wc2*N3|u-9e9aH_W}`p2~x2v2CA_&HH5D zMSS}1=7jSEG(TRjeHYW>^3owS)cqDeV|-At0dndWe7y52EARx&xIt{bU;~6rjXx14 zoKl*+bBD3tgzTq(9Hx_ldKlFtC)hW1prjz)v z905X8Jtc?ZCWLvHNtRiA>x$|6Jm7aY>J_HsG&l!R%gvdvLA2ZBg?y1c%Qo zgU8q62+kfpDrEL&YgU0BzyqH_(exK2HOifl!ryM4fAfWTmB$WF4GH}6=;pa5mEK(_ zW81Z6(u}<3sT8=m0#8}v7PTOp6&}-6bwsq(65+)QhVz#^D=)ryam=$M{c?!spQJYO@8q`ajNe?6$KlHj|Ewa>eeqyO7D4HawFXm5RZd1*7Tf+! z+Zxvh8)nEUA88Ep(=^wN^wfz|$0gen+Y?R2MKNWL>>-(im*=wHI+hvMFwF2QEX|Th zgtLKNjOGdc_&HQF%kj_Dk@sB!`r#Q&5*qa52P6Dy(mAfP#Cgy8=U}!E`NR90E5Dmz zr;zmHW{`+5-T!I3WF2$ygICuVFJE(YGF|VCoITy9DUcOG5BfW>@ylrrXt^WtzF7aA z(493Nj&td(cDA1noOeAh^mF&2|B-(N;c1mQUdbmIt)~I3oeNFi2Eg6 z0*Tjd&)Y@j7n}(ZZU=B5MqH&N4G#o}<-gGQQ5WZ%r1o*swLMh_@?oNCS`h_CNR^EK z_PP&0>p707+wU5H$Dl|HFKd9B=!e6toA)|e+{gD09s!@c!+P&fSLoQmw5_?$`abpc zap^bs^?oTG+q6}QvUlW+qQ~-Mq;39NRIBxZe#uX<&RoZy8#wsvJ&qzTk@v;6>TwsN z(OqC6c5B{vd{J`x5Uon98}z6&V5Sb&GO)9r@ln4x6=0~l8}YUz%hTAPCmNrhpzecj z{iA$G>u*j~cZ=sgqFhn9;|C0ZIYBWt+h24GqVbVUhAGFE%o8sn2^bU$4;~Ku`gA6| zbX}U&u43-^%!ZVyO`L9t)I8_4--r2=Qru^?M8Yi&VDjZx{Q07gME|I&7IP?BlIVuj)sAF1v^Zt?J znbZcbr#qI_B^v6nxumUGa}UL_zHAiR8R9q){E^DF({H(Lf32}?F(EOK3<=L zj{9D`=H7r+sw%2+s_a_oCU{?)_of5vWODxYQ^bZ;2pRJ(nk6g(dFg61EO#HbVOA{B z1C)i?7fY9)7^kMlEw_uaoN4K51AA2U{Z~L`&cyl7@Xyz%0FbxG($GIhcPz5Qq^$@A zTyk?v-DqGg$WJ}Vwi-%eu~m909}f16UN}V`cO^%=yR38S&``~nqM}eyJS&s!7(ySeE=J9q0U=4}?E&h?tX8G!-c=se^oWzfZ9x;#pxy1>dj7X0rG& z2{kb+5l1%O?zW8FPakU2ag25huf!$ueX;duS69TjsJ&mvpx46CyQ69>9UrMzI@5xG z#f%_SjdwDa8StkiS&-6@-B|->|P4hv`;}M$tKB+k56Z5Hh(l5 z^5NMEw57<0d^pmEd^oe2@`}6<#%|?U4If4?oTb|=H*d>MjP7~{wh<^U`dP3&^DJIzcz)6wDJ4U{B0|0ks-B26)uTDdW&vaZu7@& zB~B^Qh6v6&g9l+F>M*%Dp{|;{i*)nS;Kw4AlS@al8|av!M{F~3J|%kCo#&$tKSGdm z@1t3Cv}(0qIP>0&&`4l03!5uR&H)4R?7F$84_*+opiE~OBtosg?$og-yFY_?Q6Gl1 z;cgBv7kr8m)!SfGk3F-UQ!UIe;T)Un1 zpijl7+T{nGzS>WGwLyp+(`;lQNyQ~3WKd*l_dyCJqfX_}ki@?QfQo^=A5T+? z*lJ@!KSR%R^$}3X#q~Ap(k6tFAgFA?PWj*Slef@>4DbVGnB0g(0z;H5WNPm1b2hv_ zq)ozsnb`6Df0MQnOM7WAvV5fL=n2aoVBqKV5&AA~kh3>&V={Tb= z<3!NMN*Lq&h54SBQ3n>_ra?X+K06wd?z2YbfZP|n^Wka7`&L~A`#023e* z%yPLn)boC6VI^HJ@^%n-awCp!YIPUVP%%OASxcd8xwvO|>Qjmlt?$5(3E~(k9Uqd( zwk6SmtPqNtrw(`p@`D6LsDPey!x|>=z)jMkCm|4ctg%-!^aw7O2(U$PTs(GYYYgIp zJO}AD1=}K*>JvR*?k7G3yJCB4Zi^%d-hLM7?b8+5 zjGS!QxR4s@j{RF*&W9IO7s?pWV`WAv5vjrp_CFVk4C0i%Q$^V#uZIkSSQ?1L5_#(q znwumMw6R8DW3XY0VT}-bbcZ}dM|$+Uh1%ct=5~PHr67GmSP!DVkJ$| z<+uBkWTRZe-@VZG>tZZ-LFz|OQ{r#`PIFvorX z+i?m7266WAabM6!3K3gY4#ir1yCLdtOX^wule>ArQVO=Fh83O0ewPlq4pK$o4iUY} zIs!Z)LP2+u=TBh+tZ_ylBdRIZ#=0-)2b6;ZPzga4D>ENy-Cb6Px*yZetIOk(_duzD zSffpUg#{T}CTwEt8UhBcrJG>)JDnL}by2EP#Y8@E5%8l8mFDX(HkaE!W*D~qO6R~OC7xw0C11jchW#VP3X&=8~n*ujjQbba3582q$ zJH6gJ-Lzjz<*)53j)0BvY^A@82^};7MY4Y=(Gt;KyY|Wj8h|mpRJ6p^0ghd4uw?&) z#5!S#CqKmclYx?GA6I4Mn!$kWQDn190;U%}lv0Ze>_q}U`;x`9ZvXdBi~c`&l8Gj% Yp`8G7%*D>YZ diff --git a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx index af1edd9525..35e5e20257 100644 --- a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx +++ b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import "./AdapterSelectionModal.css"; +import { usableAdapters } from "../../../helpers/GetStaticData"; import { fetchAllPages } from "../../../helpers/pagination"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; @@ -59,7 +60,12 @@ function AdapterSelectionModal({ const [llm, embedding, vectorDb, x2text] = await Promise.all(requests); - setAdapters({ llm, embedding, vectorDb, x2text }); + setAdapters({ + llm: usableAdapters(llm), + embedding: usableAdapters(embedding), + vectorDb: usableAdapters(vectorDb), + x2text: usableAdapters(x2text), + }); } catch (err) { setAlertDetails( handleException(err, "Failed to fetch available adapters"), 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 e5e120399b..8974aba60d 100644 --- a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx +++ b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx @@ -19,7 +19,10 @@ import { import PropTypes from "prop-types"; import { useEffect, useState } from "react"; -import { getBackendErrorDetail } from "../../../helpers/GetStaticData"; +import { + getBackendErrorDetail, + usableAdapters, +} from "../../../helpers/GetStaticData"; import { fetchAllPages } from "../../../helpers/pagination"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; @@ -200,7 +203,7 @@ function AddLlmProfile({ const embedding = []; const x2Text = []; - data.forEach((item) => { + usableAdapters(data).forEach((item) => { const option = { value: item?.id, label: item?.adapter_name }; if (item?.adapter_type === "LLM") { llm.push(option); diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx index 4496f3faf5..001cfcbc7d 100644 --- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx +++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx @@ -3,6 +3,7 @@ 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"; @@ -66,7 +67,7 @@ function DefaultTriad() { url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`, }) .then((adapters) => { - setAdapterList(adapters); + setAdapterList(usableAdapters(adapters)); }) .catch((err) => { setAlertDetails( diff --git a/frontend/src/helpers/GetStaticData.js b/frontend/src/helpers/GetStaticData.js index 9e067896c0..22d04e28dd 100644 --- a/frontend/src/helpers/GetStaticData.js +++ b/frontend/src/helpers/GetStaticData.js @@ -380,6 +380,11 @@ const displayPromptResult = ( return String(parsedData); }; +// Deprecated adapters stay listed so users can see what to migrate off, but +// they cannot be selected or counted as configured. +const usableAdapters = (adapters) => + (adapters || []).filter((adapter) => !adapter?.is_deprecated); + const onboardCompleted = (adaptersList) => { if (!Array.isArray(adaptersList)) { return false; @@ -808,6 +813,7 @@ export { titleCase, toolIdeOutput, UNSTRACT_ADMIN, + usableAdapters, wfExecutionTypes, workflowStatus, }; diff --git a/frontend/src/hooks/useSessionValid.js b/frontend/src/hooks/useSessionValid.js index a2c2c0e092..2b890cf5e1 100644 --- a/frontend/src/hooks/useSessionValid.js +++ b/frontend/src/hooks/useSessionValid.js @@ -3,6 +3,7 @@ import Cookies from "js-cookie"; import { useNavigate } from "react-router-dom"; import { listFlags } from "../helpers/FeatureFlagsData.js"; import { getSessionData } from "../helpers/GetSessionData"; +import { usableAdapters } from "../helpers/GetStaticData"; import { useExceptionHandler } from "../hooks/useExceptionHandler.jsx"; import { useAlertStore } from "../store/alert-store"; import { useSessionStore } from "../store/session-store"; @@ -148,7 +149,7 @@ function useSessionValid() { const getAdapterDetails = await axios(requestOptions); const adapterTypes = [ ...new Set( - getAdapterDetails?.data?.map((obj) => + usableAdapters(getAdapterDetails?.data).map((obj) => obj.adapter_type.toLowerCase(), ), ), diff --git a/platform-service/src/unstract/platform_service/helper/adapter_instance.py b/platform-service/src/unstract/platform_service/helper/adapter_instance.py index 270bfc1d77..f027845c51 100644 --- a/platform-service/src/unstract/platform_service/helper/adapter_instance.py +++ b/platform-service/src/unstract/platform_service/helper/adapter_instance.py @@ -25,7 +25,8 @@ def get_adapter_instance_from_db( _type_: _description_ """ query = ( - "SELECT id, adapter_id, adapter_name, adapter_type, adapter_metadata_b" + "SELECT id, adapter_id, adapter_name, adapter_type, adapter_metadata_b," + " is_available" f' FROM "{DB_SCHEMA}".{DBTable.ADAPTER_INSTANCE} x ' f"WHERE id=%s and organization_id=%s" ) @@ -37,4 +38,15 @@ def get_adapter_instance_from_db( ) columns = [desc[0] for desc in cursor.description] data_dict: dict[str, Any] = dict(zip(columns, result_row, strict=False)) + # Deprecated adapters are no longer in the SDK registry, so resolving + # one would fail with an unrelated error further down the call. + if not data_dict.pop("is_available", True): + adapter_name = data_dict.get("adapter_name") or adapter_instance_id + raise APIError( + message=( + f"Adapter '{adapter_name}' has been deprecated and can no " + "longer be used. Please reconfigure with a supported adapter." + ), + code=400, + ) return data_dict diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md deleted file mode 100644 index 0c1a9ea131..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Unstract LLMWhisperer X2Text Adapter - -## Env variables - -The below env variables are resolved by LLMWhisperer adapter - -| Variable | Description | -| ---------------------------- | -------------------------------------------------------------------------------------------- | -| `ADAPTER_LLMW_POLL_INTERVAL` | Time in seconds to wait before polling LLMWhisperer's status API. Defaults to 30s | -| `ADAPTER_LLMW_MAX_POLLS` | Total number of times to poll the status API. Defaults to 30 | diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml deleted file mode 100644 index 4ab6656650..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "unstract-llm_whisperer-x2text" -version = "0.0.1" -description = "LLMWhisperer X2Text Adapter" -authors = [{ name = "Zipstack Inc.", email = "devsupport@zipstack.com" }] -dependencies = [] -requires-python = ">=3.9" -readme = "README.md" -classifiers = ["Programming Language :: Python"] -license = { text = "MIT" } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src"] -# source-includes = ["tests"] diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py deleted file mode 100644 index ba216498fa..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .llm_whisperer import LLMWhisperer - -metadata = { - "name": LLMWhisperer.__name__, - "version": "1.0.0", - "adapter": LLMWhisperer, - "description": "LLMWhisperer X2Text adapter", - "is_active": True, -} diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py deleted file mode 100644 index 6b11d65bbd..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/constants.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -from enum import Enum - - -class ProcessingModes(Enum): - OCR = "ocr" - TEXT = "text" - - -class Modes(Enum): - NATIVE_TEXT = "native_text" - LOW_COST = "low_cost" - HIGH_QUALITY = "high_quality" - FORM = "form" - - -class OutputModes(Enum): - LINE_PRINTER = "line-printer" - DUMP_TEXT = "dump-text" - TEXT = "text" - - -class HTTPMethod(Enum): - GET = "GET" - POST = "POST" - - -class WhispererHeader: - UNSTRACT_KEY = "unstract-key" - - -class WhispererEndpoint: - """Endpoints available at LLMWhisperer service.""" - - TEST_CONNECTION = "test-connection" - WHISPER = "whisper" - STATUS = "whisper-status" - RETRIEVE = "whisper-retrieve" - - -class WhispererEnv: - """Env variables for LLMWhisperer. - - Can be used to alter behaviour at runtime. - - Attributes: - POLL_INTERVAL: Time in seconds to wait before polling - LLMWhisperer's status API. Defaults to 30s - MAX_POLLS: Total number of times to poll the status API. - Set to -1 to poll indefinitely. Defaults to -1 - """ - - POLL_INTERVAL = "ADAPTER_LLMW_POLL_INTERVAL" - MAX_POLLS = "ADAPTER_LLMW_MAX_POLLS" - - -class WhispererConfig: - """Dictionary keys used to configure LLMWhisperer service.""" - - URL = "url" - PROCESSING_MODE = "processing_mode" - MODE = "mode" - OUTPUT_MODE = "output_mode" - UNSTRACT_KEY = "unstract_key" - MEDIAN_FILTER_SIZE = "median_filter_size" - GAUSSIAN_BLUR_RADIUS = "gaussian_blur_radius" - FORCE_TEXT_PROCESSING = "force_text_processing" - LINE_SPLITTER_TOLERANCE = "line_splitter_tolerance" - HORIZONTAL_STRETCH_FACTOR = "horizontal_stretch_factor" - PAGES_TO_EXTRACT = "pages_to_extract" - STORE_METADATA_FOR_HIGHLIGHTING = "store_metadata_for_highlighting" - ADD_LINE_NOS = "add_line_nos" - OUTPUT_JSON = "output_json" - PAGE_SEPARATOR = "page_seperator" - MARK_VERTICAL_LINES = "mark_vertical_lines" - MARK_HORIZONTAL_LINES = "mark_horizontal_lines" - - -class WhisperStatus: - """Values returned / used by /whisper-status endpoint.""" - - PROCESSING = "processing" - PROCESSED = "processed" - DELIVERED = "delivered" - UNKNOWN = "unknown" - # Used for async processing - WHISPER_HASH = "whisper-hash" - STATUS = "status" - - -class WhispererDefaults: - """Defaults meant for LLMWhisperer.""" - - MEDIAN_FILTER_SIZE = 0 - GAUSSIAN_BLUR_RADIUS = 0.0 - FORCE_TEXT_PROCESSING = False - LINE_SPLITTER_TOLERANCE = 0.75 - HORIZONTAL_STRETCH_FACTOR = 1.0 - POLL_INTERVAL = int(os.getenv(WhispererEnv.POLL_INTERVAL, 30)) - MAX_POLLS = int(os.getenv(WhispererEnv.MAX_POLLS, 30)) - PAGES_TO_EXTRACT = "" - ADD_LINE_NOS = True - OUTPUT_JSON = True - PAGE_SEPARATOR = "<<< >>>" - MARK_VERTICAL_LINES = False - MARK_HORIZONTAL_LINES = False diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py deleted file mode 100644 index 1d8f1ee388..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/llm_whisperer.py +++ /dev/null @@ -1,451 +0,0 @@ -import json -import logging -import os -import time -from pathlib import Path -from typing import Any - -import requests -from requests import Response -from requests.exceptions import ConnectionError, HTTPError, Timeout -from unstract.sdk1.adapters.exceptions import ExtractorError -from unstract.sdk1.adapters.utils import AdapterUtils -from unstract.sdk1.adapters.x2text.constants import X2TextConstants -from unstract.sdk1.adapters.x2text.dto import ( - TextExtractionMetadata, - TextExtractionResult, -) -from unstract.sdk1.adapters.x2text.llm_whisperer.src.constants import ( - HTTPMethod, - OutputModes, - ProcessingModes, - WhispererConfig, - WhispererDefaults, - WhispererEndpoint, - WhispererHeader, - WhisperStatus, -) -from unstract.sdk1.adapters.x2text.x2text_adapter import X2TextAdapter -from unstract.sdk1.constants import MimeType -from unstract.sdk1.file_storage import FileStorage, FileStorageProvider - -logger = logging.getLogger(__name__) - - -class LLMWhisperer(X2TextAdapter): - def __init__(self, settings: dict[str, Any]) -> None: - """Initialize the LLMWhisperer text extraction adapter. - - Args: - settings: Configuration dictionary containing LLMWhisperer API settings - including API key, base URL, and other parameters. - """ - super().__init__("LLMWhisperer") - self.config = settings - - SCHEMA_PATH = f"{os.path.dirname(__file__)}/static/json_schema.json" - - @staticmethod - def get_id() -> str: - return "llmwhisperer|0a1647f0-f65f-410d-843b-3d979c78350e" - - @staticmethod - def get_name() -> str: - return "LLMWhisperer" - - @staticmethod - def get_description() -> str: - return "LLMWhisperer X2Text" - - @staticmethod - def get_icon() -> str: - return "/icons/adapter-icons/LLMWhisperer.png" - - def _get_request_headers(self) -> dict[str, Any]: - """Obtains the request headers to authenticate with LLMWhisperer. - - Returns: - str: Request headers - """ - return { - "accept": MimeType.JSON, - WhispererHeader.UNSTRACT_KEY: self.config.get(WhispererConfig.UNSTRACT_KEY), - } - - def _make_request( - self, - request_method: HTTPMethod, - request_endpoint: str, - headers: dict[str, object] | None = None, - params: dict[str, object] | None = None, - data: object | None = None, - ) -> Response: - """Makes a request to LLMWhisperer service. - - Args: - request_method (HTTPMethod): HTTPMethod to call. Can be GET or POST - request_endpoint (str): LLMWhisperer endpoint to hit - headers (Optional[dict[str, Any]], optional): Headers to pass. - Defaults to None. - params (Optional[dict[str, Any]], optional): Query params to pass. - Defaults to None. - data (Optional[Any], optional): Data to pass in case of POST. - Defaults to None. - - Returns: - Response: Response from the request - """ - llm_whisperer_svc_url = ( - f"{self.config.get(WhispererConfig.URL)}/v1/{request_endpoint}" - ) - if not headers: - headers = self._get_request_headers() - - try: - response: Response - if request_method == HTTPMethod.GET: - response = requests.get( - url=llm_whisperer_svc_url, headers=headers, params=params - ) - elif request_method == HTTPMethod.POST: - response = requests.post( - url=llm_whisperer_svc_url, - headers=headers, - params=params, - data=data, - ) - else: - raise ExtractorError(f"Unsupported request method: {request_method}") - response.raise_for_status() - except ConnectionError as e: - logger.error(f"Adapter error: {e}") - raise ExtractorError( - "Unable to connect to LLMWhisperer service, please check the URL" - ) from e - except Timeout as e: - msg = "Request to LLMWhisperer has timed out" - logger.error(f"{msg}: {e}") - raise ExtractorError(msg) from e - except HTTPError as e: - logger.error(f"Adapter error: {e}") - default_err = "Error while calling the LLMWhisperer service" - msg = AdapterUtils.get_msg_from_request_exc( - err=e, message_key="message", default_err=default_err - ) - raise ExtractorError(msg) from e - return response - - def _get_whisper_params(self, enable_highlight: bool = False) -> dict[str, Any]: - """Gets query params meant for /whisper endpoint. - - The params is filled based on the configuration passed. - - Returns: - dict[str, Any]: Query params - """ - params = { - WhispererConfig.PROCESSING_MODE: self.config.get( - WhispererConfig.PROCESSING_MODE, ProcessingModes.TEXT.value - ), - # Not providing default value to maintain legacy compatablity - # Providing default value will overide the params - # processing_mode, force_text_processing - WhispererConfig.MODE: self.config.get(WhispererConfig.MODE), - WhispererConfig.OUTPUT_MODE: self.config.get( - WhispererConfig.OUTPUT_MODE, OutputModes.LINE_PRINTER.value - ), - WhispererConfig.FORCE_TEXT_PROCESSING: self.config.get( - WhispererConfig.FORCE_TEXT_PROCESSING, - WhispererDefaults.FORCE_TEXT_PROCESSING, - ), - WhispererConfig.LINE_SPLITTER_TOLERANCE: self.config.get( - WhispererConfig.LINE_SPLITTER_TOLERANCE, - WhispererDefaults.LINE_SPLITTER_TOLERANCE, - ), - WhispererConfig.HORIZONTAL_STRETCH_FACTOR: self.config.get( - WhispererConfig.HORIZONTAL_STRETCH_FACTOR, - WhispererDefaults.HORIZONTAL_STRETCH_FACTOR, - ), - WhispererConfig.PAGES_TO_EXTRACT: self.config.get( - WhispererConfig.PAGES_TO_EXTRACT, - WhispererDefaults.PAGES_TO_EXTRACT, - ), - WhispererConfig.ADD_LINE_NOS: WhispererDefaults.ADD_LINE_NOS, - WhispererConfig.OUTPUT_JSON: WhispererDefaults.OUTPUT_JSON, - WhispererConfig.PAGE_SEPARATOR: self.config.get( - WhispererConfig.PAGE_SEPARATOR, - WhispererDefaults.PAGE_SEPARATOR, - ), - WhispererConfig.MARK_VERTICAL_LINES: self.config.get( - WhispererConfig.MARK_VERTICAL_LINES, - WhispererDefaults.MARK_VERTICAL_LINES, - ), - WhispererConfig.MARK_HORIZONTAL_LINES: self.config.get( - WhispererConfig.MARK_HORIZONTAL_LINES, - WhispererDefaults.MARK_HORIZONTAL_LINES, - ), - } - if not params[WhispererConfig.FORCE_TEXT_PROCESSING]: - params.update( - { - WhispererConfig.MEDIAN_FILTER_SIZE: self.config.get( - WhispererConfig.MEDIAN_FILTER_SIZE, - WhispererDefaults.MEDIAN_FILTER_SIZE, - ), - WhispererConfig.GAUSSIAN_BLUR_RADIUS: self.config.get( - WhispererConfig.GAUSSIAN_BLUR_RADIUS, - WhispererDefaults.GAUSSIAN_BLUR_RADIUS, - ), - } - ) - - if enable_highlight: - params.update( - {WhispererConfig.STORE_METADATA_FOR_HIGHLIGHTING: enable_highlight} - ) - return params - - def test_connection(self) -> bool: - self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.TEST_CONNECTION, - ) - return True - - def _check_status_until_ready( - self, whisper_hash: str, headers: dict[str, Any], params: dict[str, Any] - ) -> WhisperStatus: - """Checks the extraction status by polling. - - Polls the /whisper-status endpoint in fixed intervals of - env: ADAPTER_LLMW_POLL_INTERVAL for a certain number of times - controlled by env: ADAPTER_LLMW_MAX_POLLS. - - Args: - whisper_hash (str): Identifier for the extraction, - returned by LLMWhisperer - headers (dict[str, Any]): Headers to pass for the status check - params (dict[str, Any]): Params to pass for the status check - - Returns: - WhisperStatus: Status of the extraction - """ - poll_interval = WhispererDefaults.POLL_INTERVAL - max_polls = WhispererDefaults.MAX_POLLS - request_count = 0 - - # Check status in fixed intervals upto max poll count. - while True: - request_count += 1 - logger.info( - f"Checking status with interval: {poll_interval}s" - f", request count: {request_count} [max: {max_polls}]" - ) - status_response = self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.STATUS, - headers=headers, - params=params, - ) - if status_response.status_code == 200: - status_data = status_response.json() - status = status_data.get(WhisperStatus.STATUS, WhisperStatus.UNKNOWN) - logger.info(f"Whisper status for {whisper_hash}: {status}") - if status in [WhisperStatus.PROCESSED, WhisperStatus.DELIVERED]: - break - else: - raise ExtractorError( - "Error checking LLMWhisperer status: " - f"{status_response.status_code} - {status_response.text}" - ) - - # Exit with error if max poll count is reached - if request_count >= max_polls: - raise ExtractorError( - f"Unable to extract text after attempting {request_count} times" - ) - time.sleep(poll_interval) - - return status - - def _extract_async(self, whisper_hash: str) -> str: - """Makes an async extraction with LLMWhisperer. - - Polls and checks the status first before proceeding to retrieve once. - - Args: - whisper_hash (str): Identifier of the extraction - - Returns: - str: Extracted contents from the file - """ - logger.info(f"Extracting async for whisper hash: {whisper_hash}") - - headers: dict[str, Any] = self._get_request_headers() - params = { - WhisperStatus.WHISPER_HASH: whisper_hash, - WhispererConfig.OUTPUT_JSON: WhispererDefaults.OUTPUT_JSON, - } - - # Polls in fixed intervals and checks status - self._check_status_until_ready( - whisper_hash=whisper_hash, headers=headers, params=params - ) - - retrieve_response = self._make_request( - request_method=HTTPMethod.GET, - request_endpoint=WhispererEndpoint.RETRIEVE, - headers=headers, - params=params, - ) - if retrieve_response.status_code == 200: - return retrieve_response.json() - else: - raise ExtractorError( - "Error retrieving from LLMWhisperer: " - f"{retrieve_response.status_code} - {retrieve_response.text}" - ) - - def _send_whisper_request( - self, - input_file_path: str, - fs: FileStorage | None = None, - enable_highlight: bool = False, - ) -> requests.Response: - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - headers = self._get_request_headers() - headers["Content-Type"] = "application/octet-stream" - params = self._get_whisper_params(enable_highlight) - - response: requests.Response - try: - response = self._make_request( - request_method=HTTPMethod.POST, - request_endpoint=WhispererEndpoint.WHISPER, - headers=headers, - params=params, - data=fs.read(path=input_file_path, mode="rb"), - ) - except OSError as e: - logger.error(f"OS error while reading {input_file_path}: {e}") - raise ExtractorError(str(e)) from e - return response - - def _extract_text_from_response( - self, - output_file_path: str | None, - response: requests.Response, - fs: FileStorage | None = None, - ) -> str: - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - output_json = {} - if response.status_code == 200: - output_json = response.json() - elif response.status_code == 202: - whisper_hash = response.json().get(WhisperStatus.WHISPER_HASH) - output_json = self._extract_async(whisper_hash=whisper_hash) - else: - raise ExtractorError("Couldn't extract text from file") - if output_file_path: - self._write_output_to_file( - output_json=output_json, output_file_path=Path(output_file_path), fs=fs - ) - return output_json.get("text", "") - - def _write_output_to_file( - self, - output_json: dict, - output_file_path: Path, - fs: FileStorage | None = None, - ) -> None: - """Write extracted text and metadata to output files. - - Writes the extracted text and metadata to the specified output file and - metadata file. - - Args: - output_json (dict): The dictionary containing the extracted data, - with "text" as the key for the main content. - output_file_path (Path): The file path where the extracted text - should be written. - - Raises: - ExtractorError: If there is an error while writing the output file. - """ - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - try: - text_output = output_json.get("text", "") - logger.info(f"Writing output to {output_file_path}") - fs.write( - path=output_file_path, - mode="w", - encoding="utf-8", - data=text_output, - ) - try: - # Define the directory of the output file and metadata paths - output_dir = output_file_path.parent - metadata_dir = output_dir / "metadata" - metadata_file_name = output_file_path.with_suffix(".json").name - metadata_file_path = metadata_dir / metadata_file_name - # Ensure the metadata directory exists - fs.mkdir(str(metadata_dir), create_parents=True) - # Remove the "text" key from the metadata - metadata = { - key: value for key, value in output_json.items() if key != "text" - } - metadata_json = json.dumps(metadata, ensure_ascii=False, indent=4) - logger.info(f"Writing metadata to {metadata_file_path}") - - fs.write( - path=metadata_file_path, - mode="w", - encoding="utf-8", - data=metadata_json, - ) - except Exception as e: - logger.error(f"Error while writing metadata to {metadata_file_path}: {e}") - - except Exception as e: - logger.error(f"Error while writing {output_file_path}: {e}") - raise ExtractorError(str(e)) from e - - def process( - self, - input_file_path: str, - output_file_path: str | None = None, - fs: FileStorage | None = None, - **kwargs: dict[Any, Any], - ) -> TextExtractionResult: - """Used to extract text from documents. - - Args: - input_file_path (str): Path to file that needs to be extracted - output_file_path (Optional[str], optional): File path to write - extracted text into, if None doesn't write to a file. - Defaults to None. - - Returns: - str: Extracted text - """ - if fs is None: - fs = FileStorage(provider=FileStorageProvider.LOCAL) - response: requests.Response = self._send_whisper_request( - input_file_path, - fs, - bool(kwargs.get(X2TextConstants.ENABLE_HIGHLIGHT, False)), - ) - - metadata = TextExtractionMetadata( - whisper_hash=response.headers.get(X2TextConstants.WHISPER_HASH, "") - ) - - return TextExtractionResult( - extracted_text=self._extract_text_from_response( - output_file_path, response, fs - ), - extraction_metadata=metadata, - ) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json b/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json deleted file mode 100644 index d957d1b27d..0000000000 --- a/unstract/sdk1/src/unstract/sdk1/adapters/x2text/llm_whisperer/src/static/json_schema.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "title": "LLMWhisperer v1 Text Extractor", - "type": "object", - "required": [ - "adapter_name", - "unstract_key", - "url" - ], - "description": "LLMWhisperer v1 is deprecated, use the cheaper and faster [LLMWhisperer v2](https://docs.unstract.com/llmwhisperer/llm_whisperer/faqs/v1_to_v2/) instead.", - "properties": { - "adapter_name": { - "type": "string", - "title": "Name", - "default": "", - "description": "Provide a unique name for this adapter instance. Example: LLMWhisperer 1" - }, - "url": { - "type": "string", - "title": "URL", - "format": "uri", - "default": "https://llmwhisperer-api.unstract.com", - "description": "Provide the URL of the LLMWhisperer service. Please note that this version of LLMWhisperer is deprecated." - }, - "unstract_key": { - "type": "string", - "title": "LLMWhisperer Key", - "format": "password", - "description": "API key obtained from the [Unstract developer portal](https://unstract-api-resource.developer.azure-api.net)" - }, - "mode": { - "type": "string", - "title": "Mode", - "enum": [ - "native_text", - "low_cost", - "high_quality", - "form" - ], - "default": "form", - "description": "Processing mode to use, described in the [LLMWhisperer v1 documentation](https://docs.unstract.com/llmwhisperer/1.0.0/llm_whisperer/apis/llm_whisperer_text_extraction_api/#processing-modes)" - }, - "output_mode": { - "type": "string", - "title": "Output Mode", - "enum": [ - "line-printer", - "dump-text", - "text" - ], - "default": "line-printer", - "description": "Output mode to use, described in the [LLMWhisperer v1 documentation](https://docs.unstract.com/llmwhisperer/1.0.0/llm_whisperer/apis/llm_whisperer_text_extraction_api/#output-modes)" - }, - - "line_splitter_tolerance": { - "type": "number", - "title": "Line Splitter Tolerance", - "default": 0.4, - "description": "Reduce this value to split lines less often, increase to split lines more often. Useful when PDFs have multi column layout with text in each column that is not aligned." - }, - "horizontal_stretch_factor": { - "type": "number", - "title": "Horizontal Stretch Factor", - "default": 1.0, - "description": "Increase this value to stretch text horizontally, decrease to compress text horizontally. Useful when multi column text merge with each other." - }, - "pages_to_extract": { - "type": "string", - "title": "Page number(s) or range to extract", - "default": "", - "pattern": "^(\\s*\\d+-\\d+|\\s*\\d+-|\\s*\\d+|^$)(,\\d+-\\d+|,\\d+-|,\\d+)*$", - "description": "Specify the range of pages to extract (e.g., 1-5, 7, 10-12, 50-). Leave it empty to extract all pages." - }, - "page_seperator": { - "type": "string", - "title": "Page separator", - "default": "<<< >>>", - "description": "Specify a pattern to separate the pages in the document (e.g., <<< {{page_no}} >>>, <<< >>>). This pattern will be inserted at the end of every page. Omit {{page_no}} if you don't want to include the page number in the separator." - } - }, - "if": { - "anyOf": [ - { - "properties": { - "mode": { - "const": "low_cost" - } - } - }, - { - "properties": { - "mode": { - "const": "high_quality" - } - } - }, - { - "properties": { - "mode": { - "const": "form" - } - } - } - ] - }, - "then": { - "properties": { - "median_filter_size": { - "type": "integer", - "title": "Median Filter Size", - "default": 0, - "description": "The size of the median filter to use for pre-processing the image during OCR based extraction. Useful to eliminate scanning artifacts and low quality JPEG artifacts. Default is 0 if the value is not explicitly set. Available only in the Enterprise version." - }, - "gaussian_blur_radius": { - "type": "number", - "title": "Gaussian Blur Radius", - "default": 0.0, - "description": "The radius of the gaussian blur to use for pre-processing the image during OCR based extraction. Useful to eliminate noise from the image. Default is 0.0 if the value is not explicitly set. Available only in the Enterprise version." - }, - "mark_vertical_lines": { - "type": "boolean", - "title": "Mark Vertical Lines", - "default": false, - "description": "Detect vertical lines in the document and replicate the same using text (using \"|\" symbol). Use this for displaying tables with borders." - }, - "mark_horizontal_lines": { - "type": "boolean", - "title": "Mark Horizontal Lines", - "default": false, - "description": "Detect horizontal lines in the document and replicate the same using text (using \"-\" symbol). Use this for displaying tables with borders and other horizontal serperators found in the document." - } - }, - "required": [ - "median_filter_size", - "gaussian_blur_radius" - ] - } -} diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/constants.py b/unstract/workflow-execution/src/unstract/workflow_execution/constants.py index 3786706cd2..fbdc52cc73 100644 --- a/unstract/workflow-execution/src/unstract/workflow_execution/constants.py +++ b/unstract/workflow-execution/src/unstract/workflow_execution/constants.py @@ -16,8 +16,6 @@ class ToolRuntimeVariable: PLATFORM_SERVICE_API_KEY = "PLATFORM_SERVICE_API_KEY" X2TEXT_HOST = "X2TEXT_HOST" X2TEXT_PORT = "X2TEXT_PORT" - ADAPTER_LLMW_POLL_INTERVAL = "ADAPTER_LLMW_POLL_INTERVAL" - ADAPTER_LLMW_MAX_POLLS = "ADAPTER_LLMW_MAX_POLLS" ADAPTER_LLMW_WAIT_TIMEOUT = "ADAPTER_LLMW_WAIT_TIMEOUT" EXECUTION_BY_TOOL = "EXECUTION_BY_TOOL" WORKFLOW_EXECUTION_DIR_PREFIX = "WORKFLOW_EXECUTION_DIR_PREFIX" diff --git a/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py b/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py index 040c9485cb..41a69ae4aa 100644 --- a/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py +++ b/unstract/workflow-execution/src/unstract/workflow_execution/tools_utils.py @@ -46,12 +46,6 @@ def __init__( ) self.x2text_host = ToolsUtils.get_env(ToolRV.X2TEXT_HOST, raise_exception=True) self.x2text_port = ToolsUtils.get_env(ToolRV.X2TEXT_PORT, raise_exception=True) - self.llmw_poll_interval = ToolsUtils.get_env( - ToolRV.ADAPTER_LLMW_POLL_INTERVAL, raise_exception=False - ) - self.llmw_max_polls = ToolsUtils.get_env( - ToolRV.ADAPTER_LLMW_MAX_POLLS, raise_exception=False - ) self.llmw_wait_timeout = ToolsUtils.get_env( ToolRV.ADAPTER_LLMW_WAIT_TIMEOUT, raise_exception=False ) @@ -242,10 +236,6 @@ def get_tool_environment_variables(self) -> dict[str, Any]: or "mymaster", } # For async LLM Whisperer extraction - if self.llmw_poll_interval: - platform_vars[ToolRV.ADAPTER_LLMW_POLL_INTERVAL] = self.llmw_poll_interval - if self.llmw_max_polls: - platform_vars[ToolRV.ADAPTER_LLMW_MAX_POLLS] = self.llmw_max_polls if self.llmw_wait_timeout: platform_vars[ToolRV.ADAPTER_LLMW_WAIT_TIMEOUT] = self.llmw_wait_timeout return platform_vars diff --git a/workers/executor/executors/legacy_executor.py b/workers/executor/executors/legacy_executor.py index b672d56ab6..eb146d9cb5 100644 --- a/workers/executor/executors/legacy_executor.py +++ b/workers/executor/executors/legacy_executor.py @@ -29,7 +29,6 @@ from unstract.sdk1.adapters.exceptions import AdapterError from unstract.sdk1.adapters.x2text.constants import X2TextConstants -from unstract.sdk1.adapters.x2text.llm_whisperer.src import LLMWhisperer from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 from unstract.sdk1.constants import LogLevel from unstract.sdk1.execution.context import ExecutionContext, Operation @@ -249,9 +248,7 @@ def _handle_extract(self, context: ExecutionContext) -> ExecutionResult: f"Extracting text using `{extractor_name}`" + (" (with highlight)" if enable_highlight else "") ) - if enable_highlight and isinstance( - x2text.x2text_instance, (LLMWhisperer, LLMWhispererV2) - ): + if enable_highlight and isinstance(x2text.x2text_instance, LLMWhispererV2): process_response: TextExtractionResult = x2text.process( input_file_path=file_path, output_file_path=output_file_path, diff --git a/workers/sample.env b/workers/sample.env index 6a80eb4556..f64415c921 100644 --- a/workers/sample.env +++ b/workers/sample.env @@ -329,8 +329,6 @@ ADAPTER_LLMW_WAIT_TIMEOUT=900 # 15 mins ADAPTER_LLMW_MAX_RETRIES=3 ADAPTER_LLMW_RETRY_MIN_WAIT=1.0 ADAPTER_LLMW_RETRY_MAX_WAIT=60.0 -ADAPTER_LLMW_POLL_INTERVAL=30 -ADAPTER_LLMW_MAX_POLLS=1000 # Tool Runner UNSTRACT_RUNNER_HOST=http://unstract-runner diff --git a/workers/tests/test_legacy_executor_extract.py b/workers/tests/test_legacy_executor_extract.py index 0711d2255a..0616eb2810 100644 --- a/workers/tests/test_legacy_executor_extract.py +++ b/workers/tests/test_legacy_executor_extract.py @@ -2,7 +2,7 @@ Verifies: 1. Happy path: extraction returns success with extracted_text -2. With highlight (LLMWhisperer): enable_highlight passed through +2. With highlight (LLMWhisperer V2): enable_highlight passed through 3. Without highlight (non-Whisperer): enable_highlight NOT passed 4. AdapterError → failure result 5. Missing required params → failure result @@ -18,12 +18,14 @@ from unittest.mock import MagicMock, patch import pytest - from executor.executors.constants import ( FileStorageKeys, +) +from executor.executors.constants import ( IndexingConstants as IKeys, ) from executor.executors.exceptions import LegacyExecutorError + from unstract.sdk1.adapters.x2text.constants import X2TextConstants from unstract.sdk1.adapters.x2text.dto import ( TextExtractionMetadata, @@ -100,9 +102,7 @@ def test_extract_returns_success(self, mock_x2text_cls, mock_get_fs): @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_extract_passes_correct_params_to_x2text( - self, mock_x2text_cls, mock_get_fs - ): + def test_extract_passes_correct_params_to_x2text(self, mock_x2text_cls, mock_get_fs): _register_legacy() executor = ExecutorRegistry.get("legacy") @@ -131,16 +131,14 @@ def test_extract_passes_correct_params_to_x2text( ) -# --- 2. With highlight (LLMWhisperer) --- +# --- 2. With highlight (LLMWhisperer V2) --- class TestWithHighlight: @patch("executor.executors.legacy_executor.ToolUtils.dump_json") @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_highlight_with_whisperer_v2( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_highlight_with_whisperer_v2(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -171,39 +169,6 @@ def test_highlight_with_whisperer_v2( call_kwargs = mock_x2text.process.call_args.kwargs assert call_kwargs.get("enable_highlight") is True - @patch("executor.executors.legacy_executor.ToolUtils.dump_json") - @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") - @patch("executor.executors.legacy_executor.X2Text") - def test_highlight_with_whisperer_v1( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): - from unstract.sdk1.adapters.x2text.llm_whisperer.src import LLMWhisperer - - _register_legacy() - executor = ExecutorRegistry.get("legacy") - - mock_x2text = MagicMock() - mock_x2text.process.return_value = _mock_process_response() - mock_x2text.x2text_instance = MagicMock(spec=LLMWhisperer) - mock_x2text_cls.return_value = mock_x2text - mock_get_fs.return_value = MagicMock() - - ctx = _make_context( - executor_params={ - "x2text_instance_id": "x2t-whisperer-v1", - "file_path": "/data/test.pdf", - "platform_api_key": "sk-key", - "enable_highlight": True, - "execution_data_dir": "/data/run", - "tool_execution_metadata": {}, - } - ) - result = executor.execute(ctx) - - assert result.success is True - call_kwargs = mock_x2text.process.call_args.kwargs - assert call_kwargs.get("enable_highlight") is True - # --- 3. Without highlight (non-Whisperer) --- @@ -239,9 +204,7 @@ def test_no_highlight_for_non_whisperer(self, mock_x2text_cls, mock_get_fs): @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_highlight_false_skips_whisperer_branch( - self, mock_x2text_cls, mock_get_fs - ): + def test_highlight_false_skips_whisperer_branch(self, mock_x2text_cls, mock_get_fs): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -356,9 +319,7 @@ class TestMetadataToolSource: @patch("executor.executors.legacy_executor.ToolUtils.dump_json") @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_tool_source_writes_metadata( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_tool_source_writes_metadata(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -391,12 +352,8 @@ def test_tool_source_writes_metadata( # ToolUtils.dump_json should have been called mock_dump.assert_called_once() dump_kwargs = mock_dump.call_args.kwargs - assert dump_kwargs["file_to_dump"] == str( - Path("/run/data") / IKeys.METADATA_FILE - ) - assert dump_kwargs["json_to_dump"] == { - X2TextConstants.WHISPER_HASH: "whash-456" - } + assert dump_kwargs["file_to_dump"] == str(Path("/run/data") / IKeys.METADATA_FILE) + assert dump_kwargs["json_to_dump"] == {X2TextConstants.WHISPER_HASH: "whash-456"} assert dump_kwargs["fs"] is mock_fs # tool_exec_metadata should be updated in-place assert tool_meta[X2TextConstants.WHISPER_HASH] == "whash-456" @@ -409,9 +366,7 @@ class TestMetadataIDESource: @patch("executor.executors.legacy_executor.ToolUtils.dump_json") @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_ide_source_skips_metadata( - self, mock_x2text_cls, mock_get_fs, mock_dump - ): + def test_ide_source_skips_metadata(self, mock_x2text_cls, mock_get_fs, mock_dump): from unstract.sdk1.adapters.x2text.llm_whisperer_v2.src import LLMWhispererV2 _register_legacy() @@ -445,6 +400,7 @@ class TestFileUtilsRouting: @patch("executor.executors.file_utils.EnvHelper.get_storage") def test_ide_returns_permanent_storage(self, mock_get_storage): from executor.executors.file_utils import FileUtils + from unstract.sdk1.file_storage.constants import StorageType mock_get_storage.return_value = MagicMock() @@ -458,6 +414,7 @@ def test_ide_returns_permanent_storage(self, mock_get_storage): @patch("executor.executors.file_utils.EnvHelper.get_storage") def test_tool_returns_temporary_storage(self, mock_get_storage): from executor.executors.file_utils import FileUtils + from unstract.sdk1.file_storage.constants import StorageType mock_get_storage.return_value = MagicMock() @@ -481,9 +438,7 @@ def test_invalid_source_raises_value_error(self): class TestOrchestratorIntegration: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_orchestrator_extract_returns_success( - self, mock_x2text_cls, mock_get_fs - ): + def test_orchestrator_extract_returns_success(self, mock_x2text_cls, mock_get_fs): _register_legacy() orchestrator = ExecutionOrchestrator() @@ -525,9 +480,7 @@ def eager_app(): class TestCeleryEager: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_eager_extract_returns_success( - self, mock_x2text_cls, mock_get_fs, eager_app - ): + def test_eager_extract_returns_success(self, mock_x2text_cls, mock_get_fs, eager_app): _register_legacy() mock_x2text = MagicMock() @@ -551,11 +504,10 @@ def test_eager_extract_returns_success( class TestExecuteErrorCatching: @patch("executor.executors.legacy_executor.FileUtils.get_fs_instance") @patch("executor.executors.legacy_executor.X2Text") - def test_extraction_error_caught_by_execute( - self, mock_x2text_cls, mock_get_fs - ): + def test_extraction_error_caught_by_execute(self, mock_x2text_cls, mock_get_fs): """ExtractionError (a LegacyExecutorError) is caught in execute() - and mapped to ExecutionResult.failure().""" + and mapped to ExecutionResult.failure(). + """ from unstract.sdk1.adapters.exceptions import AdapterError _register_legacy() From 4cecc1a60fe669c29c0479bb0b8c887dabec391b Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:37:19 +0530 Subject: [PATCH 2/3] 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 --- .../adapter_processor_v2/adapter_processor.py | 31 ++++++++---- .../deprecated_adapters.py | 16 +++++++ backend/adapter_processor_v2/serializers.py | 16 ++++++- .../tests/test_deprecated_adapters.py | 47 +++++++++++++++++++ backend/adapter_processor_v2/views.py | 5 +- .../prompt_studio_helper.py | 7 +-- .../settings/default-triad/DefaultTriad.jsx | 13 +++-- .../platform_service/controller/platform.py | 5 ++ 8 files changed, 120 insertions(+), 20 deletions(-) diff --git a/backend/adapter_processor_v2/adapter_processor.py b/backend/adapter_processor_v2/adapter_processor.py index c249f68648..216db7bf45 100644 --- a/backend/adapter_processor_v2/adapter_processor.py +++ b/backend/adapter_processor_v2/adapter_processor.py @@ -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, @@ -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: @@ -222,26 +231,30 @@ 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() @@ -249,7 +262,7 @@ def set_default_triad(default_triad: dict[str, str], user: User) -> None: 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() diff --git a/backend/adapter_processor_v2/deprecated_adapters.py b/backend/adapter_processor_v2/deprecated_adapters.py index d0e3bb2359..acacf93c2c 100644 --- a/backend/adapter_processor_v2/deprecated_adapters.py +++ b/backend/adapter_processor_v2/deprecated_adapters.py @@ -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) diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py index 8df95acc8d..ad5b4771a5 100644 --- a/backend/adapter_processor_v2/serializers.py +++ b/backend/adapter_processor_v2/serializers.py @@ -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, @@ -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, ) @@ -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 diff --git a/backend/adapter_processor_v2/tests/test_deprecated_adapters.py b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py index 621b516592..b87925c759 100644 --- a/backend/adapter_processor_v2/tests/test_deprecated_adapters.py +++ b/backend/adapter_processor_v2/tests/test_deprecated_adapters.py @@ -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 @@ -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 diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index f350a9204f..8bbe003fcf 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -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) diff --git a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py index ba157801ce..8e73f65e99 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py +++ b/backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py @@ -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 @@ -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" @@ -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." diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx index 001cfcbc7d..350e165876 100644 --- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx +++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx @@ -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"; @@ -67,7 +66,7 @@ function DefaultTriad() { url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`, }) .then((adapters) => { - setAdapterList(usableAdapters(adapters)); + setAdapterList(adapters); }) .catch((err) => { setAlertDetails( @@ -203,8 +202,14 @@ function DefaultTriad() { {dropdownData .filter((data) => data?.adapter_type === type) .map((data) => ( - ))} diff --git a/platform-service/src/unstract/platform_service/controller/platform.py b/platform-service/src/unstract/platform_service/controller/platform.py index fbc8cdd8bb..bf7bc61e97 100644 --- a/platform-service/src/unstract/platform_service/controller/platform.py +++ b/platform-service/src/unstract/platform_service/controller/platform.py @@ -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) From c7624ae224d81a1c615bc527d780ec0be0598fe0 Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:06:50 +0530 Subject: [PATCH 3/3] UN-2896 [FIX] Silence Sonar S117 on the migration's get_model calls Matches the repo's existing convention for this rule in data migrations. --- .../migrations/0007_deprecate_llmwhisperer_v1.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py index b9e57b5110..be4b52c7fc 100644 --- a/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py +++ b/backend/adapter_processor_v2/migrations/0007_deprecate_llmwhisperer_v1.py @@ -28,7 +28,9 @@ def mark_llmwhisperer_v1_deprecated(apps, schema_editor): platform-service reads this column to reject execution, so a row missed here would fail deep in the SDK instead of with the deprecation message. """ - AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance") + AdapterInstance = apps.get_model( # NOSONAR + "adapter_processor_v2", "AdapterInstance" + ) updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( is_available=False, deprecation_metadata=DEPRECATION_METADATA @@ -37,7 +39,9 @@ def mark_llmwhisperer_v1_deprecated(apps, schema_editor): def reverse_deprecation(apps, schema_editor): - AdapterInstance = apps.get_model("adapter_processor_v2", "AdapterInstance") + AdapterInstance = apps.get_model( # NOSONAR + "adapter_processor_v2", "AdapterInstance" + ) updated = AdapterInstance.objects.filter(adapter_id=ADAPTER_ID).update( is_available=True, deprecation_metadata=None