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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions backend/global_search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ changes.
- `context` is a single display-only line, or `null`. Do not parse it.
- `route.key` is a route-registry name, not a URL. `route.params` values are
strings. It is one of the keys the table below lists for the result's
type; adapters have two (see below the table). A client that does not
type; the adapter types have two (see below the table). A client that does not
know a key should render the result without a link, never guess a
screen.
- `type` is an open string. A client should render an unknown type with its
Expand Down Expand Up @@ -117,9 +117,26 @@ empty 200 (see the errors above).
| 40 | `api_deployment` | API deployments | `api_deployment.detail` | `id` | parent workflow name |
| 50 | `etl_pipeline` | ETL pipelines | `etl_pipeline.detail` | `id` | parent workflow name |
| 60 | `task_pipeline` | Task pipelines | `task_pipeline.detail` | `id` | parent workflow name |
| 80 | `adapter` | Adapters | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider |
| 80 | `llm` | LLMs | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider |
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
| 81 | `vector_db` | Vector DBs | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider |
| 82 | `embedding` | Embedding | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider |
| 83 | `text_extractor` | Text Extractor | `adapter.detail` / `adapter.info` | `id`, `adapter_type` | adapter type · provider |
| 90 | `connector` | Connectors | `connector.detail` | `id` | provider |

Adapters are split by kind into four types, one per rail destination
(`adapter_type` `LLM`, `VECTOR_DB`, `EMBEDDING` and `X2TEXT`), each with its
own `limit` and `has_more`. OCR adapters are not searchable. All four kinds
share the `adapter.*` route keys (the notifications inbox sends `adapter.info`
with the same params); `adapter_type` in the params says which adapter page to
open.

**Changed (UN-4628):** these four types replace the single `adapter` type
(order 80, label "Adapters") that the endpoint returned from v0.194.0. A
request for `types=adapter` now returns no adapter groups, since unknown keys
are ignored, and OCR adapters, which `adapter` returned, are no longer
returned at all. No released client used the `adapter` key when it was
removed: the frontend panel (P2-T10) was not built yet.

An adapter opens on `adapter.detail` only for a caller who may edit it
(owner or co-owner, org admin, service account), the same rule as the
adapter `retrieve` endpoint. Everyone else gets `adapter.info`, with the
Expand Down
7 changes: 6 additions & 1 deletion backend/global_search/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,10 @@ class OSSSearchType:
API_DEPLOYMENT = "api_deployment"
ETL_PIPELINE = "etl_pipeline"
TASK_PIPELINE = "task_pipeline"
ADAPTER = "adapter"
# Adapters split by kind, one type per rail destination. OCR adapters
# are not searchable: v1 drops OCR (decided 5 Nov 2026).
LLM = "llm"
VECTOR_DB = "vector_db"
EMBEDDING = "embedding"
TEXT_EXTRACTOR = "text_extractor"
CONNECTOR = "connector"
71 changes: 59 additions & 12 deletions backend/global_search/oss_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

Each provider starts from the same ``Model.objects.for_user(user)`` its list
view uses, so search never returns a row the caller could not open from the
list. Search may narrow further (pipeline type, No Op and frictionless
adapters), never widen.
list. Search may narrow further (pipeline type; adapter kind, which leaves
OCR adapters out; No Op and frictionless adapters), never widen.
"""

from __future__ import annotations

import dataclasses
from typing import Any

from adapter_processor_v2.constants import AdapterKeys
from adapter_processor_v2.models import AdapterInstance
from api_v2.models import APIDeployment
from connector_v2.models import ConnectorInstance
Expand Down Expand Up @@ -147,24 +148,39 @@ class TaskPipelineProvider(_PipelineProvider):


class AdapterProvider(ModelSearchProvider):
type_key = OSSSearchType.ADAPTER
label = "Adapters"
order = 80
"""Adapters of one kind (``adapter_type``), one search type per rail
destination. Not registered itself; the kinds below are.

All kinds share the ``adapter.detail`` / ``adapter.info`` route keys
(the notifications inbox sends ``adapter.info`` with the same params);
``adapter_type`` in the route params tells the client which adapter page
to open.
"""

adapter_type: str
name_fields = ("adapter_name",)
description_field = "description"

@property
def route_key(self) -> str:
return "adapter.detail"

@property
def info_route_key(self) -> str:
return "adapter.info"

def visible_queryset(self, user: Any) -> QuerySet[Any]:
# Frictionless adapters stay in the adapter list, where any member
# may delete them, but nobody can open their detail view: retrieve
# refuses them for everyone, admins included (IsFrictionLessAdapter).
return AdapterInstance.objects.for_user(user).exclude(
Q(adapter_id__startswith=NO_OP_ADAPTER_PREFIX) | Q(is_friction_less=True)
return (
AdapterInstance.objects.for_user(user)
.filter(adapter_type=self.adapter_type)
.exclude(
Q(adapter_id__startswith=NO_OP_ADAPTER_PREFIX) | Q(is_friction_less=True)
)
)

@property
def info_route_key(self) -> str:
return f"{self.type_key}.info"

def annotate(self, queryset: QuerySet[Any], user: Any) -> QuerySet[Any]:
# Same rule as IsFrictionLessAdapter, which gates retrieve: service
# accounts, org admins and owners (creator or co-owner). Anyone else
Expand Down Expand Up @@ -197,6 +213,34 @@ def route_params(self, obj: Model) -> dict[str, str]:
return {"id": str(obj.pk), "adapter_type": obj.adapter_type}


class LLMAdapterProvider(AdapterProvider):
type_key = OSSSearchType.LLM
label = "LLMs"
order = 80
adapter_type = AdapterKeys.LLM


class VectorDBAdapterProvider(AdapterProvider):
type_key = OSSSearchType.VECTOR_DB
label = "Vector DBs"
order = 81
adapter_type = AdapterKeys.VECTOR_DB


class EmbeddingAdapterProvider(AdapterProvider):
type_key = OSSSearchType.EMBEDDING
label = "Embedding"
order = 82
adapter_type = AdapterKeys.EMBEDDING


class TextExtractorAdapterProvider(AdapterProvider):
type_key = OSSSearchType.TEXT_EXTRACTOR
label = "Text Extractor"
order = 83
adapter_type = AdapterKeys.X2TEXT


class ConnectorProvider(ModelSearchProvider):
type_key = OSSSearchType.CONNECTOR
label = "Connectors"
Expand All @@ -216,6 +260,9 @@ def context(self, obj: Model) -> str | None:
APIDeploymentProvider,
ETLPipelineProvider,
TaskPipelineProvider,
AdapterProvider,
LLMAdapterProvider,
VectorDBAdapterProvider,
EmbeddingAdapterProvider,
TextExtractorAdapterProvider,
ConnectorProvider,
)
26 changes: 24 additions & 2 deletions backend/global_search/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def build(org, user, name, description=""):


def build_adapter(org, user, name, description="", **overrides):
"""An LLM adapter unless ``adapter_type`` is overridden."""
fields = {
"adapter_name": name,
"adapter_id": "openai|test",
Expand All @@ -77,6 +78,13 @@ def build_adapter(org, user, name, description="", **overrides):
return AdapterInstance.objects.create(**fields)


def _adapter(adapter_type: str) -> Callable[..., AdapterInstance]:
def build(org, user, name, description=""):
return build_adapter(org, user, name, description, adapter_type=adapter_type)

return build


def build_connector(org, user, name, description=""):
return ConnectorInstance.objects.create(
connector_name=name,
Expand All @@ -93,7 +101,10 @@ def build_connector(org, user, name, description=""):
OSSSearchType.API_DEPLOYMENT: build_api_deployment,
OSSSearchType.ETL_PIPELINE: _pipeline(Pipeline.PipelineType.ETL),
OSSSearchType.TASK_PIPELINE: _pipeline(Pipeline.PipelineType.TASK),
OSSSearchType.ADAPTER: build_adapter,
OSSSearchType.LLM: _adapter("LLM"),
OSSSearchType.VECTOR_DB: _adapter("VECTOR_DB"),
OSSSearchType.EMBEDDING: _adapter("EMBEDDING"),
OSSSearchType.TEXT_EXTRACTOR: _adapter("X2TEXT"),
OSSSearchType.CONNECTOR: build_connector,
}

Expand All @@ -102,7 +113,18 @@ def build_connector(org, user, name, description=""):
OSSSearchType.PROMPT_STUDIO_PROJECT,
OSSSearchType.WORKFLOW,
OSSSearchType.API_DEPLOYMENT,
OSSSearchType.ADAPTER,
OSSSearchType.LLM,
OSSSearchType.VECTOR_DB,
OSSSearchType.EMBEDDING,
OSSSearchType.TEXT_EXTRACTOR,
}

# The adapter kinds, keyed by type key.
ADAPTER_TYPES = {
OSSSearchType.LLM: "LLM",
OSSSearchType.VECTOR_DB: "VECTOR_DB",
OSSSearchType.EMBEDDING: "EMBEDDING",
OSSSearchType.TEXT_EXTRACTOR: "X2TEXT",
}


Expand Down
8 changes: 5 additions & 3 deletions backend/global_search/tests/test_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from workflow_manager.workflow_v2.models.workflow import Workflow

from global_search.oss_providers import OSS_PROVIDERS
from global_search.tests.base import search
from global_search.tests.base import ADAPTER_TYPES, search

ROWS_PER_TYPE = 2_000
USERS = 200
Expand Down Expand Up @@ -139,13 +139,15 @@ def setUpTestData(cls) -> None:
),
*AdapterInstance.objects.bulk_create(
AdapterInstance(
adapter_name=_name("ad", i),
adapter_name=_name(f"ad{kind}", i),
adapter_id="openai|seed",
adapter_type="LLM",
adapter_type=kind,
adapter_metadata={},
description=_name("d", i),
**common,
)
# Each adapter kind is its own search type.
for kind in ADAPTER_TYPES.values()
for i in n
),
*ConnectorInstance.objects.bulk_create(
Expand Down
12 changes: 7 additions & 5 deletions backend/global_search/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from global_search.constants import OSSSearchType as T
from global_search.oss_providers import OSS_PROVIDERS, AdapterProvider, WorkflowProvider
from global_search.providers import SearchProvider
from global_search.tests.base import OSS_BUILDERS, search
from global_search.tests.base import ADAPTER_TYPES, OSS_BUILDERS, search
from global_search.views import GlobalSearchView

APP_DIR = Path(__file__).parents[1]
Expand Down Expand Up @@ -66,19 +66,21 @@ def parent(type_key: str) -> str:
T.API_DEPLOYMENT: parent(T.API_DEPLOYMENT),
T.ETL_PIPELINE: parent(T.ETL_PIPELINE),
T.TASK_PIPELINE: parent(T.TASK_PIPELINE),
T.ADAPTER: "LLM · openai",
**{key: f"{kind} · openai" for key, kind in ADAPTER_TYPES.items()},
T.CONNECTOR: "minio",
}
for type_key, item in items.items():
with self.subTest(type=type_key):
result = results[type_key]
self.assertEqual(result["context"], expected_context[type_key])
expected_params = {"id": str(item.pk)}
if type_key == T.ADAPTER:
expected_params["adapter_type"] = "LLM"
route_key = f"{type_key}.detail"
if type_key in ADAPTER_TYPES:
expected_params["adapter_type"] = ADAPTER_TYPES[type_key]
route_key = "adapter.detail"
self.assertEqual(
result["route"],
{"key": f"{type_key}.detail", "params": expected_params},
{"key": route_key, "params": expected_params},
)

def test_owner_context_skips_service_accounts_and_takes_the_earliest(self) -> None:
Expand Down
41 changes: 33 additions & 8 deletions backend/global_search/tests/test_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from global_search.constants import OSSSearchType
from global_search.tests.base import (
ADAPTER_TYPES,
OSS_BUILDERS,
_pipeline,
build_adapter,
Expand Down Expand Up @@ -100,15 +101,13 @@ def test_frictionless_adapters_are_never_returned(self) -> None:
)
adapter.memberships.create(user=self.owner, role=ResourceRole.OWNER)
for user in (self.owner, self.admin, self.service_account, self.outsider):
self.assertFalse(
self._found(user, OSSSearchType.ADAPTER, adapter), user.email
)
self.assertFalse(self._found(user, OSSSearchType.LLM, adapter), user.email)

def test_adapter_route_follows_who_may_edit_it(self) -> None:
# adapter.detail is gated like adapter retrieve: owners, co-owners,
# admins and service accounts. Anyone else who can list the adapter
# opens the read-only adapter.info.
adapter = self._private(OSSSearchType.ADAPTER)
adapter = self._private(OSSSearchType.LLM)
set_resource_share_groups(adapter, [self.group.id])
adapter.shared_to_org = True
adapter.save()
Expand All @@ -123,9 +122,7 @@ def test_adapter_route_follows_who_may_edit_it(self) -> None:
}
for user, route_key in expected.items():
with self.subTest(user=user.email):
response = search(
user, q=QUERY, types=OSSSearchType.ADAPTER, limit=20
)
response = search(user, q=QUERY, types=OSSSearchType.LLM, limit=20)
(result,) = response.data["groups"][0]["results"]
self.assertEqual(
result["route"],
Expand Down Expand Up @@ -158,7 +155,35 @@ def test_no_op_adapters_are_never_returned(self) -> None:
adapter_id="noOpLlm|f673a5a2-90f9-40f5-94c0-9fbc663b7553",
shared_to_org=True,
)
self.assertFalse(self._found(self.admin, OSSSearchType.ADAPTER, adapter))
self.assertFalse(self._found(self.admin, OSSSearchType.LLM, adapter))

def test_each_adapter_kind_lands_only_in_its_own_type(self) -> None:
# One group per rail destination: an adapter is found under its own
# kind and no other, and OCR adapters under none (v1 drops OCR).
adapters = {
type_key: build_adapter(
self.org,
self.owner,
f"{QUERY}-{type_key}",
adapter_type=adapter_type,
shared_to_org=True,
)
for type_key, adapter_type in ADAPTER_TYPES.items()
}
ocr = build_adapter(
self.org, self.owner, f"{QUERY}-ocr", adapter_type="OCR", shared_to_org=True
)
response = search(self.admin, q=QUERY, types=",".join(ADAPTER_TYPES), limit=20)
found = {
group["type"]: {result["id"] for result in group["results"]}
for group in response.data["groups"]
}
self.assertEqual(
found,
{key: {str(adapter.pk)} for key, adapter in adapters.items()},
)
everything = search(self.admin, q=QUERY, limit=20)
self.assertNotIn(str(ocr.pk), result_ids(everything))

def test_default_and_app_pipelines_are_never_returned(self) -> None:
for pipeline_type in (Pipeline.PipelineType.DEFAULT, Pipeline.PipelineType.APP):
Expand Down
Loading