Skip to content

Commit 51dfb6c

Browse files
feat(sdk): add LLM provider test & list-models enablers
Add typed CatalogOrganizationService methods backed by ActionsApi so tooling can test connectivity and list models for LLM providers without using the raw api-client: - test_llm_provider / test_llm_provider_by_id -> CatalogLlmProviderTestResult - list_llm_provider_models / list_llm_provider_models_by_id -> CatalogLlmProviderModelsResult - New result types parse responses with a casing-robust from_api, since the API returns nested result items as camelCase dicts - Provider CRUD is unchanged JIRA: GDAI-1811 risk: nonprod
1 parent 6e3ba76 commit 51dfb6c

4 files changed

Lines changed: 329 additions & 0 deletions

File tree

packages/gooddata-sdk/src/gooddata_sdk/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@
123123
CatalogLlmProvider,
124124
CatalogLlmProviderDocument,
125125
CatalogLlmProviderModel,
126+
CatalogLlmProviderModelsResult,
126127
CatalogLlmProviderPatch,
127128
CatalogLlmProviderPatchDocument,
129+
CatalogLlmProviderTestResult,
130+
CatalogModelTestResult,
128131
CatalogOpenAiApiKeyAuth,
129132
CatalogOpenAiProviderConfig,
130133
)

packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/entity_model/llm_provider.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
from gooddata_api_client.model.json_api_llm_provider_in_document import JsonApiLlmProviderInDocument
1717
from gooddata_api_client.model.json_api_llm_provider_patch import JsonApiLlmProviderPatch
1818
from gooddata_api_client.model.json_api_llm_provider_patch_document import JsonApiLlmProviderPatchDocument
19+
from gooddata_api_client.model.list_llm_provider_models_response import ListLlmProviderModelsResponse
20+
from gooddata_api_client.model.model_test_result import ModelTestResult
1921
from gooddata_api_client.model.open_ai_provider_auth import OpenAiProviderAuth
2022
from gooddata_api_client.model.open_ai_provider_config import OpenAIProviderConfig
23+
from gooddata_api_client.model.test_llm_provider_response import TestLlmProviderResponse
2124

2225
from gooddata_sdk.catalog.base import Base
2326
from gooddata_sdk.utils import safeget
@@ -337,3 +340,105 @@ class CatalogLlmProviderPatchAttributes(Base):
337340
@staticmethod
338341
def client_class() -> type[JsonApiLlmProviderInAttributes]:
339342
return JsonApiLlmProviderInAttributes
343+
344+
345+
# --- Action result types ---
346+
347+
348+
def _as_dict(entity: Any) -> dict[str, Any]:
349+
"""Normalize an API response (model object or raw dict) to a plain dict."""
350+
return entity if isinstance(entity, dict) else entity.to_dict()
351+
352+
353+
def _first_present(data: dict[str, Any], *keys: str) -> Any:
354+
"""Return the first non-None value among the given keys.
355+
356+
Action responses are inconsistent about casing: top-level fields may be
357+
snake_case while nested items keep their camelCase wire names, so we accept
358+
both spellings of each field.
359+
"""
360+
for key in keys:
361+
value = safeget(data, [key])
362+
if value is not None:
363+
return value
364+
return None
365+
366+
367+
@define(kw_only=True)
368+
class CatalogModelTestResult(Base):
369+
"""Result of testing a single model on an LLM provider."""
370+
371+
model_id: str
372+
successful: bool
373+
message: str | None = None
374+
375+
@staticmethod
376+
def client_class() -> type[ModelTestResult]:
377+
return ModelTestResult
378+
379+
@classmethod
380+
def from_api(cls, entity: Any) -> CatalogModelTestResult:
381+
data = _as_dict(entity)
382+
return cls(
383+
model_id=_first_present(data, "modelId", "model_id"),
384+
successful=_first_present(data, "successful"),
385+
message=_first_present(data, "message"),
386+
)
387+
388+
389+
@define(kw_only=True)
390+
class CatalogLlmProviderTestResult(Base):
391+
"""Result of testing connectivity to an LLM provider."""
392+
393+
provider_reachable: bool
394+
provider_message: str | None = None
395+
model_results: list[CatalogModelTestResult] | None = None
396+
397+
@staticmethod
398+
def client_class() -> type[TestLlmProviderResponse]:
399+
return TestLlmProviderResponse
400+
401+
@classmethod
402+
def from_api(cls, entity: Any) -> CatalogLlmProviderTestResult:
403+
data = _as_dict(entity)
404+
raw_results = _first_present(data, "modelResults", "model_results")
405+
return cls(
406+
provider_reachable=_first_present(data, "providerReachable", "provider_reachable"),
407+
provider_message=_first_present(data, "providerMessage", "provider_message"),
408+
model_results=(
409+
[CatalogModelTestResult.from_api(r) for r in raw_results] if raw_results is not None else None
410+
),
411+
)
412+
413+
414+
@define(kw_only=True)
415+
class CatalogLlmProviderModelsResult(Base):
416+
"""Result of listing the models available for an LLM provider."""
417+
418+
success: bool
419+
message: str | None = None
420+
models: list[CatalogLlmProviderModel] | None = None
421+
422+
@staticmethod
423+
def client_class() -> type[ListLlmProviderModelsResponse]:
424+
return ListLlmProviderModelsResponse
425+
426+
@classmethod
427+
def from_api(cls, entity: Any) -> CatalogLlmProviderModelsResult:
428+
data = _as_dict(entity)
429+
raw_models = _first_present(data, "models")
430+
return cls(
431+
success=_first_present(data, "success"),
432+
message=_first_present(data, "message"),
433+
models=(
434+
[
435+
CatalogLlmProviderModel(
436+
id=_first_present(m, "id"),
437+
family=_first_present(m, "family"),
438+
)
439+
for m in raw_models
440+
]
441+
if raw_models is not None
442+
else None
443+
),
444+
)

packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
from gooddata_api_client.model.json_api_organization_patch import JsonApiOrganizationPatch
1717
from gooddata_api_client.model.json_api_organization_patch_document import JsonApiOrganizationPatchDocument
1818
from gooddata_api_client.model.json_api_organization_setting_in_document import JsonApiOrganizationSettingInDocument
19+
from gooddata_api_client.model.list_llm_provider_models_request import ListLlmProviderModelsRequest
1920
from gooddata_api_client.model.switch_identity_provider_request import SwitchIdentityProviderRequest
21+
from gooddata_api_client.model.test_llm_provider_by_id_request import TestLlmProviderByIdRequest
22+
from gooddata_api_client.model.test_llm_provider_definition_request import TestLlmProviderDefinitionRequest
2023

2124
from gooddata_sdk import CatalogDeclarativeExportTemplate, CatalogExportTemplate
2225
from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase
@@ -25,9 +28,13 @@
2528
from gooddata_sdk.catalog.organization.entity_model.jwk import CatalogJwk, CatalogJwkDocument
2629
from gooddata_sdk.catalog.organization.entity_model.llm_provider import (
2730
CatalogLlmProvider,
31+
CatalogLlmProviderConfig,
2832
CatalogLlmProviderDocument,
33+
CatalogLlmProviderModel,
34+
CatalogLlmProviderModelsResult,
2935
CatalogLlmProviderPatch,
3036
CatalogLlmProviderPatchDocument,
37+
CatalogLlmProviderTestResult,
3138
)
3239
from gooddata_sdk.catalog.organization.entity_model.setting import CatalogOrganizationSetting
3340
from gooddata_sdk.catalog.organization.layout.identity_provider import CatalogDeclarativeIdentityProvider
@@ -628,6 +635,77 @@ def delete_llm_provider(self, id: str) -> None:
628635
"""
629636
self._entities_api.delete_entity_llm_providers(id, _check_return_type=False)
630637

638+
def test_llm_provider(
639+
self,
640+
provider_config: CatalogLlmProviderConfig,
641+
models: list[CatalogLlmProviderModel] | None = None,
642+
) -> CatalogLlmProviderTestResult:
643+
"""Test connectivity to an unsaved LLM provider configuration.
644+
645+
Args:
646+
provider_config: Provider configuration to test (OpenAI, AWS Bedrock, or Azure Foundry).
647+
models: Optional list of models to test against the provider.
648+
649+
Returns:
650+
CatalogLlmProviderTestResult: Reachability and per-model test results.
651+
"""
652+
payload: dict = {"providerConfig": provider_config.to_api().to_dict()}
653+
if models is not None:
654+
payload["models"] = [m.to_api().to_dict() for m in models]
655+
request = TestLlmProviderDefinitionRequest.from_dict(payload)
656+
response = self._actions_api.test_llm_provider(request, _check_return_type=False)
657+
return CatalogLlmProviderTestResult.from_api(response)
658+
659+
def test_llm_provider_by_id(
660+
self,
661+
id: str,
662+
models: list[CatalogLlmProviderModel] | None = None,
663+
) -> CatalogLlmProviderTestResult:
664+
"""Test connectivity to a saved LLM provider by its identifier.
665+
666+
Args:
667+
id: LLM provider identifier.
668+
models: Optional list of models to test (overrides the provider's models).
669+
670+
Returns:
671+
CatalogLlmProviderTestResult: Reachability and per-model test results.
672+
"""
673+
kwargs: dict = {"_check_return_type": False}
674+
if models is not None:
675+
kwargs["test_llm_provider_by_id_request"] = TestLlmProviderByIdRequest.from_dict(
676+
{"models": [m.to_api().to_dict() for m in models]}
677+
)
678+
response = self._actions_api.test_llm_provider_by_id(id, **kwargs)
679+
return CatalogLlmProviderTestResult.from_api(response)
680+
681+
def list_llm_provider_models(
682+
self,
683+
provider_config: CatalogLlmProviderConfig,
684+
) -> CatalogLlmProviderModelsResult:
685+
"""List the models available for an unsaved LLM provider configuration.
686+
687+
Args:
688+
provider_config: Provider configuration to query.
689+
690+
Returns:
691+
CatalogLlmProviderModelsResult: Available models and query status.
692+
"""
693+
request = ListLlmProviderModelsRequest.from_dict({"providerConfig": provider_config.to_api().to_dict()})
694+
response = self._actions_api.list_llm_provider_models(request, _check_return_type=False)
695+
return CatalogLlmProviderModelsResult.from_api(response)
696+
697+
def list_llm_provider_models_by_id(self, id: str) -> CatalogLlmProviderModelsResult:
698+
"""List the models available for a saved LLM provider by its identifier.
699+
700+
Args:
701+
id: LLM provider identifier.
702+
703+
Returns:
704+
CatalogLlmProviderModelsResult: Available models and query status.
705+
"""
706+
response = self._actions_api.list_llm_provider_models_by_id(id, _check_return_type=False)
707+
return CatalogLlmProviderModelsResult.from_api(response)
708+
631709
# Layout APIs
632710

633711
def get_declarative_notification_channels(self) -> list[CatalogDeclarativeNotificationChannel]:
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from unittest.mock import MagicMock
5+
6+
from gooddata_api_client.model.list_llm_provider_models_response import ListLlmProviderModelsResponse
7+
from gooddata_api_client.model.llm_model import LlmModel
8+
from gooddata_api_client.model.model_test_result import ModelTestResult
9+
from gooddata_api_client.model.test_llm_provider_response import TestLlmProviderResponse
10+
from gooddata_sdk import (
11+
CatalogLlmProviderModel,
12+
CatalogLlmProviderModelsResult,
13+
CatalogLlmProviderTestResult,
14+
CatalogModelTestResult,
15+
CatalogOpenAiApiKeyAuth,
16+
CatalogOpenAiProviderConfig,
17+
)
18+
from gooddata_sdk.catalog.organization.service import CatalogOrganizationService
19+
20+
21+
def test_provider_test_result_from_api():
22+
api_response = TestLlmProviderResponse(
23+
provider_reachable=True,
24+
provider_message="ok",
25+
model_results=[ModelTestResult(model_id="gpt-4o", successful=True, message="ok")],
26+
)
27+
28+
result = CatalogLlmProviderTestResult.from_api(api_response)
29+
30+
assert result.provider_reachable is True
31+
assert result.provider_message == "ok"
32+
assert len(result.model_results) == 1
33+
assert isinstance(result.model_results[0], CatalogModelTestResult)
34+
assert result.model_results[0].model_id == "gpt-4o"
35+
assert result.model_results[0].successful is True
36+
assert result.model_results[0].message == "ok"
37+
38+
39+
def test_provider_test_result_from_api_camelcase_dict():
40+
# Mirrors the real API: nested modelResults items arrive as plain dicts with
41+
# camelCase wire keys, while top-level fields are snake_case.
42+
api_shape = {
43+
"provider_reachable": True,
44+
"provider_message": "Provider is reachable",
45+
"model_results": [{"modelId": "gpt-5.2", "successful": True, "message": "Model is available"}],
46+
}
47+
48+
result = CatalogLlmProviderTestResult.from_api(api_shape)
49+
50+
assert result.provider_reachable is True
51+
assert result.provider_message == "Provider is reachable"
52+
assert result.model_results[0].model_id == "gpt-5.2"
53+
assert result.model_results[0].successful is True
54+
assert result.model_results[0].message == "Model is available"
55+
56+
57+
def test_provider_models_result_from_api():
58+
api_response = ListLlmProviderModelsResponse(
59+
success=True,
60+
message="ok",
61+
models=[LlmModel(id="gpt-4o", family="OPENAI")],
62+
)
63+
64+
result = CatalogLlmProviderModelsResult.from_api(api_response)
65+
66+
assert result.success is True
67+
assert result.message == "ok"
68+
assert len(result.models) == 1
69+
assert isinstance(result.models[0], CatalogLlmProviderModel)
70+
assert result.models[0].id == "gpt-4o"
71+
assert result.models[0].family == "OPENAI"
72+
73+
74+
def _openai_config():
75+
return CatalogOpenAiProviderConfig(
76+
auth=CatalogOpenAiApiKeyAuth(api_key="secret"),
77+
base_url="https://api.openai.com/v1",
78+
)
79+
80+
81+
def test_test_llm_provider_maps_response():
82+
service = CatalogOrganizationService.__new__(CatalogOrganizationService)
83+
service._actions_api = MagicMock()
84+
service._actions_api.test_llm_provider.return_value = TestLlmProviderResponse(
85+
provider_reachable=False,
86+
provider_message="bad key",
87+
model_results=[],
88+
)
89+
90+
result = service.test_llm_provider(_openai_config())
91+
92+
assert result.provider_reachable is False
93+
assert result.provider_message == "bad key"
94+
# the request body passed to the client carries the provider config discriminator
95+
sent = service._actions_api.test_llm_provider.call_args.args[0]
96+
assert sent["provider_config"]["type"] == "OPENAI"
97+
98+
99+
def test_test_llm_provider_by_id_calls_client_with_id():
100+
service = CatalogOrganizationService.__new__(CatalogOrganizationService)
101+
service._actions_api = MagicMock()
102+
service._actions_api.test_llm_provider_by_id.return_value = TestLlmProviderResponse(
103+
provider_reachable=True,
104+
provider_message="ok",
105+
model_results=[],
106+
)
107+
108+
result = service.test_llm_provider_by_id("my-provider")
109+
110+
assert result.provider_reachable is True
111+
assert service._actions_api.test_llm_provider_by_id.call_args.args[0] == "my-provider"
112+
113+
114+
def test_list_llm_provider_models_maps_response():
115+
service = CatalogOrganizationService.__new__(CatalogOrganizationService)
116+
service._actions_api = MagicMock()
117+
service._actions_api.list_llm_provider_models.return_value = ListLlmProviderModelsResponse(
118+
success=True,
119+
message="ok",
120+
models=[LlmModel(id="gpt-4o", family="OPENAI")],
121+
)
122+
123+
result = service.list_llm_provider_models(_openai_config())
124+
125+
assert result.success is True
126+
assert result.models[0].id == "gpt-4o"
127+
sent = service._actions_api.list_llm_provider_models.call_args.args[0]
128+
assert sent["provider_config"]["type"] == "OPENAI"
129+
130+
131+
def test_list_llm_provider_models_by_id_calls_client_with_id():
132+
service = CatalogOrganizationService.__new__(CatalogOrganizationService)
133+
service._actions_api = MagicMock()
134+
service._actions_api.list_llm_provider_models_by_id.return_value = ListLlmProviderModelsResponse(
135+
success=True,
136+
message="ok",
137+
models=[],
138+
)
139+
140+
result = service.list_llm_provider_models_by_id("my-provider")
141+
142+
assert result.success is True
143+
assert service._actions_api.list_llm_provider_models_by_id.call_args.args[0] == "my-provider"

0 commit comments

Comments
 (0)