diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..f0a49b969 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -264,6 +264,11 @@ CatalogDependentEntitiesResponse, CatalogEntityIdentifier, ) +from gooddata_sdk.catalog.workspace.entity_model.resolved_llm import ( + 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..6932baa98 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,9 @@ 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 +688,21 @@ 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, + returns LLM Providers with their associated models. + Otherwise, falls back to the legacy LLM Endpoints. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + + Returns: + CatalogResolvedLlms: + Active LLM configuration for the workspace. + """ + response = self._actions_api.resolve_llm_providers(workspace_id, _check_return_type=False) + return CatalogResolvedLlms.from_api(response) 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..90f9f31f1 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py @@ -0,0 +1,73 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any + +import attrs +from gooddata_api_client.model.llm_model import LlmModel +from gooddata_api_client.model.resolved_llm_provider import ResolvedLlmProvider +from gooddata_api_client.model.resolved_llms import ResolvedLlms + +from gooddata_sdk.catalog.base import Base + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmModel(Base): + """A single LLM model associated with a resolved LLM provider.""" + + id: str + family: str + + @staticmethod + def client_class() -> type[LlmModel]: + return LlmModel + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmProvider(Base): + """Resolved LLM provider for a workspace. + + Represents either a ResolvedLlmProvider (when ENABLE_LLM_ENDPOINT_REPLACEMENT + feature flag is enabled) or a ResolvedLlmEndpoint (legacy fallback). + When the legacy endpoint is returned, models will be an empty list. + """ + + id: str + title: str + models: list[CatalogResolvedLlmModel] = attrs.field(factory=list) + + @staticmethod + def client_class() -> type[ResolvedLlmProvider]: + return ResolvedLlmProvider + + @classmethod + def from_api(cls, entity: Any) -> CatalogResolvedLlmProvider: + raw_models = getattr(entity, "models", None) or [] + models = [CatalogResolvedLlmModel(id=m.id, family=m.family) for m in raw_models] + return cls( + id=entity.id, + title=entity.title, + models=models, + ) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlms(Base): + """Response from the resolveLlmProviders workspace action endpoint. + + Contains the active LLM configuration for a given workspace. + The data field is present when an active LLM configuration exists. + """ + + data: CatalogResolvedLlmProvider | None = None + + @staticmethod + def client_class() -> type[ResolvedLlms]: + return ResolvedLlms + + @classmethod + def from_api(cls, entity: Any) -> CatalogResolvedLlms: + raw_data = getattr(entity, "data", None) + if raw_data is None: + return cls(data=None) + return cls(data=CatalogResolvedLlmProvider.from_api(raw_data)) diff --git a/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/demo_resolve_llm_providers.yaml b/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/demo_resolve_llm_providers.yaml new file mode 100644 index 000000000..126e036a0 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/fixtures/workspace_content/demo_resolve_llm_providers.yaml @@ -0,0 +1,40 @@ +# (C) 2026 GoodData Corporation +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: + id: openai-provider-id + models: + - family: OPENAI + id: gpt-4o + title: OpenAI Provider + 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..c12b7a833 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,9 @@ CatalogDependsOn, CatalogDependsOnDateFilter, CatalogEntityIdentifier, + CatalogResolvedLlmModel, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, CatalogValidateByItem, CatalogWorkspace, DataSourceValidator, @@ -502,3 +505,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) + + +def test_resolve_llm_providers_with_provider_response(): + """Unit test: resolve_llm_providers correctly deserializes a ResolvedLlmProvider response.""" + mock_model = MagicMock() + mock_model.id = "gpt-4o" + mock_model.family = "GPT" + + mock_data = MagicMock() + mock_data.id = "openai-provider" + mock_data.title = "OpenAI Provider" + mock_data.models = [mock_model] + + mock_response = MagicMock() + mock_response.data = mock_data + + result = CatalogResolvedLlms.from_api(mock_response) + + assert isinstance(result, CatalogResolvedLlms) + assert isinstance(result.data, CatalogResolvedLlmProvider) + assert result.data.id == "openai-provider" + assert result.data.title == "OpenAI Provider" + assert len(result.data.models) == 1 + assert isinstance(result.data.models[0], CatalogResolvedLlmModel) + assert result.data.models[0].id == "gpt-4o" + assert result.data.models[0].family == "GPT" + + +def test_resolve_llm_providers_with_no_data(): + """Unit test: resolve_llm_providers correctly handles a response with no data.""" + mock_response = MagicMock() + mock_response.data = None + + result = CatalogResolvedLlms.from_api(mock_response) + + assert isinstance(result, CatalogResolvedLlms) + assert result.data is None + + +def test_resolve_llm_providers_legacy_endpoint_fallback(): + """Unit test: resolve_llm_providers handles legacy endpoint fallback (no models).""" + mock_data = MagicMock() + mock_data.id = "legacy-endpoint" + mock_data.title = "Legacy LLM Endpoint" + mock_data.models = None + + mock_response = MagicMock() + mock_response.data = mock_data + + result = CatalogResolvedLlms.from_api(mock_response) + + assert isinstance(result, CatalogResolvedLlms) + assert isinstance(result.data, CatalogResolvedLlmProvider) + assert result.data.id == "legacy-endpoint" + assert result.data.title == "Legacy LLM Endpoint" + assert result.data.models == [] + + +@gd_vcr.use_cassette(str(_fixtures_dir / "demo_resolve_llm_providers.yaml")) +def test_resolve_llm_providers_integration(test_config): + """Integration test: resolve_llm_providers calls the API and returns a valid response.""" + 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)