diff --git a/README.md b/README.md index 66470dd6..ba306d6b 100644 --- a/README.md +++ b/README.md @@ -565,7 +565,9 @@ foreach (var tool in mcpTools) - Auto-reconnection support - Works with any MCP-compatible server (Node.js, Python, C#, etc.) -**See [samples/McpIntegrationDemo](samples/McpIntegrationDemo) for complete examples.** +**For detailed documentation and examples, see:** +- **[MCP Integration Guide](https://github.com/gunpal5/Google_GenerativeAI/wiki/MCP-(Model-Context-Protocol)-Integration)** - Complete guide with configuration options, best practices, and troubleshooting +- **[samples/McpIntegrationDemo](samples/McpIntegrationDemo)** - Working code examples --- ## Image Generation and Captioning diff --git a/README_old.md b/README_old.md deleted file mode 100644 index 0b566d52..00000000 --- a/README_old.md +++ /dev/null @@ -1,263 +0,0 @@ -# Google GenerativeAI (Gemini) - -[![Nuget package](https://img.shields.io/nuget/vpre/Google_GenerativeAI)](https://www.nuget.org/packages/Google_GenerativeAI) -[![License: MIT](https://img.shields.io/github/license/gunpal5/Google_GenerativeAI)](https://github.com/tryAGI/OpenAI/blob/main/LICENSE.txt) - - -- [Google GenerativeAI (Gemini)](#google-generativeai-gemini) - - [Usage](#usage) - - [Quick Start](#quick-start) - - [Chat Mode](#chat-mode) - - [Vision](#vision) - - [Function Calling](#function-calling) - - [Streaming](#streaming) - - [Streaming with Generative Model](#streaming-with-generative-model) - - [Streaming With GeminiProVision](#streaming-with-googlegeminipro) - - [Streaming with ChatSession](#streaming-with-chatsession) - - [ModelInfoService](#modelinfoservice) - - [Get List of Available Models](#get-list-of-available-models) - - [Get Model Info with Model ID](#get-model-info-with-model-id) - - [Credits](#credits) - - - - -Unofficial C# SDK based on Google GenerativeAI (Gemini Pro) REST APIs. - -This package includes C# Source Generator which allows you to define functions natively through a C# interface, -and also provides extensions that make it easier to call this interface later. -In addition to easy function implementation and readability, -it generates Args classes, extension methods to easily pass a functions to API, -and extension methods to simply call a function via json and return json. -Currently only System.Text.Json is supported. - -### Usage - -### Quick Start - -1) [Obtain an API](https://makersuite.google.com/app/apikey) key to use with the Google AI SDKs. - -2) Install Google_GenerativeAI Nuget Package - -``` -Install-Package Google_GenerativeAI -``` - -or - -``` -dotnet add package Google_GenerativeAI -``` - -Write some codes: - -```csharp - var apiKey = 'Your API Key'; - - var model = new GenerativeModel(apiKey); - //or var model = new GeminiProModel(apiKey) - - var res = await model.GenerateContentAsync("How are you doing?"); - -``` - - -### Chat Mode - -```csharp - var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - - var model = new GenerativeModel(apiKey); - //or var model = new GeminiProModel(apiKey) - - var chat = model.StartChat(new StartChatParams()); - - var result = await chat.SendMessageAsync("Write a poem"); - Console.WriteLine("Initial Poem\r\n"); - Console.WriteLine(result); - - var result2 = await chat.SendMessageAsync("Make it longer"); - Console.WriteLine("Long Poem\r\n"); - Console.WriteLine(result2); - -``` -### Vision - -```csharp -var imageBytes = await File.ReadAllBytesAsync("image.png"); - -string prompt = "What is in the image?"; - -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - -var visionModel = new GeminiProVision(apiKey); - -var result = await visionModel.GenerateContentAsync(prompt,new FileObject(imageBytes,"image.png")); - -Console.WriteLine(result.Text()); - -``` - -or - -```csharp -var imageBytes = await File.ReadAllBytesAsync("image.png"); - -var imagePart = new Part() -{ - InlineData = new GenerativeContentBlob() - { - MimeType = "image/png", - Data = Convert.ToBase64String(imageBytes) - } -}; - -var textPart = new Part() -{ - Text = "What is in the image?" -}; - -var parts = new[] { textPart, imagePart }; - -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); -var visionModel = new GeminiProVision(apiKey); -var result = await visionModel.GenerateContentAsync(parts); - -Console.WriteLine(result.Text()); -``` - -### Function Calling - -```csharp -using GenerativeAI; - -public enum Unit -{ - Celsius, - Fahrenheit, - Imperial -} - -public class Weather -{ - public string Location { get; set; } = string.Empty; - public double Temperature { get; set; } - public Unit Unit { get; set; } - public string Description { get; set; } = string.Empty; -} - -[GenerativeAIFunctions] -public interface IWeatherFunctions -{ - [Description("Get the current weather in a given location")] - public Task GetCurrentWeatherAsync( - [Description("The city and state, e.g. San Francisco, CA")] string location, - Unit unit = Unit.Celsius, - CancellationToken cancellationToken = default); -} - -public class WeatherService : IWeatherFunctions -{ - public Task GetCurrentWeatherAsync(string location, Unit unit = Unit.Celsius, CancellationToken cancellationToken = default) - { - return Task.FromResult(new Weather - { - Location = location, - Temperature = 22.0, - Unit = unit, - Description = "Sunny", - }); - } -} - - WeatherService service = new WeatherService(); - - var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - - var model = new GenerativeModel(apiKey); - - // Add Global Functions - model.AddGlobalFunctions(service.AsGoogleFunctions(), service.AsGoogleCalls()) - - var result = await model.GenerateContentAsync("How is the weather in San Francisco today?"); - - Console.WriteLine(result); -``` -### Streaming -***streaming doesn't support Function calling*** -#### Streaming with Generative Model - -```csharp -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - -var model = new GenerativeModel(apiKey); -//or var model = new GeminiProModel(apiKey); - -var action = new Action(s => -{ - Console.Write(s); -}); - -await model.StreamContentAsync("How are you doing?",action); -``` -#### Streaming With GeminiProVision - -```csharp -var imageBytes = await File.ReadAllBytesAsync("image.png"); - -string prompt = "What is in the image?"; - -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - -var visionModel = new GeminiProVision(apiKey); - -var chat = visionModel.StartChat(new StartChatParams()); - -Action handler = (a) => -{ - Console.WriteLine(a); -}; - -var result = await chat.StreamContentVisionAsync(prompt, new FileObject(imageBytes, "image.png"), handler); - -``` - -#### Streaming with ChatSession -```csharp -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - -var model = new GenerativeModel(apiKey); - -var handler = new Action((a) => -{ - Console.Write(a); -}); - -var chat = model.StartChat(new StartChatParams()); -await chat.StreamContentAsync("Write a poem", handler); -``` - -### ModelInfoService -This service can be used to get all the Google Generative AI Models. - -#### Get List of Available Models -```csharp -var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - -var service = new ModelInfoService(apiKey); - -var models = await service.GetModelsAsync(); -``` - -#### Get Model info with Model Id - -```csharp - var apiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY_TEST", EnvironmentVariableTarget.User); - - var service = new ModelInfoService(apiKey); - - var modelInfo = await service.GetModelInfoAsync("gemini-pro"); -``` - -### Credits -Thanks to [HavenDV](https://github.com/HavenDV) for [OpenAI SDK](https://github.com/tryAGI/OpenAI) \ No newline at end of file diff --git a/src/GenerativeAI/Constants/GoogleAIModels.cs b/src/GenerativeAI/Constants/GoogleAIModels.cs index 12bd3356..01cb9140 100644 --- a/src/GenerativeAI/Constants/GoogleAIModels.cs +++ b/src/GenerativeAI/Constants/GoogleAIModels.cs @@ -165,7 +165,19 @@ public static class GoogleAIModels /// Gemini Robotics-ER 1.5 Preview model name. /// public const string GeminiRoboticsEr15Preview = "models/gemini-robotics-er-1.5-preview"; - + + /// + /// Gemini 3 Pro Preview model name. + /// Supports advanced thinking features with thought signatures for function calling. + /// + public const string Gemini3ProPreview = "models/gemini-3-pro-preview"; + + /// + /// Gemini 3 Flash Preview model name. + /// Supports advanced thinking features with thought signatures for function calling. + /// + public const string Gemini3FlashPreview = "models/gemini-3-flash-preview"; + /// /// The current Gemini Embedding model name. /// diff --git a/src/GenerativeAI/Types/ContentGeneration/Common/Schema.cs b/src/GenerativeAI/Types/ContentGeneration/Common/Schema.cs index 62816584..20fb42a6 100644 --- a/src/GenerativeAI/Types/ContentGeneration/Common/Schema.cs +++ b/src/GenerativeAI/Types/ContentGeneration/Common/Schema.cs @@ -105,8 +105,9 @@ public Schema() : this("") { } /// /// Optional. Schema for additional properties not explicitly defined. + /// Note: This property is not supported by the Gemini API and is excluded from serialization. /// - [JsonPropertyName("additionalProperties")] + [JsonIgnore] public JsonNode? AdditionalProperties { get; set; } /// diff --git a/src/GenerativeAI/Types/ContentGeneration/Config/GenerationConfig.cs b/src/GenerativeAI/Types/ContentGeneration/Config/GenerationConfig.cs index 3810833f..cef9f05b 100644 --- a/src/GenerativeAI/Types/ContentGeneration/Config/GenerationConfig.cs +++ b/src/GenerativeAI/Types/ContentGeneration/Config/GenerationConfig.cs @@ -228,12 +228,42 @@ public class ThinkingConfig /// [JsonPropertyName("includeThoughts")] public bool? IncludeThoughts { get; set; } - + /// - /// Indicates the thinking budget in tokens + /// Indicates the thinking budget in tokens. /// [JsonPropertyName("thinkingBudget")] public int? ThinkingBudget { get; set; } + + /// + /// Controls the maximum depth of the model's internal reasoning process before it produces a response. + /// This is particularly relevant for Gemini 3 and later models. + /// + [JsonPropertyName("thinkingLevel")] + public ThinkingLevel? ThinkingLevel { get; set; } +} + +/// +/// Controls the depth of thinking/reasoning for models that support thinking features. +/// Used with Gemini 3 and later models. +/// +public enum ThinkingLevel +{ + /// + /// Unspecified thinking level. + /// + THINKING_LEVEL_UNSPECIFIED, + + /// + /// Low thinking level - faster responses with less deep reasoning. + /// + LOW, + + /// + /// High thinking level - deeper reasoning with potentially slower responses. + /// Recommended for complex tasks. + /// + HIGH } /// diff --git a/tests/GenerativeAI.IntegrationTests/Gemini3_FunctionCalling_Tests.cs b/tests/GenerativeAI.IntegrationTests/Gemini3_FunctionCalling_Tests.cs new file mode 100644 index 00000000..2a2cdc14 --- /dev/null +++ b/tests/GenerativeAI.IntegrationTests/Gemini3_FunctionCalling_Tests.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GenerativeAI.Core; +using GenerativeAI.Tests; +using GenerativeAI.Tools; +using GenerativeAI.Types; +using Shouldly; +using Xunit; + +namespace GenerativeAI.IntegrationTests; + +/// +/// Integration tests for Gemini 3 function calling with thought signatures. +/// These tests verify that thought signatures are properly returned by the API +/// and preserved during function call workflows. +/// +public class Gemini3_FunctionCalling_Tests : TestBase +{ + public Gemini3_FunctionCalling_Tests(ITestOutputHelper helper) : base(helper) + { + } + + #region Thought Signature Verification Tests + + [Fact] + public async Task ThinkingModel_ShouldReturn_ThoughtParts_WhenIncludeThoughtsEnabled() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange - Use a model that supports thinking + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingBudget = 1024 + } + } + ); + + var prompt = "What is the sum of 157 + 289? Show your reasoning."; + + // Act + var response = await model.GenerateContentAsync(prompt, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + response.Candidates.ShouldNotBeNull(); + response.Candidates.Length.ShouldBeGreaterThan(0); + + var candidate = response.Candidates[0]; + candidate.Content.ShouldNotBeNull(); + candidate.Content.Parts.ShouldNotBeNull(); + + // Log all parts to see what we get + Console.WriteLine($"Number of parts: {candidate.Content.Parts.Count}"); + for (int i = 0; i < candidate.Content.Parts.Count; i++) + { + var part = candidate.Content.Parts[i]; + Console.WriteLine($"Part {i}: Thought={part.Thought}, HasThoughtSignature={!string.IsNullOrEmpty(part.ThoughtSignature)}, Text={(part.Text?.Length > 100 ? part.Text.Substring(0, 100) + "..." : part.Text)}"); + } + + // Check if we got any thought parts or thought signatures + var hasThoughtParts = candidate.Content.Parts.Any(p => p.Thought == true); + var hasThoughtSignatures = candidate.Content.Parts.Any(p => !string.IsNullOrEmpty(p.ThoughtSignature)); + + Console.WriteLine($"HasThoughtParts: {hasThoughtParts}, HasThoughtSignatures: {hasThoughtSignatures}"); + + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + Console.WriteLine($"\nFinal Response: {text}"); + } + + [Fact] + public async Task FunctionCall_ShouldReturn_ThoughtSignature_WhenThinkingEnabled() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange - Disable auto function calling to inspect the raw response + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + // Disable auto function calling so we can inspect the raw response + model.FunctionCallingBehaviour = new FunctionCallingBehaviour + { + FunctionEnabled = true, + AutoCallFunction = false, + AutoReplyFunction = false + }; + + var service = new MultiService(); + var tools = service.AsTools(); + var calls = service.AsCalls(); + var tool = new GenericFunctionTool(tools, calls); + model.AddFunctionTool(tool); + + var prompt = "What is the weather in Paris, France?"; + + // Act + var response = await model.GenerateContentAsync(prompt, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + response.Candidates.ShouldNotBeNull(); + response.Candidates.Length.ShouldBeGreaterThan(0); + + var candidate = response.Candidates[0]; + candidate.Content.ShouldNotBeNull(); + candidate.Content.Parts.ShouldNotBeNull(); + + // Log all parts + Console.WriteLine($"Number of parts: {candidate.Content.Parts.Count}"); + for (int i = 0; i < candidate.Content.Parts.Count; i++) + { + var part = candidate.Content.Parts[i]; + Console.WriteLine($"Part {i}:"); + Console.WriteLine($" - Thought: {part.Thought}"); + Console.WriteLine($" - ThoughtSignature: {(string.IsNullOrEmpty(part.ThoughtSignature) ? "null" : part.ThoughtSignature.Substring(0, Math.Min(50, part.ThoughtSignature.Length)) + "...")}"); + Console.WriteLine($" - FunctionCall: {part.FunctionCall?.Name ?? "null"}"); + Console.WriteLine($" - Text: {(part.Text?.Length > 50 ? part.Text.Substring(0, 50) + "..." : part.Text ?? "null")}"); + } + + // Check for function call + var functionCallPart = candidate.Content.Parts.FirstOrDefault(p => p.FunctionCall != null); + if (functionCallPart != null) + { + Console.WriteLine($"\nFunction call found: {functionCallPart.FunctionCall!.Name}"); + Console.WriteLine($"Function call has thought signature: {!string.IsNullOrEmpty(functionCallPart.ThoughtSignature)}"); + + if (!string.IsNullOrEmpty(functionCallPart.ThoughtSignature)) + { + Console.WriteLine($"Thought signature (first 100 chars): {functionCallPart.ThoughtSignature.Substring(0, Math.Min(100, functionCallPart.ThoughtSignature.Length))}"); + } + } + } + + [Fact] + public async Task ManualFunctionCall_ShouldPreserve_ThoughtSignature_InConversation() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + // Disable auto function calling + model.FunctionCallingBehaviour = new FunctionCallingBehaviour + { + FunctionEnabled = true, + AutoCallFunction = false, + AutoReplyFunction = false + }; + + var service = new MultiService(); + var tools = service.AsTools(); + var calls = service.AsCalls(); + var tool = new GenericFunctionTool(tools, calls); + model.AddFunctionTool(tool); + + // Step 1: Send initial request + var request = new GenerateContentRequest(); + request.AddText("What is the weather in Tokyo, Japan?"); + + var response1 = await model.GenerateContentAsync(request, cancellationToken: TestContext.Current.CancellationToken); + + response1.ShouldNotBeNull(); + response1.Candidates.ShouldNotBeNull(); + + var modelContent = response1.Candidates[0].Content; + Console.WriteLine("=== Step 1: Initial Response ==="); + LogContentParts(modelContent); + + // Check if there's a function call + var functionCallPart = modelContent?.Parts.FirstOrDefault(p => p.FunctionCall != null); + if (functionCallPart?.FunctionCall == null) + { + Console.WriteLine("No function call in response - model may have answered directly"); + return; + } + + // Step 2: Execute the function and send response back + Console.WriteLine($"\n=== Step 2: Executing function {functionCallPart.FunctionCall.Name} ==="); + + var functionResponse = await tool.CallAsync(functionCallPart.FunctionCall); + Console.WriteLine($"Function result: {functionResponse?.Response?.ToJsonString()}"); + + // Step 3: Build the next request with the model's response (including thought signature) and function result + var request2 = new GenerateContentRequest(); + + // Add original user message + request2.Contents.Add(new Content("What is the weather in Tokyo, Japan?", Roles.User)); + + // Add model's response WITH the thought signature preserved + // This is the key part - we need to include the original parts from the model + request2.Contents.Add(modelContent!); + + // Add function response + var functionResponseContent = new Content + { + Role = Roles.Function, + Parts = new List + { + new Part { FunctionResponse = functionResponse } + } + }; + request2.Contents.Add(functionResponseContent); + + Console.WriteLine("\n=== Step 3: Sending function response back ==="); + Console.WriteLine($"Request has {request2.Contents.Count} contents"); + + // Check if thought signature is in the model content we're sending back + var modelPartWithSignature = modelContent.Parts.FirstOrDefault(p => !string.IsNullOrEmpty(p.ThoughtSignature)); + if (modelPartWithSignature != null) + { + Console.WriteLine($"Model content includes thought signature: {modelPartWithSignature.ThoughtSignature?.Substring(0, Math.Min(50, modelPartWithSignature.ThoughtSignature.Length))}..."); + } + else + { + Console.WriteLine("No thought signature found in model content being sent back"); + } + + // Act - Send the function response + var response2 = await model.GenerateContentAsync(request2, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response2.ShouldNotBeNull(); + var finalText = response2.Text(); + finalText.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"\n=== Final Response ==="); + Console.WriteLine(finalText); + + // The response should contain information about Tokyo weather + // This verifies the full round-trip worked correctly + } + + #endregion + + #region Auto Function Calling Tests + + [Fact] + public async Task AutoFunctionCalling_ShouldWork_WithThinkingEnabled() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var service = new MultiService(); + var tools = service.AsTools(); + var calls = service.AsCalls(); + var tool = new GenericFunctionTool(tools, calls); + + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + model.AddFunctionTool(tool); + + // Act + var response = await model.GenerateContentAsync( + "What's the current weather in Paris, France?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"Response: {text}"); + + // Should contain weather information + text.ToLower().ShouldContain("paris"); + } + + [Fact] + public async Task AutoFunctionCalling_WithMultipleCalls_ShouldWork_WithThinkingEnabled() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var service = new MultiService(); + var tools = service.AsTools(); + var calls = service.AsCalls(); + var tool = new GenericFunctionTool(tools, calls); + + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingBudget = 2048 + } + } + ); + model.AddFunctionTool(tool); + + // Act - Request that should trigger multiple function calls + var response = await model.GenerateContentAsync( + "I need the weather in both Tokyo and Paris, and also recommend some travel books.", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"Response: {text}"); + } + + #endregion + + #region ChatSession with Thinking and Function Calls + + [Fact] + public async Task ChatSession_WithFunctions_ShouldMaintain_ThoughtContext_AcrossTurns() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var service = new MultiService(); + var tools = service.AsTools(); + var calls = service.AsCalls(); + var tool = new GenericFunctionTool(tools, calls); + + var chatSession = new ChatSession( + history: null, + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini25Flash, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + chatSession.AddFunctionTool(tool); + + // Turn 1: Get weather + Console.WriteLine("=== Turn 1: Asking about weather ==="); + var response1 = await chatSession.GenerateContentAsync( + "What's the weather in New York right now?", + cancellationToken: TestContext.Current.CancellationToken); + + response1.ShouldNotBeNull(); + Console.WriteLine($"Response 1: {response1.Text()}"); + Console.WriteLine($"History count after turn 1: {chatSession.History.Count}"); + + // Log history parts + foreach (var content in chatSession.History) + { + Console.WriteLine($" History entry - Role: {content.Role}, Parts: {content.Parts.Count}"); + foreach (var part in content.Parts) + { + if (part.Thought == true) + Console.WriteLine($" - Thought part found"); + if (!string.IsNullOrEmpty(part.ThoughtSignature)) + Console.WriteLine($" - ThoughtSignature present"); + if (part.FunctionCall != null) + Console.WriteLine($" - FunctionCall: {part.FunctionCall.Name}"); + if (part.FunctionResponse != null) + Console.WriteLine($" - FunctionResponse: {part.FunctionResponse.Name}"); + } + } + + // Turn 2: Ask follow-up about forecast + Console.WriteLine("\n=== Turn 2: Asking about forecast ==="); + var response2 = await chatSession.GenerateContentAsync( + "What about the forecast for the next 5 days there?", + cancellationToken: TestContext.Current.CancellationToken); + + response2.ShouldNotBeNull(); + Console.WriteLine($"Response 2: {response2.Text()}"); + Console.WriteLine($"History count after turn 2: {chatSession.History.Count}"); + + // Turn 3: Ask about something else + Console.WriteLine("\n=== Turn 3: Asking about books ==="); + var response3 = await chatSession.GenerateContentAsync( + "Can you recommend some mystery books?", + cancellationToken: TestContext.Current.CancellationToken); + + response3.ShouldNotBeNull(); + Console.WriteLine($"Response 3: {response3.Text()}"); + } + + #endregion + + #region ThinkingLevel Tests + + /// + /// Tests ThinkingLevel with Gemini 3 models. + /// Note: ThinkingLevel is only supported by Gemini 3 and later models. + /// This test will be skipped if Gemini 3 models are not available. + /// + [Theory] + [InlineData(ThinkingLevel.LOW)] + [InlineData(ThinkingLevel.HIGH)] + public async Task ThinkingLevel_ShouldBeAcceptedByAPI_WithGemini3(ThinkingLevel level) + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange - Use Gemini 3 model (ThinkingLevel is only supported by Gemini 3+) + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingLevel = level + } + } + ); + + try + { + // Act + var response = await model.GenerateContentAsync( + "Solve: If x + 5 = 12, what is x?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + Console.WriteLine($"ThinkingLevel {level}: {text}"); + } + catch (GenerativeAI.Exceptions.ApiException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("not supported")) + { + // Skip if Gemini 3 model is not yet available + Assert.Skip($"Gemini 3 model not available: {ex.Message}"); + } + } + + #endregion + + #region Helper Methods + + private void LogContentParts(Content? content) + { + if (content == null) + { + Console.WriteLine("Content is null"); + return; + } + + Console.WriteLine($"Role: {content.Role}, Parts: {content.Parts.Count}"); + for (int i = 0; i < content.Parts.Count; i++) + { + var part = content.Parts[i]; + Console.WriteLine($" Part {i}:"); + if (part.Thought == true) + Console.WriteLine($" - Thought: true"); + if (!string.IsNullOrEmpty(part.ThoughtSignature)) + Console.WriteLine($" - ThoughtSignature: {part.ThoughtSignature.Substring(0, Math.Min(50, part.ThoughtSignature.Length))}..."); + if (part.FunctionCall != null) + Console.WriteLine($" - FunctionCall: {part.FunctionCall.Name}({part.FunctionCall.Args?.ToJsonString()})"); + if (part.FunctionResponse != null) + Console.WriteLine($" - FunctionResponse: {part.FunctionResponse.Name}"); + if (!string.IsNullOrEmpty(part.Text)) + Console.WriteLine($" - Text: {(part.Text.Length > 100 ? part.Text.Substring(0, 100) + "..." : part.Text)}"); + } + } + + #endregion +} diff --git a/tests/GenerativeAI.IntegrationTests/Gemini3_ThinkingConfig_Integration_Tests.cs b/tests/GenerativeAI.IntegrationTests/Gemini3_ThinkingConfig_Integration_Tests.cs new file mode 100644 index 00000000..8a50b57d --- /dev/null +++ b/tests/GenerativeAI.IntegrationTests/Gemini3_ThinkingConfig_Integration_Tests.cs @@ -0,0 +1,543 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using GenerativeAI.Core; +using GenerativeAI.Tests; +using GenerativeAI.Tools; +using GenerativeAI.Types; +using Shouldly; +using Xunit; + +namespace GenerativeAI.IntegrationTests; + +/// +/// Integration tests for Gemini 3 ThinkingConfig features with real API calls. +/// Tests thinking budget, thought signatures, and function calling with thinking enabled. +/// +public class Gemini3_ThinkingConfig_Integration_Tests : TestBase +{ + public Gemini3_ThinkingConfig_Integration_Tests(ITestOutputHelper helper) : base(helper) + { + } + + #region Basic ThinkingConfig API Tests + + [Fact] + public async Task ThinkingConfig_WithIncludeThoughts_ShouldReturnThoughtParts() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + // Act + var response = await model.GenerateContentAsync( + "What is 25 * 17? Think through this step by step.", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + response.Candidates.ShouldNotBeNull(); + response.Candidates.Length.ShouldBeGreaterThan(0); + + var parts = response.Candidates[0].Content?.Parts; + parts.ShouldNotBeNull(); + + // Log parts for debugging + Console.WriteLine($"Total parts: {parts.Count}"); + foreach (var part in parts) + { + Console.WriteLine($" Part - Thought: {part.Thought}, Text: {part.Text?.Substring(0, Math.Min(100, part.Text?.Length ?? 0))}..."); + } + + // Check for thought parts + var thoughtParts = parts.Where(p => p.Thought == true).ToList(); + Console.WriteLine($"Thought parts found: {thoughtParts.Count}"); + + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + Console.WriteLine($"Final answer: {text}"); + } + + [Fact] + public async Task ThinkingConfig_WithThinkingBudget_ShouldWork() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingBudget = 2048 + } + } + ); + + // Act + var response = await model.GenerateContentAsync( + "Explain the concept of recursion in programming with an example.", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + // Check usage metadata for thinking tokens + if (response.UsageMetadata != null) + { + Console.WriteLine($"Prompt tokens: {response.UsageMetadata.PromptTokenCount}"); + Console.WriteLine($"Response tokens: {response.UsageMetadata.CandidatesTokenCount}"); + Console.WriteLine($"Thoughts tokens: {response.UsageMetadata.ThoughtsTokenCount}"); + } + + Console.WriteLine($"Response: {text}"); + } + + #endregion + + #region Function Calling with Thinking + + [Fact] + public async Task FunctionCalling_WithThinking_ShouldReturnThoughtSignature() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + // Disable auto function calling to inspect raw response + model.FunctionCallingBehaviour = new FunctionCallingBehaviour + { + FunctionEnabled = true, + AutoCallFunction = false, + AutoReplyFunction = false + }; + + var service = new MultiService(); + model.AddFunctionTool(new GenericFunctionTool(service.AsTools(), service.AsCalls())); + + // Act + var response = await model.GenerateContentAsync( + "What's the weather like in San Francisco?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var content = response.Candidates?[0].Content; + content.ShouldNotBeNull(); + + Console.WriteLine($"Response has {content.Parts.Count} parts"); + + // Log each part + foreach (var part in content.Parts) + { + Console.WriteLine($"Part:"); + Console.WriteLine($" - Thought: {part.Thought}"); + Console.WriteLine($" - ThoughtSignature: {(part.ThoughtSignature != null ? "present (" + part.ThoughtSignature.Length + " chars)" : "null")}"); + Console.WriteLine($" - FunctionCall: {part.FunctionCall?.Name ?? "null"}"); + Console.WriteLine($" - Text: {part.Text?.Substring(0, Math.Min(50, part.Text?.Length ?? 0)) ?? "null"}"); + } + + // Check for function call with thought signature + var functionCallPart = content.Parts.FirstOrDefault(p => p.FunctionCall != null); + if (functionCallPart != null) + { + Console.WriteLine($"\nFunction call: {functionCallPart.FunctionCall!.Name}"); + Console.WriteLine($"Has thought signature: {!string.IsNullOrEmpty(functionCallPart.ThoughtSignature)}"); + } + } + + [Fact] + public async Task FunctionCalling_ManualRoundTrip_ShouldPreserveThoughtSignature() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + model.FunctionCallingBehaviour = new FunctionCallingBehaviour + { + FunctionEnabled = true, + AutoCallFunction = false, + AutoReplyFunction = false + }; + + var service = new MultiService(); + var tool = new GenericFunctionTool(service.AsTools(), service.AsCalls()); + model.AddFunctionTool(tool); + + // Step 1: Initial request + Console.WriteLine("=== Step 1: Initial Request ==="); + var request1 = new GenerateContentRequest(); + request1.AddText("What is the weather in London?"); + + var response1 = await model.GenerateContentAsync(request1, cancellationToken: TestContext.Current.CancellationToken); + response1.ShouldNotBeNull(); + + var modelContent = response1.Candidates?[0].Content; + modelContent.ShouldNotBeNull(); + + // Find function call part + var functionCallPart = modelContent.Parts.FirstOrDefault(p => p.FunctionCall != null); + if (functionCallPart?.FunctionCall == null) + { + Console.WriteLine("No function call - model answered directly"); + Console.WriteLine($"Response: {response1.Text()}"); + return; + } + + Console.WriteLine($"Function call: {functionCallPart.FunctionCall.Name}"); + Console.WriteLine($"Thought signature present: {!string.IsNullOrEmpty(functionCallPart.ThoughtSignature)}"); + + // Step 2: Execute function + Console.WriteLine("\n=== Step 2: Execute Function ==="); + var functionResponse = await tool.CallAsync(functionCallPart.FunctionCall); + Console.WriteLine($"Function response: {functionResponse?.Response?.ToJsonString()}"); + + // Step 3: Send function response back (with thought signature preserved) + Console.WriteLine("\n=== Step 3: Send Function Response ==="); + var request2 = new GenerateContentRequest(); + request2.Contents.Add(new Content("What is the weather in London?", Roles.User)); + request2.Contents.Add(modelContent); // Includes thought signature + request2.Contents.Add(new Content + { + Role = Roles.Function, + Parts = new System.Collections.Generic.List + { + new Part { FunctionResponse = functionResponse } + } + }); + + var response2 = await model.GenerateContentAsync(request2, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response2.ShouldNotBeNull(); + var finalText = response2.Text(); + finalText.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"\nFinal response: {finalText}"); + } + + [Fact] + public async Task AutoFunctionCalling_WithThinking_ShouldCompleteSuccessfully() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + var service = new MultiService(); + model.AddFunctionTool(new GenericFunctionTool(service.AsTools(), service.AsCalls())); + + // Act + var response = await model.GenerateContentAsync( + "What's the weather in Tokyo and recommend some travel books?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"Response: {text}"); + } + + #endregion + + #region ChatSession with Thinking + + [Fact] + public async Task ChatSession_WithThinking_ShouldMaintainContext() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var chat = new ChatSession( + history: null, + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + // Turn 1 + Console.WriteLine("=== Turn 1 ==="); + var response1 = await chat.GenerateContentAsync( + "I'm planning a trip to Paris. What should I know?", + cancellationToken: TestContext.Current.CancellationToken); + + response1.ShouldNotBeNull(); + Console.WriteLine($"Response 1: {response1.Text()?.Substring(0, Math.Min(200, response1.Text()?.Length ?? 0))}..."); + Console.WriteLine($"History count: {chat.History.Count}"); + + // Turn 2 - follow up + Console.WriteLine("\n=== Turn 2 ==="); + var response2 = await chat.GenerateContentAsync( + "What about the best time to visit?", + cancellationToken: TestContext.Current.CancellationToken); + + response2.ShouldNotBeNull(); + Console.WriteLine($"Response 2: {response2.Text()?.Substring(0, Math.Min(200, response2.Text()?.Length ?? 0))}..."); + Console.WriteLine($"History count: {chat.History.Count}"); + + // Verify context was maintained + var text2 = response2.Text()?.ToLower() ?? ""; + (text2.Contains("paris") || text2.Contains("france") || text2.Contains("spring") || text2.Contains("summer")).ShouldBeTrue("Response should reference Paris context"); + } + + [Fact] + public async Task ChatSession_WithFunctionsAndThinking_ShouldWork() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var chat = new ChatSession( + history: null, + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + var service = new MultiService(); + chat.AddFunctionTool(new GenericFunctionTool(service.AsTools(), service.AsCalls())); + + // Turn 1 - weather request + Console.WriteLine("=== Turn 1: Weather ==="); + var response1 = await chat.GenerateContentAsync( + "What's the weather in Berlin?", + cancellationToken: TestContext.Current.CancellationToken); + + response1.ShouldNotBeNull(); + Console.WriteLine($"Response 1: {response1.Text()}"); + + // Log history to check for thought signatures + Console.WriteLine($"\nHistory after turn 1 ({chat.History.Count} entries):"); + foreach (var content in chat.History) + { + Console.WriteLine($" Role: {content.Role}"); + foreach (var part in content.Parts) + { + if (part.Thought == true) + Console.WriteLine($" - [Thought]"); + if (!string.IsNullOrEmpty(part.ThoughtSignature)) + Console.WriteLine($" - [ThoughtSignature present]"); + if (part.FunctionCall != null) + Console.WriteLine($" - FunctionCall: {part.FunctionCall.Name}"); + if (part.FunctionResponse != null) + Console.WriteLine($" - FunctionResponse: {part.FunctionResponse.Name}"); + } + } + + // Turn 2 - follow up + Console.WriteLine("\n=== Turn 2: Follow-up ==="); + var response2 = await chat.GenerateContentAsync( + "Should I bring an umbrella?", + cancellationToken: TestContext.Current.CancellationToken); + + response2.ShouldNotBeNull(); + Console.WriteLine($"Response 2: {response2.Text()}"); + } + + #endregion + + #region Gemini 3 Specific Tests + + [Fact] + public async Task Gemini3_WithThinkingLevel_ShouldWork() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange - ThinkingLevel is only supported by Gemini 3 + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingLevel = ThinkingLevel.HIGH + } + } + ); + + try + { + // Act + var response = await model.GenerateContentAsync( + "Solve this logic puzzle: If all A are B, and some B are C, what can we conclude about A and C?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"Response with ThinkingLevel.HIGH: {text}"); + + // Check for thought parts + var parts = response.Candidates?[0].Content?.Parts; + if (parts != null) + { + var thoughtCount = parts.Count(p => p.Thought == true); + Console.WriteLine($"Thought parts: {thoughtCount}"); + } + } + catch (GenerativeAI.Exceptions.ApiException ex) when ( + ex.Message.Contains("not found") || + ex.Message.Contains("not supported") || + ex.Message.Contains("does not exist")) + { + Assert.Skip($"Gemini 3 model not available: {ex.Message}"); + } + } + + [Fact] + public async Task Gemini3_FunctionCalling_WithThoughtSignature_ShouldWork() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingLevel = ThinkingLevel.HIGH + } + } + ); + + var service = new MultiService(); + model.AddFunctionTool(new GenericFunctionTool(service.AsTools(), service.AsCalls())); + + try + { + // Act + var response = await model.GenerateContentAsync( + "What's the weather in Sydney, Australia?", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + var text = response.Text(); + text.ShouldNotBeNullOrEmpty(); + + Console.WriteLine($"Response: {text}"); + } + catch (GenerativeAI.Exceptions.ApiException ex) when ( + ex.Message.Contains("not found") || + ex.Message.Contains("not supported") || + ex.Message.Contains("does not exist")) + { + Assert.Skip($"Gemini 3 model not available: {ex.Message}"); + } + } + + #endregion + + #region Streaming with Thinking + + [Fact] + public async Task Streaming_WithThinking_ShouldWork() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var model = new GenerativeModel( + platform: GetTestGooglePlatform(), + model: GoogleAIModels.Gemini3FlashPreview, + config: new GenerationConfig + { + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true + } + } + ); + + var fullResponse = ""; + var chunkCount = 0; + + // Act + Console.WriteLine("Streaming response:"); + await foreach (var chunk in model.StreamContentAsync( + "Write a haiku about artificial intelligence.", + cancellationToken: TestContext.Current.CancellationToken)) + { + var text = chunk.Text(); + if (!string.IsNullOrEmpty(text)) + { + fullResponse += text; + chunkCount++; + Console.Write(text); + } + } + + // Assert + Console.WriteLine($"\n\nTotal chunks: {chunkCount}"); + fullResponse.ShouldNotBeNullOrEmpty(); + } + + #endregion +} diff --git a/tests/GenerativeAI.Tests/Model/Gemini3_ThinkingConfig_Tests.cs b/tests/GenerativeAI.Tests/Model/Gemini3_ThinkingConfig_Tests.cs new file mode 100644 index 00000000..c8efe39f --- /dev/null +++ b/tests/GenerativeAI.Tests/Model/Gemini3_ThinkingConfig_Tests.cs @@ -0,0 +1,298 @@ +using System.Text.Json; +using GenerativeAI.Types; +using Shouldly; +using Xunit; + +namespace GenerativeAI.Tests.Model; + +/// +/// Unit tests for Gemini 3 thinking features including ThinkingConfig and thought signatures. +/// +public class Gemini3_ThinkingConfig_Tests +{ + #region ThinkingConfig Tests + + [Fact] + public void ThinkingConfig_ShouldSerialize_WithAllProperties() + { + // Arrange + var config = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingBudget = 1024, + ThinkingLevel = ThinkingLevel.HIGH + }; + + // Act + var json = JsonSerializer.Serialize(config); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + json.ShouldContain("\"includeThoughts\":true"); + json.ShouldContain("\"thinkingBudget\":1024"); + json.ShouldContain("\"thinkingLevel\""); + + deserialized.ShouldNotBeNull(); + deserialized.IncludeThoughts.ShouldBe(true); + deserialized.ThinkingBudget.ShouldBe(1024); + deserialized.ThinkingLevel.ShouldBe(ThinkingLevel.HIGH); + } + + [Fact] + public void ThinkingConfig_ShouldSerialize_WithLowThinkingLevel() + { + // Arrange + var config = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingLevel = ThinkingLevel.LOW + }; + + // Act + var json = JsonSerializer.Serialize(config); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.ShouldNotBeNull(); + deserialized.ThinkingLevel.ShouldBe(ThinkingLevel.LOW); + } + + [Fact] + public void ThinkingConfig_ShouldSerialize_WithUnspecifiedThinkingLevel() + { + // Arrange + var config = new ThinkingConfig + { + ThinkingLevel = ThinkingLevel.THINKING_LEVEL_UNSPECIFIED + }; + + // Act + var json = JsonSerializer.Serialize(config); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.ShouldNotBeNull(); + deserialized.ThinkingLevel.ShouldBe(ThinkingLevel.THINKING_LEVEL_UNSPECIFIED); + } + + [Fact] + public void ThinkingConfig_ShouldSerialize_WithOnlyThinkingBudget() + { + // Arrange + var config = new ThinkingConfig + { + ThinkingBudget = 2048 + }; + + // Act + var json = JsonSerializer.Serialize(config); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.ShouldNotBeNull(); + deserialized.ThinkingBudget.ShouldBe(2048); + deserialized.IncludeThoughts.ShouldBeNull(); + deserialized.ThinkingLevel.ShouldBeNull(); + } + + #endregion + + #region GenerationConfig with ThinkingConfig Tests + + [Fact] + public void GenerationConfig_ShouldIncludeThinkingConfig() + { + // Arrange + var generationConfig = new GenerationConfig + { + Temperature = 0.7, + MaxOutputTokens = 8192, + ThinkingConfig = new ThinkingConfig + { + IncludeThoughts = true, + ThinkingBudget = 4096, + ThinkingLevel = ThinkingLevel.HIGH + } + }; + + // Act + var json = JsonSerializer.Serialize(generationConfig); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + json.ShouldContain("\"thinkingConfig\""); + json.ShouldContain("\"thinkingLevel\""); + + deserialized.ShouldNotBeNull(); + deserialized.ThinkingConfig.ShouldNotBeNull(); + deserialized.ThinkingConfig!.IncludeThoughts.ShouldBe(true); + deserialized.ThinkingConfig.ThinkingBudget.ShouldBe(4096); + deserialized.ThinkingConfig.ThinkingLevel.ShouldBe(ThinkingLevel.HIGH); + } + + #endregion + + #region Part Thought Properties Tests + + [Fact] + public void Part_ShouldSerialize_WithThoughtProperty() + { + // Arrange + var part = new Part + { + Text = "This is a thought from the model", + Thought = true + }; + + // Act + var json = JsonSerializer.Serialize(part); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + json.ShouldContain("\"thought\":true"); + deserialized.ShouldNotBeNull(); + deserialized.Thought.ShouldBe(true); + deserialized.Text.ShouldBe("This is a thought from the model"); + } + + [Fact] + public void Part_ShouldSerialize_WithThoughtSignature() + { + // Arrange + var signature = "dGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHNpZ25hdHVyZQ=="; // base64 encoded string + var part = new Part + { + FunctionCall = new FunctionCall("get_weather") + { + Args = System.Text.Json.Nodes.JsonNode.Parse("{\"city\": \"Paris\"}") + }, + ThoughtSignature = signature + }; + + // Act + var json = JsonSerializer.Serialize(part); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + json.ShouldContain("\"thoughtSignature\""); + json.ShouldContain(signature); + deserialized.ShouldNotBeNull(); + deserialized.ThoughtSignature.ShouldBe(signature); + deserialized.FunctionCall.ShouldNotBeNull(); + deserialized.FunctionCall!.Name.ShouldBe("get_weather"); + } + + [Fact] + public void Part_ShouldSerialize_WithBothThoughtAndSignature() + { + // Arrange + var signature = "c2lnbmF0dXJlX2Zvcl90aG91Z2h0"; + var part = new Part + { + Text = "Model's internal reasoning", + Thought = true, + ThoughtSignature = signature + }; + + // Act + var json = JsonSerializer.Serialize(part); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.ShouldNotBeNull(); + deserialized.Thought.ShouldBe(true); + deserialized.ThoughtSignature.ShouldBe(signature); + } + + #endregion + + #region Content Preservation Tests + + [Fact] + public void Content_ShouldPreserve_ThoughtSignature_InParts() + { + // Arrange - Simulating a response from Gemini 3 with function call and thought signature + var signature = "ZnVuY3Rpb25fY2FsbF9zaWduYXR1cmU="; + var parts = new List + { + new Part + { + FunctionCall = new FunctionCall("search_books") + { + Args = System.Text.Json.Nodes.JsonNode.Parse("{\"genre\": \"science fiction\"}") + }, + ThoughtSignature = signature + } + }; + + var content = new Content(parts, "model"); + + // Act + var json = JsonSerializer.Serialize(content); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + deserialized.ShouldNotBeNull(); + deserialized.Parts.ShouldNotBeNull(); + deserialized.Parts.Count.ShouldBe(1); + deserialized.Parts[0].ThoughtSignature.ShouldBe(signature); + deserialized.Parts[0].FunctionCall.ShouldNotBeNull(); + } + + [Fact] + public void Content_Constructor_ShouldPreserve_PartReferences() + { + // Arrange + var signature = "b3JpZ2luYWxfc2lnbmF0dXJl"; + var originalPart = new Part + { + FunctionCall = new FunctionCall("get_weather"), + ThoughtSignature = signature + }; + var parts = new List { originalPart }; + + // Act + var content = new Content(parts, "model"); + + // Assert - Content should reference the same parts, preserving ThoughtSignature + content.Parts[0].ThoughtSignature.ShouldBe(signature); + content.Parts[0].ShouldBe(originalPart); // Same reference + } + + #endregion + + #region ThinkingLevel Enum Tests + + [Theory] + [InlineData(ThinkingLevel.THINKING_LEVEL_UNSPECIFIED)] + [InlineData(ThinkingLevel.LOW)] + [InlineData(ThinkingLevel.HIGH)] + public void ThinkingLevel_ShouldRoundTrip_AllValues(ThinkingLevel level) + { + // Arrange + var config = new ThinkingConfig { ThinkingLevel = level }; + + // Act + var json = JsonSerializer.Serialize(config); + + // Assert + var deserialized = JsonSerializer.Deserialize(json); + + deserialized.ShouldNotBeNull(); + deserialized.ThinkingLevel.ShouldBe(level); + } + + #endregion + + #region Model Constants Tests + + [Fact] + public void GoogleAIModels_ShouldContain_Gemini3Models() + { + // Assert + GoogleAIModels.Gemini3ProPreview.ShouldBe("models/gemini-3-pro-preview"); + GoogleAIModels.Gemini3FlashPreview.ShouldBe("models/gemini-3-flash-preview"); + } + + #endregion +}