Skip to content

Commit 2de1cbe

Browse files
.Net: [RestApi] Dynamic payload generation with namespaced payload parameters (#2467)
### Motivation and Context The dynamic payload creation was requested by a few internal teams. It allows SK RestAPI functionality consumer code to only supply arguments for parameters specified in the OpenAPI payload metadata for PUT and POST operations, while the rest – payload constriction, data type conversion, parameter namespacing, etc. – is handled by SK RestAPI functionality. ### Description #### Context Today, the only way to pass a payload for a PUT or POST RestAPI operation is by adding the `payload` argument to the context variables collection before executing the operation. The `payload` argument, for operations requiring an 'application/json' payload, is expected to be a valid JSON with all the necessary properties initialized. This approach is not convenient for SK consumers who only want to provide the required payload parameters, similar to how they handle query string and header parameters, without having to construct the payload themselves. #### Dynamic creation of payload By default, dynamic payload creation is disabled to ensure backward compatibility. To enable it, the `BuildOperationPayloadDynamically` property of the `OpenApiSkillExecutionParameters` execution parameters should be set to `true` when importing the AI plugin: ```csharp var plugin = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient) { BuildOperationPayloadDynamically = true }); ``` So, if a RestAPI operation payload schema requires payload like the following: ```json { "value": "secret-value", "attributes": { "enabled": true } } ``` To dynamically build it, the consumer code needs to register the following arguments in the context variables collection: ```csharp var contextVariables = new ContextVariables(); contextVariables.Set("value", "secret-value"); contextVariables.Set("enabled", true); ``` #### Payload parameter namespacing When building payload dynamically, and a RestAPI operation requires the 'application/json' content type, it's possible that the content may have properties with the identical names, see example below, at different levels. In such cases, SK can't resolve their values unambiguously from a flat list of arguments unless there's a namespacing mechanism that allows the distinction of those properties from one another. The namespacing mechanism relies on prefixing parameter names with their parent parameter name, separated by dots. So, the 'namespaced' parameter names should be used when adding arguments to the context variables collection. For example, consider this JSON: ```json { "upn": "<sender upn>", "receiver": { "upn": "<receiver upn>" }, "cc": { "upn": "<cc upn>" } } ``` It contains `upn` properties at different levels. The argument registration for the parameters (property values) will look like: ```csharp var contextVariables = new ContextVariables(); contextVariables.Set("upn", "<sender-upn-value>"); contextVariables.Set("receiver.upn", "<receiver-upn-value>"); contextVariables.Set("cc.upn", "<cc-upn-value>"); ``` The namespacing mechanism is disabled by default for backward compatibility and can be easily enabled by setting the value `true` to the `NamespacePayloadParameters` property of the `OpenApiSkillExecutionParameters` execution parameters when importing the AI plugin: ```csharp var plugin = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient) { NamespacePayloadParameters = true }); ``` #### Parameter arguments data type conversion Currently, the SK context variables collection only supports string variables.This means that in order to set or add a primitive or complex type variable, it must first be converted or serialized into a string. However, this behavior causes issues when the RestAPI functionality sends HTTP requests with an 'application/json' payload. For instance, a property like enabled might have the string value `"true"` instead of the intended Boolean value `true`. To mitigate this problem, the RestAPIOperationRunner class iterates over the operation payload metadata. During this process, it converts all string arguments to their corresponding types as specified in the metadata. This ensures accurate type representation within the operation payload. ### 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 😄 --------- Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
1 parent f87290f commit 2de1cbe

13 files changed

+1172
-93
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
# These are optional elements. Feel free to remove any of them.
3+
status: proposed
4+
date: 2023-08-15
5+
deciders: shawncal
6+
consulted:
7+
informed:
8+
---
9+
# Dynamic payload building for PUT and POST RestAPI operations and parameter namespacing
10+
11+
## Context and Problem Statement
12+
Currently, the SK OpenAPI does not allow the dynamic creation of payload/body for PUT and POST RestAPI operations, even though all the required metadata is available. One of the reasons the functionality was not fully developed originally, and eventually removed is that JSON payload/body content of PUT and POST RestAPI operations might contain properties with identical names at various levels. It was not clear how to unambiguously resolve their values from the flat list of context variables. Another reason the functionality has not been added yet is that the 'payload' context variable, along with RestAPI operation data contract schema(OpenAPI, JSON schema, Typings?) should have been sufficient for LLM to provide fully fleshed-out JSON payload/body content without the need to build it dynamically.
13+
14+
<!-- This is an optional element. Feel free to remove. -->
15+
## Decision Drivers
16+
* Create a mechanism that enables the dynamic construction of the payload/body for PUT and POST RestAPI operations.
17+
* Develop a mechanism(namespacing) that allows differentiation of payload properties with identical names at various levels for PUT and POST RestAPI operations.
18+
* Aim to minimize breaking changes and maintain backward compatibility of the code as much as possible.
19+
20+
## Considered Options
21+
* Enable the dynamic creation of payload and/or namespacing by default.
22+
* Enable the dynamic creation of payload and/or namespacing based on configuration.
23+
24+
## Decision Outcome
25+
Chosen option: "Enable the dynamic creation of payload and/or namespacing based on configuration". This option keeps things compatible, so the change won't affect any SK consumer code. Additionally, it lets SK consumer code easily control both mechanisms, turning them on or off based on the scenario.
26+
27+
## Additional details
28+
29+
### Enabling dynamic creation of payload
30+
In order to enable the dynamic creation of payloads/bodies for PUT and POST RestAPI operations, please set the `EnableDynamicPayload` property of the `OpenApiSkillExecutionParameters` execution parameters to `true` when importing the AI plugin:
31+
32+
```csharp
33+
var plugin = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient) { EnableDynamicPayload = true });
34+
```
35+
36+
To dynamically construct a payload for a RestAPI operation that requires payload like this:
37+
```json
38+
{
39+
"value": "secret-value",
40+
"attributes": {
41+
"enabled": true
42+
}
43+
}
44+
```
45+
46+
Please register the following arguments in context variables collection:
47+
48+
```csharp
49+
var contextVariables = new ContextVariables();
50+
contextVariables.Set("value", "secret-value");
51+
contextVariables.Set("enabled", true);
52+
```
53+
54+
### Enabling namespacing
55+
To enable namespacing, set the `EnablePayloadNamespacing` property of the `OpenApiSkillExecutionParameters` execution parameters to `true` when importing the AI plugin:
56+
57+
```csharp
58+
var plugin = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient) { EnablePayloadNamespacing = true });
59+
```
60+
Remember that the namespacing mechanism depends on prefixing parameter names with their parent parameter name, separated by dots. So, use the 'namespaced' parameter names when adding arguments for them to the context variables. Let's consider this JSON:
61+
62+
```json
63+
{
64+
"upn": "<sender upn>",
65+
"receiver": {
66+
"upn": "<receiver upn>"
67+
},
68+
"cc": {
69+
"upn": "<cc upn>"
70+
}
71+
}
72+
```
73+
It contains `upn` properties at different levels. The the argument registration for the parameters(property values) will look like:
74+
```csharp
75+
var contextVariables = new ContextVariables();
76+
contextVariables.Set("upn", "<sender-upn-value>");
77+
contextVariables.Set("receiver.upn", "<receiver-upn-value>");
78+
contextVariables.Set("cc.upn", "<cc-upn-value>");
79+
```

dotnet/samples/KernelSyntaxExamples/Example21_ChatGPTPlugins.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ private static async Task RunChatGptPluginAsync()
2222
using HttpClient httpClient = new();
2323

2424
//Import a ChatGPT plugin via URI
25-
var skill = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient));
25+
var skill = await kernel.ImportAIPluginAsync("<skill name>", new Uri("<chatGPT-plugin>"), new OpenApiSkillExecutionParameters(httpClient) { EnableDynamicPayload = true });
2626

2727
//Add arguments for required parameters, arguments for optional ones can be skipped.
2828
var contextVariables = new ContextVariables();

dotnet/src/Skills/Skills.OpenAPI/Extensions/KernelAIPluginExtensions.cs

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,21 @@ public static async Task<IDictionary<string, ISKFunction>> ImportAIPluginAsync(
4343
Verify.ValidSkillName(skillName);
4444

4545
#pragma warning disable CA2000 // Dispose objects before losing scope. No need to dispose the Http client here. It can either be an internal client using NonDisposableHttpClientHandler or an external client managed by the calling code, which should handle its disposal.
46-
var internalHttpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
46+
var httpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
4747
#pragma warning restore CA2000
4848

4949
var pluginContents = await LoadDocumentFromFilePath(
5050
kernel,
5151
filePath,
5252
executionParameters,
53-
internalHttpClient,
53+
httpClient,
5454
cancellationToken).ConfigureAwait(false);
5555

5656
return await CompleteImport(
5757
kernel,
5858
pluginContents,
5959
skillName,
60-
internalHttpClient,
60+
httpClient,
6161
executionParameters,
6262
cancellationToken).ConfigureAwait(false);
6363
}
@@ -82,21 +82,21 @@ public static async Task<IDictionary<string, ISKFunction>> ImportAIPluginAsync(
8282
Verify.ValidSkillName(skillName);
8383

8484
#pragma warning disable CA2000 // Dispose objects before losing scope. No need to dispose the Http client here. It can either be an internal client using NonDisposableHttpClientHandler or an external client managed by the calling code, which should handle its disposal.
85-
var internalHttpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
85+
var httpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
8686
#pragma warning restore CA2000
8787

8888
var pluginContents = await LoadDocumentFromUri(
8989
kernel,
9090
uri,
9191
executionParameters,
92-
internalHttpClient,
92+
httpClient,
9393
cancellationToken).ConfigureAwait(false);
9494

9595
return await CompleteImport(
9696
kernel,
9797
pluginContents,
9898
skillName,
99-
internalHttpClient,
99+
httpClient,
100100
executionParameters,
101101
cancellationToken).ConfigureAwait(false);
102102
}
@@ -121,7 +121,7 @@ public static async Task<IDictionary<string, ISKFunction>> ImportAIPluginAsync(
121121
Verify.ValidSkillName(skillName);
122122

123123
#pragma warning disable CA2000 // Dispose objects before losing scope. No need to dispose the Http client here. It can either be an internal client using NonDisposableHttpClientHandler or an external client managed by the calling code, which should handle its disposal.
124-
var internalHttpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
124+
var httpClient = HttpClientProvider.GetHttpClient(kernel.Config, executionParameters?.HttpClient, kernel.LoggerFactory);
125125
#pragma warning restore CA2000
126126

127127
var pluginContents = await LoadDocumentFromStream(kernel, stream).ConfigureAwait(false);
@@ -130,7 +130,7 @@ public static async Task<IDictionary<string, ISKFunction>> ImportAIPluginAsync(
130130
kernel,
131131
pluginContents,
132132
skillName,
133-
internalHttpClient,
133+
httpClient,
134134
executionParameters,
135135
cancellationToken).ConfigureAwait(false);
136136
}
@@ -141,7 +141,7 @@ private static async Task<IDictionary<string, ISKFunction>> CompleteImport(
141141
IKernel kernel,
142142
string pluginContents,
143143
string skillName,
144-
HttpClient internalHttpClient,
144+
HttpClient httpClient,
145145
OpenApiSkillExecutionParameters? executionParameters,
146146
CancellationToken cancellationToken)
147147
{
@@ -160,7 +160,7 @@ private static async Task<IDictionary<string, ISKFunction>> CompleteImport(
160160
kernel,
161161
skillName,
162162
executionParameters,
163-
internalHttpClient,
163+
httpClient,
164164
pluginContents,
165165
cancellationToken).ConfigureAwait(false);
166166
}
@@ -169,7 +169,7 @@ private static async Task<IDictionary<string, ISKFunction>> LoadSkill(
169169
IKernel kernel,
170170
string skillName,
171171
OpenApiSkillExecutionParameters? executionParameters,
172-
HttpClient internalHttpClient,
172+
HttpClient httpClient,
173173
string pluginJson,
174174
CancellationToken cancellationToken)
175175
{
@@ -179,7 +179,12 @@ private static async Task<IDictionary<string, ISKFunction>> LoadSkill(
179179
{
180180
var operations = await parser.ParseAsync(documentStream, executionParameters?.IgnoreNonCompliantErrors ?? false, cancellationToken).ConfigureAwait(false);
181181

182-
var runner = new RestApiOperationRunner(internalHttpClient, executionParameters?.AuthCallback, executionParameters?.UserAgent);
182+
var runner = new RestApiOperationRunner(
183+
httpClient,
184+
executionParameters?.AuthCallback,
185+
executionParameters?.UserAgent,
186+
executionParameters?.EnableDynamicPayload ?? false,
187+
executionParameters?.EnablePayloadNamespacing ?? false);
183188

184189
var skill = new Dictionary<string, ISKFunction>();
185190

@@ -189,7 +194,7 @@ private static async Task<IDictionary<string, ISKFunction>> LoadSkill(
189194
try
190195
{
191196
logger.LogTrace("Registering Rest function {0}.{1}", skillName, operation.Id);
192-
var function = kernel.RegisterRestApiFunction(skillName, runner, operation, executionParameters?.ServerUrlOverride, cancellationToken);
197+
var function = kernel.RegisterRestApiFunction(skillName, runner, operation, executionParameters, cancellationToken);
193198
skill[function.Name] = function;
194199
}
195200
catch (Exception ex) when (!ex.IsCriticalException())
@@ -208,7 +213,7 @@ private static async Task<string> LoadDocumentFromUri(
208213
IKernel kernel,
209214
Uri uri,
210215
OpenApiSkillExecutionParameters? executionParameters,
211-
HttpClient internalHttpClient,
216+
HttpClient httpClient,
212217
CancellationToken cancellationToken)
213218
{
214219
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri.ToString());
@@ -218,7 +223,7 @@ private static async Task<string> LoadDocumentFromUri(
218223
requestMessage.Headers.UserAgent.Add(ProductInfoHeaderValue.Parse(executionParameters!.UserAgent));
219224
}
220225

221-
using var response = await internalHttpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
226+
using var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
222227
response.EnsureSuccessStatusCode();
223228

224229
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -228,7 +233,7 @@ private static async Task<string> LoadDocumentFromFilePath(
228233
IKernel kernel,
229234
string filePath,
230235
OpenApiSkillExecutionParameters? executionParameters,
231-
HttpClient internalHttpClient,
236+
HttpClient httpClient,
232237
CancellationToken cancellationToken)
233238
{
234239
var pluginJson = string.Empty;
@@ -293,18 +298,22 @@ private static bool TryParseAIPluginForUrl(string gptPluginJson, out string? ope
293298
/// <param name="skillName">Skill name.</param>
294299
/// <param name="runner">The REST API operation runner.</param>
295300
/// <param name="operation">The REST API operation.</param>
296-
/// <param name="serverUrlOverride">Optional override for REST API server URL if user input required</param>
301+
/// <param name="executionParameters">Skill execution parameters.</param>
297302
/// <param name="cancellationToken">The cancellation token.</param>
298303
/// <returns>An instance of <see cref="SKFunction"/> class.</returns>
299304
private static ISKFunction RegisterRestApiFunction(
300305
this IKernel kernel,
301306
string skillName,
302307
RestApiOperationRunner runner,
303308
RestApiOperation operation,
304-
Uri? serverUrlOverride = null,
309+
OpenApiSkillExecutionParameters? executionParameters,
305310
CancellationToken cancellationToken = default)
306311
{
307-
var restOperationParameters = operation.GetParameters(serverUrlOverride);
312+
var restOperationParameters = operation.GetParameters(
313+
executionParameters?.ServerUrlOverride,
314+
executionParameters?.EnableDynamicPayload ?? false,
315+
executionParameters?.EnablePayloadNamespacing ?? false
316+
);
308317

309318
var logger = kernel.LoggerFactory is not null ? kernel.LoggerFactory.CreateLogger(nameof(KernelAIPluginExtensions)) : NullLogger.Instance;
310319

dotnet/src/Skills/Skills.OpenAPI/Extensions/OpenApiSkillExecutionParameters.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,22 @@ public class OpenApiSkillExecutionParameters
3737
/// <summary>
3838
/// Optional user agent header value.
3939
/// </summary>
40-
public string? UserAgent { get; set; }
40+
public string UserAgent { get; set; }
41+
42+
/// <summary>
43+
/// Determines whether the operation payload is constructed dynamically based on operation payload metadata.
44+
/// If false, the operation payload must be provided via the 'payload' context variable.
45+
/// </summary>
46+
public bool EnableDynamicPayload { get; set; }
47+
48+
/// <summary>
49+
/// Determines whether payload parameter names are augmented with namespaces.
50+
/// Namespaces prevent naming conflicts by adding the parent parameter name as a prefix, separated by dots.
51+
/// For instance, without namespaces, the 'email' parameter for both the 'sender' and 'receiver' parent parameters
52+
/// would be resolved from the same 'email' argument, which is incorrect. However, by employing namespaces,
53+
/// the parameters 'sender.email' and 'sender.receiver' will be correctly resolved from arguments with the same names.
54+
/// </summary>
55+
public bool EnablePayloadNamespacing { get; set; }
4156

4257
/// <summary>
4358
/// Initializes a new instance of the <see cref="OpenApiSkillExecutionParameters"/> class.
@@ -49,17 +64,25 @@ public class OpenApiSkillExecutionParameters
4964
/// <param name="ignoreNonCompliantErrors">A flag indicating whether to ignore non-compliant errors or not
5065
/// If set to true, the operation execution will not throw exceptions for non-compliant documents.
5166
/// Please note that enabling this option may result in incomplete or inaccurate execution results.</param>
67+
/// <param name="enableDynamicOperationPayload">Determines whether the operation payload is constructed dynamically based on operation payload metadata.
68+
/// If false, the operation payload must be provided via the 'payload' context variable.</param>
69+
/// <param name="enablePayloadNamespacing">Determines whether payload parameter names are augmented with namespaces.
70+
/// Namespaces prevent naming conflicts by adding the parent parameter name as a prefix, separated by dots.</param>
5271
public OpenApiSkillExecutionParameters(
5372
HttpClient? httpClient = null,
5473
AuthenticateRequestAsyncCallback? authCallback = null,
5574
Uri? serverUrlOverride = null,
56-
string? userAgent = Telemetry.HttpUserAgent,
57-
bool ignoreNonCompliantErrors = false)
75+
string userAgent = Telemetry.HttpUserAgent,
76+
bool ignoreNonCompliantErrors = false,
77+
bool enableDynamicOperationPayload = false,
78+
bool enablePayloadNamespacing = false)
5879
{
5980
this.HttpClient = httpClient;
6081
this.AuthCallback = authCallback;
6182
this.ServerUrlOverride = serverUrlOverride;
6283
this.UserAgent = userAgent;
6384
this.IgnoreNonCompliantErrors = ignoreNonCompliantErrors;
85+
this.EnableDynamicPayload = enableDynamicOperationPayload;
86+
this.EnablePayloadNamespacing = enablePayloadNamespacing;
6487
}
6588
}

0 commit comments

Comments
 (0)