diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..13a477be9 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -264,6 +264,12 @@ CatalogDependentEntitiesResponse, CatalogEntityIdentifier, ) +from gooddata_sdk.catalog.workspace.entity_model.resolved_llm import ( + CatalogResolvedLlmEndpoint, + CatalogResolvedLlmModelItem, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, +) from gooddata_sdk.catalog.workspace.entity_model.user_data_filter import ( CatalogUserDataFilter, CatalogUserDataFilterAttributes, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/content_service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/content_service.py index 7be97bee2..17d003602 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/content_service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/content_service.py @@ -31,6 +31,7 @@ CatalogDependentEntitiesRequest, CatalogDependentEntitiesResponse, ) +from gooddata_sdk.catalog.workspace.entity_model.resolved_llm import CatalogResolvedLlms from gooddata_sdk.catalog.workspace.model_container import CatalogWorkspaceContent from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.compute.model.attribute import Attribute @@ -210,6 +211,24 @@ def get_aggregated_facts_catalog(self, workspace_id: str) -> list[CatalogAggrega catalog_agg_facts = [CatalogAggregatedFact.from_api(agg_fact) for agg_fact in agg_facts.data] return catalog_agg_facts + def resolve_llm_providers(self, workspace_id: str) -> CatalogResolvedLlms: + """Resolve the active LLM configuration for the given workspace. + + The returned :class:`~gooddata_sdk.CatalogResolvedLlms` object contains + the active LLM configuration (endpoint or provider) or ``None`` when no + LLM has been configured for the workspace. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + + Returns: + CatalogResolvedLlms: + Resolved LLM configuration for the workspace. + """ + response = self._actions_api.resolve_llm_providers(workspace_id, _check_return_type=False) + return CatalogResolvedLlms.from_api(response) + def get_dependent_entities_graph(self, workspace_id: str) -> CatalogDependentEntitiesResponse: """There are dependencies among all catalog objects, the chain is the following: `fact/attribute/label → dataset → metric → visualization → dashboard` diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py new file mode 100644 index 000000000..0ebc26568 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py @@ -0,0 +1,96 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any, Union + +import attrs + +from gooddata_sdk.catalog.base import Base + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmModelItem(Base): + """A model entry returned as part of a resolved LLM provider.""" + + id: str + family: str + + @staticmethod + def client_class() -> Any: + from gooddata_api_client.model.llm_model import LlmModel + + return LlmModel + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlmModelItem: + return cls(id=entity["id"], family=entity["family"]) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmEndpoint(Base): + """Resolved LLM configuration backed by an LLM endpoint.""" + + id: str + title: str + + @staticmethod + def client_class() -> Any: + from gooddata_api_client.model.resolved_llm_endpoint import ResolvedLlmEndpoint + + return ResolvedLlmEndpoint + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlmEndpoint: + return cls(id=entity["id"], title=entity["title"]) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmProvider(Base): + """Resolved LLM configuration backed by an LLM provider.""" + + id: str + title: str + models: list[CatalogResolvedLlmModelItem] = attrs.field(factory=list) + + @staticmethod + def client_class() -> Any: + from gooddata_api_client.model.resolved_llm_provider import ResolvedLlmProvider + + return ResolvedLlmProvider + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlmProvider: + models = [CatalogResolvedLlmModelItem.from_api(m) for m in entity.get("models", [])] + return cls(id=entity["id"], title=entity["title"], models=models) + + +CatalogResolvedLlmData = Union[CatalogResolvedLlmEndpoint, CatalogResolvedLlmProvider] + + +@attrs.define(kw_only=True) +class CatalogResolvedLlms(Base): + """Container for the active LLM configuration resolved for a given workspace. + + The ``data`` field is ``None`` when no LLM is configured for the workspace. + Otherwise it is either a :class:`CatalogResolvedLlmEndpoint` (when the + workspace uses an LLM endpoint) or a :class:`CatalogResolvedLlmProvider` + (when the workspace uses an LLM provider with associated models). + """ + + data: CatalogResolvedLlmData | None = None + + @staticmethod + def client_class() -> Any: + from gooddata_api_client.model.resolved_llms import ResolvedLlms + + return ResolvedLlms + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlms: + raw_data = entity.get("data") + if raw_data is None: + return cls(data=None) + # Distinguish endpoint from provider by the presence of the "models" key. + if "models" in raw_data: + return cls(data=CatalogResolvedLlmProvider.from_api(raw_data)) + return cls(data=CatalogResolvedLlmEndpoint.from_api(raw_data)) diff --git a/packages/gooddata-sdk/tests/catalog/test_resolved_llm.py b/packages/gooddata-sdk/tests/catalog/test_resolved_llm.py new file mode 100644 index 000000000..443b57ae2 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_resolved_llm.py @@ -0,0 +1,88 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +import pytest +from gooddata_sdk import ( + CatalogResolvedLlmEndpoint, + CatalogResolvedLlmModelItem, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, +) + + +class TestCatalogResolvedLlms: + """Unit tests for ResolvedLlm model classes — no network I/O required.""" + + def test_from_api_no_data(self): + """data=None means no LLM is configured.""" + result = CatalogResolvedLlms.from_api({"data": None}) + assert result.data is None + + def test_from_api_missing_data_key(self): + """Absent 'data' key is treated the same as None.""" + result = CatalogResolvedLlms.from_api({}) + assert result.data is None + + def test_from_api_endpoint(self): + """When data has no 'models' key it is parsed as a CatalogResolvedLlmEndpoint.""" + payload = { + "data": { + "id": "my-endpoint", + "title": "My Endpoint", + } + } + result = CatalogResolvedLlms.from_api(payload) + assert isinstance(result.data, CatalogResolvedLlmEndpoint) + assert result.data.id == "my-endpoint" + assert result.data.title == "My Endpoint" + + def test_from_api_provider(self): + """When data contains a 'models' key it is parsed as a CatalogResolvedLlmProvider.""" + payload = { + "data": { + "id": "my-provider", + "title": "My Provider", + "models": [{"id": "gpt-4", "family": "OPENAI"}], + } + } + result = CatalogResolvedLlms.from_api(payload) + assert isinstance(result.data, CatalogResolvedLlmProvider) + assert result.data.id == "my-provider" + assert result.data.title == "My Provider" + assert len(result.data.models) == 1 + model = result.data.models[0] + assert isinstance(model, CatalogResolvedLlmModelItem) + assert model.id == "gpt-4" + assert model.family == "OPENAI" + + def test_from_api_provider_empty_models(self): + """Provider with an empty models list is still parsed as CatalogResolvedLlmProvider.""" + payload = { + "data": { + "id": "provider-no-models", + "title": "Provider", + "models": [], + } + } + result = CatalogResolvedLlms.from_api(payload) + assert isinstance(result.data, CatalogResolvedLlmProvider) + assert result.data.models == [] + + @pytest.mark.parametrize( + "scenario, payload, expected_type", + [ + ( + "endpoint", + {"data": {"id": "ep", "title": "EP"}}, + CatalogResolvedLlmEndpoint, + ), + ( + "provider", + {"data": {"id": "prov", "title": "Prov", "models": []}}, + CatalogResolvedLlmProvider, + ), + ], + ) + def test_from_api_type_dispatch(self, scenario, payload, expected_type): + result = CatalogResolvedLlms.from_api(payload) + assert isinstance(result.data, expected_type), f"scenario={scenario}"