diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index fba74d7f0..cbef6ba92 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -114,6 +114,8 @@ CatalogAzureFoundryApiKeyAuth, CatalogAzureFoundryProviderConfig, CatalogBedrockAccessKeyAuth, + CatalogListLlmProviderModelsRequest, + CatalogListLlmProviderModelsResponse, CatalogLlmProvider, CatalogLlmProviderDocument, CatalogLlmProviderModel, @@ -121,6 +123,9 @@ CatalogLlmProviderPatchDocument, CatalogOpenAiApiKeyAuth, CatalogOpenAiProviderConfig, + CatalogTestLlmProviderByIdRequest, + CatalogTestLlmProviderDefinitionRequest, + CatalogTestLlmProviderResponse, ) from gooddata_sdk.catalog.organization.entity_model.organization import CatalogOrganization from gooddata_sdk.catalog.organization.entity_model.setting import CatalogOrganizationSetting @@ -249,6 +254,15 @@ CatalogUserDataFilterAttributes, CatalogUserDataFilterRelationships, ) +from gooddata_sdk.catalog.workspace.entity_model.analytics_catalog import ( + CatalogGenerateTitleRequest, + CatalogGenerateTitleResponse, +) +from gooddata_sdk.catalog.workspace.entity_model.llm_resolved import ( + CatalogResolvedLlmEndpoint, + CatalogResolvedLlmProvider, + CatalogResolvedLlms, +) from gooddata_sdk.catalog.workspace.entity_model.workspace import CatalogWorkspace from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.compute.compute_to_sdk_converter import ComputeToSdkConverter diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/entity_model/llm_provider.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/entity_model/llm_provider.py index 063479a89..391c60fc1 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/entity_model/llm_provider.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/entity_model/llm_provider.py @@ -16,8 +16,13 @@ from gooddata_api_client.model.json_api_llm_provider_in_document import JsonApiLlmProviderInDocument from gooddata_api_client.model.json_api_llm_provider_patch import JsonApiLlmProviderPatch from gooddata_api_client.model.json_api_llm_provider_patch_document import JsonApiLlmProviderPatchDocument +from gooddata_api_client.model.list_llm_provider_models_request import ListLlmProviderModelsRequest +from gooddata_api_client.model.list_llm_provider_models_response import ListLlmProviderModelsResponse from gooddata_api_client.model.open_ai_provider_auth import OpenAiProviderAuth from gooddata_api_client.model.open_ai_provider_config import OpenAIProviderConfig +from gooddata_api_client.model.test_llm_provider_by_id_request import TestLlmProviderByIdRequest +from gooddata_api_client.model.test_llm_provider_definition_request import TestLlmProviderDefinitionRequest +from gooddata_api_client.model.test_llm_provider_response import TestLlmProviderResponse from gooddata_sdk.catalog.base import Base from gooddata_sdk.utils import safeget @@ -333,3 +338,136 @@ class CatalogLlmProviderPatchAttributes(Base): @staticmethod def client_class() -> type[JsonApiLlmProviderInAttributes]: return JsonApiLlmProviderInAttributes + + +# --- Action request/response model types --- + + +@define(kw_only=True) +class CatalogModelTestResult(Base): + """Result for a single model connectivity test.""" + + model_id: str + success: bool + message: str | None = None + + @staticmethod + def client_class() -> type: + raise NotImplementedError() + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogModelTestResult: + return cls( + model_id=safeget(data, ["modelId"]) or "", + success=safeget(data, ["success"]) or False, + message=safeget(data, ["message"]), + ) + + +@define(kw_only=True) +class CatalogTestLlmProviderResponse(Base): + """Response from test LLM provider endpoint.""" + + success: bool + message: str | None = None + model_results: list[CatalogModelTestResult] | None = None + + @staticmethod + def client_class() -> type[TestLlmProviderResponse]: + return TestLlmProviderResponse + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogTestLlmProviderResponse: + raw_model_results = safeget(data, ["modelResults"]) or [] + model_results = [CatalogModelTestResult.from_api(r) for r in raw_model_results] + return cls( + success=safeget(data, ["success"]) or False, + message=safeget(data, ["message"]), + model_results=model_results if model_results else None, + ) + + +@define(kw_only=True) +class CatalogTestLlmProviderDefinitionRequest(Base): + """Request for testing LLM provider connectivity with a full definition.""" + + provider_config: CatalogLlmProviderConfig + models: list[CatalogLlmProviderModel] | None = None + + @staticmethod + def client_class() -> type[TestLlmProviderDefinitionRequest]: + return TestLlmProviderDefinitionRequest + + def to_api(self) -> TestLlmProviderDefinitionRequest: + kwargs: dict[str, Any] = {} + if self.models is not None: + kwargs["models"] = [m.to_api() for m in self.models] + return TestLlmProviderDefinitionRequest( + provider_config=self.provider_config.to_api(), + **kwargs, + ) + + +@define(kw_only=True) +class CatalogTestLlmProviderByIdRequest(Base): + """Request for testing an existing LLM provider by ID with optional overrides.""" + + provider_config: CatalogLlmProviderConfig | None = None + models: list[CatalogLlmProviderModel] | None = None + + @staticmethod + def client_class() -> type[TestLlmProviderByIdRequest]: + return TestLlmProviderByIdRequest + + def to_api(self) -> TestLlmProviderByIdRequest: + kwargs: dict[str, Any] = {} + if self.provider_config is not None: + kwargs["provider_config"] = self.provider_config.to_api() + if self.models is not None: + kwargs["models"] = [m.to_api() for m in self.models] + return TestLlmProviderByIdRequest(**kwargs) + + +@define(kw_only=True) +class CatalogListLlmProviderModelsRequest(Base): + """Request for listing models available on an LLM provider.""" + + provider_config: CatalogLlmProviderConfig + + @staticmethod + def client_class() -> type[ListLlmProviderModelsRequest]: + return ListLlmProviderModelsRequest + + def to_api(self) -> ListLlmProviderModelsRequest: + return ListLlmProviderModelsRequest( + provider_config=self.provider_config.to_api(), + ) + + +@define(kw_only=True) +class CatalogListLlmProviderModelsResponse(Base): + """Response from list LLM provider models endpoint.""" + + success: bool + models: list[CatalogLlmProviderModel] | None = None + message: str | None = None + + @staticmethod + def client_class() -> type[ListLlmProviderModelsResponse]: + return ListLlmProviderModelsResponse + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogListLlmProviderModelsResponse: + raw_models = safeget(data, ["models"]) or [] + models = [ + CatalogLlmProviderModel( + id=safeget(m, ["id"]) or "", + family=safeget(m, ["family"]) or "", + ) + for m in raw_models + ] + return cls( + success=safeget(data, ["success"]) or False, + models=models if models else None, + message=safeget(data, ["message"]), + ) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py index cbdd8bbf3..213b3b9a6 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py @@ -24,10 +24,15 @@ from gooddata_sdk.catalog.organization.entity_model.identity_provider import CatalogIdentityProvider from gooddata_sdk.catalog.organization.entity_model.jwk import CatalogJwk, CatalogJwkDocument from gooddata_sdk.catalog.organization.entity_model.llm_provider import ( + CatalogListLlmProviderModelsRequest, + CatalogListLlmProviderModelsResponse, CatalogLlmProvider, CatalogLlmProviderDocument, CatalogLlmProviderPatch, CatalogLlmProviderPatchDocument, + CatalogTestLlmProviderByIdRequest, + CatalogTestLlmProviderDefinitionRequest, + CatalogTestLlmProviderResponse, ) from gooddata_sdk.catalog.organization.entity_model.setting import CatalogOrganizationSetting from gooddata_sdk.catalog.organization.layout.identity_provider import CatalogDeclarativeIdentityProvider @@ -584,6 +589,70 @@ def delete_llm_provider(self, id: str) -> None: """ self._entities_api.delete_entity_llm_providers(id, _check_return_type=False) + def test_llm_provider(self, request: CatalogTestLlmProviderDefinitionRequest) -> CatalogTestLlmProviderResponse: + """Test LLM provider connectivity with a full definition. + + Args: + request: Test request with provider config and optional models + + Returns: + CatalogTestLlmProviderResponse: Test result + """ + response = self._actions_api.test_llm_provider(request.to_api(), _check_return_type=False) + return CatalogTestLlmProviderResponse.from_api(response) + + def test_llm_provider_by_id( + self, + llm_provider_id: str, + request: CatalogTestLlmProviderByIdRequest | None = None, + ) -> CatalogTestLlmProviderResponse: + """Test an existing LLM provider connectivity by its ID. + + Args: + llm_provider_id: LLM provider identifier + request: Optional override request body + + Returns: + CatalogTestLlmProviderResponse: Test result + """ + kwargs: dict[str, Any] = {"_check_return_type": False} + if request is not None: + kwargs["test_llm_provider_by_id_request"] = request.to_api() + response = self._actions_api.test_llm_provider_by_id(llm_provider_id, **kwargs) + return CatalogTestLlmProviderResponse.from_api(response) + + def list_llm_provider_models( + self, request: CatalogListLlmProviderModelsRequest + ) -> CatalogListLlmProviderModelsResponse: + """List models available on an LLM provider with a full definition. + + Args: + request: Request with provider config + + Returns: + CatalogListLlmProviderModelsResponse: Available models + """ + response = self._actions_api.list_llm_provider_models(request.to_api(), _check_return_type=False) + return CatalogListLlmProviderModelsResponse.from_api(response) + + def list_llm_provider_models_by_id( + self, + llm_provider_id: str, + request: CatalogListLlmProviderModelsRequest | None = None, + ) -> CatalogListLlmProviderModelsResponse: + """List models available on an existing LLM provider by its ID. + + Args: + llm_provider_id: LLM provider identifier + request: Optional request body (not used by the API but kept for symmetry) + + Returns: + CatalogListLlmProviderModelsResponse: Available models + """ + kwargs: dict[str, Any] = {"_check_return_type": False} + response = self._actions_api.list_llm_provider_models_by_id(llm_provider_id, **kwargs) + return CatalogListLlmProviderModelsResponse.from_api(response) + # Layout APIs def get_declarative_notification_channels(self) -> list[CatalogDeclarativeNotificationChannel]: diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/analytics_catalog.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/analytics_catalog.py new file mode 100644 index 000000000..209ac3c27 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/analytics_catalog.py @@ -0,0 +1,48 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any + +from attr import define +from gooddata_api_client.model.generate_title_request import GenerateTitleRequest +from gooddata_api_client.model.generate_title_response import GenerateTitleResponse + +from gooddata_sdk.catalog.base import Base +from gooddata_sdk.utils import safeget + + +@define(kw_only=True) +class CatalogGenerateTitleRequest(Base): + """Request for generating a title for an analytics object.""" + + object_id: str + object_type: str + + @staticmethod + def client_class() -> type[GenerateTitleRequest]: + return GenerateTitleRequest + + def to_api(self) -> GenerateTitleRequest: + return GenerateTitleRequest( + object_id=self.object_id, + object_type=self.object_type, + ) + + +@define(kw_only=True) +class CatalogGenerateTitleResponse(Base): + """Response from the generate title endpoint.""" + + title: str + note: str | None = None + + @staticmethod + def client_class() -> type[GenerateTitleResponse]: + return GenerateTitleResponse + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogGenerateTitleResponse: + return cls( + title=safeget(data, ["title"]) or "", + note=safeget(data, ["note"]), + ) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/llm_resolved.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/llm_resolved.py new file mode 100644 index 000000000..64bf08678 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/entity_model/llm_resolved.py @@ -0,0 +1,83 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any + +from attr import define + +from gooddata_sdk.catalog.base import Base +from gooddata_sdk.catalog.organization.entity_model.llm_provider import CatalogLlmProviderModel +from gooddata_sdk.utils import safeget + + +@define(kw_only=True) +class CatalogResolvedLlmProvider(Base): + """Resolved LLM provider for a workspace.""" + + id: str + title: str | None = None + models: list[CatalogLlmProviderModel] | None = None + + @staticmethod + def client_class() -> type: + raise NotImplementedError() + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogResolvedLlmProvider: + raw_models = safeget(data, ["models"]) or [] + models = [ + CatalogLlmProviderModel( + id=safeget(m, ["id"]) or "", + family=safeget(m, ["family"]) or "", + ) + for m in raw_models + ] + return cls( + id=safeget(data, ["id"]) or "", + title=safeget(data, ["title"]), + models=models if models else None, + ) + + +@define(kw_only=True) +class CatalogResolvedLlmEndpoint(Base): + """Resolved LLM endpoint for a workspace (deprecated legacy type).""" + + id: str + title: str | None = None + + @staticmethod + def client_class() -> type: + raise NotImplementedError() + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogResolvedLlmEndpoint: + return cls( + id=safeget(data, ["id"]) or "", + title=safeget(data, ["title"]), + ) + + +@define(kw_only=True) +class CatalogResolvedLlms(Base): + """Container for resolved LLM configuration of a workspace.""" + + data: list[CatalogResolvedLlmProvider | CatalogResolvedLlmEndpoint] | None = None + + @staticmethod + def client_class() -> type: + raise NotImplementedError() + + @classmethod + def from_api(cls, data: dict[str, Any]) -> CatalogResolvedLlms: + raw_data = safeget(data, ["data"]) + if raw_data is None: + return cls(data=None) + items: list[CatalogResolvedLlmProvider | CatalogResolvedLlmEndpoint] = [] + for item in raw_data if isinstance(raw_data, list) else [raw_data]: + # Dispatch: providers have "models" field; endpoints do not + if safeget(item, ["models"]) is not None: + items.append(CatalogResolvedLlmProvider.from_api(item)) + else: + items.append(CatalogResolvedLlmEndpoint.from_api(item)) + return cls(data=items if items else None) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py index 50f5f113e..8ee400e6e 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/workspace/service.py @@ -37,6 +37,11 @@ CatalogUserDataFilter, CatalogUserDataFilterDocument, ) +from gooddata_sdk.catalog.workspace.entity_model.analytics_catalog import ( + CatalogGenerateTitleRequest, + CatalogGenerateTitleResponse, +) +from gooddata_sdk.catalog.workspace.entity_model.llm_resolved import CatalogResolvedLlms from gooddata_sdk.catalog.workspace.entity_model.workspace import CatalogWorkspace from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.utils import ( @@ -1485,3 +1490,28 @@ def load_and_put_declarative_filter_views(self, workspace_id: str, layout_root_p self.layout_organization_folder(layout_root_path) ) self.put_declarative_filter_views(workspace_id, declarative_filter_views) + + def resolve_llm_providers(self, workspace_id: str) -> CatalogResolvedLlms: + """Resolve active LLM configuration for the given workspace. + + Args: + workspace_id: Workspace identifier + + Returns: + CatalogResolvedLlms: Resolved LLM providers or endpoints for this workspace + """ + response = self._actions_api.resolve_llm_providers(workspace_id, _check_return_type=False) + return CatalogResolvedLlms.from_api(response) + + def generate_title(self, workspace_id: str, request: CatalogGenerateTitleRequest) -> CatalogGenerateTitleResponse: + """Generate a title for the specified analytics object. + + Args: + workspace_id: Workspace identifier + request: Generate title request with object_id and object_type + + Returns: + CatalogGenerateTitleResponse: Generated title and optional note + """ + response = self._actions_api.generate_title(workspace_id, request.to_api(), _check_return_type=False) + return CatalogGenerateTitleResponse.from_api(response) diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py index fc1e0fb99..ec6871ce1 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py @@ -561,3 +561,83 @@ def test_layout_notification_channels(test_config, snapshot_notification_channel # sdk.catalog_organization.put_declarative_identity_providers([]) # idps = sdk.catalog_organization.get_declarative_identity_providers() # assert len(idps) == 0 + + +# --- Unit tests for LLM provider action request/response models --- + + +def test_catalog_test_llm_provider_response_from_api(): + from gooddata_sdk import CatalogTestLlmProviderResponse + + data = { + "success": True, + "message": "Connection successful", + "modelResults": [ + {"modelId": "gpt-4o", "success": True, "message": None}, + {"modelId": "gpt-4o-mini", "success": False, "message": "Quota exceeded"}, + ], + } + response = CatalogTestLlmProviderResponse.from_api(data) + assert response.success is True + assert response.message == "Connection successful" + assert response.model_results is not None + assert len(response.model_results) == 2 + assert response.model_results[0].model_id == "gpt-4o" + assert response.model_results[0].success is True + assert response.model_results[1].model_id == "gpt-4o-mini" + assert response.model_results[1].success is False + assert response.model_results[1].message == "Quota exceeded" + + +def test_catalog_test_llm_provider_definition_request_to_api(): + from gooddata_sdk import ( + CatalogOpenAiApiKeyAuth, + CatalogOpenAiProviderConfig, + CatalogTestLlmProviderDefinitionRequest, + ) + from gooddata_sdk.catalog.organization.entity_model.llm_provider import CatalogLlmProviderModel + + config = CatalogOpenAiProviderConfig( + auth=CatalogOpenAiApiKeyAuth(api_key="sk-test"), + base_url="https://api.openai.com", + ) + models = [CatalogLlmProviderModel(id="gpt-4o", family="GPT")] + request = CatalogTestLlmProviderDefinitionRequest(provider_config=config, models=models) + api_obj = request.to_api() + assert api_obj is not None + + +def test_catalog_test_llm_provider_by_id_request_to_api_no_overrides(): + from gooddata_sdk import CatalogTestLlmProviderByIdRequest + + request = CatalogTestLlmProviderByIdRequest() + api_obj = request.to_api() + assert api_obj is not None + + +def test_catalog_list_llm_provider_models_request_to_api(): + from gooddata_sdk import CatalogListLlmProviderModelsRequest, CatalogOpenAiProviderConfig + + config = CatalogOpenAiProviderConfig(base_url="https://api.openai.com") + request = CatalogListLlmProviderModelsRequest(provider_config=config) + api_obj = request.to_api() + assert api_obj is not None + + +def test_catalog_list_llm_provider_models_response_from_api(): + from gooddata_sdk import CatalogListLlmProviderModelsResponse + + data = { + "success": True, + "message": None, + "models": [ + {"id": "gpt-4o", "family": "GPT"}, + {"id": "gpt-4o-mini", "family": "GPT"}, + ], + } + response = CatalogListLlmProviderModelsResponse.from_api(data) + assert response.success is True + assert response.models is not None + assert len(response.models) == 2 + assert response.models[0].id == "gpt-4o" + assert response.models[0].family == "GPT" diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py index cd056817b..5961b1b36 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_workspace.py @@ -1032,3 +1032,66 @@ def test_layout_filter_views(test_config): assert filter_views_expected == filter_views_o finally: safe_delete(sdk.catalog_workspace.put_declarative_filter_views, workspace_id, []) + + +# --- Unit tests for workspace LLM resolved and analytics catalog models --- + + +def test_catalog_resolved_llm_provider_from_api(): + from gooddata_sdk import CatalogResolvedLlmProvider + + data = { + "id": "openai-provider", + "title": "OpenAI Provider", + "models": [ + {"id": "gpt-4o", "family": "GPT"}, + ], + } + provider = CatalogResolvedLlmProvider.from_api(data) + assert provider.id == "openai-provider" + assert provider.title == "OpenAI Provider" + assert provider.models is not None + assert len(provider.models) == 1 + assert provider.models[0].id == "gpt-4o" + + +def test_catalog_resolved_llm_endpoint_from_api(): + from gooddata_sdk import CatalogResolvedLlmEndpoint + + data = {"id": "legacy-endpoint", "title": "Legacy Endpoint"} + endpoint = CatalogResolvedLlmEndpoint.from_api(data) + assert endpoint.id == "legacy-endpoint" + assert endpoint.title == "Legacy Endpoint" + + +def test_catalog_resolved_llms_dispatches_on_type(): + from gooddata_sdk import CatalogResolvedLlmEndpoint, CatalogResolvedLlmProvider, CatalogResolvedLlms + + data = { + "data": [ + {"id": "provider-1", "title": "Provider 1", "models": [{"id": "gpt-4o", "family": "GPT"}]}, + {"id": "endpoint-1", "title": "Endpoint 1"}, + ] + } + resolved = CatalogResolvedLlms.from_api(data) + assert resolved.data is not None + assert len(resolved.data) == 2 + assert isinstance(resolved.data[0], CatalogResolvedLlmProvider) + assert isinstance(resolved.data[1], CatalogResolvedLlmEndpoint) + + +def test_catalog_generate_title_request_to_api(): + from gooddata_sdk import CatalogGenerateTitleRequest + + request = CatalogGenerateTitleRequest(object_id="vis-123", object_type="Visualization") + api_obj = request.to_api() + assert api_obj is not None + + +def test_catalog_generate_title_response_from_api(): + from gooddata_sdk import CatalogGenerateTitleResponse + + data = {"title": "Monthly Revenue", "note": None} + response = CatalogGenerateTitleResponse.from_api(data) + assert response.title == "Monthly Revenue" + assert response.note is None