From f0f29004826f1ba30a33dd19e1172c27c466922a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:16:59 +0000 Subject: [PATCH 1/7] Initial plan From 2192206e16b2cd6e04fdf8149a31de4fe97f4ef6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:38:45 +0000 Subject: [PATCH 2/7] Add Google.GenAI ChatClient support with IChatClient polyfill and extension methods Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 1 + .../Connectors.Google.UnitTests.csproj | 3 +- ...oogleAIServiceCollectionExtensionsTests.cs | 104 ++++ .../Connectors.Google.csproj | 4 + .../GoogleAIKernelBuilderExtensions.cs | 65 +++ ...ollectionExtensions.DependencyInjection.cs | 97 ++++ .../Services/GoogleGenAIChatClient.cs | 508 ++++++++++++++++++ 7 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Connectors/Connectors.Google/Services/GoogleGenAIChatClient.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 3c5217f13345..5e7208d61702 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj b/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj index 4a0ae4032f3e..1b546a8f4db5 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj @@ -8,7 +8,7 @@ enable disable false - $(NoWarn);CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0050 + $(NoWarn);CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0050,SKEXP0070 @@ -25,6 +25,7 @@ + diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs index 844a2341bbc9..6caded591225 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; @@ -113,4 +114,107 @@ public void GoogleAIEmbeddingGeneratorShouldBeRegisteredInServiceCollection() Assert.NotNull(embeddingsGenerationService); Assert.IsType(embeddingsGenerationService); } + +#if NET + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act + kernelBuilder.AddGoogleAIChatClient("modelId", "apiKey"); + var kernel = kernelBuilder.Build(); + + // Assert + var chatClient = kernel.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddGoogleAIChatClient("modelId", "apiKey"); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatClient = serviceProvider.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithClient() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + + // Act + kernelBuilder.AddGoogleAIChatClient("modelId", googleClient); + var kernel = kernelBuilder.Build(); + + // Assert + var chatClient = kernel.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithClient() + { + // Arrange + var services = new ServiceCollection(); + var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + + // Act + services.AddGoogleAIChatClient("modelId", googleClient); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatClient = serviceProvider.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldBeRegisteredWithServiceId() + { + // Arrange + var services = new ServiceCollection(); + const string serviceId = "test-service-id"; + + // Act + services.AddGoogleAIChatClient("modelId", "apiKey", serviceId: serviceId); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatClient = serviceProvider.GetKeyedService(serviceId); + Assert.NotNull(chatClient); + } + + [Fact] + [Experimental("SKEXP0070")] + public void GoogleAIChatClientShouldResolveFromServiceProviderWhenClientNotProvided() + { + // Arrange + var services = new ServiceCollection(); + var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + services.AddSingleton(googleClient); + + // Act + services.AddGoogleAIChatClient("modelId"); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var chatClient = serviceProvider.GetRequiredService(); + Assert.NotNull(chatClient); + } +#endif } diff --git a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj index e71d80d17a00..760f6322fcc6 100644 --- a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj +++ b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj @@ -24,6 +24,10 @@ + + + + diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs index d6ab3768d0e0..3443f5b549db 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -118,4 +119,68 @@ public static IKernelBuilder AddGoogleAIEmbeddingGenerator( dimensions: dimensions); return builder; } + +#if NET + /// + /// Add Google AI to the . + /// + /// The kernel builder. + /// The model for chat completion. + /// The API key for authentication with the Google AI API. + /// The optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated kernel builder. + [Experimental("SKEXP0070")] + public static IKernelBuilder AddGoogleAIChatClient( + this IKernelBuilder builder, + string modelId, + string apiKey, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(builder); + + builder.Services.AddGoogleAIChatClient( + modelId, + apiKey, + serviceId, + openTelemetrySourceName, + openTelemetryConfig); + + return builder; + } + + /// + /// Add Google AI to the . + /// + /// The kernel builder. + /// The model for chat completion. + /// The to use for the service. If null, one must be available in the service provider when this service is resolved. + /// The optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated kernel builder. + [Experimental("SKEXP0070")] + public static IKernelBuilder AddGoogleAIChatClient( + this IKernelBuilder builder, + string modelId, + Google.GenAI.Client? googleClient = null, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(builder); + + builder.Services.AddGoogleAIChatClient( + modelId, + googleClient, + serviceId, + openTelemetrySourceName, + openTelemetryConfig); + + return builder; + } +#endif } diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs index a45001278e9a..53af05b250a7 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -47,4 +49,99 @@ public static IServiceCollection AddGoogleAIEmbeddingGenerator( loggerFactory: serviceProvider.GetService(), dimensions: dimensions)); } + +#if NET + /// + /// Add Google AI to the specified service collection. + /// + /// The service collection to add the Google AI Chat Client to. + /// The model for chat completion. + /// The API key for authentication with the Google AI API. + /// Optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated service collection. + [Experimental("SKEXP0070")] + public static IServiceCollection AddGoogleAIChatClient( + this IServiceCollection services, + string modelId, + string apiKey, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + var googleClient = new Google.GenAI.Client(apiKey: apiKey); + + var builder = new GoogleGenAIChatClient(googleClient, modelId) + .AsBuilder() + .UseKernelFunctionInvocation(loggerFactory) + .UseOpenTelemetry(loggerFactory, openTelemetrySourceName, openTelemetryConfig); + + if (loggerFactory is not null) + { + builder.UseLogging(loggerFactory); + } + + return builder.Build(); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + /// + /// Add Google AI to the specified service collection. + /// + /// The service collection to add the Google AI Chat Client to. + /// The model for chat completion. + /// The to use for the service. If null, one must be available in the service provider when this service is resolved. + /// Optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated service collection. + [Experimental("SKEXP0070")] + public static IServiceCollection AddGoogleAIChatClient( + this IServiceCollection services, + string modelId, + Google.GenAI.Client? googleClient = null, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + var client = googleClient ?? serviceProvider.GetRequiredService(); + + var builder = new GoogleGenAIChatClient(client, modelId) + .AsBuilder() + .UseKernelFunctionInvocation(loggerFactory) + .UseOpenTelemetry(loggerFactory, openTelemetrySourceName, openTelemetryConfig); + + if (loggerFactory is not null) + { + builder.UseLogging(loggerFactory); + } + + return builder.Build(); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } +#endif } diff --git a/dotnet/src/Connectors/Connectors.Google/Services/GoogleGenAIChatClient.cs b/dotnet/src/Connectors/Connectors.Google/Services/GoogleGenAIChatClient.cs new file mode 100644 index 000000000000..1a6fbceb408b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google/Services/GoogleGenAIChatClient.cs @@ -0,0 +1,508 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if NET + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Google.GenAI; +using Google.GenAI.Types; +using Microsoft.Extensions.AI; +using AITextContent = Microsoft.Extensions.AI.TextContent; +using AIDataContent = Microsoft.Extensions.AI.DataContent; +using AIUriContent = Microsoft.Extensions.AI.UriContent; +using AIFunctionCallContent = Microsoft.Extensions.AI.FunctionCallContent; +using AIFunctionResultContent = Microsoft.Extensions.AI.FunctionResultContent; + +namespace Microsoft.SemanticKernel.Connectors.Google; + +/// +/// Provides an implementation based on Google.GenAI . +/// +internal sealed class GoogleGenAIChatClient : IChatClient +{ + /// The wrapped instance (optional). + private readonly Client? _client; + + /// The wrapped instance. + private readonly Models _models; + + /// The default model that should be used when no override is specified. + private readonly string? _defaultModelId; + + /// Lazily-initialized metadata describing the implementation. + private ChatClientMetadata? _metadata; + + /// Initializes a new instance. + /// The to wrap. + /// The default model ID to use for chat requests if not specified. + public GoogleGenAIChatClient(Client client, string? defaultModelId) + { + Verify.NotNull(client); + + this._client = client; + this._models = client.Models; + this._defaultModelId = defaultModelId; + } + + /// Initializes a new instance. + /// The client to wrap. + /// The default model ID to use for chat requests if not specified. + public GoogleGenAIChatClient(Models models, string? defaultModelId) + { + Verify.NotNull(models); + + this._models = models; + this._defaultModelId = defaultModelId; + } + + /// + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // Create the request. + (string? modelId, List contents, GenerateContentConfig config) = this.CreateRequest(messages, options); + + // Send it. + GenerateContentResponse generateResult = await this._models.GenerateContentAsync(modelId!, contents, config).ConfigureAwait(false); + + // Create the response. + ChatResponse chatResponse = new(new ChatMessage(ChatRole.Assistant, new List())) + { + CreatedAt = generateResult.CreateTime is { } dt ? new DateTimeOffset(dt) : null, + ModelId = !string.IsNullOrWhiteSpace(generateResult.ModelVersion) ? generateResult.ModelVersion : modelId, + RawRepresentation = generateResult, + ResponseId = generateResult.ResponseId, + }; + + // Populate the response messages. + chatResponse.FinishReason = PopulateResponseContents(generateResult, chatResponse.Messages[0].Contents); + + // Populate usage information if there is any. + if (generateResult.UsageMetadata is { } usageMetadata) + { + chatResponse.Usage = ExtractUsageDetails(usageMetadata); + } + + // Return the response. + return chatResponse; + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // Create the request. + (string? modelId, List contents, GenerateContentConfig config) = this.CreateRequest(messages, options); + + // Send it, and process the results. + await foreach (GenerateContentResponse generateResult in this._models.GenerateContentStreamAsync(modelId!, contents, config).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + // Create a response update for each result in the stream. + ChatResponseUpdate responseUpdate = new(ChatRole.Assistant, new List()) + { + CreatedAt = generateResult.CreateTime is { } dt ? new DateTimeOffset(dt) : null, + ModelId = !string.IsNullOrWhiteSpace(generateResult.ModelVersion) ? generateResult.ModelVersion : modelId, + RawRepresentation = generateResult, + ResponseId = generateResult.ResponseId, + }; + + // Populate the response update contents. + responseUpdate.FinishReason = PopulateResponseContents(generateResult, responseUpdate.Contents); + + // Populate usage information if there is any. + if (generateResult.UsageMetadata is { } usageMetadata) + { + responseUpdate.Contents.Add(new UsageContent(ExtractUsageDetails(usageMetadata))); + } + + // Yield the update. + yield return responseUpdate; + } + } + + /// + public object? GetService(System.Type serviceType, object? serviceKey = null) + { + Verify.NotNull(serviceType); + + if (serviceKey is null) + { + // If there's a request for metadata, lazily-initialize it and return it. + if (serviceType == typeof(ChatClientMetadata)) + { + return this._metadata ??= new("google.genai", new Uri("https://generativelanguage.googleapis.com/"), defaultModelId: this._defaultModelId); + } + + // Allow a consumer to access the underlying client if they need it. + if (serviceType.IsInstanceOfType(this._models)) + { + return this._models; + } + + if (this._client is not null && serviceType.IsInstanceOfType(this._client)) + { + return this._client; + } + + if (serviceType.IsInstanceOfType(this)) + { + return this; + } + } + + return null; + } + + /// + void IDisposable.Dispose() { /* nop */ } + + /// Creates the message parameters for from and . + private (string? ModelId, List Contents, GenerateContentConfig Config) CreateRequest(IEnumerable messages, ChatOptions? options) + { + // Create the GenerateContentConfig object. If the options contains a RawRepresentationFactory, try to use it to + // create the request instance, allowing the caller to populate it with GenAI-specific options. Otherwise, create + // a new instance directly. + string? model = this._defaultModelId; + List contents = []; + GenerateContentConfig config = options?.RawRepresentationFactory?.Invoke(this) as GenerateContentConfig ?? new(); + + if (options is not null) + { + if (options.FrequencyPenalty is { } frequencyPenalty) + { + config.FrequencyPenalty ??= frequencyPenalty; + } + + if (options.Instructions is { } instructions) + { + ((config.SystemInstruction ??= new()).Parts ??= []).Add(new() { Text = instructions }); + } + + if (options.MaxOutputTokens is { } maxOutputTokens) + { + config.MaxOutputTokens ??= maxOutputTokens; + } + + if (!string.IsNullOrWhiteSpace(options.ModelId)) + { + model = options.ModelId; + } + + if (options.PresencePenalty is { } presencePenalty) + { + config.PresencePenalty ??= presencePenalty; + } + + if (options.Seed is { } seed) + { + config.Seed ??= (int)seed; + } + + if (options.StopSequences is { } stopSequences) + { + (config.StopSequences ??= []).AddRange(stopSequences); + } + + if (options.Temperature is { } temperature) + { + config.Temperature ??= temperature; + } + + if (options.TopP is { } topP) + { + config.TopP ??= topP; + } + + if (options.TopK is { } topK) + { + config.TopK ??= topK; + } + + // Populate tools. Each kind of tool is added on its own, except for function declarations, + // which are grouped into a single FunctionDeclaration. + List? functionDeclarations = null; + if (options.Tools is { } tools) + { + foreach (var tool in tools) + { + switch (tool) + { + case AIFunction af: + functionDeclarations ??= []; + functionDeclarations.Add(new() + { + Name = af.Name, + Description = af.Description ?? "", + }); + break; + } + } + } + + if (functionDeclarations is { Count: > 0 }) + { + Tool functionTools = new(); + (functionTools.FunctionDeclarations ??= []).AddRange(functionDeclarations); + (config.Tools ??= []).Add(functionTools); + } + + // Transfer over the tool mode if there are any tools. + if (options.ToolMode is { } toolMode && config.Tools?.Count > 0) + { + switch (toolMode) + { + case NoneChatToolMode: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.NONE } }; + break; + + case AutoChatToolMode: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.AUTO } }; + break; + + case RequiredChatToolMode required: + config.ToolConfig = new() { FunctionCallingConfig = new() { Mode = FunctionCallingConfigMode.ANY } }; + if (required.RequiredFunctionName is not null) + { + ((config.ToolConfig.FunctionCallingConfig ??= new()).AllowedFunctionNames ??= []).Add(required.RequiredFunctionName); + } + break; + } + } + + // Set the response format if specified. + if (options.ResponseFormat is ChatResponseFormatJson responseFormat) + { + config.ResponseMimeType = "application/json"; + if (responseFormat.Schema is { } schema) + { + config.ResponseJsonSchema = schema; + } + } + } + + // Transfer messages to request, handling system messages specially + Dictionary? callIdToFunctionNames = null; + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + string instruction = message.Text; + if (!string.IsNullOrWhiteSpace(instruction)) + { + ((config.SystemInstruction ??= new()).Parts ??= []).Add(new() { Text = instruction }); + } + + continue; + } + + Content content = new() { Role = message.Role == ChatRole.Assistant ? "model" : "user" }; + content.Parts ??= []; + AddPartsForAIContents(ref callIdToFunctionNames, message.Contents, content.Parts); + + contents.Add(content); + } + + // Make sure the request contains at least one content part (the request would always fail if empty). + if (!contents.SelectMany(c => c.Parts ?? Enumerable.Empty()).Any()) + { + contents.Add(new() { Role = "user", Parts = [new() { Text = "" }] }); + } + + return (model, contents, config); + } + + /// Creates s for and adds them to . + private static void AddPartsForAIContents(ref Dictionary? callIdToFunctionNames, IList contents, List parts) + { + for (int i = 0; i < contents.Count; i++) + { + var content = contents[i]; + + Part? part = null; + switch (content) + { + case AITextContent textContent: + part = new() { Text = textContent.Text }; + break; + + case AIDataContent dataContent: + part = new() + { + InlineData = new() + { + MimeType = dataContent.MediaType, + Data = dataContent.Data.ToArray(), + } + }; + break; + + case AIUriContent uriContent: + part = new() + { + FileData = new() + { + FileUri = uriContent.Uri.AbsoluteUri, + MimeType = uriContent.MediaType, + } + }; + break; + + case AIFunctionCallContent functionCallContent: + (callIdToFunctionNames ??= [])[functionCallContent.CallId] = functionCallContent.Name; + callIdToFunctionNames[""] = functionCallContent.Name; // track last function name in case calls don't have IDs + + part = new() + { + FunctionCall = new() + { + Id = functionCallContent.CallId, + Name = functionCallContent.Name, + Args = functionCallContent.Arguments is null ? null : functionCallContent.Arguments as Dictionary ?? new(functionCallContent.Arguments!), + } + }; + break; + + case AIFunctionResultContent functionResultContent: + part = new() + { + FunctionResponse = new() + { + Id = functionResultContent.CallId, + Name = callIdToFunctionNames?.TryGetValue(functionResultContent.CallId, out string? functionName) is true || callIdToFunctionNames?.TryGetValue("", out functionName) is true ? + functionName : + null, + Response = functionResultContent.Result is null ? null : new() { ["result"] = functionResultContent.Result }, + } + }; + break; + } + + if (part is not null) + { + parts.Add(part); + } + } + } + + /// Creates s for and adds them to . + private static void AddAIContentsForParts(List parts, IList contents) + { + foreach (var part in parts) + { + AIContent? content = null; + + if (!string.IsNullOrEmpty(part.Text)) + { + content = new AITextContent(part.Text); + } + else if (part.InlineData is { } inlineData) + { + content = new AIDataContent(inlineData.Data, inlineData.MimeType ?? "application/octet-stream"); + } + else if (part.FileData is { FileUri: not null } fileData) + { + content = new AIUriContent(new Uri(fileData.FileUri), fileData.MimeType ?? "application/octet-stream"); + } + else if (part.FunctionCall is { Name: not null } functionCall) + { + content = new AIFunctionCallContent(functionCall.Id ?? "", functionCall.Name, functionCall.Args!); + } + else if (part.FunctionResponse is { } functionResponse) + { + content = new AIFunctionResultContent( + functionResponse.Id ?? "", + functionResponse.Response?.TryGetValue("output", out var output) is true ? output : + functionResponse.Response?.TryGetValue("error", out var error) is true ? error : + null); + } + + if (content is not null) + { + content.RawRepresentation = part; + contents.Add(content); + } + } + } + + private static ChatFinishReason? PopulateResponseContents(GenerateContentResponse generateResult, IList responseContents) + { + ChatFinishReason? finishReason = null; + + // Populate the response messages. There should only be at most one candidate, but if there are more, ignore all but the first. + if (generateResult.Candidates is { Count: > 0 } && + generateResult.Candidates[0] is { Content: { } candidateContent } candidate) + { + // Grab the finish reason if one exists. + finishReason = ConvertFinishReason(candidate.FinishReason); + + // Add all of the response content parts as AIContents. + if (candidateContent.Parts is { } parts) + { + AddAIContentsForParts(parts, responseContents); + } + } + + // Populate error information if there is any. + if (generateResult.PromptFeedback is { } promptFeedback) + { + responseContents.Add(new ErrorContent(promptFeedback.BlockReasonMessage)); + } + + return finishReason; + } + + /// Creates an M.E.AI from a Google . + private static ChatFinishReason? ConvertFinishReason(FinishReason? finishReason) + { + return finishReason switch + { + null => null, + + FinishReason.MAX_TOKENS => + ChatFinishReason.Length, + + FinishReason.MALFORMED_FUNCTION_CALL or + FinishReason.UNEXPECTED_TOOL_CALL => + ChatFinishReason.ToolCalls, + + FinishReason.FINISH_REASON_UNSPECIFIED or + FinishReason.STOP => + ChatFinishReason.Stop, + + _ => ChatFinishReason.ContentFilter, + }; + } + + /// Creates a populated from the supplied . + private static UsageDetails ExtractUsageDetails(GenerateContentResponseUsageMetadata usageMetadata) + { + UsageDetails details = new() + { + InputTokenCount = usageMetadata.PromptTokenCount, + OutputTokenCount = usageMetadata.CandidatesTokenCount, + TotalTokenCount = usageMetadata.TotalTokenCount, + }; + + AddIfPresent(nameof(usageMetadata.CachedContentTokenCount), usageMetadata.CachedContentTokenCount); + AddIfPresent(nameof(usageMetadata.ThoughtsTokenCount), usageMetadata.ThoughtsTokenCount); + AddIfPresent(nameof(usageMetadata.ToolUsePromptTokenCount), usageMetadata.ToolUsePromptTokenCount); + + return details; + + void AddIfPresent(string key, int? value) + { + if (value is int i) + { + (details.AdditionalCounts ??= [])[key] = i; + } + } + } +} + +#endif From 1586f0e73daf42605bc96fbcc2876ec55a27ea23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:49:00 +0000 Subject: [PATCH 3/7] Add vertexAI parameter to extension methods and update Google.GenAI to 0.6.0 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- .../src/Connectors/Connectors.Google/Connectors.Google.csproj | 2 +- .../Extensions/GoogleAIKernelBuilderExtensions.cs | 3 +++ ...GoogleAIServiceCollectionExtensions.DependencyInjection.cs | 4 +++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 5e7208d61702..2bf66842a8d8 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -35,7 +35,7 @@ - + diff --git a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj index 760f6322fcc6..9b7534700ee2 100644 --- a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj +++ b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj @@ -24,7 +24,7 @@ - + diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs index 3443f5b549db..75ed1d304658 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs @@ -127,6 +127,7 @@ public static IKernelBuilder AddGoogleAIEmbeddingGenerator( /// The kernel builder. /// The model for chat completion. /// The API key for authentication with the Google AI API. + /// Whether to use Vertex AI. /// The optional service ID. /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. @@ -136,6 +137,7 @@ public static IKernelBuilder AddGoogleAIChatClient( this IKernelBuilder builder, string modelId, string apiKey, + bool vertexAI = false, string? serviceId = null, string? openTelemetrySourceName = null, Action? openTelemetryConfig = null) @@ -145,6 +147,7 @@ public static IKernelBuilder AddGoogleAIChatClient( builder.Services.AddGoogleAIChatClient( modelId, apiKey, + vertexAI, serviceId, openTelemetrySourceName, openTelemetryConfig); diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs index 53af05b250a7..20bfd2118aa7 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs @@ -57,6 +57,7 @@ public static IServiceCollection AddGoogleAIEmbeddingGenerator( /// The service collection to add the Google AI Chat Client to. /// The model for chat completion. /// The API key for authentication with the Google AI API. + /// Whether to use Vertex AI. /// Optional service ID. /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. @@ -66,6 +67,7 @@ public static IServiceCollection AddGoogleAIChatClient( this IServiceCollection services, string modelId, string apiKey, + bool vertexAI = false, string? serviceId = null, string? openTelemetrySourceName = null, Action? openTelemetryConfig = null) @@ -78,7 +80,7 @@ IChatClient Factory(IServiceProvider serviceProvider, object? _) { var loggerFactory = serviceProvider.GetService(); - var googleClient = new Google.GenAI.Client(apiKey: apiKey); + var googleClient = new Google.GenAI.Client(apiKey: apiKey, vertexAI: vertexAI); var builder = new GoogleGenAIChatClient(googleClient, modelId) .AsBuilder() From 238fcde50f18998b741d1f47ddb91c7d28ede7a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:48:55 +0000 Subject: [PATCH 4/7] Remove [Experimental] attributes from alpha pre-release APIs Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Connectors.Google.UnitTests.csproj | 2 +- .../Extensions/GoogleAIServiceCollectionExtensionsTests.cs | 7 ------- .../Extensions/GoogleAIKernelBuilderExtensions.cs | 3 --- ...gleAIServiceCollectionExtensions.DependencyInjection.cs | 3 --- 4 files changed, 1 insertion(+), 14 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj b/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj index 1b546a8f4db5..535cb3e6389a 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Connectors.Google.UnitTests.csproj @@ -8,7 +8,7 @@ enable disable false - $(NoWarn);CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0050,SKEXP0070 + $(NoWarn);CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0050 diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs index 6caded591225..6a0e6001e03d 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; @@ -117,7 +116,6 @@ public void GoogleAIEmbeddingGeneratorShouldBeRegisteredInServiceCollection() #if NET [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() { // Arrange @@ -133,7 +131,6 @@ public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() } [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() { // Arrange @@ -149,7 +146,6 @@ public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() } [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithClient() { // Arrange @@ -166,7 +162,6 @@ public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithClient() } [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithClient() { // Arrange @@ -183,7 +178,6 @@ public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithClient() } [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldBeRegisteredWithServiceId() { // Arrange @@ -200,7 +194,6 @@ public void GoogleAIChatClientShouldBeRegisteredWithServiceId() } [Fact] - [Experimental("SKEXP0070")] public void GoogleAIChatClientShouldResolveFromServiceProviderWhenClientNotProvided() { // Arrange diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs index 75ed1d304658..e16e642f262c 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -132,7 +131,6 @@ public static IKernelBuilder AddGoogleAIEmbeddingGenerator( /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated kernel builder. - [Experimental("SKEXP0070")] public static IKernelBuilder AddGoogleAIChatClient( this IKernelBuilder builder, string modelId, @@ -165,7 +163,6 @@ public static IKernelBuilder AddGoogleAIChatClient( /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated kernel builder. - [Experimental("SKEXP0070")] public static IKernelBuilder AddGoogleAIChatClient( this IKernelBuilder builder, string modelId, diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs index 20bfd2118aa7..c5cac0575f60 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -62,7 +61,6 @@ public static IServiceCollection AddGoogleAIEmbeddingGenerator( /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated service collection. - [Experimental("SKEXP0070")] public static IServiceCollection AddGoogleAIChatClient( this IServiceCollection services, string modelId, @@ -110,7 +108,6 @@ IChatClient Factory(IServiceProvider serviceProvider, object? _) /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated service collection. - [Experimental("SKEXP0070")] public static IServiceCollection AddGoogleAIChatClient( this IServiceCollection services, string modelId, From 4f6c91cee045484bf2b9ede80f31863e221610a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:22:31 +0000 Subject: [PATCH 5/7] Add unit tests for GoogleGeminiChatClient and integration tests for ChatClient Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Services/GoogleGeminiChatClientTests.cs | 108 +++++++ .../Google/Gemini/GeminiChatClientTests.cs | 183 ++++++++++++ .../GeminiFunctionCallingChatClientTests.cs | 273 ++++++++++++++++++ .../Connectors/Google/TestsBase.cs | 32 ++ .../IntegrationTests/IntegrationTests.csproj | 1 + 5 files changed, 597 insertions(+) create mode 100644 dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs create mode 100644 dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs create mode 100644 dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs new file mode 100644 index 000000000000..8d2023ca75dc --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if NET + +using System; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Google; +using Xunit; + +namespace SemanticKernel.Connectors.Google.UnitTests.Services; + +public sealed class GoogleGeminiChatClientTests +{ + [Fact] + public void ChatClientShouldBeCreatedWithApiKey() + { + // Arrange + string modelId = "gemini-1.5-pro"; + string apiKey = "test-api-key"; + + // Act + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddGoogleAIChatClient(modelId, apiKey); + var kernel = kernelBuilder.Build(); + + // Assert + var chatClient = kernel.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + public void ChatClientShouldBeCreatedWithGoogleClient() + { + // Arrange + string modelId = "gemini-1.5-pro"; + var googleClient = new global::Google.GenAI.Client(apiKey: "test-api-key"); + + // Act + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddGoogleAIChatClient(modelId, googleClient); + var kernel = kernelBuilder.Build(); + + // Assert + var chatClient = kernel.GetRequiredService(); + Assert.NotNull(chatClient); + } + + [Fact] + public void ChatClientShouldBeCreatedWithServiceId() + { + // Arrange + string modelId = "gemini-1.5-pro"; + string apiKey = "test-api-key"; + string serviceId = "test-service"; + + // Act + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddGoogleAIChatClient(modelId, apiKey, serviceId: serviceId); + var kernel = kernelBuilder.Build(); + + // Assert + var chatClient = kernel.GetRequiredService(serviceId); + Assert.NotNull(chatClient); + } + + [Fact] + public void ChatClientThrowsForNullModelId() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient(null!, "apiKey")); + } + + [Fact] + public void ChatClientThrowsForEmptyModelId() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("", "apiKey")); + } + + [Fact] + public void ChatClientThrowsForNullApiKey() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("modelId", (string)null!)); + } + + [Fact] + public void ChatClientThrowsForEmptyApiKey() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("modelId", "")); + } +} + +#endif diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs new file mode 100644 index 000000000000..03cdeb1b7952 --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using xRetry; +using Xunit; +using Xunit.Abstractions; + +namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; + +public sealed class GeminiChatClientTests(ITestOutputHelper output) : TestsBase(output) +{ + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("Large Language Model", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Brandon", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and write a long story about my name.") + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + Assert.True(responses.Count > 1); + var message = string.Concat(responses.Select(c => c.Text)); + Assert.False(string.IsNullOrWhiteSpace(message)); + this.Output.WriteLine(message); + } + + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.System, "You are helpful assistant. Your name is Roger."), + new ChatMessage(ChatRole.System, "You know ACDD equals 1520"), + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("1520", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Roger", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.System, "You are helpful assistant. Your name is Roger."), + new ChatMessage(ChatRole.System, "You know ACDD equals 1520"), + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + Assert.True(responses.Count > 1); + var message = string.Concat(responses.Select(c => c.Text)); + this.Output.WriteLine(message); + Assert.Contains("1520", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Roger", message, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Usage); + this.Output.WriteLine($"Input tokens: {response.Usage.InputTokenCount}"); + this.Output.WriteLine($"Output tokens: {response.Usage.OutputTokenCount}"); + this.Output.WriteLine($"Total tokens: {response.Usage.TotalTokenCount}"); + } + + [RetryTheory] + [InlineData(false, Skip = "This test is for manual verification.")] + [InlineData(true, Skip = "This test is for manual verification.")] + public async Task ChatClientWithChatOptionsAsync(bool isVertexAI) + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Generate a random number between 1 and 100.") + }; + + var chatOptions = new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 100 + }; + + var sut = this.GetChatClient(isVertexAI); + + // Act + var response = await sut.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs new file mode 100644 index 000000000000..154429b1dc57 --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Google; +using xRetry; +using Xunit; +using Xunit.Abstractions; +using AIFunctionCallContent = Microsoft.Extensions.AI.FunctionCallContent; + +namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; + +public sealed class GeminiFunctionCallingChatClientTests(ITestOutputHelper output) : TestsBase(output) +{ + private const string SkipMessage = "This test is for manual verification."; + + [RetryTheory(Skip = SkipMessage)] + [InlineData(false)] + [InlineData(true)] + public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync(bool isVertexAI) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType(nameof(CustomerPlugin)); + + var sut = this.GetChatClient(isVertexAI); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Select(f => f.AsAIFunction()) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools + }; + + // Act + var response = await sut.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + + var functionCallContent = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionCallContent); + Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory(Skip = SkipMessage)] + [InlineData(false)] + [InlineData(true)] + public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync(bool isVertexAI) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType(nameof(CustomerPlugin)); + + var sut = this.GetChatClient(isVertexAI); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Select(f => f.AsAIFunction()) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools + }; + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory, chatOptions).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + + var functionCallContent = responses + .SelectMany(r => r.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionCallContent); + Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory(Skip = SkipMessage)] + [InlineData(false)] + [InlineData(true)] + public async Task ChatClientWithAutoInvokeFunctionsAsync(bool isVertexAI) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetChatClient(isVertexAI); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Select(f => f.AsAIFunction()) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("John Kowalski", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Anna Nowak", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory(Skip = SkipMessage)] + [InlineData(false)] + [InlineData(true)] + public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync(bool isVertexAI) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetChatClient(isVertexAI); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Select(f => f.AsAIFunction()) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var responses = await autoInvokingClient.GetStreamingResponseAsync(chatHistory, chatOptions).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + var content = string.Concat(responses.Select(c => c.Text)); + this.Output.WriteLine(content); + Assert.Contains("John Kowalski", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Anna Nowak", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryTheory(Skip = SkipMessage)] + [InlineData(false)] + [InlineData(true)] + public async Task ChatClientWithMultipleFunctionCallsAsync(bool isVertexAI) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetChatClient(isVertexAI); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers first and next return age of Anna customer?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Select(f => f.AsAIFunction()) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("28", content, StringComparison.OrdinalIgnoreCase); + } + + public sealed class CustomerPlugin + { + [KernelFunction(nameof(GetCustomers))] + [Description("Get list of customers.")] + [return: Description("List of customers.")] + public string[] GetCustomers() + { + return + [ + "John Kowalski", + "Anna Nowak", + "Steve Smith", + ]; + } + + [KernelFunction(nameof(GetCustomerAge))] + [Description("Get age of customer.")] + [return: Description("Age of customer.")] + public int GetCustomerAge([Description("Name of customer")] string customerName) + { + return customerName switch + { + "John Kowalski" => 35, + "Anna Nowak" => 28, + "Steve Smith" => 42, + _ => throw new ArgumentException("Customer not found."), + }; + } + } + + public sealed class MathPlugin + { + [KernelFunction(nameof(Sum))] + [Description("Sum numbers.")] + public int Sum([Description("Numbers to sum")] int[] numbers) + { + return numbers.Sum(); + } + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs b/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs index 723785497ccd..53a0880e345b 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs @@ -3,6 +3,8 @@ using System; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Google; using Microsoft.SemanticKernel.Embeddings; @@ -65,6 +67,36 @@ protected TestsBase(ITestOutputHelper output) _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, null) }; + protected IChatClient GetChatClient(bool isVertexAI, string? overrideModelId = null) + { + var modelId = isVertexAI + ? overrideModelId ?? this.VertexAI.Gemini.ModelId + : overrideModelId ?? this.GoogleAI.Gemini.ModelId; + + var apiKey = isVertexAI ? this.VertexAI.BearerKey : this.GoogleAI.ApiKey; + + var kernel = Kernel.CreateBuilder() + .AddGoogleAIChatClient(modelId, apiKey, vertexAI: isVertexAI) + .Build(); + + return kernel.GetRequiredService(); + } + + protected IChatClient GetChatClientWithVision(bool isVertexAI) + { + var modelId = isVertexAI + ? this.VertexAI.Gemini.VisionModelId + : this.GoogleAI.Gemini.VisionModelId; + + var apiKey = isVertexAI ? this.VertexAI.BearerKey : this.GoogleAI.ApiKey; + + var kernel = Kernel.CreateBuilder() + .AddGoogleAIChatClient(modelId, apiKey, vertexAI: isVertexAI) + .Build(); + + return kernel.GetRequiredService(); + } + [Obsolete("Temporary test utility for Obsolete ITextEmbeddingGenerationService")] protected ITextEmbeddingGenerationService GetEmbeddingService(ServiceType serviceType) => serviceType switch { diff --git a/dotnet/src/IntegrationTests/IntegrationTests.csproj b/dotnet/src/IntegrationTests/IntegrationTests.csproj index d0e45a75f94f..ec65cb12f288 100644 --- a/dotnet/src/IntegrationTests/IntegrationTests.csproj +++ b/dotnet/src/IntegrationTests/IntegrationTests.csproj @@ -41,6 +41,7 @@ + From f9f08fbf1d1ffd9b99ac275fba5b5709885da98c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:01:57 +0000 Subject: [PATCH 6/7] Improvements --- ...oogleAIServiceCollectionExtensionsTests.cs | 7 ++-- .../Services/GoogleGeminiChatClientTests.cs | 6 +-- .../Google/Gemini/GeminiChatClientTests.cs | 39 ++++++++++--------- .../GeminiFunctionCallingChatClientTests.cs | 14 ++----- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs index 6a0e6001e03d..3fc6265202cd 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Google.GenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; @@ -150,7 +151,7 @@ public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithClient() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); - var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + using var googleClient = new Client(apiKey: "apiKey"); // Act kernelBuilder.AddGoogleAIChatClient("modelId", googleClient); @@ -166,7 +167,7 @@ public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithClient() { // Arrange var services = new ServiceCollection(); - var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + using var googleClient = new Client(apiKey: "apiKey"); // Act services.AddGoogleAIChatClient("modelId", googleClient); @@ -198,7 +199,7 @@ public void GoogleAIChatClientShouldResolveFromServiceProviderWhenClientNotProvi { // Arrange var services = new ServiceCollection(); - var googleClient = new global::Google.GenAI.Client(apiKey: "apiKey"); + using var googleClient = new Client(apiKey: "apiKey"); services.AddSingleton(googleClient); // Act diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs index 8d2023ca75dc..e904c8d5d4b5 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs @@ -1,11 +1,11 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. #if NET using System; +using Google.GenAI; using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Google; using Xunit; namespace SemanticKernel.Connectors.Google.UnitTests.Services; @@ -34,7 +34,7 @@ public void ChatClientShouldBeCreatedWithGoogleClient() { // Arrange string modelId = "gemini-1.5-pro"; - var googleClient = new global::Google.GenAI.Client(apiKey: "test-api-key"); + using var googleClient = new Client(apiKey: "test-api-key"); // Act var kernelBuilder = Kernel.CreateBuilder(); diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs index 03cdeb1b7952..ca3d8c752005 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; @@ -12,9 +12,10 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; public sealed class GeminiChatClientTests(ITestOutputHelper output) : TestsBase(output) { - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + private const string SkipReason = "This test is for manual verification."; + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) { // Arrange @@ -40,9 +41,9 @@ public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) Assert.Contains("Brandon", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) { // Arrange @@ -66,9 +67,9 @@ public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) this.Output.WriteLine(message); } - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) { // Arrange @@ -96,9 +97,9 @@ public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) Assert.Contains("Roger", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) { // Arrange @@ -125,9 +126,9 @@ public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) Assert.Contains("Roger", message, StringComparison.OrdinalIgnoreCase); } - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) { // Arrange @@ -151,9 +152,9 @@ public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) this.Output.WriteLine($"Total tokens: {response.Usage.TotalTokenCount}"); } - [RetryTheory] - [InlineData(false, Skip = "This test is for manual verification.")] - [InlineData(true, Skip = "This test is for manual verification.")] + [RetryTheory(Skip = SkipReason)] + [InlineData(false)] + [InlineData(true)] public async Task ChatClientWithChatOptionsAsync(bool isVertexAI) { // Arrange diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs index 154429b1dc57..4b8fbecd5fdf 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.ComponentModel; @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Google; using xRetry; using Xunit; using Xunit.Abstractions; @@ -36,7 +35,6 @@ public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync(bool isVert var tools = kernel.Plugins .SelectMany(p => p) - .Select(f => f.AsAIFunction()) .Cast() .ToList(); @@ -80,7 +78,6 @@ public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync(bo var tools = kernel.Plugins .SelectMany(p => p) - .Select(f => f.AsAIFunction()) .Cast() .ToList(); @@ -122,7 +119,6 @@ public async Task ChatClientWithAutoInvokeFunctionsAsync(bool isVertexAI) var tools = kernel.Plugins .SelectMany(p => p) - .Select(f => f.AsAIFunction()) .Cast() .ToList(); @@ -133,7 +129,7 @@ public async Task ChatClientWithAutoInvokeFunctionsAsync(bool isVertexAI) }; // Use FunctionInvokingChatClient for auto-invoke - var autoInvokingClient = new FunctionInvokingChatClient(sut); + using var autoInvokingClient = new FunctionInvokingChatClient(sut); // Act var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); @@ -165,7 +161,6 @@ public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync(bool isVertexA var tools = kernel.Plugins .SelectMany(p => p) - .Select(f => f.AsAIFunction()) .Cast() .ToList(); @@ -176,7 +171,7 @@ public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync(bool isVertexA }; // Use FunctionInvokingChatClient for auto-invoke - var autoInvokingClient = new FunctionInvokingChatClient(sut); + using var autoInvokingClient = new FunctionInvokingChatClient(sut); // Act var responses = await autoInvokingClient.GetStreamingResponseAsync(chatHistory, chatOptions).ToListAsync(); @@ -208,7 +203,6 @@ public async Task ChatClientWithMultipleFunctionCallsAsync(bool isVertexAI) var tools = kernel.Plugins .SelectMany(p => p) - .Select(f => f.AsAIFunction()) .Cast() .ToList(); @@ -219,7 +213,7 @@ public async Task ChatClientWithMultipleFunctionCallsAsync(bool isVertexAI) }; // Use FunctionInvokingChatClient for auto-invoke - var autoInvokingClient = new FunctionInvokingChatClient(sut); + using var autoInvokingClient = new FunctionInvokingChatClient(sut); // Act var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); From c8be25152b3f5782a88d7ffe85b04deff109fcaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:31:27 +0000 Subject: [PATCH 7/7] Split ChatClient extensions into AddGoogleGenAIChatClient and AddGoogleVertexAIChatClient Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- ...oogleAIServiceCollectionExtensionsTests.cs | 58 +++- .../Services/GoogleGeminiChatClientTests.cs | 77 +++++- .../Connectors.Google.csproj | 1 + .../GoogleAIKernelBuilderExtensions.cs | 47 +++- ...ollectionExtensions.DependencyInjection.cs | 61 ++++- .../Google/Gemini/GeminiChatClientTests.cs | 51 ++-- .../GeminiFunctionCallingChatClientTests.cs | 42 ++- .../Gemini/GeminiVertexAIChatClientTests.cs | 173 ++++++++++++ ...iVertexAIFunctionCallingChatClientTests.cs | 257 ++++++++++++++++++ .../Connectors/Google/TestsBase.cs | 40 ++- 10 files changed, 706 insertions(+), 101 deletions(-) create mode 100644 dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIChatClientTests.cs create mode 100644 dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIFunctionCallingChatClientTests.cs diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs index 3fc6265202cd..fc05f3aaefaf 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Extensions/GoogleAIServiceCollectionExtensionsTests.cs @@ -117,13 +117,13 @@ public void GoogleAIEmbeddingGeneratorShouldBeRegisteredInServiceCollection() #if NET [Fact] - public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() + public void GoogleGenAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); // Act - kernelBuilder.AddGoogleAIChatClient("modelId", "apiKey"); + kernelBuilder.AddGoogleGenAIChatClient("modelId", "apiKey"); var kernel = kernelBuilder.Build(); // Assert @@ -132,13 +132,13 @@ public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithApiKey() } [Fact] - public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() + public void GoogleGenAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() { // Arrange var services = new ServiceCollection(); // Act - services.AddGoogleAIChatClient("modelId", "apiKey"); + services.AddGoogleGenAIChatClient("modelId", "apiKey"); var serviceProvider = services.BuildServiceProvider(); // Assert @@ -146,6 +146,36 @@ public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithApiKey() Assert.NotNull(chatClient); } + [Fact] + public void GoogleVertexAIChatClientShouldBeRegisteredInKernelServices() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act + kernelBuilder.AddGoogleVertexAIChatClient("modelId", project: "test-project", location: "us-central1"); + + // Assert - just verify no exception during registration + // Resolution requires real credentials, so skip that in unit tests + var kernel = kernelBuilder.Build(); + Assert.NotNull(kernel.Services); + } + + [Fact] + public void GoogleVertexAIChatClientShouldBeRegisteredInServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddGoogleVertexAIChatClient("modelId", project: "test-project", location: "us-central1"); + var serviceProvider = services.BuildServiceProvider(); + + // Assert - just verify no exception during registration + // Resolution requires real credentials, so skip that in unit tests + Assert.NotNull(serviceProvider); + } + [Fact] public void GoogleAIChatClientShouldBeRegisteredInKernelServicesWithClient() { @@ -179,14 +209,14 @@ public void GoogleAIChatClientShouldBeRegisteredInServiceCollectionWithClient() } [Fact] - public void GoogleAIChatClientShouldBeRegisteredWithServiceId() + public void GoogleGenAIChatClientShouldBeRegisteredWithServiceId() { // Arrange var services = new ServiceCollection(); const string serviceId = "test-service-id"; // Act - services.AddGoogleAIChatClient("modelId", "apiKey", serviceId: serviceId); + services.AddGoogleGenAIChatClient("modelId", "apiKey", serviceId: serviceId); var serviceProvider = services.BuildServiceProvider(); // Assert @@ -194,6 +224,22 @@ public void GoogleAIChatClientShouldBeRegisteredWithServiceId() Assert.NotNull(chatClient); } + [Fact] + public void GoogleVertexAIChatClientShouldBeRegisteredWithServiceId() + { + // Arrange + var services = new ServiceCollection(); + const string serviceId = "test-service-id"; + + // Act + services.AddGoogleVertexAIChatClient("modelId", project: "test-project", location: "us-central1", serviceId: serviceId); + var serviceProvider = services.BuildServiceProvider(); + + // Assert - just verify no exception during registration + // Resolution requires real credentials, so skip that in unit tests + Assert.NotNull(serviceProvider); + } + [Fact] public void GoogleAIChatClientShouldResolveFromServiceProviderWhenClientNotProvided() { diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs index e904c8d5d4b5..91bf5435efdc 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Services/GoogleGeminiChatClientTests.cs @@ -13,7 +13,7 @@ namespace SemanticKernel.Connectors.Google.UnitTests.Services; public sealed class GoogleGeminiChatClientTests { [Fact] - public void ChatClientShouldBeCreatedWithApiKey() + public void GenAIChatClientShouldBeCreatedWithApiKey() { // Arrange string modelId = "gemini-1.5-pro"; @@ -21,7 +21,7 @@ public void ChatClientShouldBeCreatedWithApiKey() // Act var kernelBuilder = Kernel.CreateBuilder(); - kernelBuilder.AddGoogleAIChatClient(modelId, apiKey); + kernelBuilder.AddGoogleGenAIChatClient(modelId, apiKey); var kernel = kernelBuilder.Build(); // Assert @@ -29,6 +29,22 @@ public void ChatClientShouldBeCreatedWithApiKey() Assert.NotNull(chatClient); } + [Fact] + public void VertexAIChatClientShouldBeCreated() + { + // Arrange + string modelId = "gemini-1.5-pro"; + + // Act + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddGoogleVertexAIChatClient(modelId, project: "test-project", location: "us-central1"); + var kernel = kernelBuilder.Build(); + + // Assert - just verify no exception during registration + // Resolution requires real credentials, so skip that in unit tests + Assert.NotNull(kernel.Services); + } + [Fact] public void ChatClientShouldBeCreatedWithGoogleClient() { @@ -47,7 +63,7 @@ public void ChatClientShouldBeCreatedWithGoogleClient() } [Fact] - public void ChatClientShouldBeCreatedWithServiceId() + public void GenAIChatClientShouldBeCreatedWithServiceId() { // Arrange string modelId = "gemini-1.5-pro"; @@ -56,7 +72,7 @@ public void ChatClientShouldBeCreatedWithServiceId() // Act var kernelBuilder = Kernel.CreateBuilder(); - kernelBuilder.AddGoogleAIChatClient(modelId, apiKey, serviceId: serviceId); + kernelBuilder.AddGoogleGenAIChatClient(modelId, apiKey, serviceId: serviceId); var kernel = kernelBuilder.Build(); // Assert @@ -65,43 +81,80 @@ public void ChatClientShouldBeCreatedWithServiceId() } [Fact] - public void ChatClientThrowsForNullModelId() + public void VertexAIChatClientShouldBeCreatedWithServiceId() + { + // Arrange + string modelId = "gemini-1.5-pro"; + string serviceId = "test-service"; + + // Act + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddGoogleVertexAIChatClient(modelId, project: "test-project", location: "us-central1", serviceId: serviceId); + var kernel = kernelBuilder.Build(); + + // Assert - just verify no exception during registration + // Resolution requires real credentials, so skip that in unit tests + Assert.NotNull(kernel.Services); + } + + [Fact] + public void GenAIChatClientThrowsForNullModelId() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleGenAIChatClient(null!, "apiKey")); + } + + [Fact] + public void GenAIChatClientThrowsForEmptyModelId() + { + // Arrange + var kernelBuilder = Kernel.CreateBuilder(); + + // Act & Assert + Assert.ThrowsAny(() => kernelBuilder.AddGoogleGenAIChatClient("", "apiKey")); + } + + [Fact] + public void GenAIChatClientThrowsForNullApiKey() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); // Act & Assert - Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient(null!, "apiKey")); + Assert.ThrowsAny(() => kernelBuilder.AddGoogleGenAIChatClient("modelId", null!)); } [Fact] - public void ChatClientThrowsForEmptyModelId() + public void GenAIChatClientThrowsForEmptyApiKey() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); // Act & Assert - Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("", "apiKey")); + Assert.ThrowsAny(() => kernelBuilder.AddGoogleGenAIChatClient("modelId", "")); } [Fact] - public void ChatClientThrowsForNullApiKey() + public void VertexAIChatClientThrowsForNullModelId() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); // Act & Assert - Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("modelId", (string)null!)); + Assert.ThrowsAny(() => kernelBuilder.AddGoogleVertexAIChatClient(null!, project: "test-project", location: "us-central1")); } [Fact] - public void ChatClientThrowsForEmptyApiKey() + public void VertexAIChatClientThrowsForEmptyModelId() { // Arrange var kernelBuilder = Kernel.CreateBuilder(); // Act & Assert - Assert.ThrowsAny(() => kernelBuilder.AddGoogleAIChatClient("modelId", "")); + Assert.ThrowsAny(() => kernelBuilder.AddGoogleVertexAIChatClient("", project: "test-project", location: "us-central1")); } } diff --git a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj index 9b7534700ee2..7e104ef8b230 100644 --- a/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj +++ b/dotnet/src/Connectors/Connectors.Google/Connectors.Google.csproj @@ -26,6 +26,7 @@ + diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs index e16e642f262c..72518e91aaf8 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIKernelBuilderExtensions.cs @@ -121,31 +121,64 @@ public static IKernelBuilder AddGoogleAIEmbeddingGenerator( #if NET /// - /// Add Google AI to the . + /// Add Google GenAI to the . /// /// The kernel builder. /// The model for chat completion. - /// The API key for authentication with the Google AI API. - /// Whether to use Vertex AI. + /// The API key for authentication with the Google GenAI API. /// The optional service ID. /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated kernel builder. - public static IKernelBuilder AddGoogleAIChatClient( + public static IKernelBuilder AddGoogleGenAIChatClient( this IKernelBuilder builder, string modelId, string apiKey, - bool vertexAI = false, string? serviceId = null, string? openTelemetrySourceName = null, Action? openTelemetryConfig = null) { Verify.NotNull(builder); - builder.Services.AddGoogleAIChatClient( + builder.Services.AddGoogleGenAIChatClient( modelId, apiKey, - vertexAI, + serviceId, + openTelemetrySourceName, + openTelemetryConfig); + + return builder; + } + + /// + /// Add Google Vertex AI to the . + /// + /// The kernel builder. + /// The model for chat completion. + /// The Google Cloud project ID. If null, will attempt to use the GOOGLE_CLOUD_PROJECT environment variable. + /// The Google Cloud location (e.g., "us-central1"). If null, will attempt to use the GOOGLE_CLOUD_LOCATION environment variable. + /// The optional for authentication. If null, the client will use its internal discovery implementation to get credentials from the environment. + /// The optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated kernel builder. + public static IKernelBuilder AddGoogleVertexAIChatClient( + this IKernelBuilder builder, + string modelId, + string? project = null, + string? location = null, + Google.Apis.Auth.OAuth2.ICredential? credential = null, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(builder); + + builder.Services.AddGoogleVertexAIChatClient( + modelId, + project, + location, + credential, serviceId, openTelemetrySourceName, openTelemetryConfig); diff --git a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs index c5cac0575f60..bb094ef08cde 100644 --- a/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs +++ b/dotnet/src/Connectors/Connectors.Google/Extensions/GoogleAIServiceCollectionExtensions.DependencyInjection.cs @@ -51,21 +51,19 @@ public static IServiceCollection AddGoogleAIEmbeddingGenerator( #if NET /// - /// Add Google AI to the specified service collection. + /// Add Google GenAI to the specified service collection. /// - /// The service collection to add the Google AI Chat Client to. + /// The service collection to add the Google GenAI Chat Client to. /// The model for chat completion. - /// The API key for authentication with the Google AI API. - /// Whether to use Vertex AI. + /// The API key for authentication with the Google GenAI API. /// Optional service ID. /// An optional name for the OpenTelemetry source. /// An optional callback that can be used to configure the instance. /// The updated service collection. - public static IServiceCollection AddGoogleAIChatClient( + public static IServiceCollection AddGoogleGenAIChatClient( this IServiceCollection services, string modelId, string apiKey, - bool vertexAI = false, string? serviceId = null, string? openTelemetrySourceName = null, Action? openTelemetryConfig = null) @@ -78,7 +76,56 @@ IChatClient Factory(IServiceProvider serviceProvider, object? _) { var loggerFactory = serviceProvider.GetService(); - var googleClient = new Google.GenAI.Client(apiKey: apiKey, vertexAI: vertexAI); + var googleClient = new Google.GenAI.Client(apiKey: apiKey); + + var builder = new GoogleGenAIChatClient(googleClient, modelId) + .AsBuilder() + .UseKernelFunctionInvocation(loggerFactory) + .UseOpenTelemetry(loggerFactory, openTelemetrySourceName, openTelemetryConfig); + + if (loggerFactory is not null) + { + builder.UseLogging(loggerFactory); + } + + return builder.Build(); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + /// + /// Add Google Vertex AI to the specified service collection. + /// + /// The service collection to add the Google Vertex AI Chat Client to. + /// The model for chat completion. + /// The Google Cloud project ID. If null, will attempt to use the GOOGLE_CLOUD_PROJECT environment variable. + /// The Google Cloud location (e.g., "us-central1"). If null, will attempt to use the GOOGLE_CLOUD_LOCATION environment variable. + /// The optional for authentication. If null, the client will use its internal discovery implementation to get credentials from the environment. + /// Optional service ID. + /// An optional name for the OpenTelemetry source. + /// An optional callback that can be used to configure the instance. + /// The updated service collection. + public static IServiceCollection AddGoogleVertexAIChatClient( + this IServiceCollection services, + string modelId, + string? project = null, + string? location = null, + Google.Apis.Auth.OAuth2.ICredential? credential = null, + string? serviceId = null, + string? openTelemetrySourceName = null, + Action? openTelemetryConfig = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + var googleClient = new Google.GenAI.Client(vertexAI: true, credential: credential, project: project, location: location); var builder = new GoogleGenAIChatClient(googleClient, modelId) .AsBuilder() diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs index ca3d8c752005..cf649b24a09f 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiChatClientTests.cs @@ -10,13 +10,12 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; -public sealed class GeminiChatClientTests(ITestOutputHelper output) : TestsBase(output) +public sealed class GeminiGenAIChatClientTests(ITestOutputHelper output) : TestsBase(output) { private const string SkipReason = "This test is for manual verification."; - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientGenerationReturnsValidResponseAsync() { // Arrange var chatHistory = new[] @@ -26,7 +25,7 @@ public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var response = await sut.GetResponseAsync(chatHistory); @@ -41,10 +40,8 @@ public async Task ChatClientGenerationReturnsValidResponseAsync(bool isVertexAI) Assert.Contains("Brandon", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) + [RetryFact(Skip = SkipReason)] + public async Task ChatClientStreamingReturnsValidResponseAsync() { // Arrange var chatHistory = new[] @@ -54,7 +51,7 @@ public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) new ChatMessage(ChatRole.User, "Call me by my name and write a long story about my name.") }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); @@ -67,10 +64,8 @@ public async Task ChatClientStreamingReturnsValidResponseAsync(bool isVertexAI) this.Output.WriteLine(message); } - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) + [RetryFact(Skip = SkipReason)] + public async Task ChatClientWithSystemMessagesAsync() { // Arrange var chatHistory = new[] @@ -82,7 +77,7 @@ public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var response = await sut.GetResponseAsync(chatHistory); @@ -97,10 +92,8 @@ public async Task ChatClientWithSystemMessagesAsync(bool isVertexAI) Assert.Contains("Roger", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) + [RetryFact(Skip = SkipReason)] + public async Task ChatClientStreamingWithSystemMessagesAsync() { // Arrange var chatHistory = new[] @@ -112,7 +105,7 @@ public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); @@ -126,10 +119,8 @@ public async Task ChatClientStreamingWithSystemMessagesAsync(bool isVertexAI) Assert.Contains("Roger", message, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) + [RetryFact(Skip = SkipReason)] + public async Task ChatClientReturnsUsageDetailsAsync() { // Arrange var chatHistory = new[] @@ -139,7 +130,7 @@ public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var response = await sut.GetResponseAsync(chatHistory); @@ -152,10 +143,8 @@ public async Task ChatClientReturnsUsageDetailsAsync(bool isVertexAI) this.Output.WriteLine($"Total tokens: {response.Usage.TotalTokenCount}"); } - [RetryTheory(Skip = SkipReason)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientWithChatOptionsAsync(bool isVertexAI) + [RetryFact(Skip = SkipReason)] + public async Task ChatClientWithChatOptionsAsync() { // Arrange var chatHistory = new[] @@ -169,7 +158,7 @@ public async Task ChatClientWithChatOptionsAsync(bool isVertexAI) MaxOutputTokens = 100 }; - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); // Act var response = await sut.GetResponseAsync(chatHistory, chatOptions); diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs index 4b8fbecd5fdf..9173365a60b9 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingChatClientTests.cs @@ -13,20 +13,18 @@ namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; -public sealed class GeminiFunctionCallingChatClientTests(ITestOutputHelper output) : TestsBase(output) +public sealed class GeminiGenAIFunctionCallingChatClientTests(ITestOutputHelper output) : TestsBase(output) { private const string SkipMessage = "This test is for manual verification."; - [RetryTheory(Skip = SkipMessage)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync(bool isVertexAI) + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync() { // Arrange var kernel = new Kernel(); kernel.ImportPluginFromType(nameof(CustomerPlugin)); - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); var chatHistory = new[] { @@ -60,16 +58,14 @@ public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync(bool isVert Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipMessage)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync(bool isVertexAI) + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync() { // Arrange var kernel = new Kernel(); kernel.ImportPluginFromType(nameof(CustomerPlugin)); - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); var chatHistory = new[] { @@ -101,16 +97,14 @@ public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync(bo Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipMessage)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientWithAutoInvokeFunctionsAsync(bool isVertexAI) + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithAutoInvokeFunctionsAsync() { // Arrange var kernel = new Kernel(); kernel.ImportPluginFromType("CustomerPlugin"); - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); var chatHistory = new[] { @@ -143,16 +137,14 @@ public async Task ChatClientWithAutoInvokeFunctionsAsync(bool isVertexAI) Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipMessage)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync(bool isVertexAI) + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync() { // Arrange var kernel = new Kernel(); kernel.ImportPluginFromType("CustomerPlugin"); - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); var chatHistory = new[] { @@ -185,16 +177,14 @@ public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync(bool isVertexA Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); } - [RetryTheory(Skip = SkipMessage)] - [InlineData(false)] - [InlineData(true)] - public async Task ChatClientWithMultipleFunctionCallsAsync(bool isVertexAI) + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithMultipleFunctionCallsAsync() { // Arrange var kernel = new Kernel(); kernel.ImportPluginFromType("CustomerPlugin"); - var sut = this.GetChatClient(isVertexAI); + var sut = this.GetGenAIChatClient(); var chatHistory = new[] { diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIChatClientTests.cs new file mode 100644 index 000000000000..9ccca355133c --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIChatClientTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using xRetry; +using Xunit; +using Xunit.Abstractions; + +namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; + +public sealed class GeminiVertexAIChatClientTests(ITestOutputHelper output) : TestsBase(output) +{ + private const string SkipReason = "This test is for manual verification."; + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientGenerationReturnsValidResponseAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("Large Language Model", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Brandon", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientStreamingReturnsValidResponseAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and write a long story about my name.") + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + Assert.True(responses.Count > 1); + var message = string.Concat(responses.Select(c => c.Text)); + Assert.False(string.IsNullOrWhiteSpace(message)); + this.Output.WriteLine(message); + } + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientWithSystemMessagesAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.System, "You are helpful assistant. Your name is Roger."), + new ChatMessage(ChatRole.System, "You know ACDD equals 1520"), + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("1520", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Roger", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientStreamingWithSystemMessagesAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.System, "You are helpful assistant. Your name is Roger."), + new ChatMessage(ChatRole.System, "You know ACDD equals 1520"), + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Tell me your name and the value of ACDD.") + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + Assert.True(responses.Count > 1); + var message = string.Concat(responses.Select(c => c.Text)); + this.Output.WriteLine(message); + Assert.Contains("1520", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Roger", message, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientReturnsUsageDetailsAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, I'm Brandon, how are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks for asking."), + new ChatMessage(ChatRole.User, "Call me by my name and expand this abbreviation: LLM") + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var response = await sut.GetResponseAsync(chatHistory); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Usage); + this.Output.WriteLine($"Input tokens: {response.Usage.InputTokenCount}"); + this.Output.WriteLine($"Output tokens: {response.Usage.OutputTokenCount}"); + this.Output.WriteLine($"Total tokens: {response.Usage.TotalTokenCount}"); + } + + [RetryFact(Skip = SkipReason)] + public async Task ChatClientWithChatOptionsAsync() + { + // Arrange + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Generate a random number between 1 and 100.") + }; + + var chatOptions = new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 100 + }; + + var sut = this.GetVertexAIChatClient(); + + // Act + var response = await sut.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIFunctionCallingChatClientTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIFunctionCallingChatClientTests.cs new file mode 100644 index 000000000000..964260a69f6f --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiVertexAIFunctionCallingChatClientTests.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using xRetry; +using Xunit; +using Xunit.Abstractions; +using AIFunctionCallContent = Microsoft.Extensions.AI.FunctionCallContent; + +namespace SemanticKernel.IntegrationTests.Connectors.Google.Gemini; + +public sealed class GeminiVertexAIFunctionCallingChatClientTests(ITestOutputHelper output) : TestsBase(output) +{ + private const string SkipMessage = "This test is for manual verification."; + + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithFunctionCallingReturnsToolCallsAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType(nameof(CustomerPlugin)); + + var sut = this.GetVertexAIChatClient(); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools + }; + + // Act + var response = await sut.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Messages); + Assert.NotEmpty(response.Messages); + + var functionCallContent = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionCallContent); + Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientStreamingWithFunctionCallingReturnsToolCallsAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType(nameof(CustomerPlugin)); + + var sut = this.GetVertexAIChatClient(); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools + }; + + // Act + var responses = await sut.GetStreamingResponseAsync(chatHistory, chatOptions).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + + var functionCallContent = responses + .SelectMany(r => r.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionCallContent); + Assert.Contains("GetCustomers", functionCallContent.Name, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithAutoInvokeFunctionsAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetVertexAIChatClient(); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + using var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("John Kowalski", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Anna Nowak", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientStreamingWithAutoInvokeFunctionsAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetVertexAIChatClient(); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + using var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var responses = await autoInvokingClient.GetStreamingResponseAsync(chatHistory, chatOptions).ToListAsync(); + + // Assert + Assert.NotEmpty(responses); + var content = string.Concat(responses.Select(c => c.Text)); + this.Output.WriteLine(content); + Assert.Contains("John Kowalski", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Anna Nowak", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Steve Smith", content, StringComparison.OrdinalIgnoreCase); + } + + [RetryFact(Skip = SkipMessage)] + public async Task ChatClientWithMultipleFunctionCallsAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType("CustomerPlugin"); + + var sut = this.GetVertexAIChatClient(); + + var chatHistory = new[] + { + new ChatMessage(ChatRole.User, "Hello, could you show me list of customers first and next return age of Anna customer?") + }; + + var tools = kernel.Plugins + .SelectMany(p => p) + .Cast() + .ToList(); + + var chatOptions = new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.Auto + }; + + // Use FunctionInvokingChatClient for auto-invoke + using var autoInvokingClient = new FunctionInvokingChatClient(sut); + + // Act + var response = await autoInvokingClient.GetResponseAsync(chatHistory, chatOptions); + + // Assert + Assert.NotNull(response); + var content = string.Join("", response.Messages.Select(m => m.Text)); + this.Output.WriteLine(content); + Assert.Contains("28", content, StringComparison.OrdinalIgnoreCase); + } + + public sealed class CustomerPlugin + { + [KernelFunction(nameof(GetCustomers))] + [Description("Get list of customers.")] + [return: Description("List of customers.")] + public string[] GetCustomers() + { + return + [ + "John Kowalski", + "Anna Nowak", + "Steve Smith", + ]; + } + + [KernelFunction(nameof(GetCustomerAge))] + [Description("Get age of customer.")] + [return: Description("Age of customer.")] + public int GetCustomerAge([Description("Name of customer")] string customerName) + { + return customerName switch + { + "John Kowalski" => 35, + "Anna Nowak" => 28, + "Steve Smith" => 42, + _ => throw new ArgumentException("Customer not found."), + }; + } + } + + public sealed class MathPlugin + { + [KernelFunction(nameof(Sum))] + [Description("Sum numbers.")] + public int Sum([Description("Numbers to sum")] int[] numbers) + { + return numbers.Sum(); + } + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs b/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs index 53a0880e345b..7e6bb8a45f54 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/TestsBase.cs @@ -67,31 +67,47 @@ protected TestsBase(ITestOutputHelper output) _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, null) }; - protected IChatClient GetChatClient(bool isVertexAI, string? overrideModelId = null) + protected IChatClient GetGenAIChatClient(string? overrideModelId = null) { - var modelId = isVertexAI - ? overrideModelId ?? this.VertexAI.Gemini.ModelId - : overrideModelId ?? this.GoogleAI.Gemini.ModelId; + var modelId = overrideModelId ?? this.GoogleAI.Gemini.ModelId; + var apiKey = this.GoogleAI.ApiKey; - var apiKey = isVertexAI ? this.VertexAI.BearerKey : this.GoogleAI.ApiKey; + var kernel = Kernel.CreateBuilder() + .AddGoogleGenAIChatClient(modelId, apiKey) + .Build(); + + return kernel.GetRequiredService(); + } + + protected IChatClient GetVertexAIChatClient(string? overrideModelId = null) + { + var modelId = overrideModelId ?? this.VertexAI.Gemini.ModelId; var kernel = Kernel.CreateBuilder() - .AddGoogleAIChatClient(modelId, apiKey, vertexAI: isVertexAI) + .AddGoogleVertexAIChatClient(modelId, project: this.VertexAI.ProjectId, location: this.VertexAI.Location) .Build(); return kernel.GetRequiredService(); } - protected IChatClient GetChatClientWithVision(bool isVertexAI) + protected IChatClient GetGenAIChatClientWithVision() { - var modelId = isVertexAI - ? this.VertexAI.Gemini.VisionModelId - : this.GoogleAI.Gemini.VisionModelId; + var modelId = this.GoogleAI.Gemini.VisionModelId; + var apiKey = this.GoogleAI.ApiKey; - var apiKey = isVertexAI ? this.VertexAI.BearerKey : this.GoogleAI.ApiKey; + var kernel = Kernel.CreateBuilder() + .AddGoogleGenAIChatClient(modelId, apiKey) + .Build(); + + return kernel.GetRequiredService(); + } + + protected IChatClient GetVertexAIChatClientWithVision() + { + var modelId = this.VertexAI.Gemini.VisionModelId; var kernel = Kernel.CreateBuilder() - .AddGoogleAIChatClient(modelId, apiKey, vertexAI: isVertexAI) + .AddGoogleVertexAIChatClient(modelId, project: this.VertexAI.ProjectId, location: this.VertexAI.Location) .Build(); return kernel.GetRequiredService();