From cc6dffcba9a593b67f9d0e241d4d1fd8098e739a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 2 Nov 2026 19:01:45 +0100 Subject: [PATCH] Generated the JSON contracts of the providers at compile time --- .../Anthropic/AnthropicContentBlockBuilder.cs | 10 +- .../ChatCompletionToolCallAccumulator.cs | 2 +- .../Provider/OpenAI/OpenAIStrictToolSchema.cs | 2 +- .../Provider/ProviderJsonContext.cs | 129 ++++++++++++++++++ .../Provider/ProviderJsonOptions.cs | 7 +- .../ChatCompletionToolCallingAdapterTests.cs | 24 +++- app/Tests/Serialization/JsonCoverageTests.cs | 2 + 7 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/ProviderJsonContext.cs diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicContentBlockBuilder.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicContentBlockBuilder.cs index 857219a61..f047f5194 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicContentBlockBuilder.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicContentBlockBuilder.cs @@ -117,8 +117,8 @@ public sealed class AnthropicContentBlockBuilder case TYPE_TEXT: return this.BuildFromSeed(new() { - ["type"] = JsonSerializer.Serialize(TYPE_TEXT), - ["text"] = JsonSerializer.Serialize(this.text.ToString()), + ["type"] = JsonSerializer.Serialize(TYPE_TEXT, ProviderJsonOptions.OPTIONS), + ["text"] = JsonSerializer.Serialize(this.text.ToString(), ProviderJsonOptions.OPTIONS), }); case TYPE_THINKING: @@ -129,9 +129,9 @@ public sealed class AnthropicContentBlockBuilder // return this.BuildFromSeed(new() { - ["type"] = JsonSerializer.Serialize(TYPE_THINKING), - ["thinking"] = JsonSerializer.Serialize(this.thinking.ToString()), - ["signature"] = JsonSerializer.Serialize(this.signature), + ["type"] = JsonSerializer.Serialize(TYPE_THINKING, ProviderJsonOptions.OPTIONS), + ["thinking"] = JsonSerializer.Serialize(this.thinking.ToString(), ProviderJsonOptions.OPTIONS), + ["signature"] = JsonSerializer.Serialize(this.signature, ProviderJsonOptions.OPTIONS), }); case TYPE_TOOL_USE: diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs index 4b455bc11..a38c461e2 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs @@ -117,7 +117,7 @@ public sealed class ChatCompletionToolCallAccumulator(Func JsonValue.Create(property.Key))]); - return JsonSerializer.Deserialize(schema.ToJsonString()); + return JsonSerializer.Deserialize(schema.ToJsonString(), ProviderJsonOptions.OPTIONS); } private static void AllowNullType(JsonObject property) diff --git a/app/MindWork AI Studio/Provider/ProviderJsonContext.cs b/app/MindWork AI Studio/Provider/ProviderJsonContext.cs new file mode 100644 index 000000000..c7520f09e --- /dev/null +++ b/app/MindWork AI Studio/Provider/ProviderJsonContext.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using AIStudio.Provider.Anthropic; +using AIStudio.Provider.Google; +using AIStudio.Provider.Groq; +using AIStudio.Provider.HuggingFace; +using AIStudio.Provider.OpenAI; +using AIStudio.Provider.OpenRouter; +using AIStudio.Provider.Requesty; + +using AnthropicDelta = AIStudio.Provider.Anthropic.Delta; +using AnthropicImageUrl = AIStudio.Provider.Anthropic.SubContentImageUrl; +using AnthropicResponseStreamLine = AIStudio.Provider.Anthropic.ResponseStreamLine; +using FireworksChoice = AIStudio.Provider.Fireworks.Choice; +using FireworksDelta = AIStudio.Provider.Fireworks.Delta; +using FireworksResponseStreamLine = AIStudio.Provider.Fireworks.ResponseStreamLine; +using HuggingFaceModelsResponse = AIStudio.Provider.HuggingFace.ModelsResponse; +using MistralModel = AIStudio.Provider.Mistral.Model; +using MistralModelsResponse = AIStudio.Provider.Mistral.ModelsResponse; +using OpenAIImageUrl = AIStudio.Provider.OpenAI.SubContentImageUrl; +using OpenAIModelsResponse = AIStudio.Provider.OpenAI.ModelsResponse; +using PerplexityChoice = AIStudio.Provider.Perplexity.Choice; +using PerplexityDelta = AIStudio.Provider.Perplexity.Delta; +using PerplexityResponseStreamLine = AIStudio.Provider.Perplexity.ResponseStreamLine; +using SelfHostedModel = AIStudio.Provider.SelfHosted.Model; +using SelfHostedModelsResponse = AIStudio.Provider.SelfHosted.ModelsResponse; + +namespace AIStudio.Provider; + +/// +/// The contracts with which System.Text.Json reads and writes the requests and answers of LLM providers, built +/// at compile time. +/// +/// +/// Some types System.Text.Json meets only while it runs, since no property names them: the messages and their +/// parts, which converters write as the type they are of; the annotations, which a converter reads as the type +/// their JSON tells; and whatever a request holds as an object, i.e., the values of the additional API +/// parameters, the tools, and the items of an earlier answer. A test fails for such a type which is missing +/// here.

+/// +/// Two types with the same name need a name of their own for their contract, else the generator builds only +/// the first of them and warns with SYSLIB1031. +///
+[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] + +// The requests: +[JsonSerializable(typeof(ChatCompletionAPIRequest))] +[JsonSerializable(typeof(ResponsesAPIRequest))] +[JsonSerializable(typeof(ChatRequest))] +[JsonSerializable(typeof(EmbeddingRequest))] +[JsonSerializable(typeof(GoogleEmbeddingRequest))] + +// The messages and their parts: +[JsonSerializable(typeof(TextMessage))] +[JsonSerializable(typeof(MultimodalMessage))] +[JsonSerializable(typeof(AssistantToolCallMessage))] +[JsonSerializable(typeof(ToolResultMessage))] +[JsonSerializable(typeof(AnthropicMessage))] +[JsonSerializable(typeof(AnthropicToolResultMessage))] +[JsonSerializable(typeof(SubContentText))] +[JsonSerializable(typeof(OpenAIImageUrl), TypeInfoPropertyName = "OpenAISubContentImageUrl")] +[JsonSerializable(typeof(SubContentImageUrlNested))] +[JsonSerializable(typeof(SubContentImageUrlData))] +[JsonSerializable(typeof(SubContentInputText))] +[JsonSerializable(typeof(SubContentInputImage))] +[JsonSerializable(typeof(SubContentImage))] +[JsonSerializable(typeof(SubContentBase64Image))] +[JsonSerializable(typeof(AnthropicImageUrl), TypeInfoPropertyName = "AnthropicSubContentImageUrl")] + +// What a request holds as an object: +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(decimal))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(ChatCompletionFunctionTool))] +[JsonSerializable(typeof(ResponsesFunctionTool))] +[JsonSerializable(typeof(ResponsesFunctionCallOutputItem))] + +// The lists of models: +[JsonSerializable(typeof(OpenAIModelsResponse), TypeInfoPropertyName = "OpenAIModelsResponse")] +[JsonSerializable(typeof(MistralModelsResponse), TypeInfoPropertyName = "MistralModelsResponse")] +[JsonSerializable(typeof(SelfHostedModelsResponse), TypeInfoPropertyName = "SelfHostedModelsResponse")] +[JsonSerializable(typeof(HuggingFaceModelsResponse), TypeInfoPropertyName = "HuggingFaceModelsResponse")] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(RequestyModelsResponse))] +[JsonSerializable(typeof(OpenRouterModelsResponse))] +[JsonSerializable(typeof(GroqModelsResponse))] + +// The lines of streamed answers, with the annotations: +[JsonSerializable(typeof(ChatCompletionDeltaStreamLine))] +[JsonSerializable(typeof(ChatCompletionAnnotationStreamLine))] +[JsonSerializable(typeof(ChatCompletionToolStreamLine))] +[JsonSerializable(typeof(ResponsesDeltaStreamLine))] +[JsonSerializable(typeof(ResponsesAnnotationStreamLine))] +[JsonSerializable(typeof(ResponsesCompletedStreamLine))] +[JsonSerializable(typeof(AnthropicResponseStreamLine), TypeInfoPropertyName = "AnthropicResponseStreamLine")] +[JsonSerializable(typeof(AnthropicStreamLine))] +[JsonSerializable(typeof(FireworksResponseStreamLine), TypeInfoPropertyName = "FireworksResponseStreamLine")] +[JsonSerializable(typeof(PerplexityResponseStreamLine), TypeInfoPropertyName = "PerplexityResponseStreamLine")] +[JsonSerializable(typeof(AnnotatingUnknown))] +[JsonSerializable(typeof(ChatCompletionAnnotatingURL))] +[JsonSerializable(typeof(ResponsesAnnotatingUrlCitationData))] + +// Transcriptions and embeddings: +[JsonSerializable(typeof(TranscriptionResponse))] +[JsonSerializable(typeof(EmbeddingResponse))] +[JsonSerializable(typeof(GoogleEmbeddingResponse))] +[JsonSerializable(typeof(GoogleEmbedding))] +[JsonSerializable(typeof(List))] + +// The types inside the answers which share their names with others: +[JsonSerializable(typeof(MistralModel), TypeInfoPropertyName = "MistralModel")] +[JsonSerializable(typeof(MistralModel[]), TypeInfoPropertyName = "MistralModelArray")] +[JsonSerializable(typeof(SelfHostedModel), TypeInfoPropertyName = "SelfHostedModel")] +[JsonSerializable(typeof(SelfHostedModel[]), TypeInfoPropertyName = "SelfHostedModelArray")] +[JsonSerializable(typeof(FireworksChoice), TypeInfoPropertyName = "FireworksChoice")] +[JsonSerializable(typeof(IList), TypeInfoPropertyName = "IListFireworksChoice")] +[JsonSerializable(typeof(PerplexityChoice), TypeInfoPropertyName = "PerplexityChoice")] +[JsonSerializable(typeof(IList), TypeInfoPropertyName = "IListPerplexityChoice")] +[JsonSerializable(typeof(AnthropicDelta), TypeInfoPropertyName = "AnthropicDelta")] +[JsonSerializable(typeof(FireworksDelta), TypeInfoPropertyName = "FireworksDelta")] +[JsonSerializable(typeof(PerplexityDelta), TypeInfoPropertyName = "PerplexityDelta")] +internal sealed partial class ProviderJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/ProviderJsonOptions.cs b/app/MindWork AI Studio/Provider/ProviderJsonOptions.cs index 213a29abe..357a51044 100644 --- a/app/MindWork AI Studio/Provider/ProviderJsonOptions.cs +++ b/app/MindWork AI Studio/Provider/ProviderJsonOptions.cs @@ -21,7 +21,7 @@ public static class ProviderJsonOptions /// /// The shared options. /// - public static readonly JsonSerializerOptions OPTIONS = new() + public static readonly JsonSerializerOptions OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = @@ -33,6 +33,7 @@ public static class ProviderJsonOptions new SubContentImageSourceConverter(), new SubContentImageUrlConverter(), }, - AllowTrailingCommas = false - }; + AllowTrailingCommas = false, + TypeInfoResolver = ProviderJsonContext.Default, + }.AsReadOnly(); } \ No newline at end of file diff --git a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs index 3a3342013..d95076c7d 100644 --- a/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs +++ b/app/Tests/Provider/ToolCalling/ChatCompletionToolCallingAdapterTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; using AIStudio.Provider; using AIStudio.Provider.OpenAI; @@ -198,10 +199,27 @@ public sealed class ChatCompletionToolCallingAdapterTests /// The call as the loop sees it, and the request of the next round as it goes over the wire. private static async Task<(ToolCallingRequestedCall Call, string NextRequest)> AnswerOneCall(string functionName, string arguments, string result) { - var toolCallLine = JsonSerializer.Serialize(new + // + // Built as nodes, since the options of the providers know no anonymous types. The nodes escape the name and the + // arguments the way a provider would: + // + var toolCallLine = new JsonObject { - Choices = new[] { new { Index = 0, Delta = new { ToolCalls = new[] { new { Index = 0, Id = "call_1", Type = "function", Function = new { Name = functionName, Arguments = arguments } } } } } }, - }, ProviderJsonOptions.OPTIONS); + ["choices"] = new JsonArray(new JsonObject + { + ["index"] = 0, + ["delta"] = new JsonObject + { + ["tool_calls"] = new JsonArray(new JsonObject + { + ["index"] = 0, + ["id"] = "call_1", + ["type"] = "function", + ["function"] = new JsonObject { ["name"] = functionName, ["arguments"] = arguments }, + }), + }, + }), + }.ToJsonString(); var sent = new List(); var adapter = Adapter(true, sent.Add, diff --git a/app/Tests/Serialization/JsonCoverageTests.cs b/app/Tests/Serialization/JsonCoverageTests.cs index b900e49f3..c6329a0ab 100644 --- a/app/Tests/Serialization/JsonCoverageTests.cs +++ b/app/Tests/Serialization/JsonCoverageTests.cs @@ -14,6 +14,8 @@ namespace AIStudio.Tests.Serialization; public sealed class JsonCoverageTests { [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] public void EveryContractComesFromSourceGeneration(JsonTestRoot root) { var typesWithoutGeneratedContract = JsonTypeGraph.Collect(root.Type, root.Options)