Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.Google;
using Microsoft.SemanticKernel.Connectors.Google.Core;
using Xunit;

namespace SemanticKernel.Connectors.Google.UnitTests.Core.Gemini.Clients;

/// <summary>
/// Unit tests for IChatClient-based function calling with Gemini using FunctionChoiceBehavior.
/// </summary>
public sealed class GeminiChatClientFunctionCallingTests : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _responseContent;
private readonly string _responseContentWithFunction;
private readonly HttpMessageHandlerStub _messageHandlerStub;
private readonly GeminiFunction _timePluginDate, _timePluginNow;
private readonly Kernel _kernelWithFunctions;
private const string ChatTestDataFilePath = "./TestData/chat_one_response.json";
private const string ChatTestDataWithFunctionFilePath = "./TestData/chat_one_function_response.json";

public GeminiChatClientFunctionCallingTests()
{
this._responseContent = File.ReadAllText(ChatTestDataFilePath);
this._responseContentWithFunction = File.ReadAllText(ChatTestDataWithFunctionFilePath)
.Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal);
this._messageHandlerStub = new HttpMessageHandlerStub();
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(
this._responseContent);

this._httpClient = new HttpClient(this._messageHandlerStub, false);

var kernelPlugin = KernelPluginFactory.CreateFromFunctions("TimePlugin", new[]
{
KernelFunctionFactory.CreateFromMethod((string? format = null)
=> DateTime.Now.Date.ToString(format, CultureInfo.InvariantCulture), "Date", "TimePlugin.Date"),
KernelFunctionFactory.CreateFromMethod(()
=> DateTime.Now.ToString("", CultureInfo.InvariantCulture), "Now", "TimePlugin.Now",
parameters: [new KernelParameterMetadata("param1") { ParameterType = typeof(string), Description = "desc", IsRequired = false }]),
});
IList<KernelFunctionMetadata> functions = kernelPlugin.GetFunctionsMetadata();

this._timePluginDate = functions[0].ToGeminiFunction();
this._timePluginNow = functions[1].ToGeminiFunction();

this._kernelWithFunctions = new Kernel();
this._kernelWithFunctions.Plugins.Add(kernelPlugin);
}

[Fact]
public async Task ChatClientWithFunctionChoiceBehaviorAutoShouldIncludeToolsInRequestAsync()
{
// Arrange
var chatCompletionService = this.CreateChatCompletionService();
var chatClient = chatCompletionService.AsChatClient();
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage("What time is it?");

var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

// Act
await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings, this._kernelWithFunctions);

// Assert
GeminiRequest? request = JsonSerializer.Deserialize<GeminiRequest>(this._messageHandlerStub.RequestContent);
Assert.NotNull(request);
Assert.NotNull(request.Tools);
Assert.Collection(request.Tools[0].Functions,
item => Assert.Equal(this._timePluginDate.FullyQualifiedName, item.Name),
item => Assert.Equal(this._timePluginNow.FullyQualifiedName, item.Name));
}

[Fact]
public async Task ChatClientWithFunctionChoiceBehaviorShouldReturnFunctionCallContentAsync()
{
// Arrange
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContentWithFunction);
var chatCompletionService = this.CreateChatCompletionService();
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage("What time is it?");

var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
};

// Act
var response = await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings, this._kernelWithFunctions);

// Assert
Assert.NotNull(response);
var geminiMessage = response as GeminiChatMessageContent;
Assert.NotNull(geminiMessage);

// Verify that FunctionCallContent was added to Items
var functionCallContents = geminiMessage.Items.OfType<Microsoft.SemanticKernel.FunctionCallContent>().ToList();
Assert.NotEmpty(functionCallContents);

var functionCall = functionCallContents.First();
Assert.Contains(this._timePluginNow.FunctionName, functionCall.FunctionName, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ChatClientWithAutoInvokeShouldProcessFunctionsAsync()
{
// Arrange
using var handlerStub = new MultipleHttpMessageHandlerStub();
handlerStub.AddJsonResponse(this._responseContentWithFunction);
handlerStub.AddJsonResponse(this._responseContent);
#pragma warning disable CA2000
var chatCompletionService = this.CreateChatCompletionService(httpClient: handlerStub.CreateHttpClient());
#pragma warning restore CA2000
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage("What time is it?");

var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: true)
};

// Act
await chatCompletionService.GetChatMessageContentAsync(chatHistory, settings, this._kernelWithFunctions);

// Assert
// Verify that we made two requests (one for function call, one for final response)
Assert.Equal(2, handlerStub.RequestContents.Count);
}

[Fact]
public async Task AsChatClientConvertsServiceToIChatClientAsync()
{
// Arrange
var chatCompletionService = this.CreateChatCompletionService();

// Act
var chatClient = chatCompletionService.AsChatClient();

// Assert
Assert.NotNull(chatClient);
Assert.IsAssignableFrom<IChatClient>(chatClient);
}

private GoogleAIGeminiChatCompletionService CreateChatCompletionService(HttpClient? httpClient = null)
{
return new GoogleAIGeminiChatCompletionService(
modelId: "fake-model",
apiKey: "fake-key",
apiVersion: GoogleAIVersion.V1,
httpClient: httpClient ?? this._httpClient);
}

public void Dispose()
{
this._httpClient.Dispose();
this._messageHandlerStub.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,111 @@ public void KernelFunctionsCloneReturnsCorrectClone()
Assert.Equivalent(toolcallbehavior, clone, strict: true);
}

[Fact]
public void FunctionChoiceBehaviorAutoConvertsToAutoInvokeKernelFunctions()
{
// Arrange
var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert
Assert.NotNull(converted.ToolCallBehavior);
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
}

[Fact]
public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnableKernelFunctions()
{
// Arrange
var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
};

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert
Assert.NotNull(converted.ToolCallBehavior);
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
}

[Fact]
public void FunctionChoiceBehaviorRequiredConvertsToAutoInvokeKernelFunctions()
{
// Arrange
var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Required()
};

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert
Assert.NotNull(converted.ToolCallBehavior);
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
}

[Fact]
public void FunctionChoiceBehaviorNoneConvertsToEnableKernelFunctions()
{
// Arrange
var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.None()
};

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert
Assert.NotNull(converted.ToolCallBehavior);
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
// None behavior doesn't auto-invoke
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
}

[Fact]
public void GeminiPromptExecutionSettingsWithNoFunctionChoiceBehaviorDoesNotSetToolCallBehavior()
{
// Arrange
var settings = new GeminiPromptExecutionSettings();

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert
Assert.Null(converted.ToolCallBehavior);
}

[Fact]
public void GeminiPromptExecutionSettingsPreservesExistingToolCallBehavior()
{
// Arrange
var settings = new GeminiPromptExecutionSettings
{
ToolCallBehavior = GeminiToolCallBehavior.EnableKernelFunctions,
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

// Act
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);

// Assert - ToolCallBehavior should be preserved when already set
Assert.NotNull(converted.ToolCallBehavior);
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
}

private static KernelPlugin GetTestPlugin()
{
var function = KernelFunctionFactory.CreateFromMethod(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ public IDictionary<string, string>? Labels
/// the function, and sending back the result. The intermediate messages will be retained in the
/// <see cref="ChatHistory"/> if an instance was provided.
/// </remarks>
/// <remarks>
/// This property is deprecated. Use <see cref="PromptExecutionSettings.FunctionChoiceBehavior"/> instead.
/// </remarks>
public GeminiToolCallBehavior? ToolCallBehavior
{
get => this._toolCallBehavior;
Expand Down Expand Up @@ -357,11 +360,72 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio
{
case null:
return new GeminiPromptExecutionSettings();
case GeminiPromptExecutionSettings settings:
return settings;
case GeminiPromptExecutionSettings geminiSettings:
// If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it
if (geminiSettings.FunctionChoiceBehavior is not null && geminiSettings.ToolCallBehavior is null)
{
geminiSettings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(geminiSettings.FunctionChoiceBehavior);
}
return geminiSettings;
}

var json = JsonSerializer.Serialize(executionSettings);
return JsonSerializer.Deserialize<GeminiPromptExecutionSettings>(json, JsonOptionsCache.ReadPermissive)!;
var settings = JsonSerializer.Deserialize<GeminiPromptExecutionSettings>(json, JsonOptionsCache.ReadPermissive)!;

// If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it
if (executionSettings.FunctionChoiceBehavior is not null && settings.ToolCallBehavior is null)
{
settings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(executionSettings.FunctionChoiceBehavior);
}

return settings;
}

/// <summary>
/// Converts a <see cref="FunctionChoiceBehavior"/> to a <see cref="GeminiToolCallBehavior"/>.
/// </summary>
/// <param name="functionChoiceBehavior">The <see cref="FunctionChoiceBehavior"/> to convert.</param>
/// <returns>The converted <see cref="GeminiToolCallBehavior"/>.</returns>
internal static GeminiToolCallBehavior? ConvertFunctionChoiceBehaviorToToolCallBehavior(FunctionChoiceBehavior? functionChoiceBehavior)
{
if (functionChoiceBehavior is null)
{
return null;
}

// Static empty kernel to avoid creating new instances for each conversion
static Kernel GetEmptyKernel()
{
return new Kernel();
}

// Check the type and determine auto-invoke by reflection or known behavior types
// All FunctionChoiceBehavior types (Auto, Required, None) support auto-invoke
// We use a simple approach: get the configuration with minimal context to check AutoInvoke
try
{
var context = new FunctionChoiceBehaviorConfigurationContext(new ChatHistory())
{
Kernel = GetEmptyKernel(), // Provide an empty kernel for the configuration
RequestSequenceIndex = 0
};
var config = functionChoiceBehavior.GetConfiguration(context);

// Return appropriate GeminiToolCallBehavior based on AutoInvoke setting
if (config.AutoInvoke)
{
return GeminiToolCallBehavior.AutoInvokeKernelFunctions;
}

return GeminiToolCallBehavior.EnableKernelFunctions;
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
#pragma warning restore CA1031
{
// If we can't get configuration (e.g., due to missing dependencies or unexpected state),
// default to EnableKernelFunctions as the safer option that doesn't auto-invoke
return GeminiToolCallBehavior.EnableKernelFunctions;
}
}
}
Loading
Loading