Skip to content

Commit

Permalink
Open API GitHub Plugin Skill + Frontend Auth (MSAL + Basic / PAT) (mi…
Browse files Browse the repository at this point in the history
…crosoft#641)

### Motivation and Context
This PR adds:

- Swagger files detailing List and Get Pull Request(s) functions. 
- Example 22 c on how to leverage imported OpenAPI GitHub Skill
- Basic (+ User PAT) and Msal Authentication flows in Copilot chat for
connector plug-ins, sending auth data if user enables plug-in
- Parsing headers in SemanticKernelController to import respective
OpenAPI Skill if auth information is provided

### Description
![image](https://user-images.githubusercontent.com/125500434/234473631-cb01eac2-12b7-4f78-a939-bc99f70d2184.png)

![image](https://user-images.githubusercontent.com/125500434/234473648-976fa0a5-f857-4693-b72b-b2f85a4d1eee.png)

![image](https://user-images.githubusercontent.com/125500434/234473826-9d0264a8-1e4c-464e-a59f-84affa1ae99a.png)

![image](https://user-images.githubusercontent.com/125500434/234473842-1efcbf7d-4bd4-4015-b903-c13bdd173191.png)

![image](https://user-images.githubusercontent.com/125500434/235235863-1353180f-f679-4445-afaf-4b4c2154d922.png)
  • Loading branch information
teresaqhoang committed May 1, 2023
1 parent b44707d commit eb114e4
Show file tree
Hide file tree
Showing 37 changed files with 6,970 additions and 131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,21 @@ internal sealed class RestApiOperationRunner : IRestApiOperationRunner
/// </summary>
private readonly AuthenticateRequestAsyncCallback _authCallback;

/// <summary>
/// Request-header field containing information about the user agent originating the request
/// </summary>
private readonly string? _userAgent;

/// <summary>
/// Creates an instance of a <see cref="RestApiOperationRunner"/> class.
/// </summary>
/// <param name="httpClient">An instance of the HttpClient class.</param>
/// <param name="authCallback">Optional callback for adding auth data to the API requests.</param>
public RestApiOperationRunner(HttpClient httpClient, AuthenticateRequestAsyncCallback? authCallback = null)
/// <param name="userAgent">Optional request-header field containing information about the user agent originating the request</param>
public RestApiOperationRunner(HttpClient httpClient, AuthenticateRequestAsyncCallback? authCallback = null, string? userAgent = null)
{
this._httpClient = httpClient;
this._userAgent = userAgent;

// If no auth callback provided, use empty function
if (authCallback == null)
Expand Down Expand Up @@ -84,6 +91,11 @@ public RestApiOperationRunner(HttpClient httpClient, AuthenticateRequestAsyncCal
requestMessage.Content = payload;
}

if (!string.IsNullOrWhiteSpace(this._userAgent))
{
requestMessage.Headers.Add("User-Agent", this._userAgent);
}

if (headers != null)
{
foreach (var header in headers)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,39 @@ public async Task ItShouldAddHeadersToHttpRequestAsync()
Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "fake-header" && h.Value.Contains("fake-header-value"));
}

[Fact]
public async Task ItShouldAddUserAgentHeaderToHttpRequestIfConfiguredAsync()
{
// Arrange
var headers = new Dictionary<string, string>();
headers.Add("fake-header", string.Empty);

var operation = new RestApiOperation(
"fake-id",
"https://fake-random-test-host",
"fake-path",
HttpMethod.Get,
"fake-description",
new List<RestApiOperationParameter>(),
headers
);

var arguments = new Dictionary<string, string>();
arguments.Add("fake-header", "fake-header-value");

var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, "fake-user-agent");

// Act
await sut.RunAsync(operation, arguments);

// Assert
Assert.NotNull(this._httpMessageHandlerStub.RequestHeaders);
Assert.Equal(2, this._httpMessageHandlerStub.RequestHeaders.Count());

Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "fake-header" && h.Value.Contains("fake-header-value"));
Assert.Contains(this._httpMessageHandlerStub.RequestHeaders, h => h.Key == "User-Agent" && h.Value.Contains("fake-user-agent"));
}

[Fact]
public async Task ItShouldUsePayloadAndContentTypeArgumentsIfPayloadMetadataIsMissingAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,14 @@ public static class KernelOpenApiExtensions
AuthenticateRequestAsyncCallback? authCallback = null,
CancellationToken cancellationToken = default)
{
const string OpenAPIFile = "openapi.json";
const string OPENAPI_FILE = "openapi.json";

Verify.ValidSkillName(skillDirectoryName);

var skillDir = Path.Combine(parentDirectory, skillDirectoryName);
Verify.DirectoryExists(skillDir);

var openApiDocumentPath = Path.Combine(skillDir, OpenAPIFile);
var openApiDocumentPath = Path.Combine(skillDir, OPENAPI_FILE);
if (!File.Exists(openApiDocumentPath))
{
throw new FileNotFoundException($"No OpenApi document for the specified path - {openApiDocumentPath} is found.");
Expand Down Expand Up @@ -214,7 +214,7 @@ public static class KernelOpenApiExtensions
try
{
kernel.Log.LogTrace("Registering Rest function {0}.{1}", skillName, operation.Id);
var function = kernel.RegisterRestApiFunction(skillName, operation, authCallback, cancellationToken);
var function = kernel.RegisterRestApiFunction(skillName, operation, authCallback, cancellationToken: cancellationToken);
skill[function.Name] = function;
}
catch (Exception ex) when (!ex.IsCriticalException())
Expand All @@ -238,21 +238,27 @@ public static class KernelOpenApiExtensions
/// <param name="operation">The REST API operation.</param>
/// <param name="authCallback">Optional callback for adding auth data to the API requests.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="userAgent">Optional override for request-header field containing information about the user agent originating the request</param>
/// <returns>An instance of <see cref="SKFunction"/> class.</returns>
private static ISKFunction RegisterRestApiFunction(
this IKernel kernel,
string skillName,
RestApiOperation operation,
AuthenticateRequestAsyncCallback? authCallback = null,
CancellationToken cancellationToken = default)
string? userAgent = "Microsoft-Semantic-Kernel",
CancellationToken cancellationToken = default
)
{
var restOperationParameters = operation.GetParameters();

// User Agent may be a required request header fields for some Rest APIs,
// but this detail isn't specified in OpenAPI specs, so defaulting for all Rest APIs imported.
// Other applications can override this value by passing it as a parameter on execution.
async Task<SKContext> ExecuteAsync(SKContext context)
{
try
{
var runner = new RestApiOperationRunner(new HttpClient(), authCallback);
var runner = new RestApiOperationRunner(new HttpClient(), authCallback, userAgent);

// Extract function arguments from context
var arguments = new Dictionary<string, string>();
Expand Down
38 changes: 38 additions & 0 deletions dotnet/src/Skills/Skills.OpenAPI/OpenApi/OpenApiDocumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
using Microsoft.SemanticKernel.Connectors.WebApi.Rest.Model;
using Microsoft.SemanticKernel.Diagnostics;
using Microsoft.SemanticKernel.Text;

namespace Microsoft.SemanticKernel.Skills.OpenAPI.OpenApi;
Expand Down Expand Up @@ -145,6 +147,15 @@ private static List<RestApiOperation> CreateRestApiOperations(string serverUrl,

var operationItem = operationPair.Value;

try
{
Verify.ValidFunctionName(operationItem.OperationId);
}
catch (KernelException)
{
operationItem.OperationId = ConvertOperationIdToValidFunctionName(operationItem.OperationId);
}

var operation = new RestApiOperation(
operationItem.OperationId,
serverUrl,
Expand Down Expand Up @@ -373,5 +384,32 @@ private static List<RestApiOperationParameter> CreateRestApiOperationParameters(
/// </summary>
private const string OpenApiVersionPropertyName = "openapi";

/// <summary>
/// Converts operation id to valid SK Function name.
/// A function name can contain only ASCII letters, digits, and underscores.
/// </summary>
/// <param name="operationId">The operation id.</param>
/// <returns>Valid SK Function name.</returns>
private static string ConvertOperationIdToValidFunctionName(string operationId)
{

// Tokenize operation id on forward and back slashes
string[] tokens = operationId.Split('/', '\\');
string result = "";

foreach (string token in tokens)
{
// Removes all characters that are not ASCII letters, digits, and underscores.
string formattedToken = Regex.Replace(token, "[^0-9A-Za-z_]", "");
result += CultureInfo.CurrentCulture.TextInfo.ToTitleCase(formattedToken.ToLower(CultureInfo.CurrentCulture));
}

Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Operation name \"{0}\" converted to \"{1}\" to comply with SK Function name requirements. Use \"{1}\" when invoking function.", operationId, result);
Console.ResetColor();

return result;
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
using Microsoft.SemanticKernel.AI;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;
using Microsoft.SemanticKernel.Skills.OpenAPI.Authentication;
using SemanticKernel.Service.Config;
using SemanticKernel.Service.Model;
using SemanticKernel.Service.Skills;
using SemanticKernel.Service.Skills.OpenAPI.Authentication;
using SemanticKernel.Service.Storage;

namespace SemanticKernel.Service.Controllers;
Expand Down Expand Up @@ -47,6 +49,7 @@ public class SemanticKernelController : ControllerBase
/// <param name="planner">Planner to use to create function sequences.</param>
/// <param name="plannerOptions">Options for the planner.</param>
/// <param name="ask">Prompt along with its parameters</param>
/// <param name="openApiSkillsAuthHeaders">Authentication headers to connect to OpenAPI Skills</param>
/// <param name="skillName">Skill in which function to invoke resides</param>
/// <param name="functionName">Name of function to invoke</param>
/// <returns>Results consisting of text generated by invoked function along with the variable in the SK that generated it</returns>
Expand All @@ -64,6 +67,7 @@ public class SemanticKernelController : ControllerBase
[FromServices] CopilotChatPlanner planner,
[FromServices] IOptions<PlannerOptions> plannerOptions,
[FromBody] Ask ask,
[FromHeader] OpenApiSkillsAuthHeaders openApiSkillsAuthHeaders,
string skillName, string functionName)
{
this._logger.LogDebug("Received call to invoke {SkillName}/{FunctionName}", skillName, functionName);
Expand All @@ -82,7 +86,7 @@ public class SemanticKernelController : ControllerBase
// Register skills with the planner if enabled.
if (plannerOptions.Value.Enabled)
{
await this.RegisterPlannerSkillsAsync(planner, plannerOptions.Value);
await this.RegisterPlannerSkillsAsync(planner, plannerOptions.Value, openApiSkillsAuthHeaders);
}

// Register native skills with the chat's kernel
Expand Down Expand Up @@ -131,10 +135,22 @@ public class SemanticKernelController : ControllerBase
/// <summary>
/// Register skills with the planner's kernel.
/// </summary>
private async Task RegisterPlannerSkillsAsync(CopilotChatPlanner planner, PlannerOptions options)
private async Task RegisterPlannerSkillsAsync(CopilotChatPlanner planner, PlannerOptions options, OpenApiSkillsAuthHeaders openApiSkillsAuthHeaders)
{
await planner.Kernel.ImportChatGptPluginSkillFromUrlAsync("KlarnaShopping", new Uri("https://www.klarna.com/.well-known/ai-plugin.json"));

// Register authenticated OpenAPI skills with the planner's kernel
// if the request includes an auth header for an OpenAPI skill.
// Else, don't register the skill as it'll fail on auth.
if (openApiSkillsAuthHeaders.GithubAuthentication != null)
{
var authenticationProvider = new BearerAuthenticationProvider(() => { return Task.FromResult(openApiSkillsAuthHeaders.GithubAuthentication); });
this._logger.LogInformation("Registering GitHub Skill");

var filePath = Path.Combine(Directory.GetCurrentDirectory(), @"Skills/OpenApiSkills/GitHubSkill/openapi.json");
var skill = await planner.Kernel.ImportOpenApiSkillFromFileAsync("GitHubSkill", filePath, authenticationProvider.AuthenticateRequestAsync);
}

planner.Kernel.ImportSkill(new Microsoft.SemanticKernel.CoreSkills.TextSkill(), "text");
planner.Kernel.ImportSkill(new Microsoft.SemanticKernel.CoreSkills.TimeSkill(), "time");
planner.Kernel.ImportSkill(new Microsoft.SemanticKernel.CoreSkills.MathSkill(), "math");
Expand Down
1 change: 1 addition & 0 deletions samples/apps/copilot-chat-app/webapi/CopilotChatApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

<ItemGroup>
<ProjectReference Include="..\..\..\..\dotnet\src\Connectors\Connectors.AI.OpenAI\Connectors.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\dotnet\src\Skills\Skills.OpenAPI\Skills.OpenAPI.csproj" />
<ProjectReference Include="..\..\..\..\dotnet\src\Connectors\Connectors.Memory.Qdrant\Connectors.Memory.Qdrant.csproj" />
<ProjectReference Include="..\..\..\..\dotnet\src\Extensions\Planning.SequentialPlanner\Planning.SequentialPlanner.csproj" />
<ProjectReference Include="..\..\..\..\dotnet\src\SemanticKernel\SemanticKernel.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Mvc;

namespace SemanticKernel.Service.Skills.OpenAPI.Authentication;

/// /// <summary>
/// Represents the authentication headers for imported OpenAPI Plugin Skills.
/// </summary>
public class OpenApiSkillsAuthHeaders
{
/// <summary>
/// Gets or sets the MS Graph authentication header value.
/// </summary>
[FromHeader(Name = "x-sk-copilot-graph-auth")]
public string? GraphAuthentication { get; set; }

/// <summary>
/// Gets or sets the Jira authentication header value.
/// </summary>
[FromHeader(Name = "x-sk-copilot-jira-auth")]
public string? JiraAuthentication { get; set; }

/// <summary>
/// Gets or sets the GitHub authentication header value.
/// </summary>
[FromHeader(Name = "x-sk-copilot-github-auth")]
public string? GithubAuthentication { get; set; }
}

0 comments on commit eb114e4

Please sign in to comment.