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/Anthropic/AnthropicMessage.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessage.cs index 489958d43..a55840454 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessage.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessage.cs @@ -10,4 +10,12 @@ namespace AIStudio.Provider.Anthropic; /// thinking blocks, and the thinking blocks have to return exactly as they arrived — reading and /// rebuilding them would risk changing them. /// -public sealed record AnthropicMessage(IList Content, string Role = "assistant") : IMessage>; \ No newline at end of file +public sealed record AnthropicMessage(IList Content, string Role = "assistant") : IMessage> +{ + // + // The properties of the parameters, declared again to be settable, as the messages demand it. Keep them in + // the order of the parameters, which is the order they get written in: + // + public IList Content { get; set; } = Content; + public string Role { get; set; } = Role; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessageStreamAccumulator.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessageStreamAccumulator.cs index f12c157f1..95845673f 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessageStreamAccumulator.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessageStreamAccumulator.cs @@ -152,9 +152,9 @@ public sealed class AnthropicMessageStreamAccumulator this.unparsableToolArguments[builder.ToolUseId] = builder.UnparsableToolArguments; } - private static JsonElement EmptyTextBlock() => JsonSerializer.SerializeToElement(new + private static JsonElement EmptyTextBlock() { - type = "text", - text = string.Empty, - }); + using var document = JsonDocument.Parse("""{"type":"text","text":""}"""); + return document.RootElement.Clone(); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicStreamMessage.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicStreamMessage.cs index 660400f3e..fcf017e8b 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicStreamMessage.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicStreamMessage.cs @@ -14,5 +14,5 @@ public sealed record AnthropicStreamMessage /// /// What the request carried, where the stream states it. /// - public AnthropicUsage? Usage { get; init; } + public AnthropicUsage? Usage { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicTool.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicTool.cs index aca8c9012..7509412d7 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicTool.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicTool.cs @@ -11,11 +11,11 @@ namespace AIStudio.Provider.Anthropic; /// public sealed record AnthropicTool { - public string Name { get; init; } = string.Empty; + public string Name { get; set; } = string.Empty; - public string Description { get; init; } = string.Empty; + public string Description { get; set; } = string.Empty; - public bool Strict { get; init; } + public bool Strict { get; set; } - public JsonElement InputSchema { get; init; } + public JsonElement InputSchema { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultContent.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultContent.cs index 304aa5870..523eafdc0 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultContent.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultContent.cs @@ -4,11 +4,11 @@ namespace AIStudio.Provider.Anthropic; public sealed record AnthropicToolResultContent { - public string Type { get; init; } = "tool_result"; + public string Type { get; set; } = "tool_result"; - public string ToolUseId { get; init; } = string.Empty; + public string ToolUseId { get; set; } = string.Empty; - public string Content { get; init; } = string.Empty; + public string Content { get; set; } = string.Empty; /// /// Whether the tool failed rather than returning a result. @@ -18,5 +18,5 @@ public sealed record AnthropicToolResultContent /// result stays byte-identical to what earlier versions sent. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public bool IsError { get; init; } + public bool IsError { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultMessage.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultMessage.cs index b634efb0b..0d44b2765 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultMessage.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolResultMessage.cs @@ -7,4 +7,12 @@ namespace AIStudio.Provider.Anthropic; /// All results of one turn belong in a single message. Splitting them across several messages /// teaches the model to stop asking for more than one tool at a time. /// -public sealed record AnthropicToolResultMessage(IList Content, string Role = "user") : IMessage>; \ No newline at end of file +public sealed record AnthropicToolResultMessage(IList Content, string Role = "user") : IMessage> +{ + // + // The properties of the parameters, declared again to be settable, as the messages demand it. Keep them in + // the order of the parameters, which is the order they get written in: + // + public IList Content { get; set; } = Content; + public string Role { get; set; } = Role; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicUsage.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicUsage.cs index 70a9f7a87..5a9866d13 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicUsage.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicUsage.cs @@ -23,17 +23,17 @@ public sealed record AnthropicUsage /// /// What the request carried after its last cache breakpoint, which is all of it without caching. /// - public int? InputTokens { get; init; } + public int? InputTokens { get; set; } /// /// What the request wrote to the cache. /// - public int? CacheCreationInputTokens { get; init; } + public int? CacheCreationInputTokens { get; set; } /// /// What the request read from the cache. /// - public int? CacheReadInputTokens { get; init; } + public int? CacheReadInputTokens { get; set; } /// /// States what this block reports, as far as it can be believed. diff --git a/app/MindWork AI Studio/Provider/Anthropic/ChatRequest.cs b/app/MindWork AI Studio/Provider/Anthropic/ChatRequest.cs index 99632e477..708b22bdb 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ChatRequest.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ChatRequest.cs @@ -10,7 +10,11 @@ namespace AIStudio.Provider.Anthropic; /// The maximum number of tokens to generate. /// Whether to stream the chat completion. /// The system prompt for the chat completion. -public readonly record struct ChatRequest( +/// +/// Not readonly, so that its properties can be settable: the source generation of System.Text.Json refuses to +/// write extension data which is init-only. +/// +public record struct ChatRequest( string Model, IList Messages, int MaxTokens, @@ -26,9 +30,12 @@ public readonly record struct ChatRequest( /// tools at all, and the final round of a tool conversation has to offer none. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Tools { get; init; } + public IList? Tools { get; set; } - // Attention: The "required" modifier is not supported for [JsonExtensionData]. + // + // Attention: The "required" modifier is not supported for [JsonExtensionData], and neither is init: the source + // generation of System.Text.Json refuses to write the request at all then. + // [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); + public IDictionary AdditionalApiParameters { get; set; } = new Dictionary(); } diff --git a/app/MindWork AI Studio/Provider/Anthropic/SubContentBase64Image.cs b/app/MindWork AI Studio/Provider/Anthropic/SubContentBase64Image.cs index 8123c8146..beb0ae6c2 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/SubContentBase64Image.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/SubContentBase64Image.cs @@ -4,7 +4,7 @@ public record SubContentBase64Image : ISubContentImageSource { public SubContentImageType Type => SubContentImageType.BASE64; - public string MediaType { get; init; } = string.Empty; + public string MediaType { get; set; } = string.Empty; - public string Data { get; init; } = string.Empty; + public string Data { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/SubContentImage.cs b/app/MindWork AI Studio/Provider/Anthropic/SubContentImage.cs index 074f5db0e..11321fd9c 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/SubContentImage.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/SubContentImage.cs @@ -4,6 +4,15 @@ namespace AIStudio.Provider.Anthropic; public record SubContentImage(SubContentType Type, ISubContentImageSource Source) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public ISubContentImageSource Source { get; set; } = Source; + public SubContentImage() : this(SubContentType.IMAGE, new SubContentImageUrl()) { } diff --git a/app/MindWork AI Studio/Provider/Anthropic/SubContentImageUrl.cs b/app/MindWork AI Studio/Provider/Anthropic/SubContentImageUrl.cs index 0247a40c0..21e10fa5a 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/SubContentImageUrl.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/SubContentImageUrl.cs @@ -4,5 +4,5 @@ public record SubContentImageUrl : ISubContentImageSource { public SubContentImageType Type => SubContentImageType.URL; - public string Url { get; init; } = string.Empty; + public string Url { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index eddb53ade..ceec463f6 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1377,7 +1377,7 @@ public abstract class BaseProvider : IProvider, ISecretId if (runnableTools.Count > 0) { var adapter = new ChatCompletionToolCallingAdapter(requestFactory, systemPrompt, apiParameters, - runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition, enforcesStrictToolSchemas)).ToList(), mayAskForSequentialToolCalls, runnableTools, + runnableTools.Select(x => (object)ProviderToolAdapters.ToChatCompletionTool(x.Definition, enforcesStrictToolSchemas)).ToList(), mayAskForSequentialToolCalls, runnableTools, (requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, requestHeadersAction, requestToken), ChatCompletionSourceReader.Read, this.logger); @@ -1662,13 +1662,7 @@ public abstract class BaseProvider : IProvider, ISecretId modelName = "placeholder"; // Prepare the HTTP embedding request: - var payload = new - { - model = modelName, - input = texts, - encoding_format = "float" - }; - + var payload = new EmbeddingRequest(modelName, texts); var embeddingRequest = JsonSerializer.Serialize(payload, JSON_SERIALIZER_OPTIONS); using var request = new HttpRequestMessage(HttpMethod.Post, host.EmbeddingURL()); diff --git a/app/MindWork AI Studio/Provider/EmbeddingRequest.cs b/app/MindWork AI Studio/Provider/EmbeddingRequest.cs new file mode 100644 index 000000000..1b833726c --- /dev/null +++ b/app/MindWork AI Studio/Provider/EmbeddingRequest.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Provider; + +/// +/// A request for embeddings, in the shape of the OpenAI API, which most providers take. +/// +/// The name of the embedding model. +/// The texts to embed. +public sealed record EmbeddingRequest(string Model, IList Input) +{ + /// + /// Asks for the vectors as numbers, instead of as base64. + /// + public string EncodingFormat => "float"; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/EmbeddingResponse.cs b/app/MindWork AI Studio/Provider/EmbeddingResponse.cs index 6a6c6a86f..7e848f6fe 100644 --- a/app/MindWork AI Studio/Provider/EmbeddingResponse.cs +++ b/app/MindWork AI Studio/Provider/EmbeddingResponse.cs @@ -2,13 +2,13 @@ public sealed record EmbeddingResponse { - public string? Id { get; init; } + public string? Id { get; set; } - public string? Object { get; init; } + public string? Object { get; set; } - public List? Data { get; init; } + public List? Data { get; set; } - public string? Model { get; init; } + public string? Model { get; set; } - public EmbeddingUsage? Usage { get; init; } + public EmbeddingUsage? Usage { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs index 9a7d9b380..d57f491f2 100644 --- a/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbedding.cs @@ -2,5 +2,5 @@ public sealed record GoogleEmbedding { - public List? Values { get; init; } + public List? Values { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingContent.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingContent.cs new file mode 100644 index 000000000..45a27ea3b --- /dev/null +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingContent.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Provider.Google; + +/// +/// The content of a request for an embedding to the Gemini API. +/// +/// The texts, each of them as a part of its own. +public sealed record GoogleEmbeddingContent(IList Parts); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingPart.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingPart.cs new file mode 100644 index 000000000..6950c3b95 --- /dev/null +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingPart.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Provider.Google; + +/// +/// One text in a request for an embedding to the Gemini API. +/// +/// The text to embed. +public sealed record GoogleEmbeddingPart(string Text); \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingRequest.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingRequest.cs new file mode 100644 index 000000000..9dba8790d --- /dev/null +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingRequest.cs @@ -0,0 +1,22 @@ +namespace AIStudio.Provider.Google; + +/// +/// A request for an embedding to the Gemini API. +/// +/// The texts to embed. +public sealed record GoogleEmbeddingRequest(GoogleEmbeddingContent Content) +{ + /// + /// Asks for vectors which tell how similar texts are. + /// + /// + /// Written as task_type, like every other field of our requests. The API reads both its field names in + /// snake case and in camel case. + /// + public string TaskType => "SEMANTIC_SIMILARITY"; + + /// + /// Creates the request for the given texts, each of them as a part of its own. + /// + public static GoogleEmbeddingRequest Of(IEnumerable texts) => new(new GoogleEmbeddingContent([..texts.Select(text => new GoogleEmbeddingPart(text))])); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs index 24d9c175c..d45914a4e 100644 --- a/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs +++ b/app/MindWork AI Studio/Provider/Google/GoogleEmbeddingResponse.cs @@ -6,9 +6,13 @@ namespace AIStudio.Provider.Google; public sealed record GoogleEmbeddingResponse { [JsonConverter(typeof(GoogleEmbeddingListConverter))] - public List? Embedding { get; init; } + public List? Embedding { get; set; } - private sealed class GoogleEmbeddingListConverter : JsonConverter> + /// + /// Internal rather than private: the JSON context of the providers has to reach the converter, else it reads + /// the embeddings without it. + /// + internal sealed class GoogleEmbeddingListConverter : JsonConverter> { public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index bc08b617f..0cdc533e2 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -92,16 +92,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https } // Prepare the Google Gemini embedding request: - var payload = new - { - content = new - { - parts = texts.Select(text => new { text }).ToArray() - }, - - taskType = "SEMANTIC_SIMILARITY" - }; - + var payload = GoogleEmbeddingRequest.Of(texts); var embeddingRequest = JsonSerializer.Serialize(payload, JSON_SERIALIZER_OPTIONS); var embedUrl = $"https://generativelanguage.googleapis.com/v1beta/models/{modelName}:embedContent"; using var request = new HttpRequestMessage(HttpMethod.Post, embedUrl); diff --git a/app/MindWork AI Studio/Provider/IMessage.cs b/app/MindWork AI Studio/Provider/IMessage.cs index 2a8c9e2f4..485f4413c 100644 --- a/app/MindWork AI Studio/Provider/IMessage.cs +++ b/app/MindWork AI Studio/Provider/IMessage.cs @@ -11,5 +11,5 @@ public interface IMessage : IMessageBase /// The content encapsulates the core information or data being transmitted, /// and its type can vary based on the specific implementation or use case. /// - public T Content { get; init; } + public T Content { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/IMessageBase.cs b/app/MindWork AI Studio/Provider/IMessageBase.cs index 8f67cc8b3..9da827275 100644 --- a/app/MindWork AI Studio/Provider/IMessageBase.cs +++ b/app/MindWork AI Studio/Provider/IMessageBase.cs @@ -10,5 +10,5 @@ public interface IMessageBase /// This property typically identifies whether the entity is acting /// as a user, assistant, or system in the context of the interaction. /// - public string Role { get; init; } + public string Role { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs index cd4048789..a3cebb0b1 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/AssistantToolCallMessage.cs @@ -5,12 +5,12 @@ namespace AIStudio.Provider.OpenAI; public sealed record AssistantToolCallMessage : IMessageBase { - public string Role { get; init; } = "assistant"; + public string Role { get; set; } = "assistant"; - public JsonElement? Content { get; init; } + public JsonElement? Content { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ReasoningContent { get; init; } + public string? ReasoningContent { get; set; } - public IList ToolCalls { get; init; } = []; + public IList ToolCalls { get; set; } = []; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs index 5c64ce441..60cc07473 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionAPIRequest.cs @@ -14,15 +14,25 @@ public record ChatCompletionAPIRequest( bool Stream ) { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public string Model { get; set; } = Model; + public IList Messages { get; set; } = Messages; + public bool Stream { get; set; } = Stream; + public ChatCompletionAPIRequest() : this(string.Empty, [], true) { } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Tools { get; init; } + public IList? Tools { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? ParallelToolCalls { get; init; } + public bool? ParallelToolCalls { get; set; } /// /// Asks a streamed request to end with what it cost. @@ -35,7 +45,10 @@ public record ChatCompletionAPIRequest( [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ChatCompletionStreamOptions? StreamOptions => this.Stream ? ChatCompletionStreamOptions.INCLUDE_USAGE : null; - // Attention: The "required" modifier is not supported for [JsonExtensionData]. + // + // Attention: The "required" modifier is not supported for [JsonExtensionData], and neither is init: the source + // generation of System.Text.Json refuses to write the request at all then. + // [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); + public IDictionary AdditionalApiParameters { get; set; } = new Dictionary(); } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionChoice.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionChoice.cs index 22788bbeb..ae4a8e2b6 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionChoice.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionChoice.cs @@ -7,6 +7,14 @@ namespace AIStudio.Provider.OpenAI; /// The delta text of the choice. public record ChatCompletionChoice(int Index, ChatCompletionDelta Delta) { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters: + // + public int Index { get; set; } = Index; + public ChatCompletionDelta Delta { get; set; } = Delta; + public ChatCompletionChoice() : this(0, new()) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs index 9f6f6cfcc..7981f0375 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDelta.cs @@ -9,7 +9,7 @@ namespace AIStudio.Provider.OpenAI; public sealed record ChatCompletionDelta { [JsonPropertyName("content")] - public JsonElement? RawContent { get; init; } + public JsonElement? RawContent { get; set; } [JsonIgnore] public string Content => ChatCompletionContent.GetText(this.RawContent) ?? string.Empty; diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs index cdd1dfef7..663381561 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionDeltaStreamLine.cs @@ -11,6 +11,19 @@ namespace AIStudio.Provider.OpenAI; /// The choices made by the AI. public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Created, string Model, string SystemFingerprint, IList Choices) : IResponseStreamLine { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost, e.g., the empty list of choices of the line which carries the + // usage. Keep them first and in the order of the parameters: + // + public string Id { get; set; } = Id; + public string Object { get; set; } = Object; + public uint Created { get; set; } = Created; + public string Model { get; set; } = Model; + public string SystemFingerprint { get; set; } = SystemFingerprint; + public IList Choices { get; set; } = Choices; + public ChatCompletionDeltaStreamLine() : this(string.Empty, string.Empty, 0, string.Empty, string.Empty, []) { } @@ -25,7 +38,7 @@ public record ChatCompletionDeltaStreamLine(string Id, string Object, uint Creat /// line of its own which carries no choices -- which is why the usage is read apart from the /// content rather than next to it. /// - public ChatCompletionUsage? Usage { get; init; } + public ChatCompletionUsage? Usage { get; set; } /// public bool ContainsContent() => this.Choices.Count > 0; diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionDeclaration.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionDeclaration.cs new file mode 100644 index 000000000..cf3ab37ba --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionDeclaration.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace AIStudio.Provider.OpenAI; + +/// +/// The function a tool of a Chat Completions compatible API offers to the model. +/// +public sealed record ChatCompletionFunctionDeclaration +{ + public string Name { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public JsonElement Parameters { get; set; } + + public bool Strict { get; set; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionTool.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionTool.cs new file mode 100644 index 000000000..e4865dd21 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionFunctionTool.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Provider.OpenAI; + +/// +/// The function tool definition shape expected by Chat Completions compatible APIs, with the function nested. +/// +public sealed record ChatCompletionFunctionTool +{ + public string Type { get; set; } = "function"; + + public ChatCompletionFunctionDeclaration Function { get; set; } = new(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamDelta.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamDelta.cs index 2949f9f75..6b7d6ab18 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamDelta.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamDelta.cs @@ -16,7 +16,7 @@ public sealed record ChatCompletionStreamDelta /// The content as it arrived: a string for most providers, a list of parts for some. /// [JsonPropertyName("content")] - public JsonElement? RawContent { get; init; } + public JsonElement? RawContent { get; set; } /// /// The text of this fragment, whichever shape it arrived in. @@ -27,10 +27,10 @@ public sealed record ChatCompletionStreamDelta /// /// The reasoning text some providers stream next to the answer. /// - public string? ReasoningContent { get; init; } + public string? ReasoningContent { get; set; } /// /// The fragments of the tool calls the model is asking for. /// - public IList? ToolCalls { get; init; } + public IList? ToolCalls { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs index 9c0a38c0d..651d6f37a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCall.cs @@ -5,12 +5,12 @@ namespace AIStudio.Provider.OpenAI; public sealed record ChatCompletionToolCall { - public string? Id { get; init; } + public string? Id { get; set; } - public string? Type { get; init; } = "function"; + public string? Type { get; set; } = "function"; - public ChatCompletionToolFunction? Function { get; init; } + public ChatCompletionToolFunction? Function { get; set; } [JsonExtensionData] - public IDictionary AdditionalMetadata { get; init; } = new Dictionary(); + public IDictionary AdditionalMetadata { get; set; } = new Dictionary(); } 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 - public ChatCompletionUsage? Usage { get; init; } + public ChatCompletionUsage? Usage { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs index 186cee76c..4b4b2f671 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionUsage.cs @@ -17,7 +17,7 @@ public sealed record ChatCompletionUsage /// /// What everything sent to the model cost. /// - public int? PromptTokens { get; init; } + public int? PromptTokens { get; set; } /// /// States what this block reports, as far as it can be believed. diff --git a/app/MindWork AI Studio/Provider/OpenAI/ISubContent.cs b/app/MindWork AI Studio/Provider/OpenAI/ISubContent.cs index 165d07b1c..8472738db 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ISubContent.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ISubContent.cs @@ -8,5 +8,5 @@ public interface ISubContent /// /// The type of the sub-content. /// - public SubContentType Type { get; init; } + public SubContentType Type { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ISubContentImageUrl.cs b/app/MindWork AI Studio/Provider/OpenAI/ISubContentImageUrl.cs index b2f9b51df..3be1d5f53 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ISubContentImageUrl.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ISubContentImageUrl.cs @@ -15,5 +15,5 @@ public interface ISubContentImageUrl /// /// The URL or base64-encoded data URI of the image. /// - public string Url { get; init; } + public string Url { get; set; } } diff --git a/app/MindWork AI Studio/Provider/OpenAI/MultimodalMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/MultimodalMessage.cs index 8b7ff8e0e..7b84f5283 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/MultimodalMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/MultimodalMessage.cs @@ -7,6 +7,15 @@ namespace AIStudio.Provider.OpenAI; /// The role of the message. public record MultimodalMessage(List Content, string Role) : IMessage> { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public List Content { get; set; } = Content; + public string Role { get; set; } = Role; + public MultimodalMessage() : this([], string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/OpenAIStrictToolSchema.cs b/app/MindWork AI Studio/Provider/OpenAI/OpenAIStrictToolSchema.cs index c7c56cbc7..3ea0cbb41 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/OpenAIStrictToolSchema.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/OpenAIStrictToolSchema.cs @@ -59,7 +59,7 @@ public static class OpenAIStrictToolSchema // schema stays stable across requests, which prompt caching depends on. // schema["required"] = new JsonArray([..properties.Select(property => 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/OpenAI/ResponsesAPIRequest.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs index 155400aef..8df508e2d 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesAPIRequest.cs @@ -17,11 +17,26 @@ public record ResponsesAPIRequest( bool Store, IList Tools) { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public string Model { get; set; } = Model; + public IList Input { get; set; } = Input; + public bool Stream { get; set; } = Stream; + public bool Store { get; set; } = Store; + public IList Tools { get; set; } = Tools; + public ResponsesAPIRequest() : this(string.Empty, [], true, false, []) { } - - // Attention: The "required" modifier is not supported for [JsonExtensionData]. + + // + // Attention: The "required" modifier is not supported for [JsonExtensionData], and neither is init: the source + // generation of System.Text.Json refuses to write the request at all then. + // [JsonExtensionData] - public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); + public IDictionary AdditionalApiParameters { get; set; } = new Dictionary(); } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallOutputItem.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallOutputItem.cs index 19e9bedb4..a7c6b3701 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallOutputItem.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionCallOutputItem.cs @@ -5,9 +5,9 @@ namespace AIStudio.Provider.OpenAI; /// public sealed record ResponsesFunctionCallOutputItem { - public string Type { get; init; } = "function_call_output"; + public string Type { get; set; } = "function_call_output"; - public string CallId { get; init; } = string.Empty; + public string CallId { get; set; } = string.Empty; - public string Output { get; init; } = string.Empty; + public string Output { get; set; } = string.Empty; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionTool.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionTool.cs index fe9f5dc0a..c4c73bc01 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionTool.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesFunctionTool.cs @@ -7,13 +7,13 @@ namespace AIStudio.Provider.OpenAI; /// public sealed record ResponsesFunctionTool { - public string Type { get; init; } = "function"; + public string Type { get; set; } = "function"; - public string Name { get; init; } = string.Empty; + public string Name { get; set; } = string.Empty; - public string Description { get; init; } = string.Empty; + public string Description { get; set; } = string.Empty; - public JsonElement Parameters { get; init; } + public JsonElement Parameters { get; set; } - public bool Strict { get; init; } + public bool Strict { get; set; } } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs index 91f106286..8f9eb16e8 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesResponse.cs @@ -17,18 +17,18 @@ public sealed record ResponsesResponse /// private static readonly HashSet OUTPUT_ITEMS_LEAVING_THE_INPUT_ALONE = ["message", "reasoning", "function_call"]; - public string Id { get; init; } = string.Empty; + public string Id { get; set; } = string.Empty; - public string Model { get; init; } = string.Empty; + public string Model { get; set; } = string.Empty; - public string? OutputText { get; init; } + public string? OutputText { get; set; } - public IList Output { get; init; } = []; + public IList Output { get; set; } = []; /// /// What OpenAI says the response cost. Only the completed response carries it. /// - public ResponsesUsage? Usage { get; init; } + public ResponsesUsage? Usage { get; set; } /// /// States what the request of this response carried, as far as it can be believed. diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesUsage.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesUsage.cs index 1a161ecb5..e9b24fd6a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesUsage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesUsage.cs @@ -17,7 +17,7 @@ public sealed record ResponsesUsage /// /// What everything sent to the model cost, the cached part included. /// - public int? InputTokens { get; init; } + public int? InputTokens { get; set; } /// /// States what this block reports, as far as it can be believed. diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrl.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrl.cs index fe190fb8f..149d4878f 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrl.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrl.cs @@ -5,6 +5,15 @@ namespace AIStudio.Provider.OpenAI; /// public record SubContentImageUrl(SubContentType Type, string ImageUrl) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public string ImageUrl { get; set; } = ImageUrl; + public SubContentImageUrl() : this(SubContentType.IMAGE_URL, string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlData.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlData.cs index af0ffc9ee..3e16eed81 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlData.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlData.cs @@ -13,5 +13,5 @@ namespace AIStudio.Provider.OpenAI; public record SubContentImageUrlData : ISubContentImageUrl { /// - public string Url { get; init; } = string.Empty; + public string Url { get; set; } = string.Empty; } diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlNested.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlNested.cs index 297a73fe4..80e87bd1f 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlNested.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentImageUrlNested.cs @@ -12,6 +12,15 @@ namespace AIStudio.Provider.OpenAI; /// public record SubContentImageUrlNested(SubContentType Type, ISubContentImageUrl ImageUrl) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public ISubContentImageUrl ImageUrl { get; set; } = ImageUrl; + public SubContentImageUrlNested() : this(SubContentType.IMAGE_URL, new SubContentImageUrlData()) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentInputImage.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentInputImage.cs index 211449524..44246525e 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentInputImage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentInputImage.cs @@ -8,6 +8,15 @@ namespace AIStudio.Provider.OpenAI; /// public record SubContentInputImage(SubContentType Type, string ImageUrl) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public string ImageUrl { get; set; } = ImageUrl; + public SubContentInputImage() : this(SubContentType.INPUT_IMAGE, string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentInputText.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentInputText.cs index 30fb51d82..4e27b9e4a 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentInputText.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentInputText.cs @@ -8,6 +8,15 @@ namespace AIStudio.Provider.OpenAI; /// public record SubContentInputText(SubContentType Type, string Text) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public string Text { get; set; } = Text; + public SubContentInputText() : this(SubContentType.INPUT_TEXT, string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/SubContentText.cs b/app/MindWork AI Studio/Provider/OpenAI/SubContentText.cs index f94dd710e..f6bfb686c 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/SubContentText.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/SubContentText.cs @@ -5,6 +5,15 @@ namespace AIStudio.Provider.OpenAI; /// public record SubContentText(SubContentType Type, string Text) : ISubContent { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public SubContentType Type { get; set; } = Type; + public string Text { get; set; } = Text; + public SubContentText() : this(SubContentType.TEXT, string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/TextMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/TextMessage.cs index 0e75f87f5..36689a5a6 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/TextMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/TextMessage.cs @@ -7,6 +7,15 @@ namespace AIStudio.Provider.OpenAI; /// The role of the message. public record TextMessage(string Content, string Role) : IMessage { + // + // The properties of the parameters, declared again to be settable. The source generation of System.Text.Json + // sets every init-only property while it creates the object, also one missing in the JSON, so its value from + // the parameterless constructor would get lost. Keep them first and in the order of the parameters, which is + // the order they get written in: + // + public string Content { get; set; } = Content; + public string Role { get; set; } = Role; + public TextMessage() : this(string.Empty, string.Empty) { } diff --git a/app/MindWork AI Studio/Provider/OpenAI/ToolResultMessage.cs b/app/MindWork AI Studio/Provider/OpenAI/ToolResultMessage.cs index 3972ac09e..809a2de64 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ToolResultMessage.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ToolResultMessage.cs @@ -2,9 +2,9 @@ namespace AIStudio.Provider.OpenAI; public sealed record ToolResultMessage : IMessage { - public string Role { get; init; } = "tool"; + public string Role { get; set; } = "tool"; - public string Content { get; init; } = string.Empty; + public string Content { get; set; } = string.Empty; - public string ToolCallId { get; init; } = string.Empty; + public string ToolCallId { get; set; } = string.Empty; } 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/MindWork AI Studio/Provider/ProviderToolAdapters.cs b/app/MindWork AI Studio/Provider/ProviderToolAdapters.cs index d8016bb2d..3187153ab 100644 --- a/app/MindWork AI Studio/Provider/ProviderToolAdapters.cs +++ b/app/MindWork AI Studio/Provider/ProviderToolAdapters.cs @@ -24,19 +24,18 @@ public static class ProviderToolAdapters /// /// The tool to describe. /// Whether the host binds the model's tool calls to a strict schema. Only then is the tool sent in strict mode. - public static object ToChatCompletionTool(ToolDefinition definition, bool hostEnforcesStrict) + public static ChatCompletionFunctionTool ToChatCompletionTool(ToolDefinition definition, bool hostEnforcesStrict) { var isStrict = definition.Function.Strict && hostEnforcesStrict; - return new + return new() { - type = "function", - function = new + Function = new() { - name = definition.Function.Name, - description = definition.Function.DescriptionForLLM, - parameters = ToOpenAIParameters(definition, isStrict), - strict = isStrict, - } + Name = definition.Function.Name, + Description = definition.Function.DescriptionForLLM, + Parameters = ToOpenAIParameters(definition, isStrict), + Strict = isStrict, + }, }; } diff --git a/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs index 44adaf0cd..e4466ba53 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamDocumentDetails.cs @@ -5,8 +5,8 @@ namespace AIStudio.Tools; public sealed class ContentStreamDocumentDetails { [JsonPropertyName("page_number")] - public int? PageNumber { get; init; } + public int? PageNumber { get; set; } [JsonPropertyName("image")] - public ContentStreamPptxImageData? Image { get; init; } + public ContentStreamPptxImageData? Image { get; set; } } diff --git a/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs index 7a47d4728..12f3ecb51 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamDocumentMetadata.cs @@ -6,5 +6,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamDocumentMetadata : ContentStreamSseMetadata { [JsonPropertyName("Document")] - public ContentStreamDocumentDetails? Document { get; init; } + public ContentStreamDocumentDetails? Document { get; set; } } diff --git a/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs index 55bb4d05b..1a6642549 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamErrorDetails.cs @@ -7,23 +7,23 @@ namespace AIStudio.Tools; public sealed class ContentStreamErrorDetails { [JsonPropertyName("code")] - public string? Code { get; init; } + public string? Code { get; set; } [JsonPropertyName("message")] - public string? Message { get; init; } + public string? Message { get; set; } /// /// The page the failure belongs to, when the failure affects a single page only. /// [JsonPropertyName("page_number")] - public int? PageNumber { get; init; } + public int? PageNumber { get; set; } /// /// The format the runtime identified by looking at the content, e.g. when it contradicts the /// file extension. /// [JsonPropertyName("detected_format")] - public string? DetectedFormat { get; init; } + public string? DetectedFormat { get; set; } /// /// Gets the parsed error code. diff --git a/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs index d32f24f99..3ff829135 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamErrorMetadata.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamErrorMetadata : ContentStreamSseMetadata { [JsonPropertyName("Error")] - public ContentStreamErrorDetails? Error { get; init; } + public ContentStreamErrorDetails? Error { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPdfDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamPdfDetails.cs index dd727ce20..692c1c563 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPdfDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPdfDetails.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamPdfDetails { [JsonPropertyName("page_number")] - public int? PageNumber { get; init; } + public int? PageNumber { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPdfMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamPdfMetadata.cs index 172adf444..e5a430858 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPdfMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPdfMetadata.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamPdfMetadata : ContentStreamSseMetadata { [JsonPropertyName("Pdf")] - public ContentStreamPdfDetails? Pdf { get; init; } + public ContentStreamPdfDetails? Pdf { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs b/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs index b036fe022..9c13e146d 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPptxImageData.cs @@ -5,17 +5,17 @@ namespace AIStudio.Tools; public sealed class ContentStreamPptxImageData { [JsonPropertyName("id")] - public string? Id { get; init; } + public string? Id { get; set; } [JsonPropertyName("content")] - public string? Content { get; init; } + public string? Content { get; set; } [JsonPropertyName("segment")] - public int? Segment { get; init; } + public int? Segment { get; set; } [JsonPropertyName("is_end")] - public bool IsEnd { get; init; } + public bool IsEnd { get; set; } [JsonPropertyName("media_type")] - public string? MediaType { get; init; } + public string? MediaType { get; set; } } diff --git a/app/MindWork AI Studio/Tools/ContentStreamPresentationDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamPresentationDetails.cs index e0b39fb85..6a518eddf 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPresentationDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPresentationDetails.cs @@ -7,8 +7,8 @@ namespace AIStudio.Tools; public sealed class ContentStreamPresentationDetails { [JsonPropertyName("slide_number")] - public int? SlideNumber { get; init; } + public int? SlideNumber { get; set; } [JsonPropertyName("image")] - public ContentStreamPptxImageData? Image { get; init; } + public ContentStreamPptxImageData? Image { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPresentationMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamPresentationMetadata.cs index 3ce7305e2..5cb491224 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPresentationMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPresentationMetadata.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamPresentationMetadata : ContentStreamSseMetadata { [JsonPropertyName("Presentation")] - public ContentStreamPresentationDetails? Presentation { get; init; } + public ContentStreamPresentationDetails? Presentation { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionDetails.cs index 14fcbb31d..b48bf24ab 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionDetails.cs @@ -17,12 +17,12 @@ namespace AIStudio.Tools; public sealed class ContentStreamPromptInjectionDetails { [JsonPropertyName("findings")] - public List? Findings { get; init; } + public List? Findings { get; set; } /// /// How many passages were filtered. Can exceed the number of findings, because the runtime /// caps how many it reports in detail while it filters every single one. /// [JsonPropertyName("redacted_count")] - public int RedactedCount { get; init; } + public int RedactedCount { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionMetadata.cs index 133106e5b..95e5ba4ff 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamPromptInjectionMetadata.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamPromptInjectionMetadata : ContentStreamSseMetadata { [JsonPropertyName("PromptInjection")] - public ContentStreamPromptInjectionDetails? PromptInjection { get; init; } + public ContentStreamPromptInjectionDetails? PromptInjection { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetDetails.cs index 717277996..0ae04a2a7 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetDetails.cs @@ -7,8 +7,8 @@ namespace AIStudio.Tools; public sealed class ContentStreamSpreadsheetDetails { [JsonPropertyName("sheet_name")] - public string? SheetName { get; init; } + public string? SheetName { get; set; } [JsonPropertyName("row_number")] - public int? RowNumber { get; init; } + public int? RowNumber { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetMetadata.cs index 2a8452c1b..71553f3df 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSpreadsheetMetadata.cs @@ -7,5 +7,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamSpreadsheetMetadata : ContentStreamSseMetadata { [JsonPropertyName("Spreadsheet")] - public ContentStreamSpreadsheetDetails? Spreadsheet { get; init; } + public ContentStreamSpreadsheetDetails? Spreadsheet { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamSseEvent.cs b/app/MindWork AI Studio/Tools/ContentStreamSseEvent.cs index 02d69c0d2..bc567debb 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamSseEvent.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamSseEvent.cs @@ -5,14 +5,14 @@ namespace AIStudio.Tools; public sealed class ContentStreamSseEvent { [JsonPropertyName("content")] - public string? Content { get; init; } + public string? Content { get; set; } [JsonPropertyName("stream_id")] - public string? StreamId { get; init; } + public string? StreamId { get; set; } [JsonPropertyName("metadata")] - public ContentStreamSseMetadata? Metadata { get; init; } + public ContentStreamSseMetadata? Metadata { get; set; } [JsonPropertyName("token_count")] - public int? TokenCount { get; init; } + public int? TokenCount { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamTextDetails.cs b/app/MindWork AI Studio/Tools/ContentStreamTextDetails.cs index 25d1f6a56..47d241822 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamTextDetails.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamTextDetails.cs @@ -6,5 +6,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamTextDetails { [JsonPropertyName("line_number")] - public int? LineNumber { get; init; } + public int? LineNumber { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ContentStreamTextMetadata.cs b/app/MindWork AI Studio/Tools/ContentStreamTextMetadata.cs index b9d304956..93976d8af 100644 --- a/app/MindWork AI Studio/Tools/ContentStreamTextMetadata.cs +++ b/app/MindWork AI Studio/Tools/ContentStreamTextMetadata.cs @@ -6,5 +6,5 @@ namespace AIStudio.Tools; public sealed class ContentStreamTextMetadata : ContentStreamSseMetadata { [JsonPropertyName("Text")] - public ContentStreamTextDetails? Text { get; init; } + public ContentStreamTextDetails? Text { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs index 140fcc346..6eec27bc9 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs @@ -215,31 +215,35 @@ public sealed class QdrantEdgeClientImplementation( return client; } - // ReSharper disable NotAccessedPositionalProperty.Local - private sealed record EnsureVectorStoreRequest(string StoreName, string DataSourceName, int VectorSize); + // + // The requests are internal rather than private, so that the JSON context of the runtime can name them. It + // generates the contracts they get written with, and a context sees no private type of another class. + // + // ReSharper disable NotAccessedPositionalProperty.Global + internal sealed record EnsureVectorStoreRequest(string StoreName, string DataSourceName, int VectorSize); - private sealed record CheckVectorStoreRequest(string StoreName); + internal sealed record CheckVectorStoreRequest(string StoreName); - private sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList Points); + internal sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList Points); /// The only points the search may return, or null for the whole store. - private sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList Vector, int MaxMatches, IReadOnlyList? PointIds, IReadOnlyList HiddenChunks); + internal sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList Vector, int MaxMatches, IReadOnlyList? PointIds, IReadOnlyList HiddenChunks); - private sealed record RetrieveVectorsRequest(string StoreName, IReadOnlyList PointIds); + internal sealed record RetrieveVectorsRequest(string StoreName, IReadOnlyList PointIds); - private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath, string ParentFileId, IReadOnlyCollection KeepPointIds); + internal sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath, string ParentFileId, IReadOnlyCollection KeepPointIds); - private sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint); + internal sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint); - private sealed record OptimizeVectorStoreRequest(string StoreName); - - private sealed record DeleteVectorStoreRequest(string StoreName); + internal sealed record OptimizeVectorStoreRequest(string StoreName); + + internal sealed record DeleteVectorStoreRequest(string StoreName); /// /// Preparing a backup needs nothing but the request itself. /// - private sealed record PrepareBackupRequest; + internal sealed record PrepareBackupRequest; - private sealed record CopyStoreForBackupRequest(string BackupId, string StoreName); - // ReSharper restore NotAccessedPositionalProperty.Local + internal sealed record CopyStoreForBackupRequest(string BackupId, string StoreName); + // ReSharper restore NotAccessedPositionalProperty.Global } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorSearchResult.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorSearchResult.cs index 4c66ff088..96eb11240 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorSearchResult.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorSearchResult.cs @@ -27,7 +27,7 @@ public sealed record VectorSearchResult( /// /// The vector store keeps only the pages of a chunk, cf. LocalIndexSearchService.SearchVectorsAsync. /// - public ChunkLocation? StoredLocation { get; init; } + public ChunkLocation? StoredLocation { get; set; } /// /// Where the chunk is: what the index store keeps, or the pages alone when it could not say. diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs index 1a11a59f9..9b616d85f 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs @@ -9,7 +9,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable { protected readonly IERIDataSource DataSource = dataSource; - protected static readonly JsonSerializerOptions JSON_OPTIONS = new() + internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { WriteIndented = true, AllowTrailingCommas = true, @@ -20,8 +20,9 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), - } - }; + }, + TypeInfoResolver = ERIClientJsonContext.Default, + }.AsReadOnly(); protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}"), ExternalHttpTrustPolicy.ALLOW_CUSTOM_ROOTS_WHEN_HOST_WHITELISTED); diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientJsonContext.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientJsonContext.cs new file mode 100644 index 000000000..30db29e2b --- /dev/null +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientJsonContext.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.ERIClient.DataModel; + +namespace AIStudio.Tools.ERIClient; + +/// +/// The contracts with which System.Text.Json reads and writes the calls to ERI servers, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] + +// The request: +[JsonSerializable(typeof(RetrievalRequest))] + +// The answers: +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AuthResponse))] +[JsonSerializable(typeof(DataSourceInfo))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SecurityRequirements))] +internal sealed partial class ERIClientJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/CountTokensRequest.cs b/app/MindWork AI Studio/Tools/Rust/CountTokensRequest.cs new file mode 100644 index 000000000..f2112f75d --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/CountTokensRequest.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to count the tokens of a text in the Rust runtime. +/// The text to count. +/// Path of the tokenizer file to count with. +public sealed record CountTokensRequest(string Text, string TokenizerPath); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/DeleteTokenizerRequest.cs b/app/MindWork AI Studio/Tools/Rust/DeleteTokenizerRequest.cs new file mode 100644 index 000000000..fb7885ad7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/DeleteTokenizerRequest.cs @@ -0,0 +1,5 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to delete the stored tokenizer of a model in the Rust runtime. +/// The model whose tokenizer gets deleted. +public sealed record DeleteTokenizerRequest(string ModelId); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/PrepareImageForModelRequest.cs b/app/MindWork AI Studio/Tools/Rust/PrepareImageForModelRequest.cs new file mode 100644 index 000000000..a42e650c5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/PrepareImageForModelRequest.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to prepare a local image for a model in the Rust runtime. +/// Absolute path of the image. +/// The size the prepared image may have at most. +public sealed record PrepareImageForModelRequest(string Path, long MaxBytes); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/PrepareImageRequest.cs b/app/MindWork AI Studio/Tools/Rust/PrepareImageRequest.cs new file mode 100644 index 000000000..ef18fe0a8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/PrepareImageRequest.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to check and optionally optimize a local image in the Rust runtime. +/// Absolute path of the image. +/// Whether the maximum-edge policy and re-encoding are applied. +public sealed record PrepareImageRequest(string Path, bool Optimize); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/StoreTokenizerRequest.cs b/app/MindWork AI Studio/Tools/Rust/StoreTokenizerRequest.cs new file mode 100644 index 000000000..8903435f1 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/StoreTokenizerRequest.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to store a copy of a tokenizer file for a model in the Rust runtime. +/// The model the tokenizer belongs to. +/// Absolute path of the tokenizer file. +public sealed record StoreTokenizerRequest(string ModelId, string FilePath); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/ValidateTokenizerRequest.cs b/app/MindWork AI Studio/Tools/Rust/ValidateTokenizerRequest.cs new file mode 100644 index 000000000..08a1f4ea1 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ValidateTokenizerRequest.cs @@ -0,0 +1,5 @@ +namespace AIStudio.Tools.Rust; + +/// Request body used to check a tokenizer file in the Rust runtime. +/// Absolute path of the tokenizer file. +public sealed record ValidateTokenizerRequest(string FilePath); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Security/PromptInjectionFinding.cs b/app/MindWork AI Studio/Tools/Security/PromptInjectionFinding.cs index fb1c315f5..6326f4f52 100644 --- a/app/MindWork AI Studio/Tools/Security/PromptInjectionFinding.cs +++ b/app/MindWork AI Studio/Tools/Security/PromptInjectionFinding.cs @@ -15,17 +15,17 @@ public sealed record PromptInjectionFinding /// Which rule matched, e.g. "instruction_override". /// [JsonPropertyName("rule_id")] - public string RuleId { get; init; } = string.Empty; + public string RuleId { get; set; } = string.Empty; /// /// The rule's family, e.g. "exfiltration". /// [JsonPropertyName("category")] - public PromptInjectionFindingCategory Category { get; init; } = PromptInjectionFindingCategory.UNKNOWN; + public PromptInjectionFindingCategory Category { get; set; } = PromptInjectionFindingCategory.UNKNOWN; /// /// The passage as it appeared in the content, so the user can see what was removed. /// [JsonPropertyName("snippet")] - public string Snippet { get; init; } = string.Empty; + public string Snippet { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs new file mode 100644 index 000000000..449593c85 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads the events of a file extraction from the runtime, built at +/// compile time. +/// +/// +/// A converter reads the metadata of an event as the type its JSON tells, so each of these types needs a +/// contract of its own here. A test fails for one which is missing. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ContentStreamSseEvent))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(ContentStreamTextMetadata))] +[JsonSerializable(typeof(ContentStreamPdfMetadata))] +[JsonSerializable(typeof(ContentStreamSpreadsheetMetadata))] +[JsonSerializable(typeof(ContentStreamPresentationMetadata))] +[JsonSerializable(typeof(ContentStreamImageMetadata))] +[JsonSerializable(typeof(ContentStreamDocumentMetadata))] +[JsonSerializable(typeof(ContentStreamErrorMetadata))] +[JsonSerializable(typeof(ContentStreamPromptInjectionMetadata))] +internal sealed partial class RustContentStreamJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs new file mode 100644 index 000000000..c0ec6ae74 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.Databases.VectorStore; +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads and writes nearly every call to the runtime, built at +/// compile time. +/// +/// +/// The calls to the vector store are generic: every query of its client needs the answer type with the type +/// of its result, e.g., DatabaseQueryResponse<bool?>. A test fails for a request or an answer which is +/// missing here. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] + +// The requests: +[JsonSerializable(typeof(SelectSecretRequest))] +[JsonSerializable(typeof(StoreSecretRequest))] +[JsonSerializable(typeof(RustService.ShareFileRequest))] +[JsonSerializable(typeof(PreviousDirectory))] +[JsonSerializable(typeof(SelectFileOptions))] +[JsonSerializable(typeof(SaveFileOptions))] +[JsonSerializable(typeof(OpenPathRequest))] +[JsonSerializable(typeof(OpenDocumentRequest))] +[JsonSerializable(typeof(RegisterShortcutRequest))] +[JsonSerializable(typeof(ValidateShortcutRequest))] +[JsonSerializable(typeof(SetInterfaceSizeRequest))] +[JsonSerializable(typeof(CreateMediaJobRequest))] +[JsonSerializable(typeof(LogEventRequest))] +[JsonSerializable(typeof(ValidateTokenizerRequest))] +[JsonSerializable(typeof(StoreTokenizerRequest))] +[JsonSerializable(typeof(DeleteTokenizerRequest))] +[JsonSerializable(typeof(CountTokensRequest))] +[JsonSerializable(typeof(PrepareImageRequest))] +[JsonSerializable(typeof(PrepareImageForModelRequest))] + +// The answers: +[JsonSerializable(typeof(RequestedSecret))] +[JsonSerializable(typeof(StoreSecretResponse))] +[JsonSerializable(typeof(DeleteSecretResponse))] +[JsonSerializable(typeof(ShareFileResponse))] +[JsonSerializable(typeof(ImagePrepareResponse))] +[JsonSerializable(typeof(PreparedModelImage))] +[JsonSerializable(typeof(TauriEvent))] +[JsonSerializable(typeof(UpdateResponse))] +[JsonSerializable(typeof(SetClipboardResponse))] +[JsonSerializable(typeof(DirectorySelectionResponse))] +[JsonSerializable(typeof(FileSelectionResponse))] +[JsonSerializable(typeof(FilesSelectionResponse))] +[JsonSerializable(typeof(FileSaveResponse))] +[JsonSerializable(typeof(OpenPathResponse))] +[JsonSerializable(typeof(OpenDocumentResponse))] +[JsonSerializable(typeof(ShortcutRegistrationResult))] +[JsonSerializable(typeof(ShortcutValidationResponse))] +[JsonSerializable(typeof(ShortcutResponse))] +[JsonSerializable(typeof(RuntimeInfoResponse))] +[JsonSerializable(typeof(AppExitResponse))] +[JsonSerializable(typeof(AppRestartResponse))] +[JsonSerializable(typeof(InterfaceSizeResponse))] +[JsonSerializable(typeof(CreateMediaJobResponse))] +[JsonSerializable(typeof(MediaJobEvent))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GetLogPathsResponse))] +[JsonSerializable(typeof(TokenizerResponse))] + +// The vector store, with the result of every query its client sends: +[JsonSerializable(typeof(QdrantEdgeInfo))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.EnsureVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.CheckVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.InsertEmbeddingRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.SearchEmbeddingRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.RetrieveVectorsRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.DeleteEmbeddingByFileRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.RelocateEmbeddingFileRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.OptimizeVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.DeleteVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.PrepareBackupRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.CopyStoreForBackupRequest))] +[JsonSerializable(typeof(RustService.DatabaseOperationResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse>))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse>))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +internal sealed partial class RustJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs new file mode 100644 index 000000000..4e0a74a4b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads and writes the calls to the prompt injection filter of the +/// runtime, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(SanitizePromptInjectionsRequest))] +[JsonSerializable(typeof(SanitizePromptInjectionsResponse))] +[JsonSerializable(typeof(SanitizePromptInjectionsBatchRequest))] +[JsonSerializable(typeof(SanitizePromptInjectionsBatchResponse))] +internal sealed partial class RustPromptInjectionJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs index b842196fd..befcca6f3 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs @@ -63,7 +63,7 @@ public sealed partial class RustService private async Task GetAPIKeyByKey(string secretKey, bool isTrying) { var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { if(!isTrying) @@ -71,7 +71,7 @@ public sealed partial class RustService return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the API key due to an API issue.")); } - var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var secret = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (secret.Success) this.logger!.LogDebug($"Successfully retrieved the API key for '{secretKey}'."); else if (isTrying) @@ -106,14 +106,14 @@ public sealed partial class RustService private async Task StoreEncryptedAPIKeyByKey(string secretKey, EncryptedText encryptedKey) { var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedKey); - var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to store the API key for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new StoreSecretResponse(false, TB("Failed to store the API key due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to store the API key for '{secretKey}': '{state.Issue}'"); else @@ -149,14 +149,14 @@ public sealed partial class RustService private async Task DeleteAPIKeyByKey(string secretKey, bool isTrying = false) { var request = new SelectSecretRequest(secretKey, Environment.UserName, false); - var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to delete the API key for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the API key due to an API issue.")}; } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success && !isTrying) this.logger!.LogError($"Failed to delete the API key for '{secretKey}': '{state.Issue}'"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.App.cs b/app/MindWork AI Studio/Tools/Services/RustService.App.cs index 45db32648..c6c0f64d7 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.App.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.App.cs @@ -122,7 +122,7 @@ public sealed partial class RustService public async Task GetRuntimeInfo() { - var response = await this.http.GetFromJsonAsync("/system/runtime/info", this.jsonRustSerializerOptions); + var response = await this.http.GetFromJsonAsync("/system/runtime/info", JSON_OPTIONS); return response; } @@ -140,7 +140,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to exit the app: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -177,7 +177,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to restart the app: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -203,7 +203,7 @@ public sealed partial class RustService { try { - var response = await this.http.PostAsJsonAsync("/app/interface-size", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/app/interface-size", request, JSON_OPTIONS); var result = await this.ReadInterfaceSizeResponse(response, "set the interface size"); if (result is not null) this.logger?.LogInformation("Set the interface sizes of {ScreenCount} screens. AI Studio shows with the size '{Size}' at {CurrentPercent} % and a zoom of {Zoom:0.###} on the screen '{ScreenKey}'.", request.Screens.Count, result.Size, result.CurrentPercent, result.Zoom, result.ScreenKey); @@ -243,7 +243,7 @@ public sealed partial class RustService return null; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to {Action}: {Error}", action, result?.ErrorMessage ?? "Unknown error"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs b/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs index 773f379f0..00121d7e1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs @@ -28,7 +28,7 @@ public sealed partial class RustService return; } - var state = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) { this.logger!.LogError("Failed to copy the text to the clipboard."); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs index 406861b8d..08eb67600 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -24,7 +24,7 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(45)); - var databaseInfo = await this.http.GetFromJsonAsync(infoPath, this.jsonRustSerializerOptions, cts.Token); + var databaseInfo = await this.http.GetFromJsonAsync(infoPath, JSON_OPTIONS, cts.Token); return databaseInfo ?? unavailableFactory("The database information response was empty."); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -52,10 +52,10 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(5)); - using var response = await this.http.PostAsJsonAsync(path, request, this.jsonRustSerializerOptions, cts.Token); + using var response = await this.http.PostAsJsonAsync(path, request, JSON_OPTIONS, cts.Token); response.EnsureSuccessStatusCode(); - var operation = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, cts.Token); + var operation = await response.Content.ReadFromJsonAsync(JSON_OPTIONS, cts.Token); if (operation is not { Success: true }) throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} operation failed."); } @@ -65,10 +65,10 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(5)); - using var response = await this.http.PostAsJsonAsync(path, request, this.jsonRustSerializerOptions, cts.Token); + using var response = await this.http.PostAsJsonAsync(path, request, JSON_OPTIONS, cts.Token); response.EnsureSuccessStatusCode(); - var operation = await response.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions, cts.Token); + var operation = await response.Content.ReadFromJsonAsync>(JSON_OPTIONS, cts.Token); if (operation is not { Success: true }) throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} query failed."); @@ -94,7 +94,10 @@ public sealed partial class RustService }; } - private sealed record DatabaseOperationResponse(bool Success, string Issue, string IssueCode); + // + // Internal rather than private, so that the JSON context of the runtime can name them: + // + internal sealed record DatabaseOperationResponse(bool Success, string Issue, string IssueCode); - private sealed record DatabaseQueryResponse(bool Success, string Issue, string IssueCode, TResult? Data); + internal sealed record DatabaseQueryResponse(bool Success, string Issue, string IssueCode, TResult? Data); } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index f11556450..988ecacde 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -39,7 +39,7 @@ public sealed partial class RustService throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'"); } - var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); + var configs = await result.Content.ReadFromJsonAsync>(JSON_OPTIONS); if (configs is null) throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust."); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs index ca93739d8..4818d60a6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs @@ -35,7 +35,7 @@ public partial class RustService continue; // Deserialize the Tauri event: - var tauriEvent = JsonSerializer.Deserialize(line, this.jsonRustSerializerOptions); + var tauriEvent = JsonSerializer.Deserialize(line, JSON_OPTIONS); // Forward relevant events to the message bus: if (tauriEvent != default && tauriEvent.EventType diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index c9e1c81a1..9fe4ed89d 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -16,10 +16,10 @@ public sealed partial class RustService var startDirectory = initialDirectory is null ? null : PathTools.FindNearestExistingPath(initialDirectory); var result = startDirectory is null ? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null) - : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), this.jsonRustSerializerOptions); + : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a directory: '{StatusCode}'", result.StatusCode); return new DirectorySelectionResponse(true, string.Empty); @@ -63,9 +63,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/select/file", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a file: '{StatusCode}'", result.StatusCode); return new FileSelectionResponse(true, string.Empty); @@ -87,9 +87,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/select/files", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select files: '{StatusCode}'", result.StatusCode); return new FilesSelectionResponse(true, Array.Empty()); @@ -118,9 +118,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/save/file", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a file for writing operation: '{StatusCode}'", result.StatusCode); return new FileSaveResponse(true, string.Empty); @@ -133,7 +133,7 @@ public sealed partial class RustService HttpResponseMessage result; try { - result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions); + result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), JSON_OPTIONS); } catch (HttpRequestException e) { @@ -154,7 +154,7 @@ public sealed partial class RustService return new OpenPathResponse(false, string.Format(TB("The runtime file manager endpoint returned '{0}'."), result.StatusCode)); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); var normalizedResponse = response.Success ? response : new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue); @@ -187,7 +187,7 @@ public sealed partial class RustService HttpResponseMessage result; try { - result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), this.jsonRustSerializerOptions); + result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), JSON_OPTIONS); } catch (HttpRequestException e) { @@ -208,7 +208,7 @@ public sealed partial class RustService return new OpenDocumentResponse(false, false, string.Format(TB("The runtime document endpoint returned '{0}'."), result.StatusCode)); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (response.Success) { // diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Image.cs b/app/MindWork AI Studio/Tools/Services/RustService.Image.cs index 541a903e2..1890e8384 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Image.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Image.cs @@ -20,10 +20,10 @@ public sealed partial class RustService bool optimize, CancellationToken token = default) { - using var response = await this.http.PostAsJsonAsync("/image/prepare", new { path, optimize }, this.jsonRustSerializerOptions, token); + using var response = await this.http.PostAsJsonAsync("/image/prepare", new PrepareImageRequest(path, optimize), JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token) + return await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token) ?? throw new InvalidDataException("The Rust image preparation returned an empty response."); } @@ -44,10 +44,10 @@ public sealed partial class RustService { try { - using var response = await this.http.PostAsJsonAsync("/image/prepare-for-model", new { path, maxBytes }, this.jsonRustSerializerOptions, token); + using var response = await this.http.PostAsJsonAsync("/image/prepare-for-model", new PrepareImageForModelRequest(path, maxBytes), JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token) + return await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token) ?? PreparedModelImage.Failed(ModelImageErrorCode.UNKNOWN); } catch (Exception e) when (e is not OperationCanceledException) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs index 5268310ea..cb5e30b28 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs @@ -10,7 +10,7 @@ public sealed partial class RustService /// The paths of the log files. public async Task GetLogPaths() { - return await this.http.GetFromJsonAsync("/log/paths", this.jsonRustSerializerOptions); + return await this.http.GetFromJsonAsync("/log/paths", JSON_OPTIONS); } /// @@ -33,7 +33,7 @@ public sealed partial class RustService // feed itself. The unobserved task exception handler in Program.cs remains the safety net here. // var request = new LogEventRequest(timestamp, level, category, message, exception, stackTrace); - _ = this.http.PostAsJsonAsync("/log/event", request, this.jsonRustSerializerOptions); + _ = this.http.PostAsJsonAsync("/log/event", request, JSON_OPTIONS); } catch { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs index 85c6f3927..a339857e6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs @@ -18,11 +18,11 @@ public partial class RustService using var response = await this.http.PostAsJsonAsync( "/media/jobs", new CreateMediaJobRequest(inputPath, outputPath, OpusBitrateBps: opusBitrateBps), - this.jsonRustSerializerOptions, + JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token); return result?.JobId ?? throw new InvalidOperationException("The Rust runtime did not return a media job ID."); } @@ -49,7 +49,7 @@ public partial class RustService continue; var json = line["data:".Length..].Trim(); - var mediaEvent = JsonSerializer.Deserialize(json, this.jsonRustSerializerOptions); + var mediaEvent = JsonSerializer.Deserialize(json, JSON_OPTIONS); if (mediaEvent is not null) yield return mediaEvent; diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs index 220a72105..0954589f0 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -112,7 +112,7 @@ public sealed partial class RustService try { - var sseEvent = JsonSerializer.Deserialize(jsonContent); + var sseEvent = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); if (sseEvent is null) continue; @@ -364,7 +364,7 @@ public sealed partial class RustService ContentStreamSseEvent? sseEvent = null; try { - sseEvent = JsonSerializer.Deserialize(jsonContent); + sseEvent = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); } catch (JsonException) { @@ -460,7 +460,7 @@ public sealed partial class RustService { try { - var errorMessage = JsonSerializer.Deserialize(jsonContent); + var errorMessage = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); if (string.IsNullOrWhiteSpace(errorMessage)) return false; diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs index bc44d46cd..81c1495fc 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs @@ -65,14 +65,14 @@ public sealed partial class RustService var secretKey = SecretKey(secretId, storeType); var encryptedSecret = await this.encryptor!.Encrypt(secretData); var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedSecret); - var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to store the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new StoreSecretResponse(false, TB("Failed to store the secret data due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to store the secret data for '{secretKey}': '{state.Issue}'"); @@ -104,7 +104,7 @@ public sealed partial class RustService private async Task GetSecretByKey(string secretKey, bool isTrying) { var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { if(isTrying) @@ -114,7 +114,7 @@ public sealed partial class RustService return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) { // @@ -138,14 +138,14 @@ public sealed partial class RustService private async Task DeleteSecretByKey(string secretKey) { var request = new SelectSecretRequest(secretKey, Environment.UserName, false); - var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to delete the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the secret data due to an API issue.")}; } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to delete the secret data for '{secretKey}': '{state.Issue}'"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs index 07b829aed..ac831ef76 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs @@ -40,7 +40,8 @@ public sealed partial class RustService using var response = await this.http.PostAsJsonAsync( "/security/prompt-injection/sanitize", new SanitizePromptInjectionsRequest(text), - cancellationToken: timeoutTokenSource.Token); + PROMPT_INJECTION_JSON_OPTIONS, + timeoutTokenSource.Token); if (!response.IsSuccessStatusCode) { @@ -48,7 +49,7 @@ public sealed partial class RustService return null; } - return await response.Content.ReadFromJsonAsync(timeoutTokenSource.Token); + return await response.Content.ReadFromJsonAsync(PROMPT_INJECTION_JSON_OPTIONS, timeoutTokenSource.Token); } catch (Exception exception) { @@ -82,7 +83,8 @@ public sealed partial class RustService using var response = await this.http.PostAsJsonAsync( "/security/prompt-injection/sanitize-batch", new SanitizePromptInjectionsBatchRequest(texts), - cancellationToken: timeoutTokenSource.Token); + PROMPT_INJECTION_JSON_OPTIONS, + timeoutTokenSource.Token); if (!response.IsSuccessStatusCode) { @@ -90,7 +92,7 @@ public sealed partial class RustService return null; } - var batchResponse = await response.Content.ReadFromJsonAsync(timeoutTokenSource.Token); + var batchResponse = await response.Content.ReadFromJsonAsync(PROMPT_INJECTION_JSON_OPTIONS, timeoutTokenSource.Token); if (batchResponse.Results is null || batchResponse.Results.Count != texts.Count) { this.logger?.LogError("The prompt injection filter answered with {ResultCount} result(s) for {TextCount} text(s).", batchResponse.Results?.Count ?? 0, texts.Count); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs index a59dfdb43..ea3709afd 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs @@ -1,4 +1,4 @@ -// ReSharper disable NotAccessedPositionalProperty.Local +// ReSharper disable NotAccessedPositionalProperty.Global using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; @@ -14,14 +14,14 @@ public sealed partial class RustService { try { - using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), this.jsonRustSerializerOptions); + using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), JSON_OPTIONS); if (!response.IsSuccessStatusCode) { this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}."); return new(ShareSheetOutcome.FAILED, $"The Rust runtime rejected the share request: {response.StatusCode}."); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null) { this.logger?.LogError("The Rust runtime answered the share request without a result."); @@ -50,5 +50,8 @@ public sealed partial class RustService } } - private sealed record ShareFileRequest(string FilePath); + /// + /// Internal rather than private, so that the JSON context of the runtime can name it. + /// + internal sealed record ShareFileRequest(string FilePath); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs index 5273a05fc..4089d6316 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs @@ -18,7 +18,7 @@ public sealed partial class RustService try { var request = new RegisterShortcutRequest(shortcutId, shortcut, description, reconfigure); - var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, JSON_OPTIONS); if (!response.IsSuccessStatusCode) { @@ -26,7 +26,7 @@ public sealed partial class RustService return ShortcutRegistrationResult.Failed(TB("The global shortcut could not be registered because the desktop service is unavailable.")); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error"); @@ -53,7 +53,7 @@ public sealed partial class RustService try { var request = new ValidateShortcutRequest(shortcut); - var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, JSON_OPTIONS); if (!response.IsSuccessStatusCode) { @@ -61,7 +61,7 @@ public sealed partial class RustService return new ShortcutValidationResult(false, "Network error during validation", false, string.Empty); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null) return new ShortcutValidationResult(false, "Invalid response from server", false, string.Empty); @@ -91,7 +91,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to suspend shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -123,7 +123,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to resume shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs b/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs index 46f8601af..8162919ca 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs @@ -15,9 +15,7 @@ public sealed partial class RustService : ITokenizerStorage public async Task ValidateTokenizer(string filePath) { - var result = await this.http.PostAsJsonAsync("/tokenizer/validate", new { - file_path = filePath, - }, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/tokenizer/validate", new ValidateTokenizerRequest(filePath), JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -25,7 +23,7 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the path to the Rust framework for validation: "+result.StatusCode); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); return response; } @@ -33,10 +31,7 @@ public sealed partial class RustService : ITokenizerStorage public async Task StoreTokenizer(string modelId, string filePath) { this.logger!.LogInformation($"Storing tokenizer for model '{modelId}' from file '{filePath}'"); - var result = await this.http.PostAsJsonAsync("/tokenizer/store", new { - model_id = modelId, - file_path = filePath, - }, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/tokenizer/store", new StoreTokenizerRequest(modelId, filePath), JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -44,15 +39,13 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the path to the Rust framework for storing: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); } public async Task DeleteTokenizer(string modelId) { this.logger!.LogInformation($"Deleting tokenizer for model '{modelId}'"); - var result = await this.http.PostAsJsonAsync("/tokenizer/delete", new { - model_id = modelId, - }, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/tokenizer/delete", new DeleteTokenizerRequest(modelId), JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -60,7 +53,7 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the tokenizer delete request to the Rust framework: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); } public Task GetTokenCount(AIStudio.Settings.Provider provider, string text, CancellationToken cancellationToken = default) => @@ -71,10 +64,7 @@ public sealed partial class RustService : ITokenizerStorage private async Task GetTokenCount(string providerName, string tokenizerPath, string text, CancellationToken cancellationToken) { - var result = await this.http.PostAsJsonAsync("/tokenizer/count", new { - text, - tokenizer_path = tokenizerPath, - }, this.jsonRustSerializerOptions, cancellationToken); + var result = await this.http.PostAsJsonAsync("/tokenizer/count", new CountTokensRequest(text, tokenizerPath), JSON_OPTIONS, cancellationToken); if (!result.IsSuccessStatusCode) { @@ -82,6 +72,6 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("Error while getting token count from Rust service: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, cancellationToken); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS, cancellationToken); } } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs b/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs index fdf2a2110..8c065ee34 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs @@ -9,7 +9,7 @@ public sealed partial class RustService try { var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - var response = await this.http.GetFromJsonAsync("/updates/check", this.jsonRustSerializerOptions, cts.Token); + var response = await this.http.GetFromJsonAsync("/updates/check", JSON_OPTIONS, cts.Token); if (response == default) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 4d9f4b97c..8e849379a 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -34,15 +34,41 @@ public sealed partial class RustService : BackgroundService private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly SemaphoreSlim userNameLock = new(1, 1); - private readonly JsonSerializerOptions jsonRustSerializerOptions = new() + /// + /// The JSON options of nearly every call to the runtime, which names its fields in snake case. + /// + internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { new RustEnumConverter(), }, - }; - + TypeInfoResolver = RustJsonContext.Default, + }.AsReadOnly(); + + /// + /// The JSON options of the events of a file extraction, which name their fields themselves. + /// + /// + /// The same as the defaults of System.Text.Json, with which these events were read before. + /// + internal static readonly JsonSerializerOptions CONTENT_STREAM_JSON_OPTIONS = new JsonSerializerOptions + { + TypeInfoResolver = RustContentStreamJsonContext.Default, + }.AsReadOnly(); + + /// + /// The JSON options of the prompt injection filter, which names its fields in camel case. + /// + /// + /// The same as the defaults of the JSON calls of HttpClient, with which the filter was called before. + /// + internal static readonly JsonSerializerOptions PROMPT_INJECTION_JSON_OPTIONS = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = RustPromptInjectionJsonContext.Default, + }.AsReadOnly(); + private ILogger? logger; private Encryption? encryptor; private string? cachedUserLanguage; diff --git a/app/MindWork AI Studio/Tools/Source.cs b/app/MindWork AI Studio/Tools/Source.cs index 0c75371e7..78bda4c21 100644 --- a/app/MindWork AI Studio/Tools/Source.cs +++ b/app/MindWork AI Studio/Tools/Source.cs @@ -5,4 +5,15 @@ namespace AIStudio.Tools; /// /// The title of the source. /// The URL of the source. -public record Source(string Title, string URL, SourceOrigin Origin) : ISource; \ No newline at end of file +public record Source(string Title, string URL, SourceOrigin Origin) : ISource +{ + // + // The properties of the parameters, declared again to be settable. A derived record which passes a value of + // its own to one of them, as the search results of Perplexity do with the origin, would otherwise lose it: + // the source generation of System.Text.Json sets every init-only property while it creates the object, also + // one missing in the JSON. Keep them in the order of the parameters, which is the order they get stored in: + // + public string Title { get; set; } = Title; + public string URL { get; set; } = URL; + public SourceOrigin Origin { get; set; } = Origin; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanQueryInfo.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanQueryInfo.cs index e725a1982..411352dc3 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanQueryInfo.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanQueryInfo.cs @@ -8,5 +8,5 @@ internal sealed record StaanQueryInfo /// /// The query Staan searched for after correcting it, or empty when it searched what it was given. /// - public string AlteredQuery { get; init; } = string.Empty; + public string AlteredQuery { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchRequest.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchRequest.cs index 09fb03f85..ef44f3687 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchRequest.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchRequest.cs @@ -16,15 +16,15 @@ internal sealed record StaanSearchRequest /// What to search for. Staan rejects a query longer than 400 characters. /// [JsonPropertyName("q")] - public required string Query { get; init; } + public required string Query { get; set; } /// /// The market to search in. Staan offers de-de, en-us, and fr-fr, and defaults to fr-fr. /// - public string? Market { get; init; } + public string? Market { get; set; } /// /// Where in the result list to start, in steps of ten up to 30, or null for the first page. /// - public int? Offset { get; init; } + public int? Offset { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResponse.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResponse.cs index 169a30120..aa4f23584 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResponse.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResponse.cs @@ -10,7 +10,7 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record StaanSearchResponse { - public StaanQueryInfo? Query { get; init; } + public StaanQueryInfo? Query { get; set; } - public StaanWebSection? Web { get; init; } + public StaanWebSection? Web { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResult.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResult.cs index 460f70a88..da1197fb4 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResult.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanSearchResult.cs @@ -10,9 +10,9 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record StaanSearchResult { - public string Title { get; init; } = string.Empty; + public string Title { get; set; } = string.Empty; - public string Url { get; init; } = string.Empty; + public string Url { get; set; } = string.Empty; - public string Snippet { get; init; } = string.Empty; + public string Snippet { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanWebSection.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanWebSection.cs index 986b10c16..ddeb907f7 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanWebSection.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Staan/StaanWebSection.cs @@ -5,5 +5,5 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record StaanWebSection { - public IReadOnlyList Results { get; init; } = []; + public IReadOnlyList Results { get; set; } = []; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchRequest.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchRequest.cs index 97aecca6b..b4d85345f 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchRequest.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchRequest.cs @@ -10,35 +10,35 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record TavilySearchRequest { - public required string Query { get; init; } + public required string Query { get; set; } /// /// How thoroughly to search. A basic search costs one credit, an advanced one costs two. /// - public required string SearchDepth { get; init; } + public required string SearchDepth { get; set; } /// /// The most hits to return, at most 20. /// - public int? MaxResults { get; init; } + public int? MaxResults { get; set; } /// /// How far back to look: day, week, month, or year, or null for no restriction. /// - public string? TimeRange { get; init; } + public string? TimeRange { get; set; } /// /// The language to search in, as an ISO 639-1 code, or null for no restriction. /// - public string? Language { get; init; } + public string? Language { get; set; } /// /// Whether the language is a requirement rather than a preference. Needs the language field. /// - public bool? FilterByLanguage { get; init; } + public bool? FilterByLanguage { get; set; } /// /// Whether to filter explicit results or null to leave the decision to Tavily. /// - public bool? SafeSearch { get; init; } + public bool? SafeSearch { get; set; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResponse.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResponse.cs index 171780135..008744209 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResponse.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResponse.cs @@ -10,5 +10,5 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record TavilySearchResponse { - public IReadOnlyList Results { get; init; } = []; + public IReadOnlyList Results { get; set; } = []; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResult.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResult.cs index 6ca30faa0..c088d56b5 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResult.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/Tavily/TavilySearchResult.cs @@ -11,12 +11,12 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch. /// internal sealed record TavilySearchResult { - public string Title { get; init; } = string.Empty; + public string Title { get; set; } = string.Empty; - public string Url { get; init; } = string.Empty; + public string Url { get; set; } = string.Empty; /// /// The excerpt of the page that matched, which is what other services call a snippet. /// - public string Content { get; init; } = string.Empty; + public string Content { get; set; } = string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJson.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJson.cs index 6ac1d24aa..136c31e8a 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJson.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJson.cs @@ -14,10 +14,11 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; /// internal static class WebSearchJson { - public static readonly JsonSerializerOptions OPTIONS = new() + public static readonly JsonSerializerOptions OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; + TypeInfoResolver = WebSearchJsonContext.Default, + }.AsReadOnly(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJsonContext.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJsonContext.cs new file mode 100644 index 000000000..8185029af --- /dev/null +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchJsonContext.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily; + +namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; + +/// +/// The contracts with which System.Text.Json reads and writes the calls to the search services, built at +/// compile time. +/// +/// +/// SearXNG is missing on purpose: its answers differ between instances, so its client reads them as a tree +/// of JSON nodes instead. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(StaanSearchRequest))] +[JsonSerializable(typeof(StaanSearchResponse))] +[JsonSerializable(typeof(TavilySearchRequest))] +[JsonSerializable(typeof(TavilySearchResponse))] +internal sealed partial class WebSearchJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs index e5f462336..7ec7d2005 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolParameterSchemaBuilder.cs @@ -80,7 +80,8 @@ public sealed class ToolParameterSchemaBuilder ["additionalProperties"] = false, }; - return JsonSerializer.Deserialize(schema.ToJsonString()); + using var document = JsonDocument.Parse(schema.ToJsonString()); + return document.RootElement.Clone(); } private ToolParameterSchemaBuilder Add(string name, string jsonType, string description, bool isRequired, IReadOnlyList? allowedValues = null) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md index fc3e17dda..4e4643b31 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md @@ -4,6 +4,7 @@ - Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere. - Improved the security of the connection between the app window and AI Studio. - Improved how reliably AI Studio reads and stores your settings, chats, and backups. +- Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services. - Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka. - Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again. - Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration. 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/Fixtures/anthropic-messages-request.json b/app/Tests/Serialization/Fixtures/anthropic-messages-request.json new file mode 100644 index 000000000..071345cc9 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/anthropic-messages-request.json @@ -0,0 +1 @@ +{"model":"messages-model","messages":[{"content":"What is the weather?","role":"user"},{"content":[{"type":"text","text":"Compare these pictures."},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}},{"type":"image","source":{"type":"url","url":"https://example.org/picture.png"}}],"role":"user"},{"content":[{"type":"text","text":"Let me search."},{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}],"role":"assistant"},{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"It is sunny.","is_error":true}],"role":"user"}],"max_tokens":1024,"stream":true,"system":"You answer briefly.","tools":[{"name":"web_search","description":"Search the web.","strict":true,"input_schema":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":"integer","description":"The page, starting at 1."}},"required":["query"],"additionalProperties":false}}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/chat-completion-request.json b/app/Tests/Serialization/Fixtures/chat-completion-request.json new file mode 100644 index 000000000..7930cf85f --- /dev/null +++ b/app/Tests/Serialization/Fixtures/chat-completion-request.json @@ -0,0 +1 @@ +{"model":"chat-model","messages":[{"content":"You answer briefly.","role":"system"},{"content":[{"type":"text","text":"What do the pictures show?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":"https://example.org/picture.png"}],"role":"user"},{"role":"assistant","content":null,"reasoning_content":"The weather needs a search.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},"index":0}]},{"role":"tool","content":"It is sunny.","tool_call_id":"call_1"}],"stream":true,"tools":[{"type":"function","function":{"name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}}],"parallel_tool_calls":false,"stream_options":{"include_usage":true},"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/embedding-request.json b/app/Tests/Serialization/Fixtures/embedding-request.json new file mode 100644 index 000000000..e8bd13f1e --- /dev/null +++ b/app/Tests/Serialization/Fixtures/embedding-request.json @@ -0,0 +1 @@ +{"model":"embedding-model","input":["The first text.","The second text."],"encoding_format":"float"} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/google-embedding-request.json b/app/Tests/Serialization/Fixtures/google-embedding-request.json new file mode 100644 index 000000000..91529ea26 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/google-embedding-request.json @@ -0,0 +1 @@ +{"content":{"parts":[{"text":"The first text."},{"text":"The second text."}]},"task_type":"SEMANTIC_SIMILARITY"} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/responses-request.json b/app/Tests/Serialization/Fixtures/responses-request.json new file mode 100644 index 000000000..eae34f16b --- /dev/null +++ b/app/Tests/Serialization/Fixtures/responses-request.json @@ -0,0 +1 @@ +{"model":"responses-model","input":[{"content":"You answer briefly.","role":"developer"},{"content":[{"type":"input_text","text":"What does the picture show?"},{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="}],"role":"user"},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},{"type":"function_call_output","call_id":"call_1","output":"It is sunny."}],"stream":true,"store":false,"tools":[{"type":"function","name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-count-tokens-request.json b/app/Tests/Serialization/Fixtures/runtime-count-tokens-request.json new file mode 100644 index 000000000..1cbf13dd4 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-count-tokens-request.json @@ -0,0 +1 @@ +{"text":"Count these tokens.","tokenizer_path":"/samples/tokenizer.json"} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-delete-tokenizer-request.json b/app/Tests/Serialization/Fixtures/runtime-delete-tokenizer-request.json new file mode 100644 index 000000000..0724c8b11 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-delete-tokenizer-request.json @@ -0,0 +1 @@ +{"model_id":"model-a"} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-prepare-image-for-model-request.json b/app/Tests/Serialization/Fixtures/runtime-prepare-image-for-model-request.json new file mode 100644 index 000000000..09c6394b0 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-prepare-image-for-model-request.json @@ -0,0 +1 @@ +{"path":"/samples/picture.png","max_bytes":5242880} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-prepare-image-request.json b/app/Tests/Serialization/Fixtures/runtime-prepare-image-request.json new file mode 100644 index 000000000..6fa65e640 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-prepare-image-request.json @@ -0,0 +1 @@ +{"path":"/samples/picture.png","optimize":true} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-store-tokenizer-request.json b/app/Tests/Serialization/Fixtures/runtime-store-tokenizer-request.json new file mode 100644 index 000000000..384983b42 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-store-tokenizer-request.json @@ -0,0 +1 @@ +{"model_id":"model-a","file_path":"/samples/tokenizer.json"} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/runtime-validate-tokenizer-request.json b/app/Tests/Serialization/Fixtures/runtime-validate-tokenizer-request.json new file mode 100644 index 000000000..5773203ca --- /dev/null +++ b/app/Tests/Serialization/Fixtures/runtime-validate-tokenizer-request.json @@ -0,0 +1 @@ +{"file_path":"/samples/tokenizer.json"} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonContractTests.cs b/app/Tests/Serialization/JsonContractTests.cs index d88a05909..3f4ac8961 100644 --- a/app/Tests/Serialization/JsonContractTests.cs +++ b/app/Tests/Serialization/JsonContractTests.cs @@ -12,6 +12,12 @@ namespace AIStudio.Tests.Serialization; public sealed class JsonContractTests { [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] public void TheContractsMatchThoseOfReflection(JsonTestRoot root) { var baseline = JsonReflectionBaseline.Of(root.Options); diff --git a/app/Tests/Serialization/JsonCoverageTests.cs b/app/Tests/Serialization/JsonCoverageTests.cs index b900e49f3..716585793 100644 --- a/app/Tests/Serialization/JsonCoverageTests.cs +++ b/app/Tests/Serialization/JsonCoverageTests.cs @@ -14,6 +14,12 @@ 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))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] public void EveryContractComesFromSourceGeneration(JsonTestRoot root) { var typesWithoutGeneratedContract = JsonTypeGraph.Collect(root.Type, root.Options) diff --git a/app/Tests/Serialization/JsonDefaultsTests.cs b/app/Tests/Serialization/JsonDefaultsTests.cs index c5986ad24..cbdad19a5 100644 --- a/app/Tests/Serialization/JsonDefaultsTests.cs +++ b/app/Tests/Serialization/JsonDefaultsTests.cs @@ -7,11 +7,12 @@ namespace AIStudio.Tests.Serialization; /// Checks that a property missing in the JSON keeps the value a new object starts with. /// /// -/// Files written by an older version of AI Studio lack every property added since. Reading them has to give -/// those properties their defaults, as a new object has them, e.g., a sub-object of the settings instead of -/// null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while creating -/// the object, and a property missing in the JSON gets the default of its type instead (dotnet/runtime#84484). -/// Hence, the types AI Studio stores use set instead of init.

+/// Files written by an older version of AI Studio lack every property added since, and an answer of a +/// provider, of the runtime, or of a service leaves out whatever it has nothing to say about. Reading them has +/// to give those properties their defaults, as a new object has them, e.g., a sub-object of the settings +/// instead of null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while +/// creating the object, and a property missing in the JSON gets the default of its type instead +/// (dotnet/runtime#84484). Hence, the types AI Studio reads use set instead of init.

/// /// A new object is created the way reflection creates it: with its parameterless constructor, or, for a /// struct, as the default of the struct. Source generation passes the values of a positional record struct @@ -21,6 +22,9 @@ namespace AIStudio.Tests.Serialization; public sealed class JsonDefaultsTests { [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] public void AnEmptyObjectGetsTheDefaultsOfANewOne(JsonTestRoot root) { var baseline = JsonReflectionBaseline.Of(root.Options); @@ -60,7 +64,14 @@ public sealed class JsonDefaultsTests Assert.That(failures, Is.Empty); } - private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => JsonSerializer.Serialize(property.Get!(instance), property.PropertyType, options); + private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => AsJson(property.Get!(instance), property.PropertyType, options); - private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? JsonSerializer.Serialize(Activator.CreateInstance(type), type, options) : "null"; + private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? AsJson(Activator.CreateInstance(type), type, options) : "null"; + + private static string AsJson(object? value, Type type, JsonSerializerOptions options) => value switch + { + // The default of a JsonElement holds no JSON at all, so it cannot be written: + JsonElement { ValueKind: JsonValueKind.Undefined } => "no JSON", + _ => JsonSerializer.Serialize(value, type, options), + }; } \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenFileTests.cs b/app/Tests/Serialization/JsonGoldenFileTests.cs index 1c43590aa..b9e8ca756 100644 --- a/app/Tests/Serialization/JsonGoldenFileTests.cs +++ b/app/Tests/Serialization/JsonGoldenFileTests.cs @@ -85,5 +85,5 @@ public sealed class JsonGoldenFileTests /// /// Read from the source tree, the way the other fixtures are, so the file needs no entry in the project file. /// - private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)); + internal static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)); } \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenRequest.cs b/app/Tests/Serialization/JsonGoldenRequest.cs new file mode 100644 index 000000000..78e05a010 --- /dev/null +++ b/app/Tests/Serialization/JsonGoldenRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace AIStudio.Tests.Serialization; + +/// +/// A request in the fixtures, together with how AI Studio builds it and the options it writes it with. +/// +/// The name of the file in the folder Fixtures next to the tests, which holds the request as it got sent before. +/// Builds the request the way AI Studio does. +/// The options AI Studio writes the request with. +public sealed record JsonGoldenRequest(string FileName, Func Create, JsonSerializerOptions Options) +{ + /// + public override string ToString() => this.FileName; +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenRequestTests.cs b/app/Tests/Serialization/JsonGoldenRequestTests.cs new file mode 100644 index 000000000..d63c4f388 --- /dev/null +++ b/app/Tests/Serialization/JsonGoldenRequestTests.cs @@ -0,0 +1,45 @@ +using System.Text.Json; + +using AIStudio.Provider; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tests.Serialization; + +/// +/// Writes requests the way AI Studio sends them, and checks that they get sent just as before. +/// +/// +/// The files in the folder Fixtures were written with the contracts of reflection, before AI Studio got +/// contracts of its own. A provider or the runtime reads every byte of a request, and one which got written +/// otherwise, e.g., with a field under another name or without the tools, gets refused or misread. The requests +/// are made up, see . +/// +[TestFixture] +public sealed class JsonGoldenRequestTests +{ + private static IEnumerable GoldenRequests() + { + TestHost.PrepareStaticState(); + yield return new("chat-completion-request.json", JsonSampleRequests.ChatCompletion, ProviderJsonOptions.OPTIONS); + yield return new("responses-request.json", JsonSampleRequests.Responses, ProviderJsonOptions.OPTIONS); + yield return new("anthropic-messages-request.json", () => JsonSampleRequests.Anthropic(), ProviderJsonOptions.OPTIONS); + yield return new("embedding-request.json", JsonSampleRequests.Embedding, ProviderJsonOptions.OPTIONS); + yield return new("google-embedding-request.json", JsonSampleRequests.GoogleEmbedding, ProviderJsonOptions.OPTIONS); + + // The requests to the runtime which were anonymous types once: + yield return new("runtime-validate-tokenizer-request.json", () => new ValidateTokenizerRequest("/samples/tokenizer.json"), RustService.JSON_OPTIONS); + yield return new("runtime-store-tokenizer-request.json", () => new StoreTokenizerRequest("model-a", "/samples/tokenizer.json"), RustService.JSON_OPTIONS); + yield return new("runtime-delete-tokenizer-request.json", () => new DeleteTokenizerRequest("model-a"), RustService.JSON_OPTIONS); + yield return new("runtime-count-tokens-request.json", () => new CountTokensRequest("Count these tokens.", "/samples/tokenizer.json"), RustService.JSON_OPTIONS); + yield return new("runtime-prepare-image-request.json", () => new PrepareImageRequest("/samples/picture.png", true), RustService.JSON_OPTIONS); + yield return new("runtime-prepare-image-for-model-request.json", () => new PrepareImageForModelRequest("/samples/picture.png", 5_242_880), RustService.JSON_OPTIONS); + } + + [TestCaseSource(nameof(GoldenRequests))] + public void ARequestIsSentAsBefore(JsonGoldenRequest goldenRequest) + { + var request = goldenRequest.Create(); + Assert.That(JsonSerializer.Serialize(request, request.GetType(), goldenRequest.Options), Is.EqualTo(JsonGoldenFileTests.ReadFixture(goldenRequest.FileName))); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonSampleRequests.cs b/app/Tests/Serialization/JsonSampleRequests.cs new file mode 100644 index 000000000..74cc5115d --- /dev/null +++ b/app/Tests/Serialization/JsonSampleRequests.cs @@ -0,0 +1,154 @@ +using System.Text.Json; + +using AIStudio.Provider; +using AIStudio.Provider.Anthropic; +using AIStudio.Provider.Google; +using AIStudio.Provider.OpenAI; +using AIStudio.Tools.ToolCallingSystem; + +using AnthropicImageUrl = AIStudio.Provider.Anthropic.SubContentImageUrl; +using OpenAIImageUrl = AIStudio.Provider.OpenAI.SubContentImageUrl; + +namespace AIStudio.Tests.Serialization; + +/// +/// Requests to LLM providers as AI Studio builds them, with every kind of content they carry. +/// +/// +/// The values are made up. What counts is that every part of a request holds one, so that its golden file shows +/// how each part gets written. +/// +public static class JsonSampleRequests +{ + /// + /// A tool with one required and one optional argument, the shape the web search has. + /// + public static ToolDefinition Tool() => new() + { + Id = "web_search", + Function = new() + { + Name = "web_search", + DescriptionForLLM = "Search the web.", + Strict = true, + Parameters = ToolParameterSchemaBuilder.Create() + .RequiredString("query", "The search query.") + .OptionalInteger("page", "The page, starting at 1.") + .Build(), + }, + }; + + /// + /// A request to a Chat Completions API, with a tool, a call of it, and its result. + /// + public static ChatCompletionAPIRequest ChatCompletion() => new("chat-model", Messages(), true) + { + Tools = [ProviderToolAdapters.ToChatCompletionTool(Tool(), hostEnforcesStrict: true)], + ParallelToolCalls = false, + AdditionalApiParameters = ApiParameters(), + }; + + /// + /// A request to the Responses API of OpenAI, with an item of an earlier answer and the result of a tool. + /// + public static ResponsesAPIRequest Responses() => new("responses-model", + [ + new TextMessage("You answer briefly.", "developer"), + new MultimodalMessage( + [ + new SubContentInputText(SubContentType.INPUT_TEXT, "What does the picture show?"), + new SubContentInputImage(SubContentType.INPUT_IMAGE, "data:image/png;base64,iVBORw0KGgo="), + ], "user"), + Parse("""{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}"""), + new ResponsesFunctionCallOutputItem { CallId = "call_1", Output = "It is sunny." }, + ], + true, false, [ProviderToolAdapters.ToResponsesTool(Tool())]) + { + AdditionalApiParameters = ApiParameters(), + }; + + /// + /// A request to the Messages API of Anthropic, with images from a URL and as data, and a tool round. + /// + public static ChatRequest Anthropic() => new("messages-model", + [ + new TextMessage("What is the weather?", "user"), + new MultimodalMessage( + [ + new SubContentText(SubContentType.TEXT, "Compare these pictures."), + new SubContentImage(SubContentType.IMAGE, new SubContentBase64Image { MediaType = "image/png", Data = "iVBORw0KGgo=" }), + new SubContentImage(SubContentType.IMAGE, new AnthropicImageUrl { Url = "https://example.org/picture.png" }), + ], "user"), + new AnthropicMessage( + [ + Parse("""{"type":"text","text":"Let me search."}"""), + Parse("""{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}"""), + ]), + new AnthropicToolResultMessage([new AnthropicToolResultContent { ToolUseId = "toolu_1", Content = "It is sunny.", IsError = true }]), + ], + 1024, true, "You answer briefly.") + { + Tools = [ProviderToolAdapters.ToAnthropicTool(Tool())], + AdditionalApiParameters = ApiParameters(), + }; + + /// + /// A request for embeddings in the shape of the OpenAI API. + /// + public static EmbeddingRequest Embedding() => new("embedding-model", ["The first text.", "The second text."]); + + /// + /// A request for an embedding to the Gemini API. + /// + public static GoogleEmbeddingRequest GoogleEmbedding() => GoogleEmbeddingRequest.Of(["The first text.", "The second text."]); + + private static List Messages() => + [ + new TextMessage("You answer briefly.", "system"), + new MultimodalMessage( + [ + new SubContentText(SubContentType.TEXT, "What do the pictures show?"), + new SubContentImageUrlNested(SubContentType.IMAGE_URL, new SubContentImageUrlData { Url = "data:image/png;base64,iVBORw0KGgo=" }), + new OpenAIImageUrl(SubContentType.IMAGE_URL, "https://example.org/picture.png"), + ], "user"), + new AssistantToolCallMessage + { + Content = Parse("null"), + ReasoningContent = "The weather needs a search.", + ToolCalls = + [ + new ChatCompletionToolCall + { + Id = "call_1", + Function = new() { Name = "web_search", Arguments = """{"query":"weather"}""" }, + AdditionalMetadata = { ["index"] = Parse("0") }, + }, + ], + }, + new ToolResultMessage { Content = "It is sunny.", ToolCallId = "call_1" }, + ]; + + /// + /// Additional API parameters of every kind, read the way the provider settings read them. + /// + private static IDictionary ApiParameters() + { + if (!AdditionalApiParametersParser.TryParse(""" + "temperature": 0.5, + "top_k": 40, + "seed": 12345678901, + "stop": ["END", 7], + "reasoning": { "effort": "low", "summary": null }, + "logprobs": false + """, out var parameters, out var errorMessage)) + throw new InvalidOperationException(errorMessage); + + return parameters; + } + + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Providers.cs b/app/Tests/Serialization/JsonTestRoots.Providers.cs new file mode 100644 index 000000000..db4b25620 --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Providers.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +using AIStudio.Provider; +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 AnthropicResponseStreamLine = AIStudio.Provider.Anthropic.ResponseStreamLine; +using FireworksResponseStreamLine = AIStudio.Provider.Fireworks.ResponseStreamLine; +using PerplexityResponseStreamLine = AIStudio.Provider.Perplexity.ResponseStreamLine; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to LLM providers. + /// + public static IEnumerable ProviderRequests() + { + TestHost.PrepareStaticState(); + var options = ProviderJsonOptions.OPTIONS; + + yield return CaseWithNamespace(typeof(ChatCompletionAPIRequest), options); + yield return CaseWithNamespace(typeof(ResponsesAPIRequest), options); + yield return CaseWithNamespace(typeof(ChatRequest), options); + yield return CaseWithNamespace(typeof(EmbeddingRequest), options); + yield return CaseWithNamespace(typeof(GoogleEmbeddingRequest), options); + + // + // The messages and their parts, which converters write as the type they are of: + // + foreach (var type in new[] { typeof(IMessageBase), typeof(ISubContent), typeof(ISubContentImageUrl), typeof(ISubContentImageSource) }.SelectMany(ImplementationsOf)) + yield return CaseWithNamespace(type, options); + + // + // What a request holds as an object, which gets written as the type it is of, too. The additional API + // parameters hold the values the parser of the provider settings reads, and the input of the Responses API + // holds the items of an earlier answer as they came in: + // + foreach (var type in new[] { typeof(string), typeof(int), typeof(long), typeof(double), typeof(decimal), typeof(bool), typeof(Dictionary), typeof(List), typeof(JsonElement) }) + yield return Case(new(type, options), "value of a request"); + + yield return CaseWithNamespace(typeof(ChatCompletionFunctionTool), options); + yield return CaseWithNamespace(typeof(ResponsesFunctionTool), options); + yield return CaseWithNamespace(typeof(ResponsesFunctionCallOutputItem), options); + } + + /// + /// What AI Studio receives from LLM providers. + /// + public static IEnumerable ProviderAnswers() + { + TestHost.PrepareStaticState(); + var options = ProviderJsonOptions.OPTIONS; + + // The lists of models: + yield return CaseWithNamespace(typeof(AIStudio.Provider.OpenAI.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.Mistral.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.SelfHosted.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.HuggingFace.ModelsResponse), options); + yield return Case(new(typeof(IList), options), "HuggingFace"); + yield return CaseWithNamespace(typeof(RequestyModelsResponse), options); + yield return CaseWithNamespace(typeof(OpenRouterModelsResponse), options); + yield return CaseWithNamespace(typeof(GroqModelsResponse), options); + + // The lines of streamed answers: + yield return CaseWithNamespace(typeof(ChatCompletionDeltaStreamLine), options); + yield return CaseWithNamespace(typeof(ChatCompletionAnnotationStreamLine), options); + yield return CaseWithNamespace(typeof(ChatCompletionToolStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesDeltaStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesAnnotationStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesCompletedStreamLine), options); + yield return CaseWithNamespace(typeof(AnthropicResponseStreamLine), options); + yield return CaseWithNamespace(typeof(AnthropicStreamLine), options); + yield return CaseWithNamespace(typeof(FireworksResponseStreamLine), options); + yield return CaseWithNamespace(typeof(PerplexityResponseStreamLine), options); + + // + // The annotations, which a converter reads as the type their JSON tells: + // + foreach (var type in ImplementationsOf(typeof(Annotation))) + yield return CaseWithNamespace(type, options); + + // Transcriptions and embeddings, where a converter reads the embeddings of Google as one or as a list: + yield return CaseWithNamespace(typeof(TranscriptionResponse), options); + yield return CaseWithNamespace(typeof(EmbeddingResponse), options); + yield return CaseWithNamespace(typeof(GoogleEmbeddingResponse), options); + yield return CaseWithNamespace(typeof(GoogleEmbedding), options); + yield return Case(new(typeof(List), options), "Google"); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Runtime.cs b/app/Tests/Serialization/JsonTestRoots.Runtime.cs new file mode 100644 index 000000000..8cc355fa5 --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Runtime.cs @@ -0,0 +1,108 @@ +using System.Reflection; + +using AIStudio.Tools; +using AIStudio.Tools.Databases.VectorStore; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to its runtime. + /// + public static IEnumerable RuntimeRequests() + { + TestHost.PrepareStaticState(); + var options = RustService.JSON_OPTIONS; + + yield return Case(new(typeof(SelectSecretRequest), options)); + yield return Case(new(typeof(StoreSecretRequest), options)); + yield return Case(new(typeof(RustService.ShareFileRequest), options)); + yield return Case(new(typeof(PreviousDirectory), options)); + yield return Case(new(typeof(SelectFileOptions), options)); + yield return Case(new(typeof(SaveFileOptions), options)); + yield return Case(new(typeof(OpenPathRequest), options)); + yield return Case(new(typeof(OpenDocumentRequest), options)); + yield return Case(new(typeof(RegisterShortcutRequest), options)); + yield return Case(new(typeof(ValidateShortcutRequest), options)); + yield return Case(new(typeof(SetInterfaceSizeRequest), options)); + yield return Case(new(typeof(CreateMediaJobRequest), options)); + yield return Case(new(typeof(LogEventRequest), options)); + yield return Case(new(typeof(ValidateTokenizerRequest), options)); + yield return Case(new(typeof(StoreTokenizerRequest), options)); + yield return Case(new(typeof(DeleteTokenizerRequest), options)); + yield return Case(new(typeof(CountTokensRequest), options)); + yield return Case(new(typeof(PrepareImageRequest), options)); + yield return Case(new(typeof(PrepareImageForModelRequest), options)); + + // Every request of the vector store: + foreach (var type in typeof(QdrantEdgeClientImplementation).GetNestedTypes(BindingFlags.NonPublic).Where(type => type.Name.EndsWith("Request", StringComparison.Ordinal)).OrderBy(type => type.Name, StringComparer.Ordinal)) + yield return Case(new(type, options)); + + // The prompt injection filter, which names its fields in camel case: + yield return Case(new(typeof(SanitizePromptInjectionsRequest), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchRequest), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + } + + /// + /// What AI Studio receives from its runtime. + /// + public static IEnumerable RuntimeAnswers() + { + TestHost.PrepareStaticState(); + var options = RustService.JSON_OPTIONS; + + yield return Case(new(typeof(RequestedSecret), options)); + yield return Case(new(typeof(StoreSecretResponse), options)); + yield return Case(new(typeof(DeleteSecretResponse), options)); + yield return Case(new(typeof(ShareFileResponse), options)); + yield return Case(new(typeof(ImagePrepareResponse), options)); + yield return Case(new(typeof(PreparedModelImage), options)); + yield return Case(new(typeof(TauriEvent), options)); + yield return Case(new(typeof(UpdateResponse), options)); + yield return Case(new(typeof(SetClipboardResponse), options)); + yield return Case(new(typeof(DirectorySelectionResponse), options)); + yield return Case(new(typeof(FileSelectionResponse), options)); + yield return Case(new(typeof(FilesSelectionResponse), options)); + yield return Case(new(typeof(FileSaveResponse), options)); + yield return Case(new(typeof(OpenPathResponse), options)); + yield return Case(new(typeof(OpenDocumentResponse), options)); + yield return Case(new(typeof(ShortcutRegistrationResult), options)); + yield return Case(new(typeof(ShortcutValidationResponse), options)); + yield return Case(new(typeof(ShortcutResponse), options)); + yield return Case(new(typeof(RuntimeInfoResponse), options)); + yield return Case(new(typeof(AppExitResponse), options)); + yield return Case(new(typeof(AppRestartResponse), options)); + yield return Case(new(typeof(InterfaceSizeResponse), options)); + yield return Case(new(typeof(CreateMediaJobResponse), options)); + yield return Case(new(typeof(MediaJobEvent), options)); + yield return Case(new(typeof(List), options)); + yield return Case(new(typeof(GetLogPathsResponse), options)); + yield return Case(new(typeof(TokenizerResponse), options)); + + // The vector store, with the result of every query its client sends: + yield return Case(new(typeof(QdrantEdgeInfo), options)); + yield return Case(new(typeof(RustService.DatabaseOperationResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse>), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse>), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + + // + // The events of a file extraction, which name their fields themselves. A converter reads their metadata as + // the type the JSON tells: + // + yield return Case(new(typeof(ContentStreamSseEvent), RustService.CONTENT_STREAM_JSON_OPTIONS)); + yield return Case(new(typeof(string), RustService.CONTENT_STREAM_JSON_OPTIONS), "error of a file extraction"); + foreach (var type in ImplementationsOf(typeof(ContentStreamSseMetadata))) + yield return Case(new(type, RustService.CONTENT_STREAM_JSON_OPTIONS)); + + // The prompt injection filter, which names its fields in camel case: + yield return Case(new(typeof(SanitizePromptInjectionsResponse), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchResponse), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Services.cs b/app/Tests/Serialization/JsonTestRoots.Services.cs new file mode 100644 index 000000000..2b70cd0cd --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Services.cs @@ -0,0 +1,38 @@ +using AIStudio.Tools.ERIClient; +using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to ERI servers and to search services. + /// + public static IEnumerable ServiceRequests() + { + TestHost.PrepareStaticState(); + yield return Case(new(typeof(RetrievalRequest), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(StaanSearchRequest), WebSearchJson.OPTIONS)); + yield return Case(new(typeof(TavilySearchRequest), WebSearchJson.OPTIONS)); + } + + /// + /// What AI Studio receives from ERI servers and from search services. + /// + public static IEnumerable ServiceAnswers() + { + TestHost.PrepareStaticState(); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(AuthResponse), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(DataSourceInfo), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(SecurityRequirements), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(StaanSearchResponse), WebSearchJson.OPTIONS)); + yield return Case(new(typeof(TavilySearchResponse), WebSearchJson.OPTIONS)); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.cs b/app/Tests/Serialization/JsonTestRoots.cs index d2324b970..af7e0167f 100644 --- a/app/Tests/Serialization/JsonTestRoots.cs +++ b/app/Tests/Serialization/JsonTestRoots.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using AIStudio.Assistants.SWOTAnalysis; using AIStudio.Chat; using AIStudio.Settings; @@ -15,9 +17,11 @@ namespace AIStudio.Tests.Serialization; /// /// /// Only the outermost types belong here: whatever they hold gets checked along with them. Add a type whenever -/// AI Studio starts to store a new kind of file, with the options it stores the file with. +/// AI Studio starts to store a new kind of file or to exchange a new kind of message, with the options it uses +/// for them. What AI Studio sends is listed apart from what it receives: only what it reads has to keep the +/// defaults of a new object. /// -public static class JsonTestRoots +public static partial class JsonTestRoots { /// /// The data AI Studio keeps on the disk of the user. @@ -67,6 +71,27 @@ public static class JsonTestRoots return new TestCaseData(root).SetArgDisplayNames(purpose is null ? root.ToString() : $"{root}, {purpose}"); } + /// + /// Names the case after its type together with the last part of the namespace, which tells apart types of the + /// same name, e.g., the lists of models of two providers. + /// + private static TestCaseData CaseWithNamespace(Type type, JsonSerializerOptions options) + { + var typeNamespace = type.Namespace!; + return Case(new(type, options), typeNamespace[(typeNamespace.LastIndexOf('.') + 1)..]); + } + + /// + /// Returns the classes and structs of AI Studio which are the given type. + /// + /// + /// A converter which writes a value as the type it is of meets these types without any property naming them. + /// Listing them all here keeps a new one from going unchecked. + /// + private static IEnumerable ImplementationsOf(Type type) => typeof(Program).Assembly.GetTypes() + .Where(candidate => candidate is { IsAbstract: false, IsInterface: false, IsGenericTypeDefinition: false } && type.IsAssignableFrom(candidate)) + .OrderBy(candidate => candidate.FullName, StringComparer.Ordinal); + private static IEnumerable ManagedSettingValueTypes() { //