diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index b340fed5f7..58a28f76c4 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -25,6 +25,9 @@ + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 70ea1603c0..816ffdb678 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -52,6 +52,7 @@ + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj new file mode 100644 index 0000000000..cf6774e8d8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);IDE0059 + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs new file mode 100644 index 0000000000..2a1d47a456 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GeminiChatClient.cs @@ -0,0 +1,558 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using Google.Apis.Util; +using Google.GenAI; +using Google.GenAI.Types; + +namespace Microsoft.Extensions.AI; + +/// Provides an implementation based on . +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. + public GoogleGenAIChatClient(Client client, string? defaultModelId) + { + this._client = client; + this._models = client.Models; + this._defaultModelId = defaultModelId; + } + + /// Initializes a new instance. + public GoogleGenAIChatClient(Models client, string? defaultModelId) + { + this._models = client; + this._defaultModelId = defaultModelId; + } + + /// + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Utilities.ThrowIfNull(messages, nameof(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) + { + Utilities.ThrowIfNull(messages, nameof(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) + { + Utilities.ThrowIfNull(serviceType, nameof(serviceType)); + + if (serviceKey is null) + { + // If there's a request for metadata, lazily-initialize it and return it. We don't need to worry about race conditions, + // as there's no requirement that the same instance be returned each time, and creation is idempotent. + if (serviceType == typeof(ChatClientMetadata)) + { + return this._metadata ??= new("gcp.gen_ai", new("https://generativelanguage.googleapis.com/"), defaultModelId: this._defaultModelId); + } + + // Allow a consumer to "break glass" and 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 = new(); + 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 ??= new()).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 ??= new()).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 AIFunctionDeclaration af: + functionDeclarations ??= new(); + functionDeclarations.Add(new() + { + Name = af.Name, + Description = af.Description ?? "", + ParametersJsonSchema = af.JsonSchema, + }); + break; + + case HostedCodeInterpreterTool: + (config.Tools ??= new()).Add(new() { CodeExecution = new() }); + break; + + case HostedFileSearchTool: + (config.Tools ??= new()).Add(new() { Retrieval = new() }); + break; + + case HostedWebSearchTool: + (config.Tools ??= new()).Add(new() { GoogleSearch = new() }); + break; + } + } + } + + if (functionDeclarations is { Count: > 0 }) + { + Tool functionTools = new(); + (functionTools.FunctionDeclarations ??= new()).AddRange(functionDeclarations); + (config.Tools ??= new()).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 ??= new()).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 ??= new()).Add(new() { Text = instruction }); + } + + continue; + } + + Content content = new() { Role = message.Role == ChatRole.Assistant ? "model" : "user" }; + content.Parts ??= new(); + 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() { { 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]; + + byte[]? thoughtSignature = null; + if (content is not TextReasoningContent { ProtectedData: not null } && + i + 1 < contents.Count && + contents[i + 1] is TextReasoningContent nextReasoning && + string.IsNullOrWhiteSpace(nextReasoning.Text) && + nextReasoning.ProtectedData is { } protectedData) + { + i++; + thoughtSignature = Convert.FromBase64String(protectedData); + } + + Part? part = null; + switch (content) + { + case TextContent textContent: + part = new() { Text = textContent.Text }; + break; + + case TextReasoningContent reasoningContent: + part = new() + { + Thought = true, + Text = !string.IsNullOrWhiteSpace(reasoningContent.Text) ? reasoningContent.Text : null, + ThoughtSignature = reasoningContent.ProtectedData is not null ? Convert.FromBase64String(reasoningContent.ProtectedData) : null, + }; + break; + + case DataContent dataContent: + part = new() + { + InlineData = new() + { + MimeType = dataContent.MediaType, + Data = dataContent.Data.ToArray(), + DisplayName = dataContent.Name, + } + }; + break; + + case UriContent uriContent: + part = new() + { + FileData = new() + { + FileUri = uriContent.Uri.AbsoluteUri, + MimeType = uriContent.MediaType, + } + }; + break; + + case FunctionCallContent functionCallContent: + (callIdToFunctionNames ??= new())[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 FunctionResultContent 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) + { + part.ThoughtSignature ??= thoughtSignature; + 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 = part.Thought is true ? + new TextReasoningContent(part.Text) : + new TextContent(part.Text); + } + else if (part.InlineData is { } inlineData) + { + content = new DataContent(inlineData.Data, inlineData.MimeType ?? "application/octet-stream") + { + Name = inlineData.DisplayName, + }; + } + else if (part.FileData is { FileUri: not null } fileData) + { + content = new UriContent(new Uri(fileData.FileUri), fileData.MimeType ?? "application/octet-stream"); + } + else if (part.FunctionCall is { Name: not null } functionCall) + { + content = new FunctionCallContent(functionCall.Id ?? "", functionCall.Name, functionCall.Args!); + } + else if (part.FunctionResponse is { } functionResponse) + { + content = new FunctionResultContent( + 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); + + if (part.ThoughtSignature is { } thoughtSignature) + { + contents.Add(new TextReasoningContent(null) + { + ProtectedData = Convert.ToBase64String(thoughtSignature), + }); + } + } + } + } + + 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); + } + + // Add any citation metadata. + if (candidate.CitationMetadata is { Citations: { Count: > 0 } citations } && + responseContents.OfType().FirstOrDefault() is TextContent textContent) + { + foreach (var citation in citations) + { + textContent.Annotations = new List() + { + new CitationAnnotation() + { + Title = citation.Title, + Url = Uri.TryCreate(citation.Uri, UriKind.Absolute, out Uri? uri) ? uri : null, + AnnotatedRegions = new List() + { + new TextSpanAnnotatedRegion() + { + StartIndex = citation.StartIndex, + EndIndex = citation.EndIndex, + } + }, + } + }; + } + } + } + + // 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 ??= new())[key] = i; + } + } + } +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs new file mode 100644 index 0000000000..b1044fa373 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/GoogleGenAIExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Google.Apis.Util; +using Google.GenAI; + +namespace Microsoft.Extensions.AI; + +/// Provides implementations of Microsoft.Extensions.AI abstractions based on . +public static class GoogleGenAIExtensions +{ + /// + /// Creates an wrapper around the specified . + /// + /// The to wrap. + /// The default model ID to use for chat requests if not specified in . + /// An that wraps the specified client. + /// is . + public static IChatClient AsIChatClient(this Client client, string? defaultModelId = null) + { + Utilities.ThrowIfNull(client, nameof(client)); + return new GoogleGenAIChatClient(client, defaultModelId); + } + + /// + /// Creates an wrapper around the specified . + /// + /// The client to wrap. + /// The default model ID to use for chat requests if not specified in . + /// An that wraps the specified client. + /// is . + public static IChatClient AsIChatClient(this Models models, string? defaultModelId = null) + { + Utilities.ThrowIfNull(models, nameof(models)); + return new GoogleGenAIChatClient(models, defaultModelId); + } +} diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs new file mode 100644 index 0000000000..db633dc47d --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to create and use an AI agent with Google Gemini + +using Google.GenAI; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Mscc.GenerativeAI.Microsoft; + +const string JokerInstructions = "You are good at telling jokes."; +const string JokerName = "JokerAgent"; + +string apiKey = Environment.GetEnvironmentVariable("GOOGLE_GENAI_API_KEY") ?? throw new InvalidOperationException("Please set the GOOGLE_GENAI_API_KEY environment variable."); +string model = Environment.GetEnvironmentVariable("GOOGLE_GENAI_MODEL") ?? "gemini-2.5-fast"; + +// Using a Google GenAI IChatClient implementation +// Until the PR https://github.com/googleapis/dotnet-genai/pull/81 is not merged this option +// requires usage of also both GeminiChatClient.cs and GoogleGenAIExtensions.cs polyfills to work. + +ChatClientAgent agentGenAI = new( + new Client(vertexAI: false, apiKey: apiKey).AsIChatClient(model), + name: JokerName, + instructions: JokerInstructions); + +AgentRunResponse response = await agentGenAI.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine($"Google GenAI client based agent response:\n{response}"); + +// Using a community driven Mscc.GenerativeAI.Microsoft package + +ChatClientAgent agentCommunity = new( + new GeminiChatClient(apiKey, model), + name: JokerName, + instructions: JokerInstructions); + +response = await agentCommunity.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine($"Community client based agent response:\n{response}"); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md new file mode 100644 index 0000000000..bc3a3592e6 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_GoogleGemini/README.md @@ -0,0 +1,37 @@ +# Creating an AIAgent with Google Gemini + +This sample demonstrates how to create an AIAgent using Google Gemini models as the underlying inference service. + +The sample showcases two different `IChatClient` implementations: + +1. **Google GenAI** - Using the official [Google.GenAI](https://www.nuget.org/packages/Google.GenAI) package +2. **Mscc.GenerativeAI.Microsoft** - Using the community-driven [Mscc.GenerativeAI.Microsoft](https://www.nuget.org/packages/Mscc.GenerativeAI.Microsoft) package + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- Google AI Studio API key (get one at [Google AI Studio](https://aistudio.google.com/apikey)) + +Set the following environment variables: + +```powershell +$env:GOOGLE_GENAI_API_KEY="your-google-api-key" # Replace with your Google AI Studio API key +$env:GOOGLE_GENAI_MODEL="gemini-2.5-fast" # Optional, defaults to gemini-2.5-fast +``` + +## Package Options + +### Google GenAI (Official) + +The official Google GenAI package provides direct access to Google's Generative AI models. This sample uses an extension method to convert the Google client to an `IChatClient`. + +> [!NOTE] +> Until PR [googleapis/dotnet-genai#81](https://github.com/googleapis/dotnet-genai/pull/81) is merged, this option requires the additional `GeminiChatClient.cs` and `GoogleGenAIExtensions.cs` files included in this sample. +> +> We appreciate any community push by liking and commenting in the above PR to get it merged and release as part of official Google GenAI package. + +### Mscc.GenerativeAI.Microsoft (Community) + +The community-driven Mscc.GenerativeAI.Microsoft package provides a ready-to-use `IChatClient` implementation for Google Gemini models through the `GeminiChatClient` class.