diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..2abe0eabe 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, + CatalogResolvedLlmModel, + 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..338444fb9 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 @@ -685,3 +686,26 @@ def get_label_elements( workspace_id, request, _check_return_type=False, **paging_params ) return [v["title"] for v in values["elements"]] + + def resolve_llm_providers(self, workspace_id: str) -> CatalogResolvedLlms: + """Resolve the active LLM configuration for a workspace. + + When the ``ENABLE_LLM_ENDPOINT_REPLACEMENT`` feature flag is enabled on the + GoodData server, the response contains a :class:`CatalogResolvedLlmProvider` + with its associated models. Otherwise it falls back to the legacy + :class:`CatalogResolvedLlmEndpoint`. The ``data`` field is ``None`` when no + LLM is configured for the workspace. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + + Returns: + CatalogResolvedLlms: + The resolved LLM configuration for the workspace. + """ + response = self._actions_api.resolve_llm_providers( + workspace_id=workspace_id, + _check_return_type=False, + ) + return CatalogResolvedLlms.from_api(response.to_dict()) 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..e7d607075 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py @@ -0,0 +1,79 @@ +# (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 CatalogResolvedLlmModel(Base): + """An LLM model available for a resolved LLM provider.""" + + id: str + family: str + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlmModel: + return cls(id=entity["id"], family=entity["family"]) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmEndpoint(Base): + """Legacy resolved LLM endpoint returned when ENABLE_LLM_ENDPOINT_REPLACEMENT is disabled.""" + + id: str + title: str + + @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 provider with associated models, returned when ENABLE_LLM_ENDPOINT_REPLACEMENT is enabled.""" + + id: str + title: str + models: list[CatalogResolvedLlmModel] = attrs.field(factory=list) + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlmProvider: + return cls( + id=entity["id"], + title=entity["title"], + models=[CatalogResolvedLlmModel.from_api(m) for m in entity.get("models", [])], + ) + + +CatalogResolvedLlmsData = Union[CatalogResolvedLlmEndpoint, CatalogResolvedLlmProvider] + + +def _resolved_llms_data_from_api(data: dict[str, Any]) -> CatalogResolvedLlmsData: + """Distinguish between endpoint and provider based on presence of the 'models' field.""" + if "models" in data: + return CatalogResolvedLlmProvider.from_api(data) + return CatalogResolvedLlmEndpoint.from_api(data) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlms(Base): + """The resolved LLM configuration for a workspace. + + The ``data`` field is either a :class:`CatalogResolvedLlmProvider` (when + the ``ENABLE_LLM_ENDPOINT_REPLACEMENT`` feature flag is enabled) or a + :class:`CatalogResolvedLlmEndpoint` (legacy fallback), or ``None`` if no + LLM is configured for the workspace. + """ + + data: CatalogResolvedLlmsData | None = None + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogResolvedLlms: + raw_data = entity.get("data") + if raw_data is None: + return cls(data=None) + return cls(data=_resolved_llms_data_from_api(raw_data)) diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/test_resolve_llm_providers.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/test_resolve_llm_providers.yaml new file mode 100644 index 000000000..c6e89780c --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/test_resolve_llm_providers.yaml @@ -0,0 +1,34 @@ +interactions: + - request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - br, gzip, deflate + X-GDC-VALIDATE-RELATIONS: + - 'true' + X-Requested-With: + - XMLHttpRequest + method: GET + uri: http://localhost:3000/api/v1/actions/workspaces/demo/ai/resolveLlmProviders + response: + body: + string: + data: null + headers: + Content-Type: + - application/json + DATE: &id001 + - PLACEHOLDER + Expires: + - '0' + Pragma: + - no-cache + X-Content-Type-Options: + - nosniff + X-GDC-TRACE-ID: *id001 + status: + code: 200 + message: OK +version: 1 diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace_content.py b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace_content.py index 312088e9f..0ebaa841f 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace_content.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace_content.py @@ -18,6 +18,10 @@ CatalogDependsOn, CatalogDependsOnDateFilter, CatalogEntityIdentifier, + CatalogResolvedLlmEndpoint, + CatalogResolvedLlmModel, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, CatalogValidateByItem, CatalogWorkspace, DataSourceValidator, @@ -502,3 +506,67 @@ def test_export_definition_analytics_layout(test_config): assert deep_eq(analytics_o.analytics.export_definitions, analytics_e.analytics.export_definitions) finally: safe_delete(_refresh_workspaces, sdk) + + +# --------------------------------------------------------------------------- +# Unit tests for CatalogResolvedLlms model deserialization +# --------------------------------------------------------------------------- + + +def test_resolved_llms_from_api_provider(): + """CatalogResolvedLlms.from_api returns a CatalogResolvedLlmProvider when 'models' is present.""" + data = { + "data": { + "id": "my-provider", + "title": "My Provider", + "models": [ + {"id": "gpt-5", "family": "OPENAI"}, + ], + } + } + result = CatalogResolvedLlms.from_api(data) + 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, CatalogResolvedLlmModel) + assert model.id == "gpt-5" + assert model.family == "OPENAI" + + +def test_resolved_llms_from_api_endpoint(): + """CatalogResolvedLlms.from_api returns a CatalogResolvedLlmEndpoint when 'models' is absent.""" + data = { + "data": { + "id": "legacy-endpoint", + "title": "Legacy Endpoint", + } + } + result = CatalogResolvedLlms.from_api(data) + assert isinstance(result.data, CatalogResolvedLlmEndpoint) + assert result.data.id == "legacy-endpoint" + assert result.data.title == "Legacy Endpoint" + + +def test_resolved_llms_from_api_none(): + """CatalogResolvedLlms.from_api handles a missing 'data' field (no LLM configured).""" + result = CatalogResolvedLlms.from_api({}) + assert result.data is None + + +# --------------------------------------------------------------------------- +# Integration test – requires a VCR cassette recorded against a live server +# --------------------------------------------------------------------------- + + +@gd_vcr.use_cassette(str(_fixtures_dir / "test_resolve_llm_providers.yaml")) +def test_resolve_llm_providers_integration(test_config): + sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) + result = sdk.catalog_workspace_content.resolve_llm_providers(test_config["workspace"]) + assert isinstance(result, CatalogResolvedLlms) + # data may be None if no LLM provider is configured in the test environment + if result.data is not None: + assert isinstance(result.data, (CatalogResolvedLlmEndpoint, CatalogResolvedLlmProvider)) + assert result.data.id + assert result.data.title