Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/gooddata-sdk/src/gooddata_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
CatalogDependsOn,
CatalogDependsOnDateFilter,
CatalogEntityIdentifier,
CatalogResolvedLlmEndpoint,
CatalogResolvedLlmModel,
CatalogResolvedLlmProvider,
CatalogResolvedLlms,
CatalogValidateByItem,
CatalogWorkspace,
DataSourceValidator,
Expand Down Expand Up @@ -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
Loading