From c1205022153621dea2c9ede28f8882510c1d98bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peer=20Sch=C3=BCtt?= Date: Thu, 12 Mar 2026 12:11:54 +0100 Subject: [PATCH] Improved additional API parameter validation (#686) Co-authored-by: Thorsten Sommer --- .../Assistants/I18N/allTexts.lua | 9 + .../Dialogs/ProviderDialog.razor | 4 +- .../Dialogs/ProviderDialog.razor.cs | 168 +++++++++++++++++- .../plugin.lua | 9 + .../plugin.lua | 9 + .../Provider/Anthropic/ProviderAnthropic.cs | 7 +- .../Provider/BaseProvider.cs | 104 ++++++++++- .../Provider/Mistral/ChatRequest.cs | 5 +- .../Provider/Mistral/ProviderMistral.cs | 7 +- .../wwwroot/changelog/v26.3.1.md | 1 + 10 files changed, 305 insertions(+), 18 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index eeb90c5b..d3a4c32b 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3532,6 +3532,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1870831108"] = "Failed to l -- Please enter a model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1936099896"] = "Please enter a model name." +-- Additional API parameters must form a JSON object. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object." + -- Model UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2189814010"] = "Model" @@ -3553,12 +3556,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Na -- Show Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Show Expert Settings" +-- Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3502745319"] = "Invalid JSON: Add the parameters in proper JSON formatting, e.g., \\\"temperature\\\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though." + -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Show available models" -- This host uses the model configured at the provider level. No model selection is available. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." +-- Duplicate key '{0}' found. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate key '{0}' found." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor index 96e94a2f..4c09da2f 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor @@ -160,7 +160,7 @@ @T("Please be aware: This section is for experts only. You are responsible for verifying the correctness of the additional parameters you provide to the API call. By default, AI Studio uses the OpenAI-compatible chat completions API, when that it is supported by the underlying service and model.") - + @@ -181,4 +181,4 @@ } - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 216f49ee..9e84bea8 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -1,3 +1,6 @@ +using System.Text; +using System.Text.Json; + using AIStudio.Components; using AIStudio.Provider; using AIStudio.Provider.HuggingFace; @@ -334,7 +337,168 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private void OnInputChangeExpertSettings() { - this.AdditionalJsonApiParameters = this.AdditionalJsonApiParameters.Trim().TrimEnd(',', ' '); + this.AdditionalJsonApiParameters = NormalizeAdditionalJsonApiParameters(this.AdditionalJsonApiParameters) + .Trim() + .TrimEnd(',', ' '); + } + + private string? ValidateAdditionalJsonApiParameters(string additionalParams) + { + if (string.IsNullOrWhiteSpace(additionalParams)) + return null; + + var normalized = NormalizeAdditionalJsonApiParameters(additionalParams); + if (!string.Equals(normalized, additionalParams, StringComparison.Ordinal)) + this.AdditionalJsonApiParameters = normalized; + + var json = $"{{{normalized}}}"; + try + { + if (!this.TryValidateJsonObjectWithDuplicateCheck(json, out var errorMessage)) + return errorMessage; + + return null; + } + catch (JsonException) + { + return T("Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though."); + } + } + + private static string NormalizeAdditionalJsonApiParameters(string input) + { + var sb = new StringBuilder(input.Length); + var inString = false; + var escape = false; + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (inString) + { + sb.Append(c); + if (escape) + { + escape = false; + continue; + } + + if (c == '\\') + { + escape = true; + continue; + } + + if (c == '"') + inString = false; + + continue; + } + + if (c == '"') + { + inString = true; + sb.Append(c); + continue; + } + + if (TryReadToken(input, i, "True", out var tokenLength)) + { + sb.Append("true"); + i += tokenLength - 1; + continue; + } + + if (TryReadToken(input, i, "False", out tokenLength)) + { + sb.Append("false"); + i += tokenLength - 1; + continue; + } + + if (TryReadToken(input, i, "Null", out tokenLength)) + { + sb.Append("null"); + i += tokenLength - 1; + continue; + } + + sb.Append(c); + } + + return sb.ToString(); + } + + private static bool TryReadToken(string input, int startIndex, string token, out int tokenLength) + { + tokenLength = 0; + if (startIndex + token.Length > input.Length) + return false; + + if (!input.AsSpan(startIndex, token.Length).SequenceEqual(token)) + return false; + + var beforeIndex = startIndex - 1; + if (beforeIndex >= 0 && IsIdentifierChar(input[beforeIndex])) + return false; + + var afterIndex = startIndex + token.Length; + if (afterIndex < input.Length && IsIdentifierChar(input[afterIndex])) + return false; + + tokenLength = token.Length; + return true; + } + + private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + + private bool TryValidateJsonObjectWithDuplicateCheck(string json, out string? errorMessage) + { + errorMessage = null; + var bytes = Encoding.UTF8.GetBytes(json); + var reader = new Utf8JsonReader(bytes, new JsonReaderOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow + }); + + var objectStack = new Stack>(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + objectStack.Push(new HashSet(StringComparer.Ordinal)); + break; + + case JsonTokenType.EndObject: + if (objectStack.Count > 0) + objectStack.Pop(); + break; + + case JsonTokenType.PropertyName: + if (objectStack.Count == 0) + { + errorMessage = T("Additional API parameters must form a JSON object."); + return false; + } + + var name = reader.GetString() ?? string.Empty; + if (!objectStack.Peek().Add(name)) + { + errorMessage = string.Format(T("Duplicate key '{0}' found."), name); + return false; + } + break; + } + } + + if (objectStack.Count != 0) + { + errorMessage = T("Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though."); + return false; + } + + return true; } private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty; @@ -345,4 +509,4 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId "top_p": 0.9, "frequency_penalty": 0.0 """; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 08d78e5a..339d8507 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3534,6 +3534,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1870831108"] = "Der API-Sch -- Please enter a model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1936099896"] = "Bitte geben Sie einen Modellnamen ein." +-- Additional API parameters must form a JSON object. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Zusätzliche API-Parameter müssen ein JSON-Objekt bilden." + -- Model UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2189814010"] = "Modell" @@ -3555,12 +3558,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instanzname -- Show Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Experten-Einstellungen anzeigen" +-- Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3502745319"] = "Ungültiges JSON: Fügen Sie die Parameter in korrektem JSON-Format hinzu, z. B. \"temperature\": 0.5. Entfernen Sie abschließende Kommas. Die üblichen umgebenden geschweiften Klammern {} dürfen jedoch nicht verwendet werden." + -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Verfügbare Modelle anzeigen" -- This host uses the model configured at the provider level. No model selection is available. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "Dieser Host verwendet das auf Anbieterebene konfigurierte Modell. Es ist keine Modellauswahl verfügbar." +-- Duplicate key '{0}' found. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Doppelter Schlüssel '{0}' gefunden." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Derzeit können wir die Modelle für den ausgewählten Anbieter und/oder Host nicht abfragen. Bitte geben Sie daher den Modellnamen manuell ein." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 5ab2e446..d5da8afc 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3534,6 +3534,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1870831108"] = "Failed to l -- Please enter a model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1936099896"] = "Please enter a model name." +-- Additional API parameters must form a JSON object. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object." + -- Model UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2189814010"] = "Model" @@ -3555,12 +3558,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Na -- Show Expert Settings UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Show Expert Settings" +-- Invalid JSON: Add the parameters in proper JSON formatting, e.g., \"temperature\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3502745319"] = "Invalid JSON: Add the parameters in proper JSON formatting, e.g., \\\"temperature\\\": 0.5. Remove trailing commas. The usual surrounding curly brackets {} must not be used, though." + -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Show available models" -- This host uses the model configured at the provider level. No model selection is available. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." +-- Duplicate key '{0}' found. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate key '{0}' found." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index 5eb8fe2b..49a0e6ea 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -29,6 +29,9 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " // Parse the API parameters: var apiParameters = this.ParseAdditionalApiParameters("system"); + var maxTokens = 4_096; + if (TryPopIntParameter(apiParameters, "max_tokens", out var parsedMaxTokens)) + maxTokens = parsedMaxTokens; // Build the list of messages: var messages = await chatThread.Blocks.BuildMessagesAsync( @@ -73,7 +76,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " Messages = [..messages], System = chatThread.PrepareSystemPrompt(settingsManager), - MaxTokens = apiParameters.TryGetValue("max_tokens", out var value) && value is int intValue ? intValue : 4_096, + MaxTokens = maxTokens, // Right now, we only support streaming completions: Stream = true, @@ -188,4 +191,4 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, " var modelResponse = await response.Content.ReadFromJsonAsync(JSON_SERIALIZER_OPTIONS, token); return modelResponse.Data; } -} \ 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 4acefc62..9b729824 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -731,7 +731,7 @@ public abstract class BaseProvider : IProvider, ISecretId /// Optional list of keys to remove from the final dictionary /// (case-insensitive). The parameters stream, model, and messages are removed by default. protected IDictionary ParseAdditionalApiParameters( - params List keysToRemove) + params string[] keysToRemove) { if(string.IsNullOrWhiteSpace(this.AdditionalJsonApiParameters)) return new Dictionary(); @@ -744,14 +744,23 @@ public abstract class BaseProvider : IProvider, ISecretId var dict = ConvertToDictionary(jsonDoc); // Some keys are always removed because we set them: - keysToRemove.Add("stream"); - keysToRemove.Add("model"); - keysToRemove.Add("messages"); + var removeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + if (keysToRemove.Length > 0) + removeSet.UnionWith(keysToRemove); + + removeSet.Add("stream"); + removeSet.Add("model"); + removeSet.Add("messages"); // Remove the specified keys (case-insensitive): - var removeSet = new HashSet(keysToRemove, StringComparer.OrdinalIgnoreCase); - foreach (var key in removeSet) - dict.Remove(key); + if (removeSet.Count > 0) + { + foreach (var key in dict.Keys.ToList()) + { + if (removeSet.Contains(key)) + dict.Remove(key); + } + } return dict; } @@ -761,6 +770,85 @@ public abstract class BaseProvider : IProvider, ISecretId return new Dictionary(); } } + + protected static bool TryPopIntParameter(IDictionary parameters, string key, out int value) + { + value = default; + if (!TryPopParameter(parameters, key, out var raw) || raw is null) + return false; + + switch (raw) + { + case int i: + value = i; + return true; + + case long l when l is >= int.MinValue and <= int.MaxValue: + value = (int)l; + return true; + + case double d when d is >= int.MinValue and <= int.MaxValue: + value = (int)d; + return true; + + case decimal m when m is >= int.MinValue and <= int.MaxValue: + value = (int)m; + return true; + } + + return false; + } + + protected static bool TryPopBoolParameter(IDictionary parameters, string key, out bool value) + { + value = default; + if (!TryPopParameter(parameters, key, out var raw) || raw is null) + return false; + + switch (raw) + { + case bool b: + value = b; + return true; + + case string s when bool.TryParse(s, out var parsed): + value = parsed; + return true; + + case int i: + value = i != 0; + return true; + + case long l: + value = l != 0; + return true; + + case double d: + value = Math.Abs(d) > double.Epsilon; + return true; + + case decimal m: + value = m != 0; + return true; + } + + return false; + } + + private static bool TryPopParameter(IDictionary parameters, string key, out object? value) + { + value = null; + if (parameters.Count == 0) + return false; + + var foundKey = parameters.Keys.FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase)); + if (foundKey is null) + return false; + + value = parameters[foundKey]; + parameters.Remove(foundKey); + return true; + } private static IDictionary ConvertToDictionary(JsonElement element) { @@ -785,4 +873,4 @@ public abstract class BaseProvider : IProvider, ISecretId _ => string.Empty, }; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs b/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs index 01a45a89..1d42081f 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ChatRequest.cs @@ -14,11 +14,12 @@ public readonly record struct ChatRequest( string Model, IList Messages, bool Stream, - int RandomSeed, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? RandomSeed, bool SafePrompt = false ) { // Attention: The "required" modifier is not supported for [JsonExtensionData]. [JsonExtensionData] public IDictionary AdditionalApiParameters { get; init; } = new Dictionary(); -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index f4cb07f4..485729fb 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -36,6 +36,8 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http // Parse the API parameters: var apiParameters = this.ParseAdditionalApiParameters(); + var safePrompt = TryPopBoolParameter(apiParameters, "safe_prompt", out var parsedSafePrompt) && parsedSafePrompt; + var randomSeed = TryPopIntParameter(apiParameters, "random_seed", out var parsedRandomSeed) ? parsedRandomSeed : (int?)null; // Build the list of messages: var messages = await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.Provider, chatModel); @@ -52,7 +54,8 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http // Right now, we only support streaming completions: Stream = true, - SafePrompt = apiParameters.TryGetValue("safe_prompt", out var value) && value is true, + RandomSeed = randomSeed, + SafePrompt = safePrompt, AdditionalApiParameters = apiParameters }, JSON_SERIALIZER_OPTIONS); @@ -165,4 +168,4 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, "http var modelResponse = await response.Content.ReadFromJsonAsync(token); return modelResponse; } -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index 3cbd4960..25befa8a 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -7,4 +7,5 @@ - Improved the user-language logging by limiting language detection logs to a single entry per app start. - Improved the logbook readability by removing non-readable special characters from log entries. - Improved the logbook reliability by significantly reducing duplicate log entries. +- Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier. - Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown. Thanks Inga for reporting this issue and providing some context on how to reproduce it. \ No newline at end of file