Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/gooddata-sdk/src/gooddata_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,15 @@
CatalogAzureFoundryApiKeyAuth,
CatalogAzureFoundryProviderConfig,
CatalogBedrockAccessKeyAuth,
CatalogListLlmProviderModelsResponse,
CatalogLlmProvider,
CatalogLlmProviderDocument,
CatalogLlmProviderModel,
CatalogLlmProviderPatch,
CatalogLlmProviderPatchDocument,
CatalogOpenAiApiKeyAuth,
CatalogOpenAiProviderConfig,
CatalogResolvedLlm,
)
from gooddata_sdk.catalog.organization.entity_model.organization import CatalogOrganization
from gooddata_sdk.catalog.organization.entity_model.setting import CatalogOrganizationSetting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from typing import Any, Union

import attrs
from attr import define
from gooddata_api_client.model.aws_bedrock_provider_config import AwsBedrockProviderConfig
from gooddata_api_client.model.azure_foundry_provider_auth import AzureFoundryProviderAuth
Expand Down Expand Up @@ -337,3 +338,54 @@ class CatalogLlmProviderPatchAttributes(Base):
@staticmethod
def client_class() -> type[JsonApiLlmProviderInAttributes]:
return JsonApiLlmProviderInAttributes


# --- Resolved LLM response types (read-only, from action API) ---


@attrs.define(kw_only=True)
class CatalogResolvedLlm:
"""Represents a resolved LLM configuration (provider or legacy endpoint) for a workspace."""

id: str
title: str
models: list[CatalogLlmProviderModel] = attrs.field(factory=list)

@classmethod
def from_api(cls, entity: Any) -> CatalogResolvedLlm:
raw_models = safeget(entity, ["models"]) or []
return cls(
id=safeget(entity, ["id"]) or "",
title=safeget(entity, ["title"]) or "",
models=[
CatalogLlmProviderModel(
id=safeget(m, ["id"]) or "",
family=safeget(m, ["family"]) or "",
)
for m in raw_models
],
)


@attrs.define(kw_only=True)
class CatalogListLlmProviderModelsResponse:
"""Response from listing available models for an LLM provider."""

success: bool
message: str
models: list[CatalogLlmProviderModel] = attrs.field(factory=list)

@classmethod
def from_api(cls, entity: Any) -> CatalogListLlmProviderModelsResponse:
raw_models = safeget(entity, ["models"]) or []
return cls(
success=safeget(entity, ["success"]) or False,
message=safeget(entity, ["message"]) or "",
models=[
CatalogLlmProviderModel(
id=safeget(m, ["id"]) or "",
family=safeget(m, ["family"]) or "",
)
for m in raw_models
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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 (
CatalogListLlmProviderModelsResponse,
CatalogLlmProvider,
CatalogLlmProviderDocument,
CatalogLlmProviderPatch,
Expand Down Expand Up @@ -584,6 +585,19 @@ def delete_llm_provider(self, id: str) -> None:
"""
self._entities_api.delete_entity_llm_providers(id, _check_return_type=False)

def list_llm_provider_models_by_id(self, llm_provider_id: str) -> CatalogListLlmProviderModelsResponse:
"""List available models for an existing LLM provider by its ID.

Args:
llm_provider_id: LLM provider identifier

Returns:
CatalogListLlmProviderModelsResponse: Response containing available models
and a success flag.
"""
response = self._actions_api.list_llm_provider_models_by_id(llm_provider_id, _check_return_type=False)
return CatalogListLlmProviderModelsResponse.from_api(response)

# Layout APIs

def get_declarative_notification_channels(self) -> list[CatalogDeclarativeNotificationChannel]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from gooddata_sdk.catalog.data_source.validation.data_source import DataSourceValidator
from gooddata_sdk.catalog.depends_on import CatalogDependsOn, CatalogDependsOnDateFilter
from gooddata_sdk.catalog.filter_by import CatalogFilterBy
from gooddata_sdk.catalog.organization.entity_model.llm_provider import CatalogResolvedLlm
from gooddata_sdk.catalog.types import ValidObjects
from gooddata_sdk.catalog.validate_by_item import CatalogValidateByItem
from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.analytics_model import (
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) -> CatalogResolvedLlm | None:
"""Resolve the active LLM configuration for a workspace.

When the ENABLE_LLM_ENDPOINT_REPLACEMENT feature flag is enabled, returns
the active LLM provider with its associated models. Otherwise, falls back
to the legacy LLM endpoint response format.

Args:
workspace_id (str):
Workspace identification string e.g. "demo".

Returns:
CatalogResolvedLlm | None:
Resolved LLM configuration, or None if no active configuration exists.
"""
response = self._actions_api.resolve_llm_providers(workspace_id, _check_return_type=False)
if response is None:
return None
data = response.data if hasattr(response, "data") else None
if data is None:
return None
return CatalogResolvedLlm.from_api(data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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/entities/llmProviders
response:
body:
string:
data: []
links:
next: http://localhost:3000/api/v1/entities/llmProviders?page=1&size=20
self: http://localhost:3000/api/v1/entities/llmProviders?page=0&size=20
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 @@ -3,13 +3,16 @@

from pathlib import Path

import pytest
from gooddata_api_client.exceptions import NotFoundException
from gooddata_sdk import (
CatalogCspDirective,
CatalogDeclarativeNotificationChannel,
CatalogJwk,
CatalogListLlmProviderModelsResponse,
CatalogOrganization,
CatalogOrganizationSetting,
CatalogResolvedLlm,
CatalogRsaSpecification,
CatalogWebhook,
GoodDataSdk,
Expand Down Expand Up @@ -563,3 +566,85 @@ 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 response model classes (no cassettes needed)
# ---------------------------------------------------------------------------


def test_catalog_resolved_llm_from_api():
"""CatalogResolvedLlm.from_api deserializes a plain dict correctly."""
data = {
"id": "my-provider",
"title": "My LLM Provider",
"models": [
{"id": "gpt-4o", "family": "OPENAI"},
{"id": "gpt-4-turbo", "family": "OPENAI"},
],
}
result = CatalogResolvedLlm.from_api(data)
assert result.id == "my-provider"
assert result.title == "My LLM Provider"
assert len(result.models) == 2
assert result.models[0].id == "gpt-4o"
assert result.models[0].family == "OPENAI"
assert result.models[1].id == "gpt-4-turbo"


def test_catalog_resolved_llm_from_api_empty_models():
"""CatalogResolvedLlm.from_api handles missing models gracefully."""
data = {"id": "provider-no-models", "title": "Provider"}
result = CatalogResolvedLlm.from_api(data)
assert result.id == "provider-no-models"
assert result.title == "Provider"
assert result.models == []


def test_catalog_list_llm_provider_models_response_from_api():
"""CatalogListLlmProviderModelsResponse.from_api deserializes correctly."""
data = {
"success": True,
"message": "Models listed successfully.",
"models": [
{"id": "claude-3-5-sonnet-20241022", "family": "ANTHROPIC"},
],
}
result = CatalogListLlmProviderModelsResponse.from_api(data)
assert result.success is True
assert result.message == "Models listed successfully."
assert len(result.models) == 1
assert result.models[0].id == "claude-3-5-sonnet-20241022"
assert result.models[0].family == "ANTHROPIC"


def test_catalog_list_llm_provider_models_response_failure():
"""CatalogListLlmProviderModelsResponse.from_api handles failure response."""
data = {
"success": False,
"message": "Authentication failed.",
"models": [],
}
result = CatalogListLlmProviderModelsResponse.from_api(data)
assert result.success is False
assert result.message == "Authentication failed."
assert result.models == []


# ---------------------------------------------------------------------------
# Integration tests for LLM provider CRUD and action methods
# (cassettes to be recorded against a live server)
# ---------------------------------------------------------------------------


@gd_vcr.use_cassette(str(_fixtures_dir / "list_llm_provider_models_by_id.yaml"))
def test_list_llm_provider_models_by_id(test_config):
sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"])
providers = sdk.catalog_organization.list_llm_providers()
if not providers:
pytest.skip("No LLM providers configured — cannot test list_llm_provider_models_by_id")
provider_id = providers[0].id
response = sdk.catalog_organization.list_llm_provider_models_by_id(provider_id)
assert isinstance(response, CatalogListLlmProviderModelsResponse)
assert isinstance(response.success, bool)
assert isinstance(response.message, str)
Loading