Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,178 @@
// 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 ChatClientShouldConvertToIChatClientSuccessfullyAsync()
{
// Arrange
var chatCompletionService = this.CreateChatCompletionService();

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

// Assert - Verify conversion works
Assert.NotNull(chatClient);
Assert.IsAssignableFrom<IChatClient>(chatClient);

// Verify we can make a basic call through IChatClient
var messages = new List<ChatMessage>
{
new(ChatRole.User, "What time is it?")
};

var response = await chatClient.GetResponseAsync(messages);

Assert.NotNull(response);
Assert.NotEmpty(response.Messages);
}

[Fact]
public async Task ChatClientShouldReceiveFunctionCallsInResponseAsync()
{
// Arrange
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContentWithFunction);
var chatCompletionService = this.CreateChatCompletionService();
var chatClient = chatCompletionService.AsChatClient();

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

var messages = new List<ChatMessage>
{
new(ChatRole.User, "What time is it?")
};

// Act
var response = await chatClient.GetResponseAsync(messages, chatOptions);

// Assert - Verify that FunctionCallContent is returned in the response
Assert.NotNull(response);
var functionCalls = response.Messages
.SelectMany(m => m.Contents)
.OfType<Microsoft.Extensions.AI.FunctionCallContent>()
.ToList();

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

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

var settings = new GeminiPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
var chatOptions = settings.ToChatOptions(this._kernelWithFunctions);

var messages = new List<ChatMessage>
{
new(ChatRole.User, "What time is it?")
};

// Act
var updates = new List<ChatResponseUpdate>();
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, chatOptions))
{
updates.Add(update);
}

// Assert - Verify that streaming works and returns updates
Assert.NotEmpty(updates);
}

[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,71 @@ 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>
/// Shared empty kernel instance used for FunctionChoiceBehavior conversion.
/// </summary>
private static readonly Kernel s_emptyKernel = new();

/// <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;
}

// 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 = s_emptyKernel, // 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