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..1e4cdb4c6 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 @@ -249,6 +250,23 @@ def get_dependent_entities_graph_from_entry_points( ) ) + def resolve_llm_providers(self, workspace_id: str) -> CatalogResolvedLlms: + """Resolve the active LLM configuration for a given workspace. + + Returns the active LLM provider and its available models. When no LLM + is configured for the workspace, ``data`` will be ``None``. + + Args: + workspace_id (str): + Workspace identification string e.g. "demo" + + Returns: + CatalogResolvedLlms: + Object containing the resolved LLM configuration for the workspace. + """ + response = self._actions_api.resolve_llm_providers(workspace_id, _check_return_type=False) + return CatalogResolvedLlms.from_api(response) + # Declarative methods for logical data model def get_declarative_ldm(self, workspace_id: str) -> CatalogDeclarativeModel: 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..295f70cdb --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/resolved_llm.py @@ -0,0 +1,72 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any + +import attrs + +from gooddata_sdk.catalog.base import Base +from gooddata_sdk.utils import safeget + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmModel(Base): + """Represents a single LLM model returned by the resolveLlmProviders endpoint.""" + + id: str + family: str + + @staticmethod + def client_class() -> type: + return NotImplemented + + +@attrs.define(kw_only=True) +class CatalogResolvedLlmProvider(Base): + """Represents the resolved LLM provider configuration for a workspace.""" + + id: str + title: str + models: list[CatalogResolvedLlmModel] = attrs.field(factory=list) + + @staticmethod + def client_class() -> type: + return NotImplemented + + @classmethod + def from_api(cls, data: Any) -> CatalogResolvedLlmProvider: + raw_models = safeget(data, ["models"]) or [] + models = [ + CatalogResolvedLlmModel( + id=safeget(m, ["id"]) or m["id"], + family=safeget(m, ["family"]) or m["family"], + ) + for m in raw_models + ] + return cls( + id=data["id"], + title=data["title"], + models=models, + ) + + +@attrs.define(kw_only=True) +class CatalogResolvedLlms(Base): + """Wraps the response from the resolveLlmProviders endpoint. + + Contains the active LLM configuration for a workspace, or None if none is configured. + """ + + data: CatalogResolvedLlmProvider | None = None + + @staticmethod + def client_class() -> type: + return NotImplemented + + @classmethod + def from_api(cls, response: Any) -> CatalogResolvedLlms: + raw_data = safeget(response, ["data"]) + if raw_data is None: + return cls(data=None) + provider = CatalogResolvedLlmProvider.from_api(raw_data) + return cls(data=provider) 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_resolved_llm.py b/packages/gooddata-sdk/tests/catalog/test_resolved_llm.py new file mode 100644 index 000000000..5ffa459d5 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/test_resolved_llm.py @@ -0,0 +1,113 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +from gooddata_sdk import ( + CatalogResolvedLlmModel, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, + GoodDataSdk, +) +from tests_support.vcrpy_utils import get_vcr + +gd_vcr = get_vcr() + +_current_dir = Path(__file__).parent.absolute() +_fixtures_dir = _current_dir / "fixtures" / "workspace_content" + + +# --- Unit tests --- + + +def test_resolved_llm_model_from_dict(): + data = {"id": "gpt-4o", "family": "OPENAI"} + model = CatalogResolvedLlmModel(id=data["id"], family=data["family"]) + assert model.id == "gpt-4o" + assert model.family == "OPENAI" + + +def test_resolved_llm_provider_from_api(): + raw = { + "id": "my_provider", + "title": "My Provider", + "models": [ + {"id": "gpt-4o", "family": "OPENAI"}, + ], + } + provider = CatalogResolvedLlmProvider.from_api(raw) + assert provider.id == "my_provider" + assert provider.title == "My Provider" + assert len(provider.models) == 1 + assert provider.models[0].id == "gpt-4o" + assert provider.models[0].family == "OPENAI" + + +def test_resolved_llms_from_api_with_data(): + raw_response = { + "data": { + "id": "my_provider", + "title": "My Provider", + "models": [{"id": "gpt-4o", "family": "OPENAI"}], + } + } + result = CatalogResolvedLlms.from_api(raw_response) + assert result.data is not None + assert result.data.id == "my_provider" + assert result.data.title == "My Provider" + assert len(result.data.models) == 1 + + +def test_resolved_llms_from_api_no_data(): + raw_response = {"data": None} + result = CatalogResolvedLlms.from_api(raw_response) + assert result.data is None + + +def test_resolved_llms_from_api_missing_data_key(): + raw_response = {} + result = CatalogResolvedLlms.from_api(raw_response) + assert result.data is None + + +def test_resolve_llm_providers_calls_actions_api(): + """Verify that resolve_llm_providers calls the correct actions API method.""" + mock_client = MagicMock() + mock_actions_api = MagicMock() + mock_client.actions_api = mock_actions_api + mock_client.entities_api = MagicMock() + mock_client.layout_api = MagicMock() + mock_client.user_management_api = MagicMock() + + from gooddata_sdk.catalog.workspace.content_service import CatalogWorkspaceContentService + + service = CatalogWorkspaceContentService(mock_client) + + # Mock the API response as a dict with no data + mock_actions_api.resolve_llm_providers.return_value = {"data": None} + + result = service.resolve_llm_providers("test_workspace") + + mock_actions_api.resolve_llm_providers.assert_called_once_with("test_workspace", _check_return_type=False) + assert isinstance(result, CatalogResolvedLlms) + assert result.data is None + + +# --- Integration tests --- + + +@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 + if result.data is not None: + assert isinstance(result.data, CatalogResolvedLlmProvider) + assert result.data.id + assert result.data.title + for model in result.data.models: + assert isinstance(model, CatalogResolvedLlmModel) + assert model.id + assert model.family