diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 91f87c918..84f9d6954 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -116,6 +116,8 @@ CatalogRsaSpecification, ) from gooddata_sdk.catalog.organization.entity_model.llm_provider import ( + CatalogAnthropicApiKeyAuth, + CatalogAnthropicProviderConfig, CatalogAwsBedrockProviderConfig, CatalogAzureFoundryApiKeyAuth, CatalogAzureFoundryProviderConfig, 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 51d089eb5..39c8a597e 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 @@ -75,6 +75,24 @@ def client_class() -> type[AzureFoundryProviderAuth]: CatalogAzureFoundryAuth = Union[CatalogAzureFoundryApiKeyAuth] +# --- Anthropic auth --- + + +@define(kw_only=True) +class CatalogAnthropicApiKeyAuth(Base): + """API key authentication for the Anthropic provider.""" + + api_key: str | None = None + type: str = "API_KEY" + + @staticmethod + def client_class() -> type[OpenAiProviderAuth]: + # Stand-in: AnthropicProviderAuth not yet present in the generated client. + return OpenAiProviderAuth + + +CatalogAnthropicAuth = Union[CatalogAnthropicApiKeyAuth] + # --- Provider config types --- @@ -118,10 +136,25 @@ def client_class() -> type[AzureFoundryProviderConfig]: return AzureFoundryProviderConfig +@define(kw_only=True) +class CatalogAnthropicProviderConfig(Base): + """Anthropic provider configuration.""" + + auth: CatalogAnthropicAuth | None = None + base_url: str | None = None + type: str = "ANTHROPIC" + + @staticmethod + def client_class() -> type[OpenAIProviderConfig]: + # Stand-in: AnthropicProviderConfig not yet present in the generated client. + return OpenAIProviderConfig + + CatalogLlmProviderConfig = Union[ CatalogOpenAiProviderConfig, CatalogAwsBedrockProviderConfig, CatalogAzureFoundryProviderConfig, + CatalogAnthropicProviderConfig, ] @@ -157,6 +190,35 @@ def _azure_foundry_auth_from_api(data: dict[str, Any]) -> CatalogAzureFoundryAut raise ValueError(f"Unknown Azure Foundry auth type: {auth_type}") +def _anthropic_auth_from_api(data: dict[str, Any]) -> CatalogAnthropicAuth: + auth_type = safeget(data, ["type"]) or "API_KEY" + if auth_type == "API_KEY": + return CatalogAnthropicApiKeyAuth( + api_key="", # Credentials are not returned for security reasons + type=auth_type, + ) + raise ValueError(f"Unknown Anthropic auth type: {auth_type}") + + +def _anthropic_config_to_camel_dict(config: CatalogAnthropicProviderConfig) -> dict[str, Any]: + """Convert CatalogAnthropicProviderConfig to a camelCase dict for direct _data_store injection. + + The generated API client's JsonApiLlmProviderInAttributesProviderConfig oneOf does not yet + include AnthropicProviderConfig. We bypass schema validation by storing the config as a + plain camelCase dict in _data_store; model_to_dict(serialize=True) serialises it correctly. + """ + result: dict[str, Any] = {"type": config.type} + if config.base_url is not None: + result["baseUrl"] = config.base_url + if config.auth is not None: + auth = config.auth + auth_dict: dict[str, Any] = {"type": auth.type} + if isinstance(auth, CatalogAnthropicApiKeyAuth) and auth.api_key is not None: + auth_dict["apiKey"] = auth.api_key + result["auth"] = auth_dict + return result + + def _provider_config_from_api(data: dict[str, Any]) -> CatalogLlmProviderConfig: provider_type = safeget(data, ["type"]) or "OPENAI" auth_data = safeget(data, ["auth"]) @@ -173,6 +235,12 @@ def _provider_config_from_api(data: dict[str, Any]) -> CatalogLlmProviderConfig: endpoint=safeget(data, ["endpoint"]), ) + if provider_type == "ANTHROPIC": + return CatalogAnthropicProviderConfig( + auth=_anthropic_auth_from_api(auth_data) if auth_data is not None else None, + base_url=safeget(data, ["baseUrl"]), + ) + # Default: OpenAI return CatalogOpenAiProviderConfig( auth=_openai_auth_from_api(auth_data) if auth_data is not None else None, @@ -192,6 +260,30 @@ class CatalogLlmProviderDocument(Base): def client_class() -> type[JsonApiLlmProviderInDocument]: return JsonApiLlmProviderInDocument + def to_api(self) -> JsonApiLlmProviderInDocument: + """Build the API model with special handling for Anthropic provider config. + + The generated API client's JsonApiLlmProviderInAttributesProviderConfig oneOf schema + does not yet include AnthropicProviderConfig, so the normal Base.to_api() / from_dict() + path raises ApiValueError for type='ANTHROPIC'. When the provider config is Anthropic, + we build the document without providerConfig (it is optional in the API), then inject + the config as a raw camelCase dict directly into _data_store to bypass schema validation. + """ + provider_config = ( + self.data.attributes.provider_config if self.data.attributes is not None else None + ) + if isinstance(provider_config, CatalogAnthropicProviderConfig): + snake_dict = self._get_snake_dict() + # Remove provider_config to avoid the oneOf validation failure in the generated client + snake_dict["data"]["attributes"].pop("provider_config", None) + api_doc = self.client_class().from_dict(snake_dict, camel_case=False) + # Inject provider_config directly as a raw camelCase dict, bypassing oneOf schema + api_doc.data.attributes._data_store["provider_config"] = ( + _anthropic_config_to_camel_dict(provider_config) + ) + return api_doc + return super().to_api() + @define(kw_only=True) class CatalogLlmProviderPatchDocument(Base): diff --git a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py index 53e88c566..e921ec26d 100644 --- a/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py +++ b/packages/gooddata-sdk/tests/catalog/test_catalog_organization.py @@ -5,9 +5,13 @@ from gooddata_api_client.exceptions import NotFoundException from gooddata_sdk import ( + CatalogAnthropicApiKeyAuth, + CatalogAnthropicProviderConfig, CatalogCspDirective, CatalogDeclarativeNotificationChannel, CatalogJwk, + CatalogLlmProvider, + CatalogLlmProviderModel, CatalogOrganization, CatalogOrganizationSetting, CatalogRsaSpecification, @@ -334,6 +338,29 @@ def test_layout_notification_channels(test_config, snapshot_notification_channel # snapshot_notification_channels fixture restores original state in teardown +@gd_vcr.use_cassette(str(_fixtures_dir / "test_anthropic_llm_provider.yaml")) +def test_create_anthropic_llm_provider(test_config): + sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"]) + provider_id = "test-anthropic-provider" + new_provider = CatalogLlmProvider.init( + id=provider_id, + models=[CatalogLlmProviderModel(id="claude-3-5-sonnet-20241022", family="ANTHROPIC")], + provider_config=CatalogAnthropicProviderConfig( + auth=CatalogAnthropicApiKeyAuth(api_key="sk-ant-test-key"), + base_url="https://api.anthropic.com", + ), + name="Test Anthropic Provider", + ) + try: + created = sdk.catalog_organization.create_llm_provider(new_provider) + assert created.id == provider_id + fetched = sdk.catalog_organization.get_llm_provider(provider_id) + assert fetched.id == provider_id + assert isinstance(fetched.attributes.provider_config, CatalogAnthropicProviderConfig) + finally: + safe_delete(sdk.catalog_organization.delete_llm_provider, provider_id) + + # # The following tests are commented out as they require the organization to have the FEDERATED_IDENTITY_MANAGEMENT # entitlement enabled which cannot be done via SDK and must be done by GoodData support.