From 037e637d3bccec7781bf4d8257b339c158c5d056 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 17 Mar 2026 09:03:18 -0400 Subject: [PATCH] Update Microsoft.Extensions.AI to 10.4.0 Also fix an issue with schema handling around nullable parameters. --- Directory.Packages.props | 6 +- .../MicrosoftAi/AbstractionMapper.cs | 54 ++++---- test/AbstractionMapperTests.cs | 127 ++++++++++++++++-- 3 files changed, 141 insertions(+), 46 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 43fdd6e..6c5fc18 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,11 +4,11 @@ - + - - + + diff --git a/src/OllamaSharp/MicrosoftAi/AbstractionMapper.cs b/src/OllamaSharp/MicrosoftAi/AbstractionMapper.cs index 8c6db8f..d6fe530 100644 --- a/src/OllamaSharp/MicrosoftAi/AbstractionMapper.cs +++ b/src/OllamaSharp/MicrosoftAi/AbstractionMapper.cs @@ -4,6 +4,7 @@ using OllamaSharp.Constants; using OllamaSharp.Models; using OllamaSharp.Models.Chat; + using ChatRole = OllamaSharp.Models.Chat.ChatRole; namespace OllamaSharp.MicrosoftAi; @@ -80,6 +81,18 @@ public static ChatRequest ToOllamaSharpChatRequest(IChatClient? chatClient, IEnu request.Options.TopK ??= options?.TopK; request.Options.NumPredict = options?.MaxOutputTokens; + if (options?.Reasoning is { } reasoning) + { + request.Think ??= reasoning.Effort switch + { + null => (ThinkValue?)null, + ReasoningEffort.None => false, + ReasoningEffort.Low => new ThinkValue(ThinkValue.Low), + ReasoningEffort.Medium => new ThinkValue(ThinkValue.Medium), + _ => new ThinkValue(ThinkValue.High), + }; + } + request.Messages = request.Messages is null ? mappedMessages : mappedMessages is null ? request.Messages : @@ -176,45 +189,26 @@ private static void TryAddOption(ChatOptions? microsoftChatOptions, string op } /// - /// Converts an to a . + /// Converts an to a JSON tool representation + /// compatible with the Ollama API. /// /// The function to convert. - /// A object containing the converted data. - private static Tool ToOllamaSharpTool(AIFunctionDeclaration function) + /// A representing the tool in Ollama's expected format. + private static JsonNode? ToOllamaSharpTool(AIFunctionDeclaration function) { JsonElement transformedSchema = _schemaTransformCache.GetOrCreateTransformedSchema(function); - return new Tool + return new JsonObject { - Function = new Function + ["type"] = Application.Function, + [Application.Function] = new JsonObject { - Description = function.Description, - Name = function.Name, - Parameters = JsonSerializer.Deserialize(transformedSchema), - }, - Type = Application.Function + [Application.Name] = function.Name, + ["description"] = function.Description, + [Application.Parameters] = JsonNode.Parse(transformedSchema.GetRawText()), + } }; } - /// - /// Converts parameter schema object to a function type string. - /// - /// The schema object holding schema type information. - /// A collection of strings containing the function types. - private static IEnumerable GetPossibleValues(JsonObject? schema) - { - return []; // TODO others supported? - } - - /// - /// Converts parameter schema object to a function type string. - /// - /// The schema object holding schema type information. - /// A string containing the function type. - private static string ToFunctionTypeString(JsonObject? schema) - { - return "string"; // TODO others supported? - } - /// /// Converts a list of Microsoft.Extensions.AI. to a list of Ollama . /// diff --git a/test/AbstractionMapperTests.cs b/test/AbstractionMapperTests.cs index 4e7df25..13dca23 100644 --- a/test/AbstractionMapperTests.cs +++ b/test/AbstractionMapperTests.cs @@ -252,19 +252,52 @@ public void Maps_Messages_With_Tools() var chatRequest = AbstractionMapper.ToOllamaSharpChatRequest(null, chatMessages, options, stream: true, JsonSerializerOptions.Default); - var tool = (Tool)chatRequest.Tools.Single(); - tool.Function.Description.ShouldBe("Gets the current weather for a current location"); - tool.Function.Name.ShouldBe("get_weather"); - tool.Function.Parameters.Properties.Count.ShouldBe(2); - tool.Function.Parameters.Properties["city"].Description.ShouldBe("The city to get the weather for"); - tool.Function.Parameters.Properties["city"].Enum.ShouldBeNull(); - tool.Function.Parameters.Properties["city"].Type.ShouldBe("string"); - tool.Function.Parameters.Properties["unit"].Description.ShouldBe("The unit to calculate the current temperature to"); - tool.Function.Parameters.Properties["unit"].Enum.ShouldBeNull(); - tool.Function.Parameters.Properties["unit"].Type.ShouldBe("string"); - tool.Function.Parameters.Required.ShouldBe(["city"], ignoreOrder: true); - tool.Function.Parameters.Type.ShouldBe("object"); - tool.Type.ShouldBe("function"); + var toolJson = JsonSerializer.SerializeToElement(chatRequest.Tools.Single()); + toolJson.GetProperty("type").GetString().ShouldBe("function"); + toolJson.GetProperty("function").GetProperty("description").GetString().ShouldBe("Gets the current weather for a current location"); + toolJson.GetProperty("function").GetProperty("name").GetString().ShouldBe("get_weather"); + + var parameters = toolJson.GetProperty("function").GetProperty("parameters"); + parameters.GetProperty("type").GetString().ShouldBe("object"); + parameters.GetProperty("required").EnumerateArray().Select(e => e.GetString()).ShouldBe(["city"], ignoreOrder: true); + + var properties = parameters.GetProperty("properties"); + properties.GetProperty("city").GetProperty("description").GetString().ShouldBe("The city to get the weather for"); + properties.GetProperty("city").GetProperty("type").GetString().ShouldBe("string"); + properties.GetProperty("unit").GetProperty("description").GetString().ShouldBe("The unit to calculate the current temperature to"); + properties.GetProperty("unit").GetProperty("type").GetString().ShouldBe("string"); + } + + /// + /// Verifies that tools with nullable parameters (where JSON schema type is an array) are handled correctly. + /// + [Test] + public void Maps_Messages_With_Tools_Nullable_Parameters() + { + var chatMessages = new List + { + new(Microsoft.Extensions.AI.ChatRole.User, "Filter files") + }; + + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(( + [System.ComponentModel.Description("The filter to apply")] string? filenameFilter) => "done", + "filter_files", "Filters files by name")], + }; + + // This should not throw - previously it crashed when Property.Type was an array like ["string", "null"] + var chatRequest = AbstractionMapper.ToOllamaSharpChatRequest(null, chatMessages, options, stream: true, JsonSerializerOptions.Default); + + var toolJson = JsonSerializer.SerializeToElement(chatRequest.Tools.Single()); + toolJson.GetProperty("type").GetString().ShouldBe("function"); + toolJson.GetProperty("function").GetProperty("name").GetString().ShouldBe("filter_files"); + + var parameters = toolJson.GetProperty("function").GetProperty("parameters"); + parameters.GetProperty("type").GetString().ShouldBe("object"); + + var filenameFilter = parameters.GetProperty("properties").GetProperty("filenameFilter"); + filenameFilter.GetProperty("description").GetString().ShouldBe("The filter to apply"); } /// @@ -779,6 +812,74 @@ public void Maps_UsesRawRepresentation() ollamaRequest.Options.VocabOnly.Value.ShouldBeTrue(); } + /// + /// Verifies that ReasoningOptions.Output=None without Effort leaves Think as null. + /// + [Test] + public void Maps_Reasoning_Output_None_Disables_Think() + { + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Output = ReasoningOutput.None } + }; + + var request = AbstractionMapper.ToOllamaSharpChatRequest(null, [], options, stream: true, JsonSerializerOptions.Default); + + request.Think.ShouldBeNull(); + } + + /// + /// Verifies that ReasoningOptions with effort maps to Ollama think levels. + /// + [TestCase(ReasoningEffort.Low, "low")] + [TestCase(ReasoningEffort.Medium, "medium")] + [TestCase(ReasoningEffort.High, "high")] + [TestCase(ReasoningEffort.ExtraHigh, "high")] + public void Maps_Reasoning_Effort_To_Think_Level(ReasoningEffort effort, string expectedThink) + { + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = effort } + }; + + var request = AbstractionMapper.ToOllamaSharpChatRequest(null, [], options, stream: true, JsonSerializerOptions.Default); + + request.Think.ShouldBe(expectedThink); + } + + /// + /// Verifies that ReasoningOptions with no specific effort leaves Think as null. + /// + [Test] + public void Maps_Reasoning_Without_Effort_Enables_Think() + { + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Output = ReasoningOutput.Full } + }; + + var request = AbstractionMapper.ToOllamaSharpChatRequest(null, [], options, stream: true, JsonSerializerOptions.Default); + + request.Think.ShouldBeNull(); + } + + /// + /// Verifies that Reasoning does not override a value set via RawRepresentationFactory. + /// + [Test] + public void Maps_Reasoning_Does_Not_Override_RawRepresentation() + { + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = ReasoningEffort.High }, + RawRepresentationFactory = _ => new ChatRequest { Think = false }, + }; + + var request = AbstractionMapper.ToOllamaSharpChatRequest(new MockChatClient(), [], options, stream: true, JsonSerializerOptions.Default); + + request.Think.ShouldBe(false); + } + private sealed class MockChatClient : IChatClient { public void Dispose() { }