Skip to content

Commit c4c7464

Browse files
authoredApr 1, 2024··
Python: Introduce operations to handle OpenAI plugins, improve OpenAPI plugins, and allow for auth (#5695)
### Motivation and Context Python had the ability to handle operations for OpenAPI plugins, but at a basic level. It did not have the ability to handle OpenAI plugins, nor did it allow the user to configure an authentication callback. This PR brings in that functionality. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description This PR: - Allows a user to handle OpenAI plugins with/without auth. Closes #5355 - Improves the functionality around handling OpenAPI specs. - Introduces an auth callback mechanism to allow for REST API calls to use auth - Introduces OpenAI/OpenAPI function execution settings. - Provides two new kernel examples that show how to handle a simple OpenAI plugin (Klarna) as well as a more complex plugin (Azure Key Vault) that requires auth. - Updates unit tests. <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [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 😄

27 files changed

+1406
-335
lines changed
 

‎python/poetry.lock

+16-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎python/pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ defusedxml = "^0.7.1"
3434
pybars4 = "^0.9.13"
3535
jinja2 = "^3.1.3"
3636
nest-asyncio = "^1.6.0"
37-
eval_type_backport = { version = "^0.1.3", markers = "python_version < '3.9'" }
37+
eval_type_backport = { version = "^0.1.3", markers = "python_version < '3.10'" }
3838

3939
# Optional dependencies
4040
ipykernel = { version = "^6.21.1", optional = true}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import Dict, Optional
7+
8+
import httpx
9+
from aiohttp import ClientSession
10+
11+
from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationType
12+
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
13+
OpenAIFunctionExecutionParameters,
14+
)
15+
from semantic_kernel.functions.kernel_plugin import KernelPlugin
16+
from semantic_kernel.kernel import Kernel
17+
from semantic_kernel.utils.settings import azure_key_vault_settings_from_dot_env
18+
19+
20+
async def add_secret_to_key_vault(kernel: Kernel, plugin: KernelPlugin):
21+
"""Adds a secret to the Azure Key Vault."""
22+
result = await kernel.invoke(
23+
functions=plugin["SetSecret"],
24+
path_params={"secret-name": "Foo"},
25+
query_params={"api-version": "7.0"},
26+
request_body={"value": "Bar", "enabled": True},
27+
headers={},
28+
)
29+
30+
print(f"Secret added to Key Vault: {result}")
31+
32+
33+
async def get_secret_from_key_vault(kernel: Kernel, plugin: KernelPlugin):
34+
"""Gets a secret from the Azure Key Vault."""
35+
result = await kernel.invoke(
36+
functions=plugin["GetSecret"],
37+
path_params={"secret-name ": "Foo"},
38+
query_params={"api-version": "7.0"},
39+
headers={},
40+
)
41+
42+
print(f"Secret retrieved from Key Vault: {result}")
43+
44+
45+
class OpenAIAuthenticationProvider:
46+
"""A Sample Authentication Provider for an OpenAI/OpenAPI plugin"""
47+
48+
def __init__(
49+
self, oauth_values: Optional[Dict[str, Dict[str, str]]] = None, credentials: Optional[Dict[str, str]] = None
50+
):
51+
"""Initializes the OpenAIAuthenticationProvider."""
52+
self.oauth_values = oauth_values or {}
53+
self.credentials = credentials or {}
54+
55+
async def authenticate_request(
56+
self,
57+
plugin_name: str,
58+
openai_auth_config: OpenAIAuthenticationType,
59+
**kwargs,
60+
) -> dict[str, str] | None:
61+
"""An example of how to authenticate a request as part of an auth callback."""
62+
if openai_auth_config.type == OpenAIAuthenticationType.NoneType:
63+
return
64+
65+
scheme = ""
66+
credential = ""
67+
68+
if openai_auth_config.type == OpenAIAuthenticationType.OAuth:
69+
if not openai_auth_config.authorization_url:
70+
raise ValueError("Authorization URL is required for OAuth.")
71+
72+
domain = openai_auth_config.authorization_url.host
73+
domain_oauth_values = self.oauth_values.get(domain)
74+
75+
if not domain_oauth_values:
76+
raise ValueError("No OAuth values found for the provided authorization URL.")
77+
78+
values = domain_oauth_values | {"scope": openai_auth_config.scope or ""}
79+
80+
content_type = openai_auth_config.authorization_content_type or "application/x-www-form-urlencoded"
81+
async with ClientSession() as session:
82+
authorization_url = str(openai_auth_config.authorization_url)
83+
84+
if content_type == "application/x-www-form-urlencoded":
85+
response = await session.post(authorization_url, data=values)
86+
elif content_type == "application/json":
87+
response = await session.post(authorization_url, json=values)
88+
else:
89+
raise ValueError(f"Unsupported authorization content type: {content_type}")
90+
91+
response.raise_for_status()
92+
93+
token_response = await response.json()
94+
scheme = token_response.get("token_type", "")
95+
credential = token_response.get("access_token", "")
96+
97+
else:
98+
token = openai_auth_config.verification_tokens.get(plugin_name, "")
99+
scheme = openai_auth_config.authorization_type.value
100+
credential = token
101+
102+
auth_header = f"{scheme} {credential}"
103+
return {"Authorization": auth_header}
104+
105+
106+
async def main():
107+
# This example demonstrates how to connect an Azure Key Vault plugin to the Semantic Kernel.
108+
# To use this example, there are a few requirements:
109+
# 1. Register a client application with the Microsoft identity platform.
110+
# https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app
111+
#
112+
# 2. Create an Azure Key Vault
113+
# https://learn.microsoft.com/en-us/azure/key-vault/general/quick-create-portal
114+
# Please make sure to configure the AKV with a Vault Policy, instead of the default RBAC policy
115+
# This is because you will need to assign the Key Vault access policy to the client application you
116+
# registered in step 1. You should give the client application the "Get," "List," and "Set"
117+
# permissions for secrets.
118+
#
119+
# 3. Set your Key Vault endpoint, client ID, and client secret as user secrets using in your .env file:
120+
# AZURE_KEY_VAULT_ENDPOINT = ""
121+
# AZURE_KEY_VAULT_CLIENT_ID = ""
122+
# AZURE_KEY_VAULT_CLIENT_SECRET = ""
123+
#
124+
# 4. Replace your tenant ID with the "TENANT_ID" placeholder in
125+
# python/samples/kernel-syntax-examples/resources/akv-openai.json
126+
127+
endpoint, client_id, client_secret = azure_key_vault_settings_from_dot_env()
128+
129+
authentication_provider = OpenAIAuthenticationProvider(
130+
{
131+
"login.microsoftonline.com": {
132+
"client_id": client_id,
133+
"client_secret": client_secret,
134+
"grant_type": "client_credentials",
135+
}
136+
}
137+
)
138+
139+
kernel = Kernel()
140+
141+
openai_spec_file = os.path.join(
142+
os.path.dirname(os.path.realpath(__file__)), "resources", "open_ai_plugins", "akv-openai.json"
143+
)
144+
with open(openai_spec_file, "r") as file:
145+
openai_spec = file.read()
146+
147+
http_client = httpx.AsyncClient()
148+
149+
plugin = await kernel.import_plugin_from_openai(
150+
plugin_name="AzureKeyVaultPlugin",
151+
plugin_str=openai_spec,
152+
execution_parameters=OpenAIFunctionExecutionParameters(
153+
http_client=http_client,
154+
auth_callback=authentication_provider.authenticate_request,
155+
server_url_override=endpoint,
156+
enable_dynamic_payload=True,
157+
),
158+
)
159+
160+
await add_secret_to_key_vault(kernel, plugin)
161+
162+
163+
if __name__ == "__main__":
164+
import asyncio
165+
166+
asyncio.run(main())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import asyncio
4+
5+
from semantic_kernel.kernel import Kernel
6+
7+
8+
async def main():
9+
10+
# This is an example of how to import a plugin from OpenAI and invoke a function from the plugin
11+
# It does not require authentication
12+
13+
kernel = Kernel()
14+
plugin = await kernel.import_plugin_from_openai(
15+
plugin_name="Klarna",
16+
plugin_url="https://www.klarna.com/.well-known/ai-plugin.json",
17+
)
18+
19+
# Query parameters for the function
20+
# q = Category or product that needs to be searched for
21+
# size = Number of results to be returned
22+
# budget = Maximum price of the matching product in Local Currency
23+
# countryCode = currently, only US, GB, DE, SE, and DK are supported
24+
query_params = {"q": "Laptop", "size": "3", "budget": "200", "countryCode": "US"}
25+
26+
result = await kernel.invoke(
27+
plugin["productsUsingGET"], query_params=query_params, headers={}, path_params={}, request_body={}
28+
)
29+
30+
print(f"Function execution result: {str(result)}")
31+
32+
33+
if __name__ == "__main__":
34+
asyncio.run(main())

‎python/samples/kernel-syntax-examples/openapi_example/openapi.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
openapi: 3.0.0
1+
openapi: 3.1.0
22
info:
33
title: Test API
44
version: 1.0.0
@@ -41,4 +41,4 @@ paths:
4141
required: false
4242
schema:
4343
type: string
44-
description: The query parameter
44+
description: The query parameter
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import asyncio
22

33
import semantic_kernel as sk
4-
from semantic_kernel.connectors.openapi import register_openapi_plugin
4+
from semantic_kernel.functions.kernel_arguments import KernelArguments
55

6-
if __name__ == "__main__":
6+
7+
async def main():
78
"""Client"""
89
kernel = sk.Kernel()
910

10-
openapi_plugin = register_openapi_plugin(kernel, "openApiPlugin", "openapi.yaml")
11-
12-
context_variables = sk.ContextVariables(
13-
variables={
14-
"request_body": '{"input": "hello world"}',
15-
"path_params": '{"name": "mark"}',
16-
"query_params": '{"q": "0.7"}',
17-
"headers": '{"Content-Type": "application/json", "Header": "example"}',
18-
}
19-
)
20-
result = asyncio.run(
21-
# Call the function defined in openapi.yaml
22-
openapi_plugin["helloWorld"].invoke(variables=context_variables)
11+
openapi_plugin = kernel.import_plugin_from_openapi(
12+
plugin_name="openApiPlugin", openapi_document_path="./openapi.yaml"
2313
)
14+
15+
arguments = {
16+
"request_body": '{"input": "hello world"}',
17+
"path_params": '{"name": "mark"}',
18+
"query_params": '{"q": "0.7"}',
19+
"headers": '{"Content-Type": "application/json", "Header": "example"}',
20+
}
21+
22+
kernel_arguments = KernelArguments(**arguments)
23+
24+
result = kernel.invoke(openapi_plugin["helloWorld"], arguments=kernel_arguments)
25+
2426
print(result)
27+
28+
29+
if __name__ == "__main__":
30+
asyncio.run(main())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"schema_version": "v1",
3+
"name_for_model": "AzureKeyVault",
4+
"name_for_human": "AzureKeyVault",
5+
"description_for_model": "An Azure Key Vault plugin for interacting with secrets.",
6+
"description_for_human": "An Azure Key Vault plugin for interacting with secrets.",
7+
"auth": {
8+
"type": "oauth",
9+
"scope": "https://vault.azure.net/.default",
10+
"authorization_url": "https://login.microsoftonline.com/e80e3e25-bb8d-4b4d-ab3f-b91669dd8ae4/oauth2/v2.0/token",
11+
"authorization_content_type": "application/x-www-form-urlencoded"
12+
},
13+
"api": {
14+
"type": "openapi",
15+
"url": "file:///./python/samples/kernel-syntax-examples/resources/open_ai_plugins/akv-openapi.yaml"
16+
},
17+
"logo_url": "",
18+
"contact_email": "",
19+
"legal_info_url": ""
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Azure Key Vault [Sample]
4+
description: "A sample connector for the Azure Key Vault service. This connector is built for the Azure Key Vault REST API. You can see the details of the API here: https://docs.microsoft.com/rest/api/keyvault/."
5+
version: "1.0"
6+
servers:
7+
- url: https://my-key-vault.vault.azure.net/
8+
paths:
9+
/secrets/{secret-name}:
10+
get:
11+
summary: Get secret
12+
description: "Get a specified secret from a given key vault. For details, see: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret/get-secret."
13+
operationId: GetSecret
14+
parameters:
15+
- name: secret-name
16+
in: path
17+
required: true
18+
schema:
19+
type: string
20+
- name: api-version
21+
in: query
22+
required: true
23+
schema:
24+
type: string
25+
default: "7.0"
26+
x-ms-visibility: internal
27+
responses:
28+
'200':
29+
description: default
30+
content:
31+
application/json:
32+
schema:
33+
type: object
34+
properties:
35+
attributes:
36+
type: object
37+
properties:
38+
created:
39+
type: integer
40+
format: int32
41+
description: created
42+
enabled:
43+
type: boolean
44+
description: enabled
45+
recoverylevel:
46+
type: string
47+
description: recoverylevel
48+
updated:
49+
type: integer
50+
format: int32
51+
description: updated
52+
id:
53+
type: string
54+
description: id
55+
value:
56+
type: string
57+
format: byte
58+
description: value
59+
put:
60+
summary: Create or update secret value
61+
description: "Sets a secret in a specified key vault. This operation adds a secret to the Azure Key Vault. If the named secret already exists, Azure Key Vault creates a new version of that secret. This operation requires the secrets/set permission. For details, see: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/set-secret/set-secret."
62+
operationId: SetSecret
63+
parameters:
64+
- name: secret-name
65+
in: path
66+
required: true
67+
schema:
68+
type: string
69+
- name: api-version
70+
in: query
71+
required: true
72+
schema:
73+
type: string
74+
default: "7.0"
75+
x-ms-visibility: internal
76+
requestBody:
77+
required: true
78+
content:
79+
application/json:
80+
schema:
81+
type: object
82+
properties:
83+
attributes:
84+
type: object
85+
properties:
86+
enabled:
87+
type: boolean
88+
description: Determines whether the object is enabled.
89+
value:
90+
type: string
91+
description: The value of the secret.
92+
required:
93+
- value
94+
responses:
95+
'200':
96+
description: default
97+
content:
98+
application/json:
99+
schema:
100+
type: object
101+
properties:
102+
attributes:
103+
type: object
104+
properties:
105+
created:
106+
type: integer
107+
format: int32
108+
description: created
109+
enabled:
110+
type: boolean
111+
description: enabled
112+
recoverylevel:
113+
type: string
114+
description: recoverylevel
115+
updated:
116+
type: integer
117+
format: int32
118+
description: updated
119+
id:
120+
type: string
121+
description: id
122+
value:
123+
type: string
124+
description: value
125+
components:
126+
securitySchemes:
127+
oauth2_auth:
128+
type: oauth2
129+
flows:
130+
authorizationCode:
131+
authorizationUrl: https://login.windows.net/common/oauth2/authorize
132+
tokenUrl: https://login.windows.net/common/oauth2/token
133+
scopes: {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from semantic_kernel.connectors.openai_plugin.openai_authentication_config import (
4+
OpenAIAuthenticationConfig,
5+
)
6+
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
7+
OpenAIFunctionExecutionParameters,
8+
)
9+
from semantic_kernel.connectors.openai_plugin.openai_utils import (
10+
OpenAIUtils,
11+
)
12+
13+
__all__ = [
14+
"OpenAIUtils",
15+
"OpenAIFunctionExecutionParameters",
16+
"OpenAIAuthenticationConfig",
17+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
from enum import Enum
6+
7+
from pydantic import HttpUrl
8+
9+
from semantic_kernel.kernel_pydantic import KernelBaseModel
10+
11+
12+
class OpenAIAuthenticationType(str, Enum):
13+
OAuth = "oauth"
14+
NoneType = "none"
15+
16+
17+
class OpenAIAuthorizationType(str, Enum):
18+
Bearer = "Bearer"
19+
Basic = "Basic"
20+
21+
22+
class OpenAIAuthenticationConfig(KernelBaseModel):
23+
"""OpenAI authentication configuration."""
24+
25+
type: OpenAIAuthenticationType | None = None
26+
authorization_type: OpenAIAuthorizationType | None = None
27+
client_url: HttpUrl | None = None
28+
authorization_url: HttpUrl | None = None
29+
authorization_content_type: str | None = None
30+
scope: str | None = None
31+
verification_tokens: dict[str, str] | None = None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Awaitable, Callable
6+
7+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
8+
OpenAPIFunctionExecutionParameters,
9+
)
10+
11+
OpenAIAuthCallbackType = Callable[..., Awaitable[Any]]
12+
13+
14+
class OpenAIFunctionExecutionParameters(OpenAPIFunctionExecutionParameters):
15+
"""OpenAI function execution parameters."""
16+
17+
auth_callback: OpenAIAuthCallbackType | None = None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from semantic_kernel.exceptions.function_exceptions import PluginInitializationError
8+
9+
logger: logging.Logger = logging.getLogger(__name__)
10+
11+
12+
class OpenAIUtils:
13+
"""Utility functions for OpenAI plugins."""
14+
15+
@staticmethod
16+
def parse_openai_manifest_for_openapi_spec_url(plugin_json):
17+
"""Extract the OpenAPI Spec URL from the plugin JSON."""
18+
19+
try:
20+
api_type = plugin_json["api"]["type"]
21+
except KeyError as ex:
22+
raise PluginInitializationError("OpenAI manifest is missing the API type.") from ex
23+
24+
if api_type != "openapi":
25+
raise PluginInitializationError("OpenAI manifest is not of type OpenAPI.")
26+
27+
try:
28+
return plugin_json["api"]["url"]
29+
except KeyError as ex:
30+
raise PluginInitializationError("OpenAI manifest is missing the OpenAPI Spec URL.") from ex

‎python/semantic_kernel/connectors/openapi/__init__.py

-5
This file was deleted.

‎python/semantic_kernel/connectors/openapi/kernel_openapi.py

-304
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
4+
OpenAPIFunctionExecutionParameters,
5+
)
6+
from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenAPIPlugin
7+
8+
__all__ = ["OpenAPIPlugin", "OpenAPIFunctionExecutionParameters"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Awaitable, Callable, List
6+
from urllib.parse import urlparse
7+
8+
from pydantic import Field
9+
10+
from semantic_kernel.kernel_pydantic import KernelBaseModel
11+
12+
AuthCallbackType = Callable[..., Awaitable[Any]]
13+
14+
15+
class OpenAPIFunctionExecutionParameters(KernelBaseModel):
16+
"""OpenAPI function execution parameters."""
17+
18+
http_client: Any | None = None
19+
auth_callback: AuthCallbackType | None = None
20+
server_url_override: str | None = None
21+
ignore_non_compliant_errors: bool = False
22+
user_agent: str | None = None
23+
enable_dynamic_payload: bool = True
24+
enable_payload_namespacing: bool = False
25+
operations_to_exclude: List[str] = Field(default_factory=list)
26+
27+
def model_post_init(self, __context: Any) -> None:
28+
from semantic_kernel.connectors.telemetry import HTTP_USER_AGENT
29+
30+
if self.server_url_override:
31+
parsed_url = urlparse(self.server_url_override)
32+
if not parsed_url.scheme or not parsed_url.netloc:
33+
raise ValueError(f"Invalid server_url_override: {self.server_url_override}")
34+
35+
if not self.user_agent:
36+
self.user_agent = HTTP_USER_AGENT
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import sys
8+
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping
9+
10+
if sys.version_info >= (3, 9):
11+
from typing import Annotated
12+
else:
13+
from typing_extensions import Annotated
14+
from urllib.parse import urljoin, urlparse, urlunparse
15+
16+
import aiohttp
17+
import requests
18+
from openapi_core import Spec, unmarshal_request
19+
from openapi_core.contrib.requests import RequestsOpenAPIRequest
20+
from openapi_core.exceptions import OpenAPIError
21+
from prance import ResolvingParser
22+
23+
from semantic_kernel.connectors.ai.open_ai.const import (
24+
USER_AGENT,
25+
)
26+
from semantic_kernel.exceptions import ServiceInvalidRequestError
27+
from semantic_kernel.functions.kernel_function_decorator import kernel_function
28+
from semantic_kernel.functions.kernel_plugin import KernelPlugin
29+
30+
if TYPE_CHECKING:
31+
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
32+
OpenAIFunctionExecutionParameters,
33+
)
34+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
35+
OpenAPIFunctionExecutionParameters,
36+
)
37+
38+
logger: logging.Logger = logging.getLogger(__name__)
39+
40+
41+
class PreparedRestApiRequest:
42+
def __init__(self, method: str, url: str, params=None, headers=None, request_body=None):
43+
self.method = method
44+
self.url = url
45+
self.params = params
46+
self.headers = headers
47+
self.request_body = request_body
48+
49+
def __repr__(self):
50+
return (
51+
"PreparedRestApiRequest("
52+
f"method={self.method}, "
53+
f"url={self.url}, "
54+
f"params={self.params}, "
55+
f"headers={self.headers}, "
56+
f"request_body={self.request_body})"
57+
)
58+
59+
def validate_request(self, spec: Spec):
60+
"""Validate the request against the OpenAPI spec."""
61+
request = requests.Request(
62+
self.method,
63+
self.url,
64+
params=self.params,
65+
headers=self.headers,
66+
json=self.request_body,
67+
)
68+
openapi_request = RequestsOpenAPIRequest(request=request)
69+
try:
70+
unmarshal_request(openapi_request, spec=spec)
71+
return True
72+
except OpenAPIError as e:
73+
logger.debug(f"Error validating request: {e}", exc_info=True)
74+
return False
75+
76+
77+
class RestApiOperation:
78+
def __init__(
79+
self,
80+
id: str,
81+
method: str,
82+
server_url: str,
83+
path: str,
84+
summary: str | None = None,
85+
description: str | None = None,
86+
params: Mapping[str, str] | None = None,
87+
request_body: Mapping[str, str] | None = None,
88+
):
89+
self.id = id
90+
self.method = method.upper()
91+
self.server_url = server_url
92+
self.path = path
93+
self.summary = summary
94+
self.description = description
95+
self.params = params
96+
self.request_body = request_body
97+
98+
def url_join(self, base_url, path):
99+
"""Join a base URL and a path, correcting for any missing slashes."""
100+
parsed_base = urlparse(base_url)
101+
if not parsed_base.path.endswith("/"):
102+
base_path = parsed_base.path + "/"
103+
else:
104+
base_path = parsed_base.path
105+
full_path = urljoin(base_path, path.lstrip("/"))
106+
return urlunparse(parsed_base._replace(path=full_path))
107+
108+
def prepare_request(
109+
self,
110+
path_params: dict[str, Any] | None = None,
111+
query_params: dict[str, Any] | None = None,
112+
headers: dict[str, Any] | None = None,
113+
request_body: Any | None = None,
114+
) -> PreparedRestApiRequest:
115+
"""Prepare the request for this operation.
116+
117+
Args:
118+
path_params: A dictionary of path parameters
119+
query_params: A dictionary of query parameters
120+
headers: A dictionary of headers
121+
request_body: The payload of the request
122+
123+
Returns:
124+
A PreparedRestApiRequest object
125+
"""
126+
from semantic_kernel.connectors.telemetry import HTTP_USER_AGENT
127+
128+
path = self.path
129+
if path_params:
130+
path = path.format(**path_params)
131+
132+
url = self.url_join(self.server_url, path)
133+
134+
processed_query_params = {}
135+
processed_headers = headers if headers is not None else {}
136+
for param in self.params:
137+
param_name = param["name"]
138+
param_schema = param["schema"]
139+
param_default = param_schema.get("default", None)
140+
141+
if param["in"] == "query":
142+
if query_params and param_name in query_params:
143+
processed_query_params[param_name] = query_params[param_name]
144+
elif param["schema"] and "default" in param["schema"] is not None:
145+
processed_query_params[param_name] = param_default
146+
elif param["in"] == "header":
147+
if headers and param_name in headers:
148+
processed_headers[param_name] = headers[param_name]
149+
elif param_default is not None:
150+
processed_headers[param_name] = param_default
151+
elif param["in"] == "path":
152+
if not path_params or param_name not in path_params:
153+
raise ServiceInvalidRequestError(f"Required path parameter {param_name} not provided")
154+
155+
processed_payload = None
156+
if self.request_body and (self.method == "POST" or self.method == "PUT"):
157+
if request_body is None and "required" in self.request_body and self.request_body["required"]:
158+
raise ServiceInvalidRequestError("Payload is required but was not provided")
159+
content = self.request_body["content"]
160+
content_type = list(content.keys())[0]
161+
processed_headers["Content-Type"] = content_type
162+
processed_payload = request_body
163+
164+
processed_headers[USER_AGENT] = " ".join((HTTP_USER_AGENT, processed_headers.get(USER_AGENT, ""))).rstrip()
165+
166+
req = PreparedRestApiRequest(
167+
method=self.method,
168+
url=url,
169+
params=processed_query_params,
170+
headers=processed_headers,
171+
request_body=processed_payload,
172+
)
173+
return req
174+
175+
def __repr__(self):
176+
return (
177+
"RestApiOperation("
178+
f"id={self.id}, "
179+
f"method={self.method}, "
180+
f"server_url={self.server_url}, "
181+
f"path={self.path}, "
182+
f"params={self.params}, "
183+
f"request_body={self.request_body}, "
184+
f"summary={self.summary}, "
185+
f"description={self.description})"
186+
)
187+
188+
189+
class OpenApiParser:
190+
"""
191+
NOTE: SK Python only supports the OpenAPI Spec >=3.0
192+
193+
Import an OpenAPI file.
194+
195+
Args:
196+
openapi_file: The path to the OpenAPI file which can be local or a URL.
197+
198+
Returns:
199+
The parsed OpenAPI file
200+
201+
202+
:param openapi_file: The path to the OpenAPI file which can be local or a URL.
203+
:return: The parsed OpenAPI file
204+
"""
205+
206+
def parse(self, openapi_document: str) -> Any | dict[str, Any] | None:
207+
"""Parse the OpenAPI document."""
208+
parser = ResolvingParser(openapi_document)
209+
return parser.specification
210+
211+
def create_rest_api_operations(
212+
self,
213+
parsed_document: Any,
214+
execution_settings: "OpenAIFunctionExecutionParameters" | "OpenAPIFunctionExecutionParameters" | None = None,
215+
) -> Dict[str, RestApiOperation]:
216+
"""Create the REST API Operations from the parsed OpenAPI document.
217+
218+
Args:
219+
parsed_document: The parsed OpenAPI document
220+
execution_settings: The execution settings
221+
222+
Returns:
223+
A dictionary of RestApiOperation objects keyed by operationId
224+
"""
225+
paths = parsed_document.get("paths", {})
226+
request_objects = {}
227+
228+
base_url = "/"
229+
servers = parsed_document.get("servers", [])
230+
base_url = servers[0].get("url") if servers else "/"
231+
232+
if execution_settings and execution_settings.server_url_override:
233+
base_url = execution_settings.server_url_override
234+
235+
for path, methods in paths.items():
236+
for method, details in methods.items():
237+
request_method = method.lower()
238+
239+
parameters = details.get("parameters", [])
240+
operationId = details.get("operationId", path + "_" + request_method)
241+
summary = details.get("summary", None)
242+
description = details.get("description", None)
243+
244+
rest_api_operation = RestApiOperation(
245+
id=operationId,
246+
method=request_method,
247+
server_url=base_url,
248+
path=path,
249+
params=parameters,
250+
request_body=details.get("requestBody", None),
251+
summary=summary,
252+
description=description,
253+
)
254+
255+
request_objects[operationId] = rest_api_operation
256+
return request_objects
257+
258+
259+
class OpenApiRunner:
260+
"""The OpenApiRunner that runs the operations defined in the OpenAPI manifest"""
261+
262+
def __init__(
263+
self,
264+
parsed_openapi_document: Mapping[str, str],
265+
auth_callback: Callable[[Dict[str, str]], Dict[str, str]] | None = None,
266+
):
267+
self.spec = Spec.from_dict(parsed_openapi_document)
268+
self.auth_callback = auth_callback
269+
270+
async def run_operation(
271+
self,
272+
operation: RestApiOperation,
273+
path_params: Dict[str, str] | None = None,
274+
query_params: Dict[str, str] | None = None,
275+
headers: Dict[str, str] | None = None,
276+
request_body: str | Dict[str, str] | None = None,
277+
) -> str:
278+
"""Runs the operation defined in the OpenAPI manifest"""
279+
if headers is None:
280+
headers = {}
281+
282+
if self.auth_callback:
283+
headers_update = await self.auth_callback(headers=headers)
284+
headers.update(headers_update)
285+
286+
prepared_request = operation.prepare_request(
287+
path_params=path_params,
288+
query_params=query_params,
289+
headers=headers,
290+
request_body=request_body,
291+
)
292+
# TODO - figure out how to validate a request that has a dynamic API
293+
# against a spec that has a template path
294+
295+
async with aiohttp.ClientSession(raise_for_status=True) as session:
296+
async with session.request(
297+
prepared_request.method,
298+
prepared_request.url,
299+
params=prepared_request.params,
300+
headers=prepared_request.headers,
301+
json=prepared_request.request_body,
302+
) as response:
303+
return await response.text()
304+
305+
306+
class OpenAPIPlugin:
307+
@staticmethod
308+
def create(
309+
plugin_name: str,
310+
openapi_document_path: str,
311+
execution_settings: "OpenAIFunctionExecutionParameters" | "OpenAPIFunctionExecutionParameters" | None = None,
312+
) -> KernelPlugin:
313+
"""Creates an OpenAPI plugin
314+
315+
Args:
316+
plugin_name: The name of the plugin
317+
openapi_document_path: The OpenAPI document path, it must be a file path to the spec.
318+
execution_settings: The execution settings
319+
320+
Returns:
321+
The KernelPlugin
322+
"""
323+
parser = OpenApiParser()
324+
parsed_doc = parser.parse(openapi_document_path)
325+
operations = parser.create_rest_api_operations(parsed_doc, execution_settings=execution_settings)
326+
327+
auth_callback = None
328+
if execution_settings and execution_settings.auth_callback:
329+
auth_callback = execution_settings.auth_callback
330+
openapi_runner = OpenApiRunner(parsed_openapi_document=parsed_doc, auth_callback=auth_callback)
331+
332+
plugin = {}
333+
334+
def create_run_operation_function(runner: OpenApiRunner, operation: RestApiOperation):
335+
@kernel_function(
336+
description=operation.summary if operation.summary else operation.description,
337+
name=operation.id,
338+
)
339+
async def run_openapi_operation(
340+
path_params: Annotated[dict | str | None, "A dictionary of path parameters"] = None,
341+
query_params: Annotated[dict | str | None, "A dictionary of query parameters"] = None,
342+
headers: Annotated[dict | str | None, "A dictionary of headers"] = None,
343+
request_body: Annotated[dict | str | None, "A dictionary of the request body"] = None,
344+
) -> str:
345+
response = await runner.run_operation(
346+
operation,
347+
path_params=(
348+
json.loads(path_params)
349+
if isinstance(path_params, str)
350+
else path_params if path_params else None
351+
),
352+
query_params=(
353+
json.loads(query_params)
354+
if isinstance(query_params, str)
355+
else query_params if query_params else None
356+
),
357+
headers=json.loads(headers) if isinstance(headers, str) else headers if headers else None,
358+
request_body=(
359+
json.loads(request_body)
360+
if isinstance(request_body, str)
361+
else request_body if request_body else None
362+
),
363+
)
364+
return response
365+
366+
return run_openapi_operation
367+
368+
for operation_id, operation in operations.items():
369+
logger.info(f"Registering OpenAPI operation: {plugin_name}.{operation_id}")
370+
plugin[operation_id] = create_run_operation_function(openapi_runner, operation)
371+
return plugin
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from semantic_kernel.connectors.utils.document_loader import DocumentLoader
4+
5+
__all__ = ["DocumentLoader"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import logging
4+
from typing import Any, Callable, Optional
5+
6+
import httpx
7+
8+
from semantic_kernel.connectors.telemetry import HTTP_USER_AGENT
9+
10+
logger: logging.Logger = logging.getLogger(__name__)
11+
12+
13+
class DocumentLoader:
14+
15+
@staticmethod
16+
async def from_uri(
17+
url: str,
18+
http_client: httpx.AsyncClient,
19+
auth_callback: Optional[Callable[[Any], None]],
20+
user_agent: Optional[str] = HTTP_USER_AGENT,
21+
):
22+
"""Load the manifest from the given URL"""
23+
headers = {"User-Agent": user_agent}
24+
async with http_client as client:
25+
if auth_callback:
26+
await auth_callback(client, url)
27+
28+
logger.info(f"Importing document from {url}")
29+
30+
response = await client.get(url, headers=headers)
31+
response.raise_for_status()
32+
33+
return response.text

‎python/semantic_kernel/exceptions/kernel_exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class KernelPluginNotFoundError(KernelException):
2222
pass
2323

2424

25+
class KernelPluginInvalidConfigurationError(KernelException):
26+
pass
27+
28+
2529
class KernelFunctionNotFoundError(KernelException):
2630
pass
2731

@@ -41,4 +45,5 @@ class KernelInvokeException(KernelException):
4145
"KernelInvokeException",
4246
"KernelPluginNotFoundError",
4347
"KernelServiceNotFoundError",
48+
"KernelPluginInvalidConfigurationError",
4449
]

‎python/semantic_kernel/kernel.py

+109
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,36 @@
44
import glob
55
import importlib
66
import inspect
7+
import json
78
import logging
89
import os
910
from copy import copy
1011
from types import MethodType
1112
from typing import TYPE_CHECKING, Any, AsyncIterable, Callable, ItemsView, Literal, Type, TypeVar, Union
1213

14+
import httpx
1315
import yaml
1416
from pydantic import Field, field_validator
1517

1618
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
19+
from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationConfig
20+
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
21+
OpenAIFunctionExecutionParameters,
22+
)
23+
from semantic_kernel.connectors.openai_plugin.openai_utils import OpenAIUtils
24+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
25+
OpenAPIFunctionExecutionParameters,
26+
)
27+
from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenAPIPlugin
28+
from semantic_kernel.connectors.utils.document_loader import DocumentLoader
1729
from semantic_kernel.events import FunctionInvokedEventArgs, FunctionInvokingEventArgs
1830
from semantic_kernel.exceptions import (
1931
FunctionInitializationError,
2032
FunctionNameNotUniqueError,
2133
KernelFunctionAlreadyExistsError,
2234
KernelFunctionNotFoundError,
2335
KernelInvokeException,
36+
KernelPluginInvalidConfigurationError,
2437
KernelPluginNotFoundError,
2538
KernelServiceNotFoundError,
2639
PluginInitializationError,
@@ -656,6 +669,102 @@ def import_plugin_from_prompt_directory(self, parent_directory: str, plugin_dire
656669

657670
return KernelPlugin(name=plugin_directory_name, functions=functions)
658671

672+
async def import_plugin_from_openai(
673+
self,
674+
plugin_name: str,
675+
plugin_url: str | None = None,
676+
plugin_str: str | None = None,
677+
execution_parameters: OpenAIFunctionExecutionParameters | None = None,
678+
) -> KernelPlugin:
679+
"""Create a plugin from the Open AI manifest.
680+
681+
Args:
682+
plugin_name (str): The name of the plugin
683+
plugin_url (str | None): The URL of the plugin
684+
plugin_str (str | None): The JSON string of the plugin
685+
execution_parameters (OpenAIFunctionExecutionParameters | None): The execution parameters
686+
687+
Returns:
688+
KernelPlugin: The imported plugin
689+
690+
Raises:
691+
PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided
692+
"""
693+
694+
if execution_parameters is None:
695+
execution_parameters = OpenAIFunctionExecutionParameters()
696+
697+
validate_plugin_name(plugin_name)
698+
699+
if plugin_str is not None:
700+
# Load plugin from the provided JSON string/YAML string
701+
openai_manifest = plugin_str
702+
elif plugin_url is not None:
703+
# Load plugin from the URL
704+
http_client = execution_parameters.http_client if execution_parameters.http_client else httpx.AsyncClient()
705+
openai_manifest = await DocumentLoader.from_uri(
706+
url=plugin_url, http_client=http_client, auth_callback=None, user_agent=execution_parameters.user_agent
707+
)
708+
else:
709+
raise PluginInitializationError("Either plugin_url or plugin_json must be provided.")
710+
711+
try:
712+
plugin_json = json.loads(openai_manifest)
713+
openai_auth_config = OpenAIAuthenticationConfig(**plugin_json["auth"])
714+
except json.JSONDecodeError as ex:
715+
raise KernelPluginInvalidConfigurationError("Parsing of Open AI manifest for auth config failed.") from ex
716+
717+
# Modify the auth callback in execution parameters if it's provided
718+
if execution_parameters and execution_parameters.auth_callback:
719+
initial_auth_callback = execution_parameters.auth_callback
720+
721+
async def custom_auth_callback(**kwargs):
722+
return await initial_auth_callback(plugin_name, openai_auth_config, **kwargs)
723+
724+
execution_parameters.auth_callback = custom_auth_callback
725+
726+
try:
727+
openapi_spec_url = OpenAIUtils.parse_openai_manifest_for_openapi_spec_url(plugin_json)
728+
except PluginInitializationError as ex:
729+
raise KernelPluginInvalidConfigurationError(
730+
"Parsing of Open AI manifest for OpenAPI spec URL failed."
731+
) from ex
732+
733+
return self.import_plugin_from_openapi(
734+
plugin_name=plugin_name,
735+
openapi_document_path=openapi_spec_url,
736+
execution_settings=execution_parameters,
737+
)
738+
739+
def import_plugin_from_openapi(
740+
self,
741+
plugin_name: str,
742+
openapi_document_path: str,
743+
execution_settings: "OpenAIFunctionExecutionParameters" | "OpenAPIFunctionExecutionParameters" | None = None,
744+
) -> KernelPlugin:
745+
"""Create a plugin from an OpenAPI manifest.
746+
747+
Args:
748+
plugin_name (str): The name of the plugin
749+
openapi_document_path (str): The OpenAPI document path
750+
execution_settings (OpenAIFunctionExecutionParameters | OpenAPIFunctionExecutionParameters | None):
751+
The execution settings
752+
753+
Returns:
754+
KernelPlugin: The imported plugin
755+
"""
756+
validate_plugin_name(plugin_name)
757+
758+
if not openapi_document_path:
759+
raise PluginInitializationError("OpenAPI document path is required.")
760+
761+
plugin = OpenAPIPlugin.create(
762+
plugin_name=plugin_name,
763+
openapi_document_path=openapi_document_path,
764+
execution_settings=execution_settings,
765+
)
766+
return self.import_plugin_from_object(plugin, plugin_name)
767+
659768
def _validate_plugin_directory(self, parent_directory: str, plugin_directory_name: str) -> str:
660769
"""Validate the plugin name and that the plugin directory exists.
661770

‎python/semantic_kernel/utils/settings.py

+36
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,39 @@ def azure_aisearch_settings_from_dot_env_as_dict() -> Dict[str, str]:
296296
"""
297297
api_key, url, index_name = azure_aisearch_settings_from_dot_env(include_index_name=True)
298298
return {"key": api_key, "endpoint": url, "indexName": index_name}
299+
300+
301+
def azure_key_vault_settings_from_dot_env(
302+
include_client_id: bool = True, include_client_secret: bool = True
303+
) -> Tuple[str, Optional[str], Optional[str]]:
304+
"""
305+
Reads the Azure Key Vault environment variables for the .env file.
306+
307+
Returns:
308+
Tuple[str, str, str]: Azure Key Vault endpoint, the Azure Key Vault client ID, the Azure Key Vault client secret
309+
"""
310+
config = dotenv_values(".env")
311+
endpoint = config.get("AZURE_KEY_VAULT_ENDPOINT", None)
312+
client_id = config.get("AZURE_KEY_VAULT_CLIENT_ID", None)
313+
client_secret = config.get("AZURE_KEY_VAULT_CLIENT_SECRET", None)
314+
315+
assert endpoint is not None, "Azure Key Vault endpoint not found in .env file"
316+
if include_client_id:
317+
assert client_id is not None, "Azure Key Vault client ID not found in .env file"
318+
if include_client_secret:
319+
assert client_secret is not None, "Azure Key Vault client secret not found in .env file"
320+
321+
if include_client_id and include_client_secret:
322+
return endpoint, client_id, client_secret
323+
return endpoint, client_id
324+
325+
326+
def azure_key_vault_settings_from_dot_env_as_dict() -> Dict[str, str]:
327+
"""
328+
Reads the Azure Key Vault environment variables for the .env file.
329+
330+
Returns:
331+
Dict[str, str]: Azure Key Vault environment variables
332+
"""
333+
endpoint, client_id, client_secret = azure_key_vault_settings_from_dot_env()
334+
return {"endpoint": endpoint, "client_id": client_id, "client_secret": client_secret}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"schema_version": "v1",
3+
"name_for_model": "AzureKeyVault",
4+
"name_for_human": "AzureKeyVault",
5+
"description_for_model": "An Azure Key Vault plugin for interacting with secrets.",
6+
"description_for_human": "An Azure Key Vault plugin for interacting with secrets.",
7+
"auth": {
8+
"type": "oauth",
9+
"scope": "https://vault.azure.net/.default",
10+
"authorization_url": "https://login.microsoftonline.com/e80e3e25-bb8d-4b4d-ab3f-b91669dd8ae4/oauth2/v2.0/token",
11+
"authorization_content_type": "application/x-www-form-urlencoded"
12+
},
13+
"api": {
14+
"type": "openapi",
15+
"url": "file:///./tests/assets/test_plugins/TestPlugin/TestOpenAPIPlugin/akv-openapi.yaml"
16+
},
17+
"logo_url": "",
18+
"contact_email": "",
19+
"legal_info_url": ""
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Azure Key Vault [Sample]
4+
description: "A sample connector for the Azure Key Vault service. This connector is built for the Azure Key Vault REST API. You can see the details of the API here: https://docs.microsoft.com/rest/api/keyvault/."
5+
version: "1.0"
6+
servers:
7+
- url: https://my-key-vault.vault.azure.net/
8+
paths:
9+
/secrets/{secret-name}:
10+
get:
11+
summary: Get secret
12+
description: "Get a specified secret from a given key vault. For details, see: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/get-secret/get-secret."
13+
operationId: GetSecret
14+
parameters:
15+
- name: secret-name
16+
in: path
17+
required: true
18+
schema:
19+
type: string
20+
- name: api-version
21+
in: query
22+
required: true
23+
schema:
24+
type: string
25+
default: "7.0"
26+
x-ms-visibility: internal
27+
responses:
28+
'200':
29+
description: default
30+
content:
31+
application/json:
32+
schema:
33+
type: object
34+
properties:
35+
attributes:
36+
type: object
37+
properties:
38+
created:
39+
type: integer
40+
format: int32
41+
description: created
42+
enabled:
43+
type: boolean
44+
description: enabled
45+
recoverylevel:
46+
type: string
47+
description: recoverylevel
48+
updated:
49+
type: integer
50+
format: int32
51+
description: updated
52+
id:
53+
type: string
54+
description: id
55+
value:
56+
type: string
57+
format: byte
58+
description: value
59+
put:
60+
summary: Create or update secret value
61+
description: "Sets a secret in a specified key vault. This operation adds a secret to the Azure Key Vault. If the named secret already exists, Azure Key Vault creates a new version of that secret. This operation requires the secrets/set permission. For details, see: https://learn.microsoft.com/en-us/rest/api/keyvault/secrets/set-secret/set-secret."
62+
operationId: SetSecret
63+
parameters:
64+
- name: secret-name
65+
in: path
66+
required: true
67+
schema:
68+
type: string
69+
- name: api-version
70+
in: query
71+
required: true
72+
schema:
73+
type: string
74+
default: "7.0"
75+
x-ms-visibility: internal
76+
requestBody:
77+
required: true
78+
content:
79+
application/json:
80+
schema:
81+
type: object
82+
properties:
83+
attributes:
84+
type: object
85+
properties:
86+
enabled:
87+
type: boolean
88+
description: Determines whether the object is enabled.
89+
value:
90+
type: string
91+
description: The value of the secret.
92+
required:
93+
- value
94+
responses:
95+
'200':
96+
description: default
97+
content:
98+
application/json:
99+
schema:
100+
type: object
101+
properties:
102+
attributes:
103+
type: object
104+
properties:
105+
created:
106+
type: integer
107+
format: int32
108+
description: created
109+
enabled:
110+
type: boolean
111+
description: enabled
112+
recoverylevel:
113+
type: string
114+
description: recoverylevel
115+
updated:
116+
type: integer
117+
format: int32
118+
description: updated
119+
id:
120+
type: string
121+
description: id
122+
value:
123+
type: string
124+
description: value
125+
components:
126+
securitySchemes:
127+
oauth2_auth:
128+
type: oauth2
129+
flows:
130+
authorizationCode:
131+
authorizationUrl: https://login.windows.net/common/oauth2/authorize
132+
tokenUrl: https://login.windows.net/common/oauth2/token
133+
scopes: {}

‎python/tests/integration/completions/test_azure_oai_chat_service.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ async def test_azure_oai_chat_service_with_tool_call(setup_tldr_function_for_oai
173173
output = str(summary).strip()
174174
print(f"Math output: '{output}'")
175175
assert "2" in output
176-
assert 0 < len(output) < 100
176+
assert 0 < len(output)
177177

178178

179179
@pytest.mark.asyncio

‎python/tests/unit/connectors/openapi/test_sk_openapi.py

+66-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import os
2-
from unittest.mock import patch
2+
from unittest.mock import AsyncMock, patch
33

44
import pytest
55
import yaml
66
from openapi_core import Spec
77

88
from semantic_kernel.connectors.ai.open_ai.const import USER_AGENT
9-
from semantic_kernel.connectors.openapi.kernel_openapi import (
9+
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
10+
OpenAPIFunctionExecutionParameters,
11+
)
12+
from semantic_kernel.connectors.openapi_plugin.openapi_manager import (
1013
OpenApiParser,
1114
OpenApiRunner,
1215
PreparedRestApiRequest,
@@ -293,6 +296,67 @@ def openapi_runner():
293296
return runner, operations
294297

295298

299+
@pytest.fixture
300+
def openapi_runner_with_url_override():
301+
parser = OpenApiParser()
302+
parsed_doc = parser.parse(openapi_document)
303+
exec_settings = OpenAPIFunctionExecutionParameters(server_url_override="http://urloverride.com")
304+
operations = parser.create_rest_api_operations(parsed_doc, execution_settings=exec_settings)
305+
runner = OpenApiRunner(parsed_openapi_document=parsed_doc)
306+
return runner, operations
307+
308+
309+
@pytest.fixture
310+
def openapi_runner_with_auth_callback():
311+
async def dummy_auth_callback(**kwargs):
312+
return {"Authorization": "Bearer dummy-token"}
313+
314+
parser = OpenApiParser()
315+
parsed_doc = parser.parse(openapi_document)
316+
exec_settings = OpenAPIFunctionExecutionParameters(server_url_override="http://urloverride.com")
317+
operations = parser.create_rest_api_operations(parsed_doc, execution_settings=exec_settings)
318+
runner = OpenApiRunner(
319+
parsed_openapi_document=parsed_doc,
320+
auth_callback=dummy_auth_callback,
321+
)
322+
return runner, operations
323+
324+
325+
@pytest.mark.asyncio
326+
@patch("aiohttp.ClientSession.request")
327+
async def test_run_operation_with_auth_callback(mock_request, openapi_runner_with_auth_callback):
328+
runner, operations = openapi_runner_with_auth_callback
329+
operation = operations["addTodo"]
330+
headers = {"Authorization": "Bearer abc123"}
331+
request_body = {"title": "Buy milk", "completed": False}
332+
333+
mock_response = AsyncMock()
334+
mock_response.status = 200
335+
mock_request.return_value.__aenter__.return_value = mock_response
336+
337+
assert operation.server_url == "http://urloverride.com"
338+
response = await runner.run_operation(operation, headers=headers, request_body=request_body)
339+
assert response is not None
340+
341+
_, kwargs = mock_request.call_args
342+
343+
assert "Authorization" in kwargs["headers"]
344+
assert kwargs["headers"]["Authorization"] == "Bearer dummy-token"
345+
346+
347+
@patch("aiohttp.ClientSession.request")
348+
@pytest.mark.asyncio
349+
async def test_run_operation_with_url_override(mock_request, openapi_runner_with_url_override):
350+
runner, operations = openapi_runner_with_url_override
351+
operation = operations["addTodo"]
352+
headers = {"Authorization": "Bearer abc123"}
353+
request_body = {"title": "Buy milk", "completed": False}
354+
mock_request.return_value.__aenter__.return_value.text.return_value = 200
355+
assert operation.server_url == "http://urloverride.com"
356+
response = await runner.run_operation(operation, headers=headers, request_body=request_body)
357+
assert response == 200
358+
359+
296360
@patch("aiohttp.ClientSession.request")
297361
@pytest.mark.asyncio
298362
async def test_run_operation_with_valid_request(mock_request, openapi_runner):

‎python/tests/unit/kernel/test_kernel.py

+95
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
from typing import Union
66
from unittest.mock import AsyncMock, patch
77

8+
import httpx
89
import pytest
910
from pydantic import ValidationError
1011

1112
from semantic_kernel import Kernel
1213
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
1314
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
15+
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
16+
OpenAIFunctionExecutionParameters,
17+
)
1418
from semantic_kernel.events.function_invoked_event_args import FunctionInvokedEventArgs
1519
from semantic_kernel.events.function_invoking_event_args import FunctionInvokingEventArgs
1620
from semantic_kernel.exceptions import (
@@ -489,6 +493,97 @@ def test_create_function_from_valid_yaml_jinja2(kernel: Kernel):
489493
assert plugin["TestFunctionJinja2"] is not None
490494

491495

496+
@pytest.mark.asyncio
497+
@patch("semantic_kernel.connectors.openai_plugin.openai_utils.OpenAIUtils.parse_openai_manifest_for_openapi_spec_url")
498+
async def test_import_openai_plugin_from_file(mock_parse_openai_manifest, kernel: Kernel):
499+
openai_spec_file = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestPlugin")
500+
with open(os.path.join(openai_spec_file, "TestOpenAIPlugin", "akv-openai.json"), "r") as file:
501+
openai_spec = file.read()
502+
503+
openapi_spec_file_path = os.path.join(
504+
os.path.dirname(__file__), "../../assets/test_plugins", "TestPlugin", "TestOpenAPIPlugin", "akv-openapi.yaml"
505+
)
506+
mock_parse_openai_manifest.return_value = openapi_spec_file_path
507+
508+
plugin = await kernel.import_plugin_from_openai(
509+
plugin_name="TestOpenAIPlugin",
510+
plugin_str=openai_spec,
511+
execution_parameters=OpenAIFunctionExecutionParameters(
512+
http_client=AsyncMock(),
513+
auth_callback=AsyncMock(),
514+
server_url_override="http://localhost",
515+
enable_dynamic_payload=True,
516+
),
517+
)
518+
assert plugin is not None
519+
assert plugin.name == "TestOpenAIPlugin"
520+
assert plugin.functions.get("GetSecret") is not None
521+
assert plugin.functions.get("SetSecret") is not None
522+
523+
524+
@pytest.mark.asyncio
525+
@patch("httpx.AsyncClient.get")
526+
@patch("semantic_kernel.connectors.openai_plugin.openai_utils.OpenAIUtils.parse_openai_manifest_for_openapi_spec_url")
527+
async def test_import_openai_plugin_from_url(mock_parse_openai_manifest, mock_get, kernel: Kernel):
528+
openai_spec_file_path = os.path.join(
529+
os.path.dirname(__file__), "../../assets/test_plugins", "TestPlugin", "TestOpenAIPlugin", "akv-openai.json"
530+
)
531+
with open(openai_spec_file_path, "r") as file:
532+
openai_spec = file.read()
533+
534+
openapi_spec_file_path = os.path.join(
535+
os.path.dirname(__file__), "../../assets/test_plugins", "TestPlugin", "TestOpenAPIPlugin", "akv-openapi.yaml"
536+
)
537+
mock_parse_openai_manifest.return_value = openapi_spec_file_path
538+
539+
request = httpx.Request(method="GET", url="http://fake-url.com/akv-openai.json")
540+
541+
response = httpx.Response(200, text=openai_spec, request=request)
542+
mock_get.return_value = response
543+
544+
fake_plugin_url = "http://fake-url.com/akv-openai.json"
545+
plugin = await kernel.import_plugin_from_openai(
546+
plugin_name="TestOpenAIPlugin",
547+
plugin_url=fake_plugin_url,
548+
execution_parameters=OpenAIFunctionExecutionParameters(
549+
auth_callback=AsyncMock(),
550+
server_url_override="http://localhost",
551+
enable_dynamic_payload=True,
552+
),
553+
)
554+
555+
assert plugin is not None
556+
assert plugin.name == "TestOpenAIPlugin"
557+
assert plugin.functions.get("GetSecret") is not None
558+
assert plugin.functions.get("SetSecret") is not None
559+
560+
mock_get.assert_awaited_once_with(fake_plugin_url, headers={"User-Agent": "Semantic-Kernel"})
561+
562+
563+
def test_import_plugin_from_openapi(kernel: Kernel):
564+
openapi_spec_file = os.path.join(
565+
os.path.dirname(__file__), "../../assets/test_plugins", "TestPlugin", "TestOpenAPIPlugin", "akv-openapi.yaml"
566+
)
567+
568+
plugin = kernel.import_plugin_from_openapi(
569+
plugin_name="TestOpenAPIPlugin",
570+
openapi_document_path=openapi_spec_file,
571+
)
572+
573+
assert plugin is not None
574+
assert plugin.name == "TestOpenAPIPlugin"
575+
assert plugin.functions.get("GetSecret") is not None
576+
assert plugin.functions.get("SetSecret") is not None
577+
578+
579+
def test_import_plugin_from_openapi_missing_document_throws(kernel: Kernel):
580+
with pytest.raises(PluginInitializationError):
581+
kernel.import_plugin_from_openapi(
582+
plugin_name="TestOpenAPIPlugin",
583+
openapi_document_path=None,
584+
)
585+
586+
492587
# endregion
493588
# region Functions
494589

0 commit comments

Comments
 (0)
Please sign in to comment.