From 96b3595af4dc35e0bc866409677e426cf5a56912 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 Nov 2024 07:19:52 -0500 Subject: [PATCH 1/5] .Net: Update M.E.AI.Abstractions version (#9674) Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 14 +++++++------- .../ChatClientChatCompletionService.cs | 5 +++++ .../ChatCompletionServiceChatClient.cs | 5 +++++ .../AI/ServiceConversionExtensionsTests.cs | 4 ++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 233be81237d8..8ea8825027bb 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -16,7 +16,7 @@ - + @@ -62,9 +62,9 @@ - - - + + + @@ -106,8 +106,8 @@ - - + + @@ -130,7 +130,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs index 62aad390dea4..d7952fa42afa 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs @@ -130,6 +130,11 @@ public async IAsyncEnumerable GetStreamingChatMessa { options.TopK = topK; } + else if (entry.Key.Equals("seed", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out long seed)) + { + options.Seed = seed; + } else if (entry.Key.Equals("max_tokens", StringComparison.OrdinalIgnoreCase) && TryConvert(entry.Value, out int maxTokens)) { diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs index 1d76df963b06..cab0bce50d26 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs @@ -138,6 +138,11 @@ public void Dispose() settings.ExtensionData["top_k"] = options.TopK.Value; } + if (options.Seed is not null) + { + settings.ExtensionData["seed"] = options.Seed.Value; + } + if (options.ResponseFormat is not null) { if (options.ResponseFormat is ChatResponseFormatText) diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs index d4dad01050d7..09f1966e2837 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs @@ -526,7 +526,7 @@ public async Task AsChatCompletionServiceNonStreamingContentConvertedAsExpected( Assert.Equal(0.5f, actualOptions.FrequencyPenalty); Assert.Equal(0.75f, actualOptions.TopP); Assert.Equal(["hello"], actualOptions.StopSequences); - Assert.Equal(42, actualOptions.AdditionalProperties?.TryGetValue("Seed", out int seed) is true ? seed : 0); + Assert.Equal(42, actualOptions.Seed); Assert.Equal("user123", actualOptions.AdditionalProperties?["User"]); } @@ -621,7 +621,7 @@ public async Task AsChatCompletionServiceStreamingContentConvertedAsExpected() Assert.Equal(0.5f, actualOptions.FrequencyPenalty); Assert.Equal(0.75f, actualOptions.TopP); Assert.Equal(["hello"], actualOptions.StopSequences); - Assert.Equal(42, actualOptions.AdditionalProperties?.TryGetValue("Seed", out int seed) is true ? seed : 0); + Assert.Equal(42, actualOptions.Seed); Assert.Equal("user123", actualOptions.AdditionalProperties?["User"]); } From 51675d2607423f0184a6e69c92dea65c0aa88d45 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:27:32 -0600 Subject: [PATCH 2/5] Python: OpenAPI plugin updates to promote plugin to preview (#9670) ### Motivation and Context We currently handle OpenAPI plugins via the kernel, but in a pretty simple way. The changes in this PR are allowing one to define multiple server paths for an operation as well as configure security on the operations either at the operation level or at the "global" spec level (these are included as metadata in the kernel function parameter `additional_parameters` dict). The OpenAPI plugin is graduating from an experimental state to a preview state. This, in turn, removes the `experimental` tag from some public facing methods. In the near future, all `experimental` tags will be removed once we're confident there are no other underlying changes required. Closes #9719 ### Description Adds support for OpenAPI plugin: - allow for multiple servers for an API operation - allows one to define security configuration at the spec level as well as per operation level - fixes a bug where the `excluded_operations` on the execution settings were never checked during REST API operation building - adds the deprecated tag to the `OpenAI plugin` code as it was deprecated by OpenAI and we never handled it in SK Python. - renames some model class to remove the `operation` portion of the name. - adds ability to specify a callback to be able to skip certain api operations based on name, method, or description - removes the experimental tag from public facing methods. - allows one to pass in a parsed OpenAPI spec to the plugin add method - adds unit tests ### Contribution Checklist - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- .../demos/process_with_dapr/fastapi_app.py | 4 +- .../demos/process_with_dapr/flask_app.py | 5 +- .../services/azure_ai_inference_tracing.py | 2 +- .../openai_authentication_config.py | 4 + .../openai_function_execution_parameters.py | 41 +- .../connectors/openai_plugin/openai_utils.py | 3 + .../connectors/openapi_plugin/__init__.py | 6 +- .../connectors/openapi_plugin/const.py | 18 + ...ponse.py => rest_api_expected_response.py} | 6 +- .../models/rest_api_oauth_flow.py | 16 + .../models/rest_api_oauth_flows.py | 17 + .../models/rest_api_operation.py | 271 +++++++++--- .../models/rest_api_operation_parameter.py | 44 -- .../models/rest_api_operation_payload.py | 24 -- .../rest_api_operation_payload_property.py | 29 -- .../models/rest_api_parameter.py | 155 +++++++ ...tion.py => rest_api_parameter_location.py} | 4 +- ...r_style.py => rest_api_parameter_style.py} | 4 +- .../openapi_plugin/models/rest_api_payload.py | 77 ++++ .../models/rest_api_payload_property.py | 112 +++++ ...run_options.py => rest_api_run_options.py} | 2 +- .../models/rest_api_security_requirement.py | 13 + .../models/rest_api_security_scheme.py | 33 ++ .../openapi_function_execution_parameters.py | 10 +- .../openapi_plugin/openapi_manager.py | 71 +++- .../openapi_plugin/openapi_parser.py | 131 ++++-- .../openapi_plugin/openapi_runner.py | 20 +- .../operation_selection_predicate_context.py | 12 + .../functions/kernel_function_extension.py | 18 +- .../functions/kernel_plugin.py | 22 +- .../cross_language/test_cross_language.py | 3 +- .../openapi_plugin/apikey-securityV3_0.json | 79 ++++ .../openapi_plugin/no-securityV3_0.json | 74 ++++ .../openapi_plugin/oauth-securityV3_0.json | 89 ++++ .../connectors/openapi_plugin/openapi.yaml | 1 + .../openapi_plugin/test_openapi_manager.py | 22 +- .../openapi_plugin/test_openapi_parser.py | 69 +++ .../openapi_plugin/test_openapi_runner.py | 27 +- .../test_rest_api_operation_run_options.py | 6 +- .../openapi_plugin/test_sk_openapi.py | 397 ++++++++++++------ .../unit/functions/test_kernel_plugins.py | 21 +- 41 files changed, 1557 insertions(+), 405 deletions(-) create mode 100644 python/semantic_kernel/connectors/openapi_plugin/const.py rename python/semantic_kernel/connectors/openapi_plugin/models/{rest_api_operation_expected_response.py => rest_api_expected_response.py} (70%) create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py delete mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py delete mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py delete mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py rename python/semantic_kernel/connectors/openapi_plugin/models/{rest_api_operation_parameter_location.py => rest_api_parameter_location.py} (71%) rename python/semantic_kernel/connectors/openapi_plugin/models/{rest_api_operation_parameter_style.py => rest_api_parameter_style.py} (69%) create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py rename python/semantic_kernel/connectors/openapi_plugin/models/{rest_api_operation_run_options.py => rest_api_run_options.py} (92%) create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py create mode 100644 python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py create mode 100644 python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py create mode 100644 python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json create mode 100644 python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json create mode 100644 python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json diff --git a/python/samples/demos/process_with_dapr/fastapi_app.py b/python/samples/demos/process_with_dapr/fastapi_app.py index d34f96503acb..263356a8bcea 100644 --- a/python/samples/demos/process_with_dapr/fastapi_app.py +++ b/python/samples/demos/process_with_dapr/fastapi_app.py @@ -65,8 +65,8 @@ async def start_process(process_id: str): process_id=process_id, ) return JSONResponse(content={"processId": process_id}, status_code=200) - except Exception as e: - return JSONResponse(content={"error": str(e)}, status_code=500) + except Exception: + return JSONResponse(content={"error": "Error starting process"}, status_code=500) if __name__ == "__main__": diff --git a/python/samples/demos/process_with_dapr/flask_app.py b/python/samples/demos/process_with_dapr/flask_app.py index bd7483e1854f..3e86510f4a2b 100644 --- a/python/samples/demos/process_with_dapr/flask_app.py +++ b/python/samples/demos/process_with_dapr/flask_app.py @@ -55,9 +55,8 @@ def start_process(process_id): ) return jsonify({"processId": process_id}), 200 - except Exception as e: - logging.exception("Error starting process") - return jsonify({"error": str(e)}), 500 + except Exception: + return jsonify({"error": "Error starting process"}), 500 # Run application diff --git a/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py b/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py index 03d0c6c4f53a..e326ed1d9b6b 100644 --- a/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py +++ b/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py @@ -35,7 +35,7 @@ def __enter__(self) -> None: self.diagnostics_settings.enable_otel_diagnostics or self.diagnostics_settings.enable_otel_diagnostics_sensitive ): - AIInferenceInstrumentor().instrument( + AIInferenceInstrumentor().instrument( # type: ignore enable_content_recording=self.diagnostics_settings.enable_otel_diagnostics_sensitive ) diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py b/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py index fddb9a722a1a..e9025334d159 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py @@ -4,10 +4,12 @@ from enum import Enum from pydantic import HttpUrl +from typing_extensions import deprecated from semantic_kernel.kernel_pydantic import KernelBaseModel +@deprecated("The `OpenAIAuthenticationType` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthenticationType(str, Enum): """OpenAI authentication types.""" @@ -15,6 +17,7 @@ class OpenAIAuthenticationType(str, Enum): NoneType = "none" +@deprecated("The `OpenAIAuthenticationType` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthorizationType(str, Enum): """OpenAI authorization types.""" @@ -22,6 +25,7 @@ class OpenAIAuthorizationType(str, Enum): Basic = "Basic" +@deprecated("The `OpenAIAuthenticationConfig` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthenticationConfig(KernelBaseModel): """OpenAI authentication configuration.""" diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py b/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py index 0638e820fbaf..b590f3f28898 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py @@ -2,16 +2,47 @@ from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse -from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( - OpenAPIFunctionExecutionParameters, -) +import httpx +from pydantic import Field +from typing_extensions import deprecated + +from semantic_kernel.kernel_pydantic import KernelBaseModel + +if TYPE_CHECKING: + from semantic_kernel.connectors.openapi_plugin import ( + OperationSelectionPredicateContext, + ) OpenAIAuthCallbackType = Callable[..., Awaitable[Any]] -class OpenAIFunctionExecutionParameters(OpenAPIFunctionExecutionParameters): +@deprecated( + "The `OpenAIFunctionExecutionParameters` class is deprecated; use the `OpenAPI` plugin instead.", category=None +) +class OpenAIFunctionExecutionParameters(KernelBaseModel): """OpenAI function execution parameters.""" auth_callback: OpenAIAuthCallbackType | None = None + http_client: httpx.AsyncClient | None = None + server_url_override: str | None = None + ignore_non_compliant_errors: bool = False + user_agent: str | None = None + enable_dynamic_payload: bool = True + enable_payload_namespacing: bool = False + operations_to_exclude: list[str] = Field(default_factory=list, description="The operationId(s) to exclude") + operation_selection_predicate: Callable[["OperationSelectionPredicateContext"], bool] | None = None + + def model_post_init(self, __context: Any) -> None: + """Post initialization method for the model.""" + from semantic_kernel.utils.telemetry.user_agent import HTTP_USER_AGENT + + if self.server_url_override: + parsed_url = urlparse(self.server_url_override) + if not parsed_url.scheme or not parsed_url.netloc: + raise ValueError(f"Invalid server_url_override: {self.server_url_override}") + + if not self.user_agent: + self.user_agent = HTTP_USER_AGENT diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_utils.py b/python/semantic_kernel/connectors/openai_plugin/openai_utils.py index 44ce20f127ce..9e95374f7f5a 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_utils.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_utils.py @@ -4,11 +4,14 @@ import logging from typing import Any +from typing_extensions import deprecated + from semantic_kernel.exceptions.function_exceptions import PluginInitializationError logger: logging.Logger = logging.getLogger(__name__) +@deprecated("The `OpenAIUtils` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIUtils: """Utility functions for OpenAI plugins.""" diff --git a/python/semantic_kernel/connectors/openapi_plugin/__init__.py b/python/semantic_kernel/connectors/openapi_plugin/__init__.py index 8ad89fbd5635..875c5155d301 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/__init__.py +++ b/python/semantic_kernel/connectors/openapi_plugin/__init__.py @@ -3,5 +3,9 @@ from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, ) +from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser +from semantic_kernel.connectors.openapi_plugin.operation_selection_predicate_context import ( + OperationSelectionPredicateContext, +) -__all__ = ["OpenAPIFunctionExecutionParameters"] +__all__ = ["OpenAPIFunctionExecutionParameters", "OpenApiParser", "OperationSelectionPredicateContext"] diff --git a/python/semantic_kernel/connectors/openapi_plugin/const.py b/python/semantic_kernel/connectors/openapi_plugin/const.py new file mode 100644 index 000000000000..ac4cebb1aeab --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/const.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft. All rights reserved. + + +from enum import Enum + +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class OperationExtensions(Enum): + """The operation extensions.""" + + METHOD_KEY = "method" + OPERATION_KEY = "operation" + INFO_KEY = "info" + SECURITY_KEY = "security" + SERVER_URLS_KEY = "server-urls" + METADATA_KEY = "operation-extensions" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py similarity index 70% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py index f5669ecb081d..5fee34f9e2c0 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py @@ -5,11 +5,11 @@ @experimental_class -class RestApiOperationExpectedResponse: - """RestApiOperationExpectedResponse.""" +class RestApiExpectedResponse: + """RestApiExpectedResponse.""" def __init__(self, description: str, media_type: str, schema: dict[str, str] | None = None): - """Initialize the RestApiOperationExpectedResponse.""" + """Initialize the RestApiExpectedResponse.""" self.description = description self.media_type = media_type self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py new file mode 100644 index 000000000000..2de8cc4162ec --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass + +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +@dataclass +class RestApiOAuthFlow: + """Represents the OAuth flow used by the REST API.""" + + authorization_url: str + token_url: str + scopes: dict[str, str] + refresh_url: str | None = None diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py new file mode 100644 index 000000000000..f739b757cb95 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass + +from semantic_kernel.connectors.openapi_plugin.models.rest_api_oauth_flow import RestApiOAuthFlow +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +@dataclass +class RestApiOAuthFlows: + """Represents the OAuth flows used by the REST API.""" + + implicit: RestApiOAuthFlow | None = None + password: RestApiOAuthFlow | None = None + client_credentials: RestApiOAuthFlow | None = None + authorization_code: RestApiOAuthFlow | None = None diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py index 7ab80e300405..f6150e70a0a7 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py @@ -2,22 +2,23 @@ import re from typing import Any, Final -from urllib.parse import ParseResult, urlencode, urljoin, urlparse, urlunparse +from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import RestApiParameter +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import ( + RestApiParameterLocation, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_style import ( - RestApiOperationParameterStyle, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import ( + RestApiParameterStyle, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import ( + RestApiPayloadProperty, ) +from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata from semantic_kernel.utils.experimental_decorator import experimental_class @@ -51,24 +52,144 @@ def __init__( self, id: str, method: str, - server_url: str | ParseResult, + servers: list[dict[str, Any]], path: str, summary: str | None = None, description: str | None = None, - params: list["RestApiOperationParameter"] | None = None, - request_body: "RestApiOperationPayload | None" = None, - responses: dict[str, "RestApiOperationExpectedResponse"] | None = None, + params: list["RestApiParameter"] | None = None, + request_body: "RestApiPayload | None" = None, + responses: dict[str, "RestApiExpectedResponse"] | None = None, + security_requirements: list[RestApiSecurityRequirement] | None = None, ): """Initialize the RestApiOperation.""" - self.id = id - self.method = method.upper() - self.server_url = urlparse(server_url) if isinstance(server_url, str) else server_url - self.path = path - self.summary = summary - self.description = description - self.parameters = params if params else [] - self.request_body = request_body - self.responses = responses + self._id = id + self._method = method.upper() + self._servers = servers + self._path = path + self._summary = summary + self._description = description + self._parameters = params if params else [] + self._request_body = request_body + self._responses = responses + self._security_requirements = security_requirements + self._is_frozen = False + + def freeze(self): + """Make the instance and its components immutable.""" + self._is_frozen = True + + if self.request_body: + self.request_body.freeze() + + for param in self.parameters: + param.freeze() + + def _throw_if_frozen(self): + """Raise an exception if the object is frozen.""" + if self._is_frozen: + raise FunctionExecutionException( + f"The `RestApiOperation` instance with id {self.id} is frozen and cannot be modified." + ) + + @property + def id(self): + """Get the ID of the operation.""" + return self._id + + @id.setter + def id(self, value: str): + self._throw_if_frozen() + self._id = value + + @property + def method(self): + """Get the method of the operation.""" + return self._method + + @method.setter + def method(self, value: str): + self._throw_if_frozen() + self._method = value + + @property + def servers(self): + """Get the servers of the operation.""" + return self._servers + + @servers.setter + def servers(self, value: list[dict[str, Any]]): + self._throw_if_frozen() + self._servers = value + + @property + def path(self): + """Get the path of the operation.""" + return self._path + + @path.setter + def path(self, value: str): + self._throw_if_frozen() + self._path = value + + @property + def summary(self): + """Get the summary of the operation.""" + return self._summary + + @summary.setter + def summary(self, value: str | None): + self._throw_if_frozen() + self._summary = value + + @property + def description(self): + """Get the description of the operation.""" + return self._description + + @description.setter + def description(self, value: str | None): + self._throw_if_frozen() + self._description = value + + @property + def parameters(self): + """Get the parameters of the operation.""" + return self._parameters + + @parameters.setter + def parameters(self, value: list["RestApiParameter"]): + self._throw_if_frozen() + self._parameters = value + + @property + def request_body(self): + """Get the request body of the operation.""" + return self._request_body + + @request_body.setter + def request_body(self, value: "RestApiPayload | None"): + self._throw_if_frozen() + self._request_body = value + + @property + def responses(self): + """Get the responses of the operation.""" + return self._responses + + @responses.setter + def responses(self, value: dict[str, "RestApiExpectedResponse"] | None): + self._throw_if_frozen() + self._responses = value + + @property + def security_requirements(self): + """Get the security requirements of the operation.""" + return self._security_requirements + + @security_requirements.setter + def security_requirements(self, value: list[RestApiSecurityRequirement] | None): + self._throw_if_frozen() + self._security_requirements = value def url_join(self, base_url: str, path: str): """Join a base URL and a path, correcting for any missing slashes.""" @@ -81,7 +202,7 @@ def build_headers(self, arguments: dict[str, Any]) -> dict[str, str]: """Build the headers for the operation.""" headers = {} - parameters = [p for p in self.parameters if p.location == RestApiOperationParameterLocation.HEADER] + parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.HEADER] for parameter in parameters: argument = arguments.get(parameter.name) @@ -102,30 +223,62 @@ def build_operation_url(self, arguments, server_url_override=None, api_host_url= """Build the URL for the operation.""" server_url = self.get_server_url(server_url_override, api_host_url) path = self.build_path(self.path, arguments) - return urljoin(server_url.geturl(), path.lstrip("/")) + try: + return urljoin(server_url, path.lstrip("/")) + except Exception as e: + raise FunctionExecutionException(f"Error building the URL for the operation {self.id}: {e!s}") from e - def get_server_url(self, server_url_override=None, api_host_url=None): + def get_server_url(self, server_url_override=None, api_host_url=None, arguments=None): """Get the server URL for the operation.""" - if server_url_override is not None and server_url_override.geturl() != b"": + if arguments is None: + arguments = {} + + # Prioritize server_url_override + if ( + server_url_override is not None + and isinstance(server_url_override, (ParseResult, ParseResultBytes)) + and server_url_override.geturl() != b"" + ): server_url_string = server_url_override.geturl() + elif server_url_override is not None and isinstance(server_url_override, str) and server_url_override != "": + server_url_string = server_url_override + elif self.servers and len(self.servers) > 0: + # Use the first server by default + server = self.servers[0] + server_url_string = server["url"] if isinstance(server, dict) else server + server_variables = server.get("variables", {}) if isinstance(server, dict) else {} + + # Substitute server variables if available + for variable_name, variable_def in server_variables.items(): + argument_name = variable_def.get("argument_name", variable_name) + if argument_name in arguments: + value = arguments[argument_name] + server_url_string = server_url_string.replace(f"{{{variable_name}}}", value) + elif "default" in variable_def and variable_def["default"] is not None: + # Use the default value if no argument is provided + value = variable_def["default"] + server_url_string = server_url_string.replace(f"{{{variable_name}}}", value) + else: + # Raise an exception if no value is available + raise FunctionExecutionException( + f"No argument provided for the '{variable_name}' server variable of the operation '{self.id}'." + ) + elif self.server_url: + server_url_string = self.server_url + elif api_host_url is not None: + server_url_string = api_host_url else: - server_url_string = ( - self.server_url.geturl() - if self.server_url - else api_host_url.geturl() - if api_host_url - else self._raise_invalid_operation_exception() - ) + raise FunctionExecutionException(f"No valid server URL for operation {self.id}") - # make sure the base URL ends with a trailing slash + # Ensure the base URL ends with a trailing slash if not server_url_string.endswith("/"): server_url_string += "/" - return urlparse(server_url_string) + return server_url_string # Return the URL string directly def build_path(self, path_template: str, arguments: dict[str, Any]) -> str: """Build the path for the operation.""" - parameters = [p for p in self.parameters if p.location == RestApiOperationParameterLocation.PATH] + parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.PATH] for parameter in parameters: argument = arguments.get(parameter.name) if argument is None: @@ -141,7 +294,7 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str: def build_query_string(self, arguments: dict[str, Any]) -> str: """Build the query string for the operation.""" segments = [] - parameters = [p for p in self.parameters if p.location == RestApiOperationParameterLocation.QUERY] + parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.QUERY] for parameter in parameters: argument = arguments.get(parameter.name) if argument is None: @@ -163,7 +316,7 @@ def get_parameters( operation: "RestApiOperation", add_payload_params_from_metadata: bool = True, enable_payload_spacing: bool = False, - ) -> list["RestApiOperationParameter"]: + ) -> list["RestApiParameter"]: """Get the parameters for the operation.""" params = list(operation.parameters) if operation.parameters is not None else [] if operation.request_body is not None: @@ -180,9 +333,9 @@ def get_parameters( return params - def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> "RestApiOperationParameter": + def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> "RestApiParameter": """Create an artificial parameter for the REST API request body.""" - return RestApiOperationParameter( + return RestApiParameter( name=self.PAYLOAD_ARGUMENT_NAME, type=( "string" @@ -191,54 +344,52 @@ def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> else "object" ), is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description=operation.request_body.description if operation.request_body else "REST API request body.", schema=operation.request_body.schema if operation.request_body else None, ) - def create_content_type_artificial_parameter(self) -> "RestApiOperationParameter": + def create_content_type_artificial_parameter(self) -> "RestApiParameter": """Create an artificial parameter for the content type of the REST API request body.""" - return RestApiOperationParameter( + return RestApiParameter( name=self.CONTENT_TYPE_ARGUMENT_NAME, type="string", is_required=False, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Content type of REST API request body.", ) - def _get_property_name( - self, property: RestApiOperationPayloadProperty, root_property_name: bool, enable_namespacing: bool - ): + def _get_property_name(self, property: RestApiPayloadProperty, root_property_name: bool, enable_namespacing: bool): if enable_namespacing and root_property_name: return f"{root_property_name}.{property.name}" return property.name def _get_parameters_from_payload_metadata( self, - properties: list["RestApiOperationPayloadProperty"], + properties: list["RestApiPayloadProperty"], enable_namespacing: bool = False, root_property_name: bool | None = None, - ) -> list["RestApiOperationParameter"]: - parameters: list[RestApiOperationParameter] = [] + ) -> list["RestApiParameter"]: + parameters: list[RestApiParameter] = [] for property in properties: parameter_name = self._get_property_name(property, root_property_name or False, enable_namespacing) if not hasattr(property, "properties") or not property.properties: parameters.append( - RestApiOperationParameter( + RestApiParameter( name=parameter_name, type=property.type, is_required=property.is_required, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description=property.description, schema=property.schema, ) ) else: # Handle property.properties as a single instance or a list - if isinstance(property.properties, RestApiOperationPayloadProperty): + if isinstance(property.properties, RestApiPayloadProperty): nested_properties = [property.properties] else: nested_properties = property.properties @@ -269,8 +420,8 @@ def get_payload_parameters( ] def get_default_response( - self, responses: dict[str, RestApiOperationExpectedResponse], preferred_responses: list[str] - ) -> RestApiOperationExpectedResponse | None: + self, responses: dict[str, RestApiExpectedResponse], preferred_responses: list[str] + ) -> RestApiExpectedResponse | None: """Get the default response for the operation. If no appropriate response is found, returns None. diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py deleted file mode 100644 index 0f8745e08f2e..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Any - -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, -) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, -) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_style import ( - RestApiOperationParameterStyle, -) -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationParameter: - """RestApiOperationParameter.""" - - def __init__( - self, - name: str, - type: str, - location: RestApiOperationParameterLocation, - style: RestApiOperationParameterStyle | None = None, - alternative_name: str | None = None, - description: str | None = None, - is_required: bool = False, - default_value: Any | None = None, - schema: str | dict | None = None, - response: RestApiOperationExpectedResponse | None = None, - ): - """Initialize the RestApiOperationParameter.""" - self.name = name - self.type = type - self.location = location - self.style = style - self.alternative_name = alternative_name - self.description = description - self.is_required = is_required - self.default_value = default_value - self.schema = schema - self.response = response diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py deleted file mode 100644 index 6734114f28a2..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, -) -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationPayload: - """RestApiOperationPayload.""" - - def __init__( - self, - media_type: str, - properties: list["RestApiOperationPayloadProperty"], - description: str | None = None, - schema: str | None = None, - ): - """Initialize the RestApiOperationPayload.""" - self.media_type = media_type - self.properties = properties - self.description = description - self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py deleted file mode 100644 index ab0ee15f3e9d..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Any - -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationPayloadProperty: - """RestApiOperationPayloadProperty.""" - - def __init__( - self, - name: str, - type: str, - properties: "RestApiOperationPayloadProperty", - description: str | None = None, - is_required: bool = False, - default_value: Any | None = None, - schema: str | None = None, - ): - """Initialize the RestApiOperationPayloadProperty.""" - self.name = name - self.type = type - self.properties = properties - self.description = description - self.is_required = is_required - self.default_value = default_value - self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py new file mode 100644 index 000000000000..def469cbeadf --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any + +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, +) +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import ( + RestApiParameterLocation, +) +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import ( + RestApiParameterStyle, +) +from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class RestApiParameter: + """RestApiParameter.""" + + def __init__( + self, + name: str, + type: str, + location: RestApiParameterLocation, + style: RestApiParameterStyle | None = None, + alternative_name: str | None = None, + description: str | None = None, + is_required: bool = False, + default_value: Any | None = None, + schema: str | dict | None = None, + response: RestApiExpectedResponse | None = None, + ): + """Initialize the RestApiParameter.""" + self._name = name + self._type = type + self._location = location + self._style = style + self._alternative_name = alternative_name + self._description = description + self._is_required = is_required + self._default_value = default_value + self._schema = schema + self._response = response + self._is_frozen = False + + def freeze(self): + """Make the instance immutable.""" + self._is_frozen = True + + def _throw_if_frozen(self): + """Raise an exception if the object is frozen.""" + if self._is_frozen: + raise FunctionExecutionException("This `RestApiParameter` instance is frozen and cannot be modified.") + + @property + def name(self): + """Get the name of the parameter.""" + return self._name + + @name.setter + def name(self, value: str): + self._throw_if_frozen() + self._name = value + + @property + def type(self): + """Get the type of the parameter.""" + return self._type + + @type.setter + def type(self, value: str): + self._throw_if_frozen() + self._type = value + + @property + def location(self): + """Get the location of the parameter.""" + return self._location + + @location.setter + def location(self, value: RestApiParameterLocation): + self._throw_if_frozen() + self._location = value + + @property + def style(self): + """Get the style of the parameter.""" + return self._style + + @style.setter + def style(self, value: RestApiParameterStyle | None): + self._throw_if_frozen() + self._style = value + + @property + def alternative_name(self): + """Get the alternative name of the parameter.""" + return self._alternative_name + + @alternative_name.setter + def alternative_name(self, value: str | None): + self._throw_if_frozen() + self._alternative_name = value + + @property + def description(self): + """Get the description of the parameter.""" + return self._description + + @description.setter + def description(self, value: str | None): + self._throw_if_frozen() + self._description = value + + @property + def is_required(self): + """Get whether the parameter is required.""" + return self._is_required + + @is_required.setter + def is_required(self, value: bool): + self._throw_if_frozen() + self._is_required = value + + @property + def default_value(self): + """Get the default value of the parameter.""" + return self._default_value + + @default_value.setter + def default_value(self, value: Any | None): + self._throw_if_frozen() + self._default_value = value + + @property + def schema(self): + """Get the schema of the parameter.""" + return self._schema + + @schema.setter + def schema(self, value: str | dict | None): + self._throw_if_frozen() + self._schema = value + + @property + def response(self): + """Get the response of the parameter.""" + return self._response + + @response.setter + def response(self, value: Any | None): + self._throw_if_frozen() + self._response = value diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py similarity index 71% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py index f1d7b68e2f0a..25da836bd3ce 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py @@ -6,8 +6,8 @@ @experimental_class -class RestApiOperationParameterLocation(Enum): - """The location of the REST API operation parameter.""" +class RestApiParameterLocation(Enum): + """The location of the REST API parameter.""" PATH = "path" QUERY = "query" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py similarity index 69% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py index c76f9e3a8847..a5db1b921f6f 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py @@ -6,7 +6,7 @@ @experimental_class -class RestApiOperationParameterStyle(Enum): - """RestApiOperationParameterStyle.""" +class RestApiParameterStyle(Enum): + """RestApiParameterStyle.""" SIMPLE = "simple" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py new file mode 100644 index 000000000000..21c7cb288500 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import ( + RestApiPayloadProperty, +) +from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class RestApiPayload: + """RestApiPayload.""" + + def __init__( + self, + media_type: str, + properties: list[RestApiPayloadProperty], + description: str | None = None, + schema: str | None = None, + ): + """Initialize the RestApiPayload.""" + self._media_type = media_type + self._properties = properties + self._description = description + self._schema = schema + self._is_frozen = False + + def freeze(self): + """Make the instance immutable and freeze properties.""" + self._is_frozen = True + for property in self._properties: + property.freeze() + + def _throw_if_frozen(self): + """Raise an exception if the object is frozen.""" + if self._is_frozen: + raise FunctionExecutionException("This `RestApiPayload` instance is frozen and cannot be modified.") + + @property + def media_type(self): + """Get the media type of the payload.""" + return self._media_type + + @media_type.setter + def media_type(self, value: str): + self._throw_if_frozen() + self._media_type = value + + @property + def description(self): + """Get the description of the payload.""" + return self._description + + @description.setter + def description(self, value: str | None): + self._throw_if_frozen() + self._description = value + + @property + def properties(self): + """Get the properties of the payload.""" + return self._properties + + @properties.setter + def properties(self, value: list[RestApiPayloadProperty]): + self._throw_if_frozen() + self._properties = value + + @property + def schema(self): + """Get the schema of the payload.""" + return self._schema + + @schema.setter + def schema(self, value: str | None): + self._throw_if_frozen() + self._schema = value diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py new file mode 100644 index 000000000000..455609fdf927 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any + +from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class RestApiPayloadProperty: + """RestApiPayloadProperty.""" + + def __init__( + self, + name: str, + type: str, + properties: list["RestApiPayloadProperty"] | None = None, + description: str | None = None, + is_required: bool = False, + default_value: Any | None = None, + schema: str | None = None, + ): + """Initialize the RestApiPayloadProperty.""" + self._name = name + self._type = type + self._properties = properties or [] + self._description = description + self._is_required = is_required + self._default_value = default_value + self._schema = schema + self._is_frozen = False + + def freeze(self): + """Make the instance immutable, and freeze nested properties.""" + self._is_frozen = True + for prop in self._properties: + prop.freeze() + + def _throw_if_frozen(self): + """Raise an exception if the object is frozen.""" + if self._is_frozen: + raise FunctionExecutionException("This instance is frozen and cannot be modified.") + + @property + def name(self): + """Get the name of the property.""" + return self._name + + @name.setter + def name(self, value: str): + self._throw_if_frozen() + self._name = value + + @property + def type(self): + """Get the type of the property.""" + return self._type + + @type.setter + def type(self, value: str): + self._throw_if_frozen() + self._type = value + + @property + def properties(self): + """Get the properties of the property.""" + return self._properties + + @properties.setter + def properties(self, value: list["RestApiPayloadProperty"]): + self._throw_if_frozen() + self._properties = value + + @property + def description(self): + """Get the description of the property.""" + return self._description + + @description.setter + def description(self, value: str | None): + self._throw_if_frozen() + self._description = value + + @property + def is_required(self): + """Get whether the property is required.""" + return self._is_required + + @is_required.setter + def is_required(self, value: bool): + self._throw_if_frozen() + self._is_required = value + + @property + def default_value(self): + """Get the default value of the property.""" + return self._default_value + + @default_value.setter + def default_value(self, value: Any | None): + self._throw_if_frozen() + self._default_value = value + + @property + def schema(self): + """Get the schema of the property.""" + return self._schema + + @schema.setter + def schema(self, value: str | None): + self._throw_if_frozen() + self._schema = value diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py similarity index 92% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py index 332a446bf609..78ce7a760ca7 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py @@ -4,7 +4,7 @@ @experimental_class -class RestApiOperationRunOptions: +class RestApiRunOptions: """The options for running the REST API operation.""" def __init__(self, server_url_override=None, api_host_url=None) -> None: diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py new file mode 100644 index 000000000000..78a07ace5da6 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_scheme import RestApiSecurityScheme +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class RestApiSecurityRequirement(dict[RestApiSecurityScheme, list[str]]): + """Represents the security requirements used by the REST API.""" + + def __init__(self, dictionary: dict[RestApiSecurityScheme, list[str]]): + """Initializes a new instance of the RestApiSecurityRequirement class.""" + super().__init__(dictionary) diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py new file mode 100644 index 000000000000..c57669b7f121 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.openapi_plugin.models.rest_api_oauth_flows import RestApiOAuthFlows +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import ( + RestApiParameterLocation, +) +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class RestApiSecurityScheme: + """Represents the security scheme used by the REST API.""" + + def __init__( + self, + security_scheme_type: str, + name: str, + in_: RestApiParameterLocation, + scheme: str, + open_id_connect_url: str, + description: str | None = None, + bearer_format: str | None = None, + flows: RestApiOAuthFlows | None = None, + ): + """Initializes a new instance of the RestApiSecurityScheme class.""" + self.security_scheme_type = security_scheme_type + self.description = description + self.name = name + self.in_ = in_ + self.scheme = scheme + self.bearer_format = bearer_format + self.flows = flows + self.open_id_connect_url = open_id_connect_url diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py index dd014a97e55c..b5ff0ec2ae4e 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import httpx @@ -10,6 +10,11 @@ from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class +if TYPE_CHECKING: + from semantic_kernel.connectors.openapi_plugin import ( + OperationSelectionPredicateContext, + ) + AuthCallbackType = Callable[..., Awaitable[Any]] @@ -24,7 +29,8 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel): user_agent: str | None = None enable_dynamic_payload: bool = True enable_payload_namespacing: bool = False - operations_to_exclude: list[str] = Field(default_factory=list) + operations_to_exclude: list[str] = Field(default_factory=list, description="The operationId(s) to exclude") + operation_selection_predicate: Callable[["OperationSelectionPredicateContext"], bool] | None = None def model_post_init(self, __context: Any) -> None: """Post initialization method for the model.""" diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py index bc195dec1bef..c5823c7d559f 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py @@ -4,9 +4,11 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +from semantic_kernel.connectors.openapi_plugin.const import OperationExtensions from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import RestApiParameter +from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions +from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement from semantic_kernel.connectors.openapi_plugin.models.rest_api_uri import Uri from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.connectors.openapi_plugin.openapi_runner import OpenApiRunner @@ -32,24 +34,41 @@ @experimental_function def create_functions_from_openapi( plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAIFunctionExecutionParameters | OpenAPIFunctionExecutionParameters | None" = None, ) -> list[KernelFunctionFromMethod]: """Creates the functions from OpenAPI document. Args: plugin_name: The name of the plugin - openapi_document_path: The OpenAPI document path, it must be a file path to the spec. + openapi_document_path: The OpenAPI document path, it must be a file path to the spec (optional) + openapi_parsed_spec: The parsed OpenAPI spec (optional) execution_settings: The execution settings Returns: list[KernelFunctionFromMethod]: the operations as functions """ + parsed_doc: dict[str, Any] | Any = None + if openapi_parsed_spec is not None: + parsed_doc = openapi_parsed_spec + else: + if openapi_document_path is None: + raise FunctionExecutionException( + "Either `openapi_document_path` or `openapi_parsed_spec` must be provided." + ) + + # Parse the document from the given path + parser = OpenApiParser() + parsed_doc = parser.parse(openapi_document_path) + if parsed_doc is None: + raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}") + parser = OpenApiParser() - if (parsed_doc := parser.parse(openapi_document_path)) is None: - raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}") operations = parser.create_rest_api_operations(parsed_doc, execution_settings=execution_settings) + global_security_requirements = parsed_doc.get("security", []) + auth_callback = None if execution_settings and execution_settings.auth_callback: auth_callback = execution_settings.auth_callback @@ -62,10 +81,24 @@ def create_functions_from_openapi( enable_payload_namespacing=execution_settings.enable_payload_namespacing if execution_settings else False, ) - return [ - _create_function_from_operation(openapi_runner, operation, plugin_name, execution_parameters=execution_settings) - for operation in operations.values() - ] + functions = [] + for operation in operations.values(): + try: + kernel_function = _create_function_from_operation( + openapi_runner, + operation, + plugin_name, + execution_parameters=execution_settings, + security=global_security_requirements, + ) + functions.append(kernel_function) + operation.freeze() + except Exception as ex: + error_msg = f"Error while registering Rest function {plugin_name}.{operation.id}: {ex}" + logger.error(error_msg) + raise FunctionExecutionException(error_msg) from ex + + return functions @experimental_function @@ -75,10 +108,11 @@ def _create_function_from_operation( plugin_name: str | None = None, execution_parameters: "OpenAIFunctionExecutionParameters | OpenAPIFunctionExecutionParameters | None" = None, document_uri: str | None = None, + security: list[RestApiSecurityRequirement] | None = None, ) -> KernelFunctionFromMethod: logger.info(f"Registering OpenAPI operation: {plugin_name}.{operation.id}") - rest_operation_params: list[RestApiOperationParameter] = operation.get_parameters( + rest_operation_params: list[RestApiParameter] = operation.get_parameters( operation=operation, add_payload_params_from_metadata=getattr(execution_parameters, "enable_dynamic_payload", True), enable_payload_spacing=getattr(execution_parameters, "enable_payload_namespacing", False), @@ -113,7 +147,7 @@ async def run_openapi_operation( f"`{parameter.name}` parameter of the `{plugin_name}.{operation.id}` REST function." ) - options = RestApiOperationRunOptions( + options = RestApiRunOptions( server_url_override=( urlparse(execution_parameters.server_url_override) if execution_parameters else None ), @@ -145,7 +179,18 @@ async def run_openapi_operation( return_parameter = operation.get_default_return_parameter() - additional_metadata = {"method": operation.method.upper()} + additional_metadata = { + OperationExtensions.METHOD_KEY.value: operation.method.upper(), + OperationExtensions.OPERATION_KEY.value: operation, + OperationExtensions.SERVER_URLS_KEY.value: ( + [operation.servers[0]["url"]] + if operation.servers and len(operation.servers) > 0 and operation.servers[0]["url"] + else [] + ), + } + + if security is not None: + additional_metadata[OperationExtensions.SECURITY_KEY.value] = security return KernelFunctionFromMethod( method=run_openapi_operation, diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py index 984f120e837b..a08276de387d 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py @@ -4,24 +4,24 @@ from collections import OrderedDict from collections.abc import Generator from typing import TYPE_CHECKING, Any, Final -from urllib.parse import urlparse from prance import ResolvingParser -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import RestApiParameter +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import ( + RestApiParameterLocation, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import ( + RestApiPayloadProperty, ) +from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement +from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_scheme import RestApiSecurityScheme from semantic_kernel.exceptions.function_exceptions import PluginInitializationError -from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( @@ -34,7 +34,6 @@ logger: logging.Logger = logging.getLogger(__name__) -@experimental_class class OpenApiParser: """NOTE: SK Python only supports the OpenAPI Spec >=3.0. @@ -61,7 +60,7 @@ def parse(self, openapi_document: str) -> Any | dict[str, Any] | None: def _parse_parameters(self, parameters: list[dict[str, Any]]): """Parse the parameters from the OpenAPI document.""" - result: list[RestApiOperationParameter] = [] + result: list[RestApiParameter] = [] for param in parameters: name: str = param["name"] if not param.get("in"): @@ -69,14 +68,14 @@ def _parse_parameters(self, parameters: list[dict[str, Any]]): if param.get("content", None) is not None: # The schema and content fields are mutually exclusive. raise PluginInitializationError(f"Parameter {name} cannot have a 'content' field. Expected: schema.") - location = RestApiOperationParameterLocation(param["in"]) + location = RestApiParameterLocation(param["in"]) description: str = param.get("description", None) is_required: bool = param.get("required", False) default_value = param.get("default", None) schema: dict[str, Any] | None = param.get("schema", None) result.append( - RestApiOperationParameter( + RestApiParameter( name=name, type=schema.get("type", "string") if schema else "string", location=location, @@ -103,7 +102,7 @@ def _get_payload_properties(self, operation_id, schema, required_properties, lev for property_name, property_schema in schema.get("properties", {}).items(): default_value = property_schema.get("default", None) - property = RestApiOperationPayloadProperty( + property = RestApiPayloadProperty( name=property_name, type=property_schema.get("type", None), is_required=property_name in required_properties, @@ -119,7 +118,7 @@ def _get_payload_properties(self, operation_id, schema, required_properties, lev def _create_rest_api_operation_payload( self, operation_id: str, request_body: dict[str, Any] - ) -> RestApiOperationPayload | None: + ) -> RestApiPayload | None: if request_body is None or request_body.get("content") is None: return None @@ -135,16 +134,14 @@ def _create_rest_api_operation_payload( payload_properties = self._get_payload_properties( operation_id, media_type_metadata["schema"], media_type_metadata["schema"].get("required", set()) ) - return RestApiOperationPayload( + return RestApiPayload( media_type, payload_properties, request_body.get("description"), schema=media_type_metadata.get("schema", None), ) - def _create_response( - self, responses: dict[str, Any] - ) -> Generator[tuple[str, RestApiOperationExpectedResponse], None, None]: + def _create_response(self, responses: dict[str, Any]) -> Generator[tuple[str, RestApiExpectedResponse], None, None]: for response_key, response_value in responses.items(): media_type = next( (mt for mt in OpenApiParser.SUPPORTED_MEDIA_TYPES if mt in response_value.get("content", {})), None @@ -154,60 +151,130 @@ def _create_response( description = response_value.get("description") or matching_schema.get("description", "") yield ( response_key, - RestApiOperationExpectedResponse( + RestApiExpectedResponse( description=description, media_type=media_type, schema=matching_schema if matching_schema else None, ), ) + def _parse_security_schemes(self, components: dict) -> dict[str, dict]: + security_schemes = {} + schemes = components.get("securitySchemes", {}) + for scheme_name, scheme_data in schemes.items(): + security_schemes[scheme_name] = scheme_data + return security_schemes + + def _create_rest_api_security_scheme(self, security_scheme_data: dict) -> RestApiSecurityScheme: + return RestApiSecurityScheme( + security_scheme_type=security_scheme_data.get("type", ""), + description=security_scheme_data.get("description"), + name=security_scheme_data.get("name", ""), + in_=security_scheme_data.get("in", ""), + scheme=security_scheme_data.get("scheme", ""), + bearer_format=security_scheme_data.get("bearerFormat"), + flows=security_scheme_data.get("flows"), + open_id_connect_url=security_scheme_data.get("openIdConnectUrl", ""), + ) + + def _create_security_requirements( + self, + security: list[dict[str, list[str]]], + security_schemes: dict[str, dict], + ) -> list[RestApiSecurityRequirement]: + security_requirements: list[RestApiSecurityRequirement] = [] + + for requirement in security: + for scheme_name, scopes in requirement.items(): + scheme_data = security_schemes.get(scheme_name) + if not scheme_data: + raise PluginInitializationError(f"Security scheme '{scheme_name}' is not defined in components.") + scheme = self._create_rest_api_security_scheme(scheme_data) + security_requirements.append(RestApiSecurityRequirement({scheme: scopes})) + + return security_requirements + def create_rest_api_operations( self, parsed_document: Any, execution_settings: "OpenAIFunctionExecutionParameters | OpenAPIFunctionExecutionParameters | None" = None, ) -> dict[str, RestApiOperation]: - """Create the REST API Operations from the parsed OpenAPI document. + """Create REST API operations from the parsed OpenAPI document. Args: - parsed_document: The parsed OpenAPI document - execution_settings: The execution settings + parsed_document: The parsed OpenAPI document. + execution_settings: The execution settings. Returns: - A dictionary of RestApiOperation objects keyed by operationId + A dictionary of RestApiOperation instances. """ + from semantic_kernel.connectors.openapi_plugin import OperationSelectionPredicateContext + + components = parsed_document.get("components", {}) + security_schemes = self._parse_security_schemes(components) + paths = parsed_document.get("paths", {}) request_objects = {} - base_url = "/" servers = parsed_document.get("servers", []) - base_url = servers[0].get("url") if servers else "/" if execution_settings and execution_settings.server_url_override: - base_url = execution_settings.server_url_override + # Override the servers with the provided URL + server_urls = [{"url": execution_settings.server_url_override, "variables": {}}] + elif servers: + # Process servers, ensuring we capture their variables + server_urls = [] + for server in servers: + server_entry = { + "url": server.get("url", "/"), + "variables": server.get("variables", {}), + "description": server.get("description", ""), + } + server_urls.append(server_entry) + else: + # Default server if none specified + server_urls = [{"url": "/", "variables": {}, "description": ""}] for path, methods in paths.items(): for method, details in methods.items(): request_method = method.lower() - - parameters = details.get("parameters", []) operationId = details.get("operationId", path + "_" + request_method) + summary = details.get("summary", None) description = details.get("description", None) + context = OperationSelectionPredicateContext(operationId, path, method, description) + if ( + execution_settings + and execution_settings.operation_selection_predicate + and not execution_settings.operation_selection_predicate(context) + ): + logger.info(f"Skipping operation {operationId} based on custom predicate.") + continue + + if execution_settings and operationId in execution_settings.operations_to_exclude: + logger.info(f"Skipping operation {operationId} as it is excluded.") + continue + + parameters = details.get("parameters", []) parsed_params = self._parse_parameters(parameters) request_body = self._create_rest_api_operation_payload(operationId, details.get("requestBody", None)) responses = dict(self._create_response(details.get("responses", {}))) + operation_security = details.get("security", []) + security_requirements = self._create_security_requirements(operation_security, security_schemes) + rest_api_operation = RestApiOperation( id=operationId, method=request_method, - server_url=urlparse(base_url), + servers=server_urls, path=path, params=parsed_params, request_body=request_body, summary=summary, description=description, responses=OrderedDict(responses), + security_requirements=security_requirements, ) request_objects[operationId] = rest_api_operation diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py index 540ee6fc7845..8b15ddfa5222 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py @@ -11,12 +11,12 @@ import httpx from openapi_core import Spec -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.utils.experimental_decorator import experimental_class @@ -60,9 +60,7 @@ def build_operation_url( url = operation.build_operation_url(arguments, server_url_override, api_host_url) return self.build_full_url(url, operation.build_query_string(arguments)) - def build_json_payload( - self, payload_metadata: RestApiOperationPayload, arguments: dict[str, Any] - ) -> tuple[str, str]: + def build_json_payload(self, payload_metadata: RestApiPayload, arguments: dict[str, Any]) -> tuple[str, str]: """Build the JSON payload.""" if self.enable_dynamic_payload: if payload_metadata is None: @@ -118,9 +116,7 @@ def get_argument_name_for_payload(self, property_name, property_namespace=None): return property_name return f"{property_namespace}.{property_name}" if property_namespace else property_name - def _get_first_response_media_type( - self, responses: OrderedDict[str, RestApiOperationExpectedResponse] | None - ) -> str: + def _get_first_response_media_type(self, responses: OrderedDict[str, RestApiExpectedResponse] | None) -> str: if responses: first_response = next(iter(responses.values())) return first_response.media_type if first_response.media_type else self.media_type_application_json @@ -130,7 +126,7 @@ async def run_operation( self, operation: RestApiOperation, arguments: KernelArguments | None = None, - options: RestApiOperationRunOptions | None = None, + options: RestApiRunOptions | None = None, ) -> str: """Runs the operation defined in the OpenAPI manifest.""" if not arguments: diff --git a/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py b/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py new file mode 100644 index 000000000000..8d8081668eee --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft. All rights reserved. + + +class OperationSelectionPredicateContext: + """The context for the operation selection predicate.""" + + def __init__(self, operation_id: str, path: str, method: str, description: str | None = None): + """Initialize the operation selection predicate context.""" + self.operation_id = operation_id + self.path = path + self.method = method + self.description = description diff --git a/python/semantic_kernel/functions/kernel_function_extension.py b/python/semantic_kernel/functions/kernel_function_extension.py index 361e821ac0c7..97f5cf527fce 100644 --- a/python/semantic_kernel/functions/kernel_function_extension.py +++ b/python/semantic_kernel/functions/kernel_function_extension.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator +from typing_extensions import deprecated from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.exceptions import KernelFunctionNotFoundError, KernelPluginNotFoundError @@ -206,17 +207,19 @@ def add_functions( def add_plugin_from_openapi( self, plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAPIFunctionExecutionParameters | None" = None, description: str | None = None, ) -> KernelPlugin: """Add a plugin from the OpenAPI manifest. Args: - plugin_name (str): The name of the plugin - openapi_document_path (str): The path to the OpenAPI document - execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters - description (str | None): The description of the plugin + plugin_name: The name of the plugin + openapi_document_path: The path to the OpenAPI document + openapi_parsed_spec: The parsed OpenAPI spec + execution_settings: The execution parameters + description: The description of the plugin Returns: KernelPlugin: The imported plugin @@ -228,11 +231,16 @@ def add_plugin_from_openapi( KernelPlugin.from_openapi( plugin_name=plugin_name, openapi_document_path=openapi_document_path, + openapi_parsed_spec=openapi_parsed_spec, execution_settings=execution_settings, description=description, ) ) + @deprecated( + "The `add_plugin_from_openai` method is deprecated; use the `add_plugin_from_openapi` method instead.", + category=None, + ) async def add_plugin_from_openai( self, plugin_name: str, diff --git a/python/semantic_kernel/functions/kernel_plugin.py b/python/semantic_kernel/functions/kernel_plugin.py index 296d8beb562e..f4bbb7b71342 100644 --- a/python/semantic_kernel/functions/kernel_plugin.py +++ b/python/semantic_kernel/functions/kernel_plugin.py @@ -13,6 +13,7 @@ import httpx from pydantic import Field, StringConstraints +from typing_extensions import deprecated from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationConfig from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( @@ -357,17 +358,19 @@ def from_directory( def from_openapi( cls: type[_T], plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAPIFunctionExecutionParameters | None" = None, description: str | None = None, ) -> _T: """Create a plugin from an OpenAPI document. Args: - plugin_name (str): The name of the plugin - openapi_document_path (str): The path to the OpenAPI document - execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters - description (str | None): The description of the plugin + plugin_name: The name of the plugin + openapi_document_path: The path to the OpenAPI document (optional) + openapi_parsed_spec: The parsed OpenAPI spec (optional) + execution_settings: The execution parameters + description: The description of the plugin Returns: KernelPlugin: The created plugin @@ -375,8 +378,8 @@ def from_openapi( Raises: PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided """ - if not openapi_document_path: - raise PluginInitializationError("OpenAPI document path is required.") + if not openapi_document_path and not openapi_parsed_spec: + raise PluginInitializationError("Either the OpenAPI document path or a parsed OpenAPI spec is required.") return cls( # type: ignore name=plugin_name, @@ -384,10 +387,15 @@ def from_openapi( functions=create_functions_from_openapi( # type: ignore plugin_name=plugin_name, openapi_document_path=openapi_document_path, + openapi_parsed_spec=openapi_parsed_spec, execution_settings=execution_settings, ), ) + @deprecated( + "The `OpenAI` plugin is deprecated; use the `from_openapi` method to add an `OpenAPI` plugin instead.", + category=None, + ) @classmethod async def from_openai( cls: type[_T], diff --git a/python/tests/integration/cross_language/test_cross_language.py b/python/tests/integration/cross_language/test_cross_language.py index 456874a95a76..a4e9f3827bec 100644 --- a/python/tests/integration/cross_language/test_cross_language.py +++ b/python/tests/integration/cross_language/test_cross_language.py @@ -12,7 +12,6 @@ from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings import OpenAISettings -from semantic_kernel.connectors.openapi_plugin import OpenAPIFunctionExecutionParameters from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.functions.kernel_function import KernelFunction from semantic_kernel.functions.kernel_function_decorator import kernel_function @@ -539,6 +538,8 @@ async def test_yaml_prompt(is_streaming, prompt_path, expected_result_path, kern async def setup_openapi_function_call(kernel, function_name, arguments): + from semantic_kernel.connectors.openapi_plugin import OpenAPIFunctionExecutionParameters + openapi_spec_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data", "light_bulb_api.json") request_details = None diff --git a/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json new file mode 100644 index 000000000000..089c3493dc99 --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json @@ -0,0 +1,79 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + }, + "security": [ + { + "ApiKeyAuthHeader": [] + } + ] +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json new file mode 100644 index 000000000000..f5d5ea2566ad --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json @@ -0,0 +1,74 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": ["new_scope"] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + } +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json new file mode 100644 index 000000000000..cd5655c8025c --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "OAuth2Auth": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "OAuth2Auth": [ "new_scope" ] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "OAuth2Auth": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://login.windows.net/common/oauth2/authorize", + "tokenUrl": "https://login.windows.net/common/oauth2/authorize", + "scopes": {} + } + } + }, + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + }, + "security": [ + { + "OAuth2Auth": [] + } + ] +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/openapi.yaml b/python/tests/unit/connectors/openapi_plugin/openapi.yaml index c2487b4d29d6..30746c8e3dec 100644 --- a/python/tests/unit/connectors/openapi_plugin/openapi.yaml +++ b/python/tests/unit/connectors/openapi_plugin/openapi.yaml @@ -4,6 +4,7 @@ info: version: 1.0.0 servers: - url: http://example.com + - url: https://example-two.com paths: /todos: get: diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py index de5d834c1361..33823aff4006 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py @@ -4,9 +4,9 @@ import pytest -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import ( - RestApiOperationParameter, - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import ( + RestApiParameter, + RestApiParameterLocation, ) from semantic_kernel.connectors.openapi_plugin.openapi_manager import ( _create_function_from_operation, @@ -26,9 +26,7 @@ async def test_run_openapi_operation_success(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -77,9 +75,7 @@ async def test_run_openapi_operation_missing_required_param(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -127,9 +123,7 @@ async def test_run_openapi_operation_runner_exception(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -177,10 +171,10 @@ async def test_run_openapi_operation_alternative_name(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( + RestApiParameter( name="param1", type="string", - location=RestApiOperationParameterLocation.QUERY, + location=RestApiParameterLocation.QUERY, is_required=True, alternative_name="alt_param1", ) diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py index 0e4d278a1667..5d99cc5a079f 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py @@ -4,6 +4,7 @@ import pytest +from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenApiParser, create_functions_from_openapi from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import KernelFunctionFromMethod, KernelFunctionMetadata, KernelParameterMetadata @@ -109,3 +110,71 @@ def test_create_rest_api_operation_payload_media_type_none(): request_body = {"content": {"application/xml": {"schema": {"type": "object"}}}} with pytest.raises(Exception, match="Neither of the media types of operation_id is supported."): parser._create_rest_api_operation_payload("operation_id", request_body) + + +def generate_security_member_data(): + return [ + ( + "no-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["apiKey"], + "SecurityAndScope": ["apiKey"], + }, + ), + ( + "apikey-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["apiKey"], + "SecurityAndScope": ["apiKey"], + }, + ), + ( + "oauth-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["oauth2"], + "SecurityAndScope": ["oauth2"], + }, + ), + ] + + +@pytest.mark.parametrize("document_file_name, security_type_map", generate_security_member_data()) +def test_it_adds_security_metadata_to_operation(document_file_name, security_type_map): + # Arrange + current_dir = os.path.dirname(__file__) + openapi_document_path = os.path.join(current_dir, document_file_name) + + # Act + plugin_functions = create_functions_from_openapi( + plugin_name="fakePlugin", + openapi_document_path=openapi_document_path, + execution_settings=None, + ) + + # Assert + for function in plugin_functions: + additional_properties = function.metadata.additional_properties + assert "operation" in additional_properties + + function_name = function.metadata.name + security_types = security_type_map.get(function_name, []) + + operation = additional_properties["operation"] + assert isinstance(operation, RestApiOperation) + assert operation is not None + assert operation.security_requirements is not None + assert len(operation.security_requirements) == len(security_types) + + for security_type in security_types: + found = False + for security_requirement in operation.security_requirements: + for scheme in security_requirement: + if scheme.security_scheme_type.lower() == security_type.lower(): + found = True + break + if found: + break + assert found, f"Security type '{security_type}' not found in operation '{operation.id}'" diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py index 43955661d6d2..935ee40df4dc 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py @@ -6,7 +6,7 @@ import pytest from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenApiRunner from semantic_kernel.exceptions import FunctionExecutionException @@ -31,9 +31,15 @@ def test_build_operation_url(): def test_build_json_payload_dynamic_payload(): runner = OpenApiRunner({}, enable_dynamic_payload=True) - payload_metadata = RestApiOperationPayload( + + mock_property1 = Mock() + mock_property2 = Mock() + mock_property1.freeze = MagicMock() + mock_property2.freeze = MagicMock() + + payload_metadata = RestApiPayload( media_type="application/json", - properties=["property1", "property2"], + properties=[mock_property1, mock_property2], description=None, schema=None, ) @@ -41,6 +47,19 @@ def test_build_json_payload_dynamic_payload(): runner.build_json_object = MagicMock(return_value={"property1": "value1", "property2": "value2"}) + payload_metadata.description = "A dynamic payload" + assert payload_metadata.description == "A dynamic payload" + + payload_metadata.freeze() + + mock_property1.freeze.assert_called_once() + mock_property2.freeze.assert_called_once() + + with pytest.raises( + FunctionExecutionException, match="This `RestApiPayload` instance is frozen and cannot be modified." + ): + payload_metadata.description = "Should raise error" + content, media_type = runner.build_json_payload(payload_metadata, arguments) runner.build_json_object.assert_called_once_with(payload_metadata.properties, arguments) @@ -206,7 +225,7 @@ def test_get_argument_name_for_payload_with_namespacing(): def test_build_operation_payload_with_request_body(): runner = OpenApiRunner({}) - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type="application/json", properties=["property1", "property2"], description=None, diff --git a/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py b/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py index 29df73cc7040..bca9e0ca97ec 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py +++ b/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py @@ -1,20 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions def test_initialization(): server_url_override = "http://example.com" api_host_url = "http://example.com" - rest_api_operation_run_options = RestApiOperationRunOptions(server_url_override, api_host_url) + rest_api_operation_run_options = RestApiRunOptions(server_url_override, api_host_url) assert rest_api_operation_run_options.server_url_override == server_url_override assert rest_api_operation_run_options.api_host_url == api_host_url def test_initialization_no_params(): - rest_api_operation_run_options = RestApiOperationRunOptions() + rest_api_operation_run_options = RestApiRunOptions() assert rest_api_operation_run_options.server_url_override is None assert rest_api_operation_run_options.api_host_url is None diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py index 45229b6f1630..4dbab11ad34a 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py +++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py @@ -8,19 +8,20 @@ import yaml from openapi_core import Spec -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin import OperationSelectionPredicateContext +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import ( - RestApiOperationParameter, - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import ( + RestApiParameter, + RestApiParameterLocation, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_style import ( - RestApiOperationParameterStyle, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import ( + RestApiParameterStyle, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import ( + RestApiPayloadProperty, ) from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, @@ -46,7 +47,7 @@ put_operation = RestApiOperation( id="updateTodoById", method="PUT", - server_url="http://example.com", + servers="http://example.com", path="/todos/{id}", summary="Update a todo by ID", params=[ @@ -119,7 +120,7 @@ def test_parse_invalid_format(): def test_url_join_with_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="test/path") base_url = "https://example.com/" path = "test/path" expected_url = "https://example.com/test/path" @@ -127,7 +128,7 @@ def test_url_join_with_trailing_slash(): def test_url_join_without_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com"], path="test/path") base_url = "https://example.com" path = "test/path" expected_url = "https://example.com/test/path" @@ -135,7 +136,7 @@ def test_url_join_without_trailing_slash(): def test_url_join_base_path_with_path(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/base/", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers="https://example.com/base/", path="test/path") base_url = "https://example.com/base/" path = "test/path" expected_url = "https://example.com/base/test/path" @@ -143,7 +144,7 @@ def test_url_join_base_path_with_path(): def test_url_join_with_leading_slash_in_path(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/test/path") base_url = "https://example.com/" path = "/test/path" expected_url = "https://example.com/test/path" @@ -151,7 +152,7 @@ def test_url_join_with_leading_slash_in_path(): def test_url_join_base_path_without_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/base", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers="https://example.com/base", path="test/path") base_url = "https://example.com/base" path = "test/path" expected_url = "https://example.com/base/test/path" @@ -160,26 +161,56 @@ def test_url_join_base_path_without_trailing_slash(): def test_build_headers_with_required_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token"} expected_headers = {"Authorization": "Bearer token"} assert operation.build_headers(arguments) == expected_headers +def test_rest_api_operation_freeze(): + operation = RestApiOperation( + id="test", + method="GET", + servers=["https://example.com/"], + path="test/path", + summary="A test summary", + description="A test description", + params=[], + request_body=None, + responses={}, + security_requirements=[], + ) + + operation.description = "Modified description" + assert operation.description == "Modified description" + + operation.freeze() + + with pytest.raises(FunctionExecutionException, match="is frozen and cannot be modified"): + operation.description = "Another modification" + + with pytest.raises(FunctionExecutionException, match="is frozen and cannot be modified"): + operation.path = "new/test/path" + + if operation.request_body: + with pytest.raises(FunctionExecutionException): + operation.request_body.description = "New request body description" + + def test_build_headers_missing_required_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {} with pytest.raises( @@ -191,12 +222,12 @@ def test_build_headers_missing_required_parameter(): def test_build_headers_with_optional_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=False ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token"} expected_headers = {"Authorization": "Bearer token"} @@ -205,12 +236,12 @@ def test_build_headers_with_optional_parameter(): def test_build_headers_missing_optional_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=False ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {} expected_headers = {} @@ -219,15 +250,15 @@ def test_build_headers_missing_optional_parameter(): def test_build_headers_multiple_parameters(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ), - RestApiOperationParameter( - name="Content-Type", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Content-Type", type="string", location=RestApiParameterLocation.HEADER, is_required=False ), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token", "Content-Type": "application/json"} expected_headers = {"Authorization": "Bearer token", "Content-Type": "application/json"} @@ -235,13 +266,9 @@ def test_build_headers_multiple_parameters(): def test_build_operation_url_with_override(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {"id": "123"} server_url_override = urlparse("https://override.com") @@ -250,40 +277,109 @@ def test_build_operation_url_with_override(): def test_build_operation_url_without_override(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", + method="GET", + servers=[{"url": "https://example.com/"}], + path="/resource/{id}", + params=parameters, ) arguments = {"id": "123"} expected_url = "https://example.com/resource/123" assert operation.build_operation_url(arguments) == expected_url -def test_get_server_url_with_override(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="/resource/{id}") +def test_get_server_url_with_parse_result_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) server_url_override = urlparse("https://override.com") expected_url = "https://override.com/" - assert operation.get_server_url(server_url_override=server_url_override).geturl() == expected_url + assert operation.get_server_url(server_url_override=server_url_override) == expected_url + + +def test_get_server_url_with_string_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + server_url_override = "https://override.com" + expected_url = "https://override.com/" + assert operation.get_server_url(server_url_override=server_url_override) == expected_url + + +def test_get_server_url_with_servers_no_variables(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + expected_url = "https://example.com/" + assert operation.get_server_url() == expected_url + + +def test_get_server_url_with_servers_and_variables(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[ + { + "url": "https://example.com/{version}", + "variables": {"version": {"default": "v1", "argument_name": "api_version"}}, + } + ], + path="/resource/{id}", + ) + arguments = {"api_version": "v2"} + expected_url = "https://example.com/v2/" + assert operation.get_server_url(arguments=arguments) == expected_url + + +def test_get_server_url_with_servers_and_default_variable(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com/{version}", "variables": {"version": {"default": "v1"}}}], + path="/resource/{id}", + ) + expected_url = "https://example.com/v1/" + assert operation.get_server_url() == expected_url + + +def test_get_server_url_with_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + server_url_override = "https://override.com" + expected_url = "https://override.com/" + assert operation.get_server_url(server_url_override=server_url_override) == expected_url def test_get_server_url_without_override(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="/resource/{id}") + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) expected_url = "https://example.com/" - assert operation.get_server_url().geturl() == expected_url + assert operation.get_server_url() == expected_url def test_build_path_with_required_parameter(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {"id": "123"} expected_path = "/resource/123" @@ -291,13 +387,9 @@ def test_build_path_with_required_parameter(): def test_build_path_missing_required_parameter(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {} with pytest.raises( @@ -309,15 +401,11 @@ def test_build_path_missing_required_parameter(): def test_build_path_with_optional_and_required_parameters(): parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ), - RestApiOperationParameter( - name="optional", type="string", location=RestApiOperationParameterLocation.PATH, is_required=False - ), + RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True), + RestApiParameter(name="optional", type="string", location=RestApiParameterLocation.PATH, is_required=False), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}/{optional}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}/{optional}", params=parameters ) arguments = {"id": "123"} expected_path = "/resource/123/{optional}" @@ -326,12 +414,10 @@ def test_build_path_with_optional_and_required_parameters(): def test_build_query_string_with_required_parameter(): parameters = [ - RestApiOperationParameter( - name="query", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {"query": "value"} expected_query_string = "query=value" @@ -340,12 +426,10 @@ def test_build_query_string_with_required_parameter(): def test_build_query_string_missing_required_parameter(): parameters = [ - RestApiOperationParameter( - name="query", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {} with pytest.raises( @@ -357,15 +441,15 @@ def test_build_query_string_missing_required_parameter(): def test_build_query_string_with_optional_and_required_parameters(): parameters = [ - RestApiOperationParameter( - name="required_param", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True + RestApiParameter( + name="required_param", type="string", location=RestApiParameterLocation.QUERY, is_required=True ), - RestApiOperationParameter( - name="optional_param", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=False + RestApiParameter( + name="optional_param", type="string", location=RestApiParameterLocation.QUERY, is_required=False ), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {"required_param": "required_value"} expected_query_string = "required_param=required_value" @@ -374,7 +458,7 @@ def test_build_query_string_with_optional_and_required_parameters(): def test_create_payload_artificial_parameter_with_text_plain(): properties = [ - RestApiOperationPayloadProperty( + RestApiPayloadProperty( name="prop1", type="string", properties=[], @@ -384,21 +468,21 @@ def test_create_payload_artificial_parameter_with_text_plain(): schema=None, ) ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type=RestApiOperation.MEDIA_TYPE_TEXT_PLAIN, properties=properties, description="Test description", schema="Test schema", ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) - expected_parameter = RestApiOperationParameter( + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="string", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Test description", schema="Test schema", ) @@ -414,7 +498,7 @@ def test_create_payload_artificial_parameter_with_text_plain(): def test_create_payload_artificial_parameter_with_object(): properties = [ - RestApiOperationPayloadProperty( + RestApiPayloadProperty( name="prop1", type="string", properties=[], @@ -424,18 +508,18 @@ def test_create_payload_artificial_parameter_with_object(): schema=None, ) ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type="application/json", properties=properties, description="Test description", schema="Test schema" ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) - expected_parameter = RestApiOperationParameter( + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="object", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Test description", schema="Test schema", ) @@ -450,13 +534,13 @@ def test_create_payload_artificial_parameter_with_object(): def test_create_payload_artificial_parameter_without_request_body(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - expected_parameter = RestApiOperationParameter( + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="object", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="REST API request body.", schema=None, ) @@ -471,13 +555,13 @@ def test_create_payload_artificial_parameter_without_request_body(): def test_create_content_type_artificial_parameter(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - expected_parameter = RestApiOperationParameter( + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + expected_parameter = RestApiParameter( name=operation.CONTENT_TYPE_ARGUMENT_NAME, type="string", is_required=False, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Content type of REST API request body.", ) parameter = operation.create_content_type_artificial_parameter() @@ -490,32 +574,28 @@ def test_create_content_type_artificial_parameter(): def test_get_property_name_with_namespacing_and_root_property(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - property = RestApiOperationPayloadProperty( - name="child", type="string", properties=[], description="Property description" - ) + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + property = RestApiPayloadProperty(name="child", type="string", properties=[], description="Property description") result = operation._get_property_name(property, root_property_name="root", enable_namespacing=True) assert result == "root.child" def test_get_property_name_without_namespacing(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - property = RestApiOperationPayloadProperty( - name="child", type="string", properties=[], description="Property description" - ) + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + property = RestApiPayloadProperty(name="child", type="string", properties=[], description="Property description") result = operation._get_property_name(property, root_property_name="root", enable_namespacing=False) assert result == "child" def test_get_payload_parameters_with_metadata_and_text_plain(): properties = [ - RestApiOperationPayloadProperty(name="prop1", type="string", properties=[], description="Property description") + RestApiPayloadProperty(name="prop1", type="string", properties=[], description="Property description") ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type=RestApiOperation.MEDIA_TYPE_TEXT_PLAIN, properties=properties, description="Test description" ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) result = operation.get_payload_parameters(operation, use_parameters_from_metadata=True, enable_namespacing=True) assert len(result) == 1 @@ -524,13 +604,11 @@ def test_get_payload_parameters_with_metadata_and_text_plain(): def test_get_payload_parameters_with_metadata_and_json(): properties = [ - RestApiOperationPayloadProperty(name="prop1", type="string", properties=[], description="Property description") + RestApiPayloadProperty(name="prop1", type="string", properties=[], description="Property description") ] - request_body = RestApiOperationPayload( - media_type="application/json", properties=properties, description="Test description" - ) + request_body = RestApiPayload(media_type="application/json", properties=properties, description="Test description") operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) result = operation.get_payload_parameters(operation, use_parameters_from_metadata=True, enable_namespacing=True) assert len(result) == len(properties) @@ -538,7 +616,7 @@ def test_get_payload_parameters_with_metadata_and_json(): def test_get_payload_parameters_without_metadata(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") result = operation.get_payload_parameters(operation, use_parameters_from_metadata=False, enable_namespacing=False) assert len(result) == 2 assert result[0].name == operation.PAYLOAD_ARGUMENT_NAME @@ -549,7 +627,7 @@ def test_get_payload_parameters_raises_exception(): operation = RestApiOperation( id="test", method="POST", - server_url="https://example.com/", + servers=["https://example.com/"], path="/resource", request_body=None, ) @@ -561,12 +639,10 @@ def test_get_payload_parameters_raises_exception(): def test_get_default_response(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "200": RestApiOperationExpectedResponse( - description="Success", media_type="application/json", schema={"type": "object"} - ), - "default": RestApiOperationExpectedResponse( + "200": RestApiExpectedResponse(description="Success", media_type="application/json", schema={"type": "object"}), + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ), } @@ -576,9 +652,9 @@ def test_get_default_response(): def test_get_default_response_with_default(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "default": RestApiOperationExpectedResponse( + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ) } @@ -588,7 +664,7 @@ def test_get_default_response_with_default(): def test_get_default_response_none(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = {} preferred_responses = ["200", "default"] result = operation.get_default_response(responses, preferred_responses) @@ -596,12 +672,10 @@ def test_get_default_response_none(): def test_get_default_return_parameter_with_response(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "200": RestApiOperationExpectedResponse( - description="Success", media_type="application/json", schema={"type": "object"} - ), - "default": RestApiOperationExpectedResponse( + "200": RestApiExpectedResponse(description="Success", media_type="application/json", schema={"type": "object"}), + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ), } @@ -614,7 +688,7 @@ def test_get_default_return_parameter_with_response(): def test_get_default_return_parameter_none(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = {} operation.responses = responses result = operation.get_default_return_parameter(preferred_responses=["200", "default"]) @@ -657,6 +731,61 @@ async def dummy_auth_callback(**kwargs): return runner, operations +@pytest.fixture +def openapi_runner_with_predicate_callback(): + # Define a dummy predicate callback + def predicate_callback(context): + # Skip operations with DELETE method or containing 'internal' in the path + return context.method != "DELETE" and "internal" not in context.path + + parser = OpenApiParser() + parsed_doc = parser.parse(openapi_document) + exec_settings = OpenAPIFunctionExecutionParameters( + server_url_override="http://urloverride.com", + operation_selection_predicate=predicate_callback, + ) + operations = parser.create_rest_api_operations(parsed_doc, execution_settings=exec_settings) + runner = OpenApiRunner(parsed_openapi_document=parsed_doc) + return runner, operations, exec_settings + + +def test_predicate_callback_applied(openapi_runner_with_predicate_callback): + _, operations, exec_settings = openapi_runner_with_predicate_callback + + skipped_operations = [] + executed_operations = [] + + # Iterate over the operation objects instead of just the keys + for operation_id, operation in operations.items(): + context = OperationSelectionPredicateContext( + operation_id=operation_id, + path=operation.path, + method=operation.method, + description=operation.description, + ) + if not exec_settings.operation_selection_predicate(context): + skipped_operations.append(operation_id) + else: + executed_operations.append(operation_id) + + # Assertions to verify the callback's behavior + assert len(skipped_operations) > 0, "No operations were skipped, predicate not applied correctly" + assert len(executed_operations) > 0, "All operations were skipped, predicate not applied correctly" + + # Example specific checks based on the callback logic + for operation_id in skipped_operations: + operation = operations[operation_id] + assert operation.method == "DELETE" or "internal" in operation.path, ( + f"Predicate incorrectly skipped operation {operation_id}" + ) + + for operation_id in executed_operations: + operation = operations[operation_id] + assert operation.method != "DELETE" and "internal" not in operation.path, ( + f"Predicate incorrectly executed operation {operation_id}" + ) + + @patch("aiohttp.ClientSession.request") @pytest.mark.asyncio async def test_run_operation_with_invalid_request(mock_request, openapi_runner): diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index a30f16f6b2c4..986bbee99aea 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -13,6 +13,7 @@ from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( OpenAIFunctionExecutionParameters, ) +from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_function import KernelFunction @@ -589,7 +590,25 @@ def test_from_openapi(): assert plugin.functions.get("SetSecret") is not None -def test_from_openapi_missing_document_throws(): +def test_custom_spec_from_openapi(): + openapi_spec_file = os.path.join( + os.path.dirname(__file__), "../../assets/test_plugins", "TestOpenAPIPlugin", "akv-openapi.yaml" + ) + + parser = OpenApiParser() + openapi_spec = parser.parse(openapi_spec_file) + + plugin = KernelPlugin.from_openapi( + plugin_name="TestOpenAPIPlugin", + openapi_parsed_spec=openapi_spec, + ) + assert plugin is not None + assert plugin.name == "TestOpenAPIPlugin" + assert plugin.functions.get("GetSecret") is not None + assert plugin.functions.get("SetSecret") is not None + + +def test_from_openapi_missing_document_and_parsed_spec_throws(): with raises(PluginInitializationError): KernelPlugin.from_openapi( plugin_name="TestOpenAPIPlugin", From ea2ba47ea434a8b7c6c645e72a96e762da9aa577 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 21 Nov 2024 07:58:19 -0800 Subject: [PATCH 3/5] Python: Add AI connector README (#9764) ### Motivation and Context We need a README in the connectors/ai directory to list all the existing AI connectors. ### Description ### Contribution Checklist 1. Add a README 2. Refactor some connector package paths - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- .../chat_completion/chat_bedrock_api.py | 3 +- .../local_models/lm_studio_chat_completion.py | 2 +- .../local_models/lm_studio_text_embedding.py | 2 +- .../local_models/ollama_chat_completion.py | 2 +- .../model_as_a_service/mmlu_model_eval.py | 4 +- python/samples/concepts/setup/ALL_SETTINGS.md | 134 ++++++------------ .../semantic_kernel/connectors/ai/README.md | 52 +++++++ .../connectors/ai/bedrock/__init__.py | 23 +++ .../ai/google/google_ai/__init__.py | 2 + .../services/google_ai_text_completion.py | 2 +- .../ai/google/vertex_ai/__init__.py | 2 + .../connectors/ai/open_ai/__init__.py | 4 + .../completions/chat_completion_test_base.py | 42 ++---- .../completions/test_text_completion.py | 27 ++-- .../embeddings/test_embedding_service_base.py | 32 ++--- 15 files changed, 169 insertions(+), 164 deletions(-) create mode 100644 python/semantic_kernel/connectors/ai/README.md diff --git a/python/samples/concepts/chat_completion/chat_bedrock_api.py b/python/samples/concepts/chat_completion/chat_bedrock_api.py index 62e467666e06..cd56cefb7a47 100644 --- a/python/samples/concepts/chat_completion/chat_bedrock_api.py +++ b/python/samples/concepts/chat_completion/chat_bedrock_api.py @@ -3,8 +3,7 @@ import asyncio from semantic_kernel import Kernel -from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockChatPromptExecutionSettings -from semantic_kernel.connectors.ai.bedrock.services.bedrock_chat_completion import BedrockChatCompletion +from semantic_kernel.connectors.ai.bedrock import BedrockChatCompletion, BedrockChatPromptExecutionSettings from semantic_kernel.contents import ChatHistory system_message = """ diff --git a/python/samples/concepts/local_models/lm_studio_chat_completion.py b/python/samples/concepts/local_models/lm_studio_chat_completion.py index d1c480720c89..b4837f6dd731 100644 --- a/python/samples/concepts/local_models/lm_studio_chat_completion.py +++ b/python/samples/concepts/local_models/lm_studio_chat_completion.py @@ -5,7 +5,7 @@ from openai import AsyncOpenAI -from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion +from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel diff --git a/python/samples/concepts/local_models/lm_studio_text_embedding.py b/python/samples/concepts/local_models/lm_studio_text_embedding.py index 807c0aff349c..24626c879e26 100644 --- a/python/samples/concepts/local_models/lm_studio_text_embedding.py +++ b/python/samples/concepts/local_models/lm_studio_text_embedding.py @@ -4,7 +4,7 @@ from openai import AsyncOpenAI -from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_embedding import OpenAITextEmbedding +from semantic_kernel.connectors.ai.open_ai import OpenAITextEmbedding from semantic_kernel.core_plugins.text_memory_plugin import TextMemoryPlugin from semantic_kernel.kernel import Kernel from semantic_kernel.memory.semantic_text_memory import SemanticTextMemory diff --git a/python/samples/concepts/local_models/ollama_chat_completion.py b/python/samples/concepts/local_models/ollama_chat_completion.py index 32413d91a530..c94a6dcb5334 100644 --- a/python/samples/concepts/local_models/ollama_chat_completion.py +++ b/python/samples/concepts/local_models/ollama_chat_completion.py @@ -5,7 +5,7 @@ from openai import AsyncOpenAI -from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion +from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel diff --git a/python/samples/concepts/model_as_a_service/mmlu_model_eval.py b/python/samples/concepts/model_as_a_service/mmlu_model_eval.py index 74d9370a8677..6cd0c6cdc144 100644 --- a/python/samples/concepts/model_as_a_service/mmlu_model_eval.py +++ b/python/samples/concepts/model_as_a_service/mmlu_model_eval.py @@ -12,9 +12,7 @@ formatted_question, formatted_system_message, ) -from semantic_kernel.connectors.ai.azure_ai_inference.services.azure_ai_inference_chat_completion import ( - AzureAIInferenceChatCompletion, -) +from semantic_kernel.connectors.ai.azure_ai_inference import AzureAIInferenceChatCompletion from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.functions.kernel_arguments import KernelArguments diff --git a/python/samples/concepts/setup/ALL_SETTINGS.md b/python/samples/concepts/setup/ALL_SETTINGS.md index 100856c7a986..1f2536ad4738 100644 --- a/python/samples/concepts/setup/ALL_SETTINGS.md +++ b/python/samples/concepts/setup/ALL_SETTINGS.md @@ -1,99 +1,57 @@ ## AI Service Settings used across SK: -| Service | Class | Constructor Settings | Environment Variable | Required? | Settings Class | +| Provider | Service | Constructor Settings | Environment Variable | Required? | Settings Class | | --- | --- | --- | --- | --- | --- | -OpenAI | [OpenAIChatCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_chat_completion.py) | | | | [OpenAISettings](../../../semantic_kernel/connectors/ai/open_ai/settings/open_ai_settings.py) -| | | ai_model_id | OPENAI_CHAT_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -| | [OpenAITextCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_completion.py) -| | | ai_model_id | OPENAI_TEXT_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -| | [OpenAITextEmbedding](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_embedding.py) -| | | ai_model_id | OPENAI_EMBEDDING_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -| | [OpenAITextToImage](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_to_image.py) -| | | ai_model_id | OPENAI_TEXT_TO_IMAGE_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -| | [OpenAITextToAudio](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_to_audio.py) -| | | ai_model_id | OPENAI_TEXT_TO_AUDIO_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -| | [OpenAIAudioToText](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_audio_to_text.py) -| | | ai_model_id | OPENAI_AUDIO_TO_TEXT_MODEL_ID | Yes -| | | api_key | OPENAI_API_KEY | Yes -| | | org_id | OPENAI_ORG_ID | No -Azure OpenAI | [AzureOpenAIChatCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/azure_chat_completion.py) | | | | [AzureOpenAISettings](../../../semantic_kernel/connectors/ai/open_ai/settings/azure_open_ai_settings.py) -| | | deployment_name | AZURE_OPENAI_CHAT_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes -| | | api_version | AZURE_OPENAI_API_VERSION | Yes -| | | base_url | AZURE_OPENAI_BASE_URL | Yes -| | [AzureOpenAITextCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_completion.py) -| | | deployment_name | AZURE_OPENAI_TEXT_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes -| | | api_version | AZURE_OPENAI_API_VERSION | Yes -| | | base_url | AZURE_OPENAI_BASE_URL | Yes -| | [AzureOpenAITextEmbedding](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_embedding.py) -| | | deployment_name | AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes -| | | api_version | AZURE_OPENAI_API_VERSION | Yes -| | | base_url | AZURE_OPENAI_BASE_URL | Yes -| | [AzureTextToImage](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_to_image.py) -| | | deployment_name | AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes -| | [AzureTextToAudio](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_to_audio.py) -| | | deployment_name | AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes -| | [AzureAudioToText](../../../semantic_kernel/connectors/ai/open_ai/services/azure_audio_to_text.py) -| | | deployment_name | AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME | Yes -| | | api_key | AZURE_OPENAI_API_KEY | No -| | | endpoint | AZURE_OPENAI_ENDPOINT | Yes +| OpenAI | [OpenAIChatCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_chat_completion.py) | ai_model_id,
api_key,
org_id | OPENAI_CHAT_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | [OpenAISettings](../../../semantic_kernel/connectors/ai/open_ai/settings/open_ai_settings.py) | +| | [OpenAITextCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_completion.py) | ai_model_id,
api_key,
org_id | OPENAI_TEXT_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | | +| | [OpenAITextEmbedding](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_embedding.py) | ai_model_id,
api_key,
org_id | OPENAI_EMBEDDING_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | | +| | [OpenAITextToImage](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_to_image.py) | ai_model_id,
api_key,
org_id | OPENAI_TEXT_TO_IMAGE_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | | +| | [OpenAITextToAudio](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_text_to_audio.py) | ai_model_id,
api_key,
org_id | OPENAI_TEXT_TO_AUDIO_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | | +| | [OpenAIAudioToText](../../../semantic_kernel/connectors/ai/open_ai/services/open_ai_audio_to_text.py) | ai_model_id,
api_key,
org_id | OPENAI_AUDIO_TO_TEXT_MODEL_ID,
OPENAI_API_KEY,
OPENAI_ORG_ID | Yes,
Yes,
No | | +| Azure OpenAI | [AzureChatCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/azure_chat_completion.py) | deployment_name,
api_key,
endpoint,
api_version,
base_url | AZURE_OPENAI_CHAT_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT,
AZURE_OPENAI_API_VERSION,
AZURE_OPENAI_BASE_URL | Yes,
No,
Yes,
Yes,
Yes | [AzureOpenAISettings](../../../semantic_kernel/connectors/ai/open_ai/settings/azure_open_ai_settings.py) | +| | [AzureTextCompletion](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_completion.py) | deployment_name,
api_key,
endpoint,
api_version,
base_url | AZURE_OPENAI_TEXT_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT,
AZURE_OPENAI_API_VERSION,
AZURE_OPENAI_BASE_URL | Yes,
No,
Yes,
Yes,
Yes | | +| | [AzureTextEmbedding](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_embedding.py) | deployment_name,
api_key,
endpoint,
api_version,
base_url | AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT,
AZURE_OPENAI_API_VERSION,
AZURE_OPENAI_BASE_URL | Yes,
No,
Yes,
Yes,
Yes | | +| | [AzureTextToImage](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_to_image.py) | deployment_name,
api_key,
endpoint | AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT | Yes,
No,
Yes | | +| | [AzureTextToAudio](../../../semantic_kernel/connectors/ai/open_ai/services/azure_text_to_audio.py) | deployment_name,
api_key,
endpoint | AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT | Yes,
No,
Yes | | +| | [AzureAudioToText](../../../semantic_kernel/connectors/ai/open_ai/services/azure_audio_to_text.py) | deployment_name,
api_key,
endpoint | AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME,
AZURE_OPENAI_API_KEY,
AZURE_OPENAI_ENDPOINT | Yes,
No,
Yes | | +| Azure AI Inference | [AzureAIInferenceChatCompletion](../../../semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_chat_completion.py) | ai_model_id,
api_key,
endpoint | N/A,
AZURE_AI_INFERENCE_API_KEY,
AZURE_AI_INFERENCE_ENDPOINT | Yes,
No,
Yes | [AzureAIInferenceSettings](../../../semantic_kernel/connectors/ai/azure_ai_inference/azure_ai_inference_settings.py) | +| | [AzureAIInferenceTextEmbedding](../../../semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_text_embedding.py) | ai_model_id,
api_key,
endpoint | N/A,
AZURE_AI_INFERENCE_API_KEY,
AZURE_AI_INFERENCE_ENDPOINT | Yes,
No,
Yes | | +| Anthropic | [AnthropicChatCompletion](../../../semantic_kernel/connectors/ai/anthropic/services/anthropic_chat_completion.py) | api_key,
ai_model_id | ANTHROPIC_API_KEY,
ANTHROPIC_CHAT_MODEL_ID | Yes,
Yes | [AnthropicSettings](../../../semantic_kernel/connectors/ai/anthropic/settings/anthropic_settings.py) | +| Bedrock | [BedrockChatCompletion](../../../semantic_kernel/connectors/ai/bedrock/services/bedrock_chat_completion.py) | model_id | BEDROCK_CHAT_MODEL_ID | Yes | [BedrockSettings](../../../semantic_kernel/connectors/ai/bedrock/bedrock_settings.py) | +| | [BedrockTextCompletion](../../../semantic_kernel/connectors/ai/bedrock/services/bedrock_text_completion.py) | model_id | BEDROCK_TEXT_MODEL_ID | Yes | | +| | [BedrockTextEmbedding](../../../semantic_kernel/connectors/ai/bedrock/services/bedrock_text_embedding.py) | model_id | BEDROCK_EMBEDDING_MODEL_ID | Yes | | +| Google AI | [GoogleAIChatCompletion](../../../semantic_kernel/connectors/ai/google/google_ai/services/google_ai_chat_completion.py) | gemini_model_id,
api_key | GOOGLE_AI_GEMINI_MODEL_ID,
GOOGLE_AI_API_KEY | Yes,
Yes | [GoogleAISettings](../../../semantic_kernel/connectors/ai/google/google_ai/google_ai_settings.py) | +| | [GoogleAITextCompletion](../../../semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py) | gemini_model_id,
api_key | GOOGLE_AI_GEMINI_MODEL_ID,
GOOGLE_AI_API_KEY | Yes,
Yes | | +| | [GoogleAITextEmbedding](../../../semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_embedding.py) | embedding_model_id,
api_key | GOOGLE_AI_EMBEDDING_MODEL_ID,
GOOGLE_AI_API_KEY | Yes,
Yes | | +| Vertex AI | [VertexAIChatCompletion](../../../semantic_kernel/connectors/ai/google/vertex_ai/services/vertex_ai_chat_completion.py) | project_id,
region,
gemini_model_id | VERTEX_AI_PROJECT_ID,
VERTEX_AI_REGION,
VERTEX_AI_GEMINI_MODEL_ID | Yes,
No,
Yes | [VertexAISettings](../../../semantic_kernel/connectors/ai/google/vertex_ai/vertex_ai_settings.py) | +| | [VertexAITextCompletion](../../../semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py) | project_id,
region,
gemini_model_id | VERTEX_AI_PROJECT_ID,
VERTEX_AI_REGION,
VERTEX_AI_GEMINI_MODEL_ID | Yes,
No,
Yes | | +| | [VertexAITextEmbedding](../../../semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_embedding.py) | project_id,
region,
embedding_model_id | VERTEX_AI_PROJECT_ID,
VERTEX_AI_REGION,
VERTEX_AI_EMBEDDING_MODEL_ID | Yes,
No,
Yes | | +| HuggingFace | [HuggingFaceTextCompletion](../../../semantic_kernel/connectors/ai/hugging_face/services/hf_text_completion.py) | ai_model_id | N/A | Yes | | +| | [HuggingFaceTextEmbedding](../../../semantic_kernel/connectors/ai/hugging_face/services/hf_text_embedding.py) | ai_model_id | N/A | Yes | | +| Mistral AI | [MistralAIChatCompletion](../../../semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_chat_completion.py) | ai_model_id,
api_key | MISTRALAI_CHAT_MODEL_ID,
MISTRALAI_API_KEY | Yes,
Yes | [MistralAISettings](../../../semantic_kernel/connectors/ai/mistral_ai/settings/mistral_ai_settings.py) | +| | [MistralAITextEmbedding](../../../semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_text_embedding.py) | ai_model_id,
api_key | MISTRALAI_EMBEDDING_MODEL_ID,
MISTRALAI_API_KEY | Yes,
Yes | | +| Ollama | [OllamaChatCompletion](../../../semantic_kernel/connectors/ai/ollama/services/ollama_chat_completion.py) | ai_model_id,
host | OLLAMA_CHAT_MODEL_ID,
OLLAMA_HOST | Yes,
No | [OllamaSettings](../../../semantic_kernel/connectors/ai/ollama/ollama_settings.py) | +| | [OllamaTextCompletion](../../../semantic_kernel/connectors/ai/ollama/services/ollama_text_completion.py) | ai_model_id,
host | OLLAMA_TEXT_MODEL_ID,
OLLAMA_HOST | Yes,
No | | +| | [OllamaTextEmbedding](../../../semantic_kernel/connectors/ai/ollama/services/ollama_text_embedding.py) | ai_model_id,
host | OLLAMA_EMBEDDING_MODEL_ID,
OLLAMA_HOST | Yes,
No | | +| Onnx | [OnnxGenAIChatCompletion](../../../semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_chat_completion.py) | template,
ai_model_path | N/A,
ONNX_GEN_AI_CHAT_MODEL_FOLDER | Yes,
Yes | [OnnxGenAISettings](../../../semantic_kernel/connectors/ai/onnx/onnx_gen_ai_settings.py) | +| | [OnnxGenAITextCompletion](../../../semantic_kernel/connectors/ai/onnx/services/onnx_gen_ai_text_completion.py) | ai_model_path | ONNX_GEN_AI_TEXT_MODEL_FOLDER | Yes | | ## Memory Service Settings used across SK: -| Service | Class | Constructor Settings | Environment Variable | Required? | Settings Class | +| Provider | Service | Constructor Settings | Environment Variable | Required? | Settings Class | | --- | --- | --- | --- | --- | --- | -AstraDB | [AstraDBMemoryService](../../../semantic_kernel/connectors/memory/astradb/astradb_memory_store.py) | | | | [AstraDBSettings](../../../semantic_kernel/connectors/memory/astradb/astradb_settings.py) -| | | app_token | ASTRADB_APP_TOKEN | Yes -| | | db_id | ASTRADB_DB_ID | Yes -| | | region | ASTRADB_REGION | Yes -| | | keyspace | ASTRADB_KEYSPACE | Yes -Azure AI Search | [AzureAISearchMemoryService](../../../semantic_kernel/connectors/memory/azure_cognitive_search/azure_cognitive_search_memory_store.py) | | | | [AzureAISearchSettings](../../../semantic_kernel/connectors/memory/azure_cognitive_search/azure_ai_search_settings.py) -| | | api_key | AZURE_AI_SEARCH_API_KEY | No -| | | endpoint | AZURE_AI_SEARCH_ENDPOINT | Yes -| | | index_name | AZURE_AI_SEARCH_INDEX_NAME | No -Azure Cosmos DB | [AzureCosmosDBMemoryService](../../../semantic_kernel/connectors/memory/azure_cosmosdb/azure_cosmos_db_memory_store.py) | | | | [AzureCosmosDBSettings](../../../semantic_kernel/connectors/memory/azure_cosmosdb/azure_cosmosdb_settings.py) -| | | api | AZURE_COSMOS_DB_API | No -| | | connection_string | AZURE_COSMOS_DB_CONNECTION_STRING or AZCOSMOS_CONNSTR | No -Mongo DB Atlas | [MongoDBAtlasMemoryService](../../../semantic_kernel/connectors/memory/mongodb_atlas/mongodb_atlas_memory_store.py) | | | | [MongoDBAtlasSettings](../../../semantic_kernel/connectors/memory/mongodb_atlas/mongodb_atlas_settings.py) -| | | connection_string | MONGODB_ATLAS_CONNECTION_STRING | Yes -| | | database_name | MONGODB_ATLAS_DATABASE_NAME | No -| | | index_name | MONGODB_ATLAS_INDEX_NAME | No -Pinecone | [PineconeMemoryService](../../../semantic_kernel/connectors/memory/pinecone/pinecone_memory_store.py) | | | | [PineconeSettings](../../../semantic_kernel/connectors/memory/pinecone/pinecone_settings.py) -| | | api_key | PINECONE_API_KEY | Yes -Postgres | [PostgresMemoryService](../../../semantic_kernel/connectors/memory/postgres/postgres_memory_store.py) | | | | [PostgresSettings](../../../semantic_kernel/connectors/memory/postgres/postgres_settings.py) -| | | connection_string | POSTGRES_CONNECTION_STRING | Yes -Redis | [RedisMemoryService](../../../semantic_kernel/connectors/memory/redis/redis_memory_store.py) | | | | [RedisSettings](../../../semantic_kernel/connectors/memory/redis/redis_settings.py) -| | | connection_string | REDIS_CONNECTION_STRING | Yes -Weaviate | [WeaviateMemoryService](../../../semantic_kernel/connectors/memory/weaviate/weaviate_memory_store.py) | | | | [WeaviateSettings](../../../semantic_kernel/connectors/memory/weaviate/weaviate_settings.py) -| | | url | WEAVIATE_URL | No -| | | api_key | WEAVIATE_API_KEY | No -| | | use_embed | WEAVIATE_USE_EMBED | No +| AstraDB | [AstraDBMemoryService](../../../semantic_kernel/connectors/memory/astradb/astradb_memory_store.py) | app_token,
db_id,
region,
keyspace | ASTRADB_APP_TOKEN,
ASTRADB_DB_ID,
ASTRADB_REGION,
ASTRADB_KEYSPACE | Yes,
Yes,
Yes,
Yes | [AstraDBSettings](../../../semantic_kernel/connectors/memory/astradb/astradb_settings.py) | +| Azure AI Search | [AzureAISearchMemoryService](../../../semantic_kernel/connectors/memory/azure_cognitive_search/azure_cognitive_search_memory_store.py) | api_key,
endpoint,
index_name | AZURE_AI_SEARCH_API_KEY,
AZURE_AI_SEARCH_ENDPOINT,
AZURE_AI_SEARCH_INDEX_NAME | No,
Yes,
No | [AzureAISearchSettings](../../../semantic_kernel/connectors/memory/azure_cognitive_search/azure_ai_search_settings.py) | +| Azure Cosmos DB | [AzureCosmosDBMemoryService](../../../semantic_kernel/connectors/memory/azure_cosmosdb/azure_cosmos_db_memory_store.py) | api,
connection_string | AZURE_COSMOS_DB_API,
AZURE_COSMOS_DB_CONNECTION_STRING or AZCOSMOS_CONNSTR | No,
No | [AzureCosmosDBSettings](../../../semantic_kernel/connectors/memory/azure_cosmosdb/azure_cosmosdb_settings.py) | +| Mongo DB Atlas | [MongoDBAtlasMemoryService](../../../semantic_kernel/connectors/memory/mongodb_atlas/mongodb_atlas_memory_store.py) | connection_string,
database_name,
index_name | MONGODB_ATLAS_CONNECTION_STRING,
MONGODB_ATLAS_DATABASE_NAME,
MONGODB_ATLAS_INDEX_NAME | Yes,
No,
No | [MongoDBAtlasSettings](../../../semantic_kernel/connectors/memory/mongodb_atlas/mongodb_atlas_settings.py) | +| Pinecone | [PineconeMemoryService](../../../semantic_kernel/connectors/memory/pinecone/pinecone_memory_store.py) | api_key | PINECONE_API_KEY | Yes | [PineconeSettings](../../../semantic_kernel/connectors/memory/pinecone/pinecone_settings.py) | +| Postgres | [PostgresMemoryService](../../../semantic_kernel/connectors/memory/postgres/postgres_memory_store.py) | connection_string | POSTGRES_CONNECTION_STRING | Yes | [PostgresSettings](../../../semantic_kernel/connectors/memory/postgres/postgres_settings.py) | +| Redis | [RedisMemoryService](../../../semantic_kernel/connectors/memory/redis/redis_memory_store.py) | connection_string | REDIS_CONNECTION_STRING | Yes | [RedisSettings](../../../semantic_kernel/connectors/memory/redis/redis_settings.py) | +| Weaviate | [WeaviateMemoryService](../../../semantic_kernel/connectors/memory/weaviate/weaviate_memory_store.py) | url,
api_key,
use_embed | WEAVIATE_URL,
WEAVIATE_API_KEY,
WEAVIATE_USE_EMBED | No,
No,
No | [WeaviateSettings](../../../semantic_kernel/connectors/memory/weaviate/weaviate_settings.py) | ## Other settings used: -| Service | Class | Constructor Settings | Environment Variable | Required? | Settings Class | +| Provider | Service | Constructor Settings | Environment Variable | Required? | Settings Class | | --- | --- | --- | --- | --- | --- | -Bing | [BingSearch](../../../semantic_kernel/connectors/search_engine/bing_connector.py) | | | | [BingSettings](../../../semantic_kernel/connectors/search_engine/bing_connector_settings.py) -| | | api_key | BING_API_KEY | No -| | | custom_config | BING_CUSTOM_CONFIG | No -Azure Container Apps Sessions | [ACASessionsPlugin](../../../semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py) | | | | [ACASessionsSettings](../../../semantic_kernel/core_plugins/sessions_python_tool/sessions_python_settings.py) -| | | pool_management_endpoint | ACA_POOL_MANAGEMENT_ENDPOINT | Yes +| Bing | [BingSearch](../../../semantic_kernel/connectors/search_engine/bing_connector.py) | api_key,
custom_config | BING_API_KEY,
BING_CUSTOM_CONFIG | No,
No | [BingSettings](../../../semantic_kernel/connectors/search_engine/bing_connector_settings.py) | +| Azure Container Apps Sessions | [ACASessionsPlugin](../../../semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py) | pool_management_endpoint | ACA_POOL_MANAGEMENT_ENDPOINT | Yes | [ACASessionsSettings](../../../semantic_kernel/core_plugins/sessions_python_tool/sessions_python_settings.py) | diff --git a/python/semantic_kernel/connectors/ai/README.md b/python/semantic_kernel/connectors/ai/README.md new file mode 100644 index 000000000000..997a33427c65 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/README.md @@ -0,0 +1,52 @@ +# AI Connectors + +This directory contains the implementation of the AI connectors (aka AI services) that are used to interact with AI models. + +Depending on the modality, the AI connector can inherit from one of the following classes: +- [`ChatCompletionClientBase`](./chat_completion_client_base.py) for chat completion tasks. +- [`TextCompletionClientBase`](./text_completion_client_base.py) for text completion tasks. +- [`AudioToTextClientBase`](./audio_to_text_client_base.py) for audio to text tasks. +- [`TextToAudioClientBase`](./text_to_audio_client_base.py) for text to audio tasks. +- [`TextToImageClientBase`](./text_to_image_client_base.py) for text to image tasks. +- [`EmbeddingGeneratorBase`](./embeddings/embedding_generator_base.py) for text embedding tasks. + + +All base clients inherit from the [`AIServiceClientBase`](../../services/ai_service_client_base.py) class. + +## Existing AI connectors + +| Services | Connectors | +|-------------------------|--------------------------------------| +| OpenAI | [`OpenAIChatCompletion`](./open_ai/services/open_ai_chat_completion.py) | +| | [`OpenAITextCompletion`](./open_ai/services/open_ai_text_completion.py) | +| | [`OpenAITextEmbedding`](./open_ai/services/open_ai_text_embedding.py) | +| | [`OpenAITextToImage`](./open_ai/services/open_ai_text_to_image.py) | +| | [`OpenAITextToAudio`](./open_ai/services/open_ai_text_to_audio.py) | +| | [`OpenAIAudioToText`](./open_ai/services/open_ai_audio_to_text.py) | +| Azure OpenAI | [`AzureChatCompletion`](./open_ai/services/azure_chat_completion.py) | +| | [`AzureTextCompletion`](./open_ai/services/azure_text_completion.py) | +| | [`AzureTextEmbedding`](./open_ai/services/azure_text_embedding.py) | +| | [`AzureTextToImage`](./open_ai/services/azure_text_to_image.py) | +| | [`AzureTextToAudio`](./open_ai/services/azure_text_to_audio.py) | +| | [`AzureAudioToText`](./open_ai/services/azure_audio_to_text.py) | +| Azure AI Inference | [`AzureAIInferenceChatCompletion`](./azure_ai_inference/services/azure_ai_inference_chat_completion.py) | +| | [`AzureAIInferenceTextEmbedding`](./azure_ai_inference/services/azure_ai_inference_text_embedding.py) | +| Anthropic | [`AnthropicChatCompletion`](./anthropic/services/anthropic_chat_completion.py) | +| [Bedrock](./bedrock/README.md) | [`BedrockChatCompletion`](./bedrock/services/bedrock_chat_completion.py) | +| | [`BedrockTextCompletion`](./bedrock/services/bedrock_text_completion.py) | +| | [`BedrockTextEmbedding`](./bedrock/services/bedrock_text_embedding.py) | +| [Google AI](./google/README.md) | [`GoogleAIChatCompletion`](./google/google_ai/services/google_ai_chat_completion.py) | +| | [`GoogleAITextCompletion`](./google/google_ai/services/google_ai_text_completion.py) | +| | [`GoogleAITextEmbedding`](./google/google_ai/services/google_ai_text_embedding.py) | +| [Vertex AI](./google/README.md) | [`VertexAIChatCompletion`](./google/vertex_ai/services/vertex_ai_chat_completion.py) | +| | [`VertexAITextCompletion`](./google/vertex_ai/services/vertex_ai_text_completion.py) | +| | [`VertexAITextEmbedding`](./google/vertex_ai/services/vertex_ai_text_embedding.py) | +| HuggingFace | [`HuggingFaceTextCompletion`](./hugging_face/services/hf_text_completion.py) | +| | [`HuggingFaceTextEmbedding`](./hugging_face/services/hf_text_embedding.py) | +| Mistral AI | [`MistralAIChatCompletion`](./mistral_ai/services/mistral_ai_chat_completion.py) | +| | [`MistralAITextEmbedding`](./mistral_ai/services/mistral_ai_text_embedding.py) | +| Ollama | [`OllamaChatCompletion`](./ollama/services/ollama_chat_completion.py) | +| | [`OllamaTextCompletion`](./ollama/services/ollama_text_completion.py) | +| | [`OllamaTextEmbedding`](./ollama/services/ollama_text_embedding.py) | +| Onnx | [`OnnxGenAIChatCompletion`](./onnx/services/onnx_gen_ai_chat_completion.py) | +| | [`OnnxGenAITextCompletion`](./onnx/services/onnx_gen_ai_text_completion.py) | \ No newline at end of file diff --git a/python/semantic_kernel/connectors/ai/bedrock/__init__.py b/python/semantic_kernel/connectors/ai/bedrock/__init__.py index e69de29bb2d1..1c7f4b77f085 100644 --- a/python/semantic_kernel/connectors/ai/bedrock/__init__.py +++ b/python/semantic_kernel/connectors/ai/bedrock/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import ( + BedrockChatPromptExecutionSettings, + BedrockEmbeddingPromptExecutionSettings, + BedrockPromptExecutionSettings, + BedrockTextPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.bedrock.bedrock_settings import BedrockSettings +from semantic_kernel.connectors.ai.bedrock.services.bedrock_chat_completion import BedrockChatCompletion +from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_completion import BedrockTextCompletion +from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_embedding import BedrockTextEmbedding + +__all__ = [ + "BedrockChatCompletion", + "BedrockChatPromptExecutionSettings", + "BedrockEmbeddingPromptExecutionSettings", + "BedrockPromptExecutionSettings", + "BedrockSettings", + "BedrockTextCompletion", + "BedrockTextEmbedding", + "BedrockTextPromptExecutionSettings", +] diff --git a/python/semantic_kernel/connectors/ai/google/google_ai/__init__.py b/python/semantic_kernel/connectors/ai/google/google_ai/__init__.py index 71b3f019a2bb..f77875236c72 100644 --- a/python/semantic_kernel/connectors/ai/google/google_ai/__init__.py +++ b/python/semantic_kernel/connectors/ai/google/google_ai/__init__.py @@ -4,6 +4,7 @@ GoogleAIChatPromptExecutionSettings, GoogleAIEmbeddingPromptExecutionSettings, GoogleAIPromptExecutionSettings, + GoogleAITextPromptExecutionSettings, ) from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_chat_completion import GoogleAIChatCompletion from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_text_completion import GoogleAITextCompletion @@ -16,4 +17,5 @@ "GoogleAIPromptExecutionSettings", "GoogleAITextCompletion", "GoogleAITextEmbedding", + "GoogleAITextPromptExecutionSettings", ] diff --git a/python/semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py b/python/semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py index 078361e8d70c..2c2c25bc1910 100644 --- a/python/semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py +++ b/python/semantic_kernel/connectors/ai/google/google_ai/services/google_ai_text_completion.py @@ -49,7 +49,7 @@ def __init__( If no arguments are provided, the service will attempt to load the settings from the environment. The following environment variables are used: - - GOOGLE_AI_AI_MODEL_ID + - GOOGLE_AI_GEMINI_MODEL_ID - GOOGLE_AI_API_KEY Args: diff --git a/python/semantic_kernel/connectors/ai/google/vertex_ai/__init__.py b/python/semantic_kernel/connectors/ai/google/vertex_ai/__init__.py index c524ab06ecb1..8bab0d607fd0 100644 --- a/python/semantic_kernel/connectors/ai/google/vertex_ai/__init__.py +++ b/python/semantic_kernel/connectors/ai/google/vertex_ai/__init__.py @@ -7,6 +7,7 @@ VertexAIChatPromptExecutionSettings, VertexAIEmbeddingPromptExecutionSettings, VertexAIPromptExecutionSettings, + VertexAITextPromptExecutionSettings, ) __all__ = [ @@ -16,4 +17,5 @@ "VertexAIPromptExecutionSettings", "VertexAITextCompletion", "VertexAITextEmbedding", + "VertexAITextPromptExecutionSettings", ] diff --git a/python/semantic_kernel/connectors/ai/open_ai/__init__.py b/python/semantic_kernel/connectors/ai/open_ai/__init__.py index 845833d9a01d..a3103ae86446 100644 --- a/python/semantic_kernel/connectors/ai/open_ai/__init__.py +++ b/python/semantic_kernel/connectors/ai/open_ai/__init__.py @@ -40,6 +40,8 @@ from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_embedding import OpenAITextEmbedding from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_to_audio import OpenAITextToAudio from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_to_image import OpenAITextToImage +from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings +from semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings import OpenAISettings __all__ = [ "ApiKeyAuthentication", @@ -52,6 +54,7 @@ "AzureCosmosDBDataSourceParameters", "AzureDataSourceParameters", "AzureEmbeddingDependency", + "AzureOpenAISettings", "AzureTextCompletion", "AzureTextEmbedding", "AzureTextToAudio", @@ -66,6 +69,7 @@ "OpenAIChatPromptExecutionSettings", "OpenAIEmbeddingPromptExecutionSettings", "OpenAIPromptExecutionSettings", + "OpenAISettings", "OpenAITextCompletion", "OpenAITextEmbedding", "OpenAITextPromptExecutionSettings", diff --git a/python/tests/integration/completions/chat_completion_test_base.py b/python/tests/integration/completions/chat_completion_test_base.py index c4ae24bbbd9f..d94c7e61442d 100644 --- a/python/tests/integration/completions/chat_completion_test_base.py +++ b/python/tests/integration/completions/chat_completion_test_base.py @@ -12,42 +12,24 @@ from azure.identity import DefaultAzureCredential from openai import AsyncAzureOpenAI -from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import ( - AnthropicChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.anthropic.services.anthropic_chat_completion import AnthropicChatCompletion -from semantic_kernel.connectors.ai.azure_ai_inference.azure_ai_inference_prompt_execution_settings import ( - AzureAIInferenceChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.azure_ai_inference.services.azure_ai_inference_chat_completion import ( +from semantic_kernel.connectors.ai.anthropic import AnthropicChatCompletion, AnthropicChatPromptExecutionSettings +from semantic_kernel.connectors.ai.azure_ai_inference import ( AzureAIInferenceChatCompletion, + AzureAIInferenceChatPromptExecutionSettings, ) -from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockChatPromptExecutionSettings -from semantic_kernel.connectors.ai.bedrock.services.bedrock_chat_completion import BedrockChatCompletion +from semantic_kernel.connectors.ai.bedrock import BedrockChatCompletion, BedrockChatPromptExecutionSettings from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase -from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import ( - GoogleAIChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_chat_completion import GoogleAIChatCompletion -from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_chat_completion import VertexAIChatCompletion -from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import ( - VertexAIChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.mistral_ai.prompt_execution_settings.mistral_ai_prompt_execution_settings import ( - MistralAIChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.mistral_ai.services.mistral_ai_chat_completion import MistralAIChatCompletion -from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaChatPromptExecutionSettings -from semantic_kernel.connectors.ai.ollama.services.ollama_chat_completion import OllamaChatCompletion -from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.google.google_ai import GoogleAIChatCompletion, GoogleAIChatPromptExecutionSettings +from semantic_kernel.connectors.ai.google.vertex_ai import VertexAIChatCompletion, VertexAIChatPromptExecutionSettings +from semantic_kernel.connectors.ai.mistral_ai import MistralAIChatCompletion, MistralAIChatPromptExecutionSettings +from semantic_kernel.connectors.ai.ollama import OllamaChatCompletion, OllamaChatPromptExecutionSettings +from semantic_kernel.connectors.ai.open_ai import ( + AzureChatCompletion, AzureChatPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import ( + AzureOpenAISettings, + OpenAIChatCompletion, OpenAIChatPromptExecutionSettings, ) -from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion -from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion -from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.core_plugins.math_plugin import MathPlugin diff --git a/python/tests/integration/completions/test_text_completion.py b/python/tests/integration/completions/test_text_completion.py index 909ceaf5766d..9a8e60d2d0b6 100644 --- a/python/tests/integration/completions/test_text_completion.py +++ b/python/tests/integration/completions/test_text_completion.py @@ -8,26 +8,17 @@ import pytest from openai import AsyncAzureOpenAI -from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockTextPromptExecutionSettings -from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_completion import BedrockTextCompletion -from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import ( - GoogleAITextPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_text_completion import GoogleAITextCompletion -from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_text_completion import VertexAITextCompletion -from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import ( - VertexAITextPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.hugging_face.hf_prompt_execution_settings import HuggingFacePromptExecutionSettings -from semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion import HuggingFaceTextCompletion -from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaTextPromptExecutionSettings -from semantic_kernel.connectors.ai.ollama.services.ollama_text_completion import OllamaTextCompletion -from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.bedrock import BedrockTextCompletion, BedrockTextPromptExecutionSettings +from semantic_kernel.connectors.ai.google.google_ai import GoogleAITextCompletion, GoogleAITextPromptExecutionSettings +from semantic_kernel.connectors.ai.google.vertex_ai import VertexAITextCompletion, VertexAITextPromptExecutionSettings +from semantic_kernel.connectors.ai.hugging_face import HuggingFacePromptExecutionSettings, HuggingFaceTextCompletion +from semantic_kernel.connectors.ai.ollama import OllamaTextCompletion, OllamaTextPromptExecutionSettings +from semantic_kernel.connectors.ai.open_ai import ( + AzureOpenAISettings, + AzureTextCompletion, + OpenAITextCompletion, OpenAITextPromptExecutionSettings, ) -from semantic_kernel.connectors.ai.open_ai.services.azure_text_completion import AzureTextCompletion -from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_completion import OpenAITextCompletion -from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.text_content import TextContent diff --git a/python/tests/integration/embeddings/test_embedding_service_base.py b/python/tests/integration/embeddings/test_embedding_service_base.py index 38704289c8fa..d88706c17167 100644 --- a/python/tests/integration/embeddings/test_embedding_service_base.py +++ b/python/tests/integration/embeddings/test_embedding_service_base.py @@ -5,35 +5,29 @@ from azure.identity import DefaultAzureCredential from openai import AsyncAzureOpenAI -from semantic_kernel.connectors.ai.azure_ai_inference.azure_ai_inference_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.azure_ai_inference import ( AzureAIInferenceEmbeddingPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.azure_ai_inference.services.azure_ai_inference_text_embedding import ( AzureAIInferenceTextEmbedding, ) -from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import ( - BedrockEmbeddingPromptExecutionSettings, -) -from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_embedding import BedrockTextEmbedding +from semantic_kernel.connectors.ai.bedrock import BedrockEmbeddingPromptExecutionSettings, BedrockTextEmbedding from semantic_kernel.connectors.ai.embeddings.embedding_generator_base import EmbeddingGeneratorBase -from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.google.google_ai import ( GoogleAIEmbeddingPromptExecutionSettings, + GoogleAITextEmbedding, ) -from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_text_embedding import GoogleAITextEmbedding -from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_text_embedding import VertexAITextEmbedding -from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.google.vertex_ai import ( VertexAIEmbeddingPromptExecutionSettings, + VertexAITextEmbedding, ) -from semantic_kernel.connectors.ai.hugging_face.services.hf_text_embedding import HuggingFaceTextEmbedding -from semantic_kernel.connectors.ai.mistral_ai.services.mistral_ai_text_embedding import MistralAITextEmbedding -from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaEmbeddingPromptExecutionSettings -from semantic_kernel.connectors.ai.ollama.services.ollama_text_embedding import OllamaTextEmbedding -from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import ( +from semantic_kernel.connectors.ai.hugging_face import HuggingFaceTextEmbedding +from semantic_kernel.connectors.ai.mistral_ai import MistralAITextEmbedding +from semantic_kernel.connectors.ai.ollama import OllamaEmbeddingPromptExecutionSettings, OllamaTextEmbedding +from semantic_kernel.connectors.ai.open_ai import ( + AzureOpenAISettings, + AzureTextEmbedding, OpenAIEmbeddingPromptExecutionSettings, + OpenAITextEmbedding, ) -from semantic_kernel.connectors.ai.open_ai.services.azure_text_embedding import AzureTextEmbedding -from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_embedding import OpenAITextEmbedding -from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token from tests.integration.utils import is_service_setup_for_testing, is_test_running_on_supported_platforms From 6cc6822a70b880b928f196076e702c4ddb451dd2 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:27:47 -0800 Subject: [PATCH 4/5] .Net: Improved example with telemetry in filters for streaming scenario (#9775) ### Motivation and Context Improved an example with telemetry in filters for streaming scenario to return streaming chunks from filter to operation's origin and logging the result at the same time. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- .../Filtering/TelemetryWithFilters.cs | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/dotnet/samples/Concepts/Filtering/TelemetryWithFilters.cs b/dotnet/samples/Concepts/Filtering/TelemetryWithFilters.cs index 0b5938d1761c..6db70de0b87a 100644 --- a/dotnet/samples/Concepts/Filtering/TelemetryWithFilters.cs +++ b/dotnet/samples/Concepts/Filtering/TelemetryWithFilters.cs @@ -147,7 +147,17 @@ public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, F logger.LogInformation("Function {FunctionName} succeeded.", context.Function.Name); - await this.LogFunctionResultAsync(context); + if (context.IsStreaming) + { + // Overriding the result in a streaming scenario enables the filter to stream chunks + // back to the operation's origin without interrupting the data flow. + var enumerable = context.Result.GetValue>(); + context.Result = new FunctionResult(context.Result, ProcessFunctionResultStreamingAsync(enumerable!)); + } + else + { + ProcessFunctionResult(context.Result); + } } catch (Exception exception) { @@ -167,34 +177,43 @@ public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, F } } - private async Task LogFunctionResultAsync(FunctionInvocationContext context) + private void ProcessFunctionResult(FunctionResult functionResult) + { + string? result = functionResult.GetValue(); + object? usage = functionResult.Metadata?["Usage"]; + + if (!string.IsNullOrWhiteSpace(result)) + { + logger.LogTrace("Function result: {Result}", result); + } + + if (logger.IsEnabled(LogLevel.Information) && usage is not null) + { + logger.LogInformation("Usage: {Usage}", JsonSerializer.Serialize(usage)); + } + } + + private async IAsyncEnumerable ProcessFunctionResultStreamingAsync(IAsyncEnumerable data) { - string? result = null; object? usage = null; - if (context.IsStreaming) + var stringBuilder = new StringBuilder(); + + await foreach (var item in data) { - var stringBuilder = new StringBuilder(); + yield return item; - await foreach (var item in context.Result.GetValue>()!) + if (item.Content is not null) { - if (item.Content is not null) - { - stringBuilder.Append(item.Content); - } - - usage = item.Metadata?["Usage"]; + stringBuilder.Append(item.Content); } - result = stringBuilder.ToString(); - } - else - { - result = context.Result.GetValue(); - usage = context.Result.Metadata?["Usage"]; + usage = item.Metadata?["Usage"]; } - if (result is not null) + var result = stringBuilder.ToString(); + + if (!string.IsNullOrWhiteSpace(result)) { logger.LogTrace("Function result: {Result}", result); } From d8acb7528859b897bb661896aeb2b79bd3c00e7d Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:28:45 -0800 Subject: [PATCH 5/5] .Net: Removed experimental flags in Filters (#9774) ### Motivation and Context Closes: https://github.com/microsoft/semantic-kernel/issues/9499 1. Removed `Experimental` flag from `AutoFunctionInvocationFilter` classes and properties. 2. Removed `Experimental` flag from `IsStreaming` boolean property in filter context models. 3. Added a unit test to verify function sequence index property behavior. ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --- .../Core/AutoFunctionInvocationFilterTests.cs | 35 +++++++++++++++++++ .../AutoFunctionInvocationContext.cs | 2 -- .../IAutoFunctionInvocationFilter.cs | 2 -- .../Function/FunctionInvocationContext.cs | 2 -- .../Filters/Prompt/PromptRenderContext.cs | 3 +- .../src/SemanticKernel.Abstractions/Kernel.cs | 1 - 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs index e15b1f74b042..3fa17f593a4b 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs @@ -78,6 +78,41 @@ public async Task FiltersAreExecutedCorrectlyAsync() Assert.Equal("Test chat response", result.ToString()); } + [Fact] + public async Task FunctionSequenceIndexIsCorrectForConcurrentCallsAsync() + { + // Arrange + List functionSequenceNumbers = []; + List expectedFunctionSequenceNumbers = [0, 1, 0, 1]; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() + { + AllowParallelCalls = true, + AllowConcurrentInvocation = true + }) + })); + + // Assert + Assert.Equal(expectedFunctionSequenceNumbers, functionSequenceNumbers); + } + [Fact] public async Task FiltersAreExecutedCorrectlyOnStreamingAsync() { diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 68be900e1389..d943cff4fe89 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; using System.Threading; using Microsoft.SemanticKernel.ChatCompletion; @@ -9,7 +8,6 @@ namespace Microsoft.SemanticKernel; /// /// Class with data related to automatic function invocation. /// -[Experimental("SKEXP0001")] public class AutoFunctionInvocationContext { /// diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/IAutoFunctionInvocationFilter.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/IAutoFunctionInvocationFilter.cs index 92d293b7a4b7..911425e239b1 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/IAutoFunctionInvocationFilter.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/IAutoFunctionInvocationFilter.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; namespace Microsoft.SemanticKernel; @@ -11,7 +10,6 @@ namespace Microsoft.SemanticKernel; /// /// Interface for filtering actions during automatic function invocation. /// -[Experimental("SKEXP0001")] public interface IAutoFunctionInvocationFilter { /// diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/Function/FunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/Function/FunctionInvocationContext.cs index a358c1a3d22f..002101362ee4 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/Function/FunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/Function/FunctionInvocationContext.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; using System.Threading; namespace Microsoft.SemanticKernel; @@ -38,7 +37,6 @@ internal FunctionInvocationContext(Kernel kernel, KernelFunction function, Kerne /// /// Boolean flag which indicates whether a filter is invoked within streaming or non-streaming mode. /// - [Experimental("SKEXP0001")] public bool IsStreaming { get; init; } /// diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/Prompt/PromptRenderContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/Prompt/PromptRenderContext.cs index 2b04e9afc540..da16264a9fc7 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/Prompt/PromptRenderContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/Prompt/PromptRenderContext.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; using System.Threading; + namespace Microsoft.SemanticKernel; /// @@ -37,7 +37,6 @@ internal PromptRenderContext(Kernel kernel, KernelFunction function, KernelArgum /// /// Boolean flag which indicates whether a filter is invoked within streaming or non-streaming mode. /// - [Experimental("SKEXP0001")] public bool IsStreaming { get; init; } /// diff --git a/dotnet/src/SemanticKernel.Abstractions/Kernel.cs b/dotnet/src/SemanticKernel.Abstractions/Kernel.cs index f275eca325d3..9f53ddc93a7f 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Kernel.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Kernel.cs @@ -144,7 +144,6 @@ public Kernel Clone() => /// /// Gets the collection of auto function invocation filters available through the kernel. /// - [Experimental("SKEXP0001")] public IList AutoFunctionInvocationFilters => this._autoFunctionInvocationFilters ?? Interlocked.CompareExchange(ref this._autoFunctionInvocationFilters, [], null) ??