From 431f0b78d8a9a56aacc79c84075473276429d706 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Sat, 4 Jul 2026 18:28:52 +0200 Subject: [PATCH] Added icons to visualize model capabilities to the provider dropdown menu (#829) Co-authored-by: Thorsten Sommer --- .../Assistants/I18N/allTexts.lua | 18 + .../Components/ProviderSelection.razor | 19 +- .../Components/ProviderSelection.razor.cs | 39 ++ .../Plugins/configuration/plugin.lua | 1 + .../plugin.lua | 18 + .../plugin.lua | 18 + .../Provider/AdditionalApiParametersParser.cs | 96 ++++ .../Provider/BaseProvider.cs | 62 +-- app/MindWork AI Studio/Provider/Capability.cs | 11 +- .../Provider/ReasoningIndicatorState.cs | 27 + .../Settings/ProviderExtensions.Google.cs | 13 + .../Settings/ProviderExtensions.OpenAI.cs | 11 + .../Settings/ProviderExtensions.OpenSource.cs | 20 +- .../Settings/ProviderExtensions.Reasoning.cs | 516 ++++++++++++++++++ .../wwwroot/changelog/v26.6.3.md | 1 + 15 files changed, 807 insertions(+), 63 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs create mode 100644 app/MindWork AI Studio/Provider/ReasoningIndicatorState.cs create mode 100644 app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index c61ce973..3899f271 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2434,6 +2434,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" +-- Audio input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible" + +-- Uses reasoning (thinking) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2196970948"] = "Uses reasoning (thinking)" + +-- Image input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2685487365"] = "Image input possible" + +-- Speech input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3005724142"] = "Speech input possible" + +-- Uses reasoning (thinking) by default +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3891860124"] = "Uses reasoning (thinking) by default" + +-- Uses reasoning (thinking) configured by settings +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T4279448758"] = "Uses reasoning (thinking) configured by settings" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provider" diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor b/app/MindWork AI Studio/Components/ProviderSelection.razor index 527ebde6..4d5b0887 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor @@ -1,8 +1,23 @@ @using AIStudio.Settings @inherits MSGComponentBase - @foreach (var provider in this.GetAvailableProviders()) + @foreach (var providerItem in this.GetAvailableProviderSelectionItems()) { - + + + @providerItem.Provider + @if (providerItem.CapabilityIcons.Count > 0) + { + + @foreach (var capabilityIcon in providerItem.CapabilityIcons) + { + + + + } + + } + + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index 5a5375de..de7b668c 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using AIStudio.Provider; +using AIStudio.Settings; using Microsoft.AspNetCore.Components; @@ -47,6 +48,40 @@ public partial class ProviderSelection : MSGComponentBase this.ProviderSettings = provider; await this.ProviderSettingsChanged.InvokeAsync(provider); } + + private IEnumerable GetAvailableProviderSelectionItems() + { + foreach (var provider in this.GetAvailableProviders()) + yield return new(provider, this.GetCapabilityIcons(provider)); + } + + private IReadOnlyList GetCapabilityIcons(AIStudio.Settings.Provider provider) + { + var capabilities = provider.GetModelCapabilities(); + List capabilityIcons = []; + + if (capabilities.Contains(Capability.AUDIO_INPUT)) + capabilityIcons.Add(new(Icons.Material.Filled.GraphicEq, this.T("Audio input possible"))); + + if (capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) || capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)) + capabilityIcons.Add(new(Icons.Material.Filled.Image, this.T("Image input possible"))); + + if (capabilities.Contains(Capability.SPEECH_INPUT)) + capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible"))); + + var reasoningIndicatorState = provider.GetReasoningIndicatorState(); + if (reasoningIndicatorState is not ReasoningIndicatorState.NONE) + capabilityIcons.Add(new(Icons.Material.Filled.Psychology, this.GetReasoningTooltip(reasoningIndicatorState))); + + return capabilityIcons; + } + + private string GetReasoningTooltip(ReasoningIndicatorState reasoningIndicatorState) => reasoningIndicatorState switch + { + ReasoningIndicatorState.DEFAULT_ON => this.T("Uses reasoning (thinking) by default"), + ReasoningIndicatorState.CONFIGURED => this.T("Uses reasoning (thinking) configured by settings"), + _ => this.T("Uses reasoning (thinking)"), + }; [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] private IEnumerable GetAvailableProviders() @@ -90,4 +125,8 @@ public partial class ProviderSelection : MSGComponentBase } #endregion + + private readonly record struct CapabilityIcon(string Icon, string Tooltip); + + private readonly record struct ProviderSelectionItem(AIStudio.Settings.Provider Provider, IReadOnlyList CapabilityIcons); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index aa4241c7..546b4793 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -70,6 +70,7 @@ CONFIG["LLM_PROVIDERS"] = {} -- -- Please refer to the documentation of the selected host for details. -- -- Might be something like ... \"temperature\": 0.5 ... for one parameter. -- -- Could be something like ... \"temperature\": 0.5, \"max_tokens\": 1000 ... for multiple parameters. +-- -- Recognized reasoning parameters, such as reasoning_effort, thinking, think, and chat_template_kwargs.enable_thinking, may affect whether AI Studio shows the reasoning icon for this provider. -- -- Please do not add the enclosing curly braces {} here. Also, no trailing comma is allowed. -- ["AdditionalJsonApiParameters"] = "", -- 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 258fa49c..a97971b1 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 @@ -2436,6 +2436,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Profil -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "Hier können Sie zwischen ihren Profilen wechseln." +-- Audio input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audioeingabe möglich" + +-- Uses reasoning (thinking) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2196970948"] = "Nutzt Schlussfolgerungen (Denken)" + +-- Image input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2685487365"] = "Bildeingabe möglich" + +-- Speech input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3005724142"] = "Spracheingabe möglich" + +-- Uses reasoning (thinking) by default +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3891860124"] = "Nutzt standardmäßig Schlussfolgerungen (Denken)" + +-- Uses reasoning (thinking) configured by settings +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T4279448758"] = "Nutzt Schlussfolgerungen (Denken) gemäß den Einstellungen" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Anbieter" 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 59bde45f..04327411 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 @@ -2436,6 +2436,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P -- You can switch between your profiles here UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here" +-- Uses reasoning +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T163305901"] = "Uses reasoning" + +-- Uses reasoning from provider settings +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1646870133"] = "Uses reasoning from provider settings" + +-- Audio input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible" + +-- Image input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T2685487365"] = "Image input possible" + +-- Speech input possible +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3005724142"] = "Speech input possible" + +-- Uses reasoning by default +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T3202628203"] = "Uses reasoning by default" + -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provider" diff --git a/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs b/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs new file mode 100644 index 00000000..5cb2481b --- /dev/null +++ b/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +namespace AIStudio.Provider; + +/// +/// Parses the provider-specific JSON fragment stored in . +/// +/// +/// The provider settings UI stores only the body of a JSON object, such as +/// "temperature": 0.5. This parser wraps that fragment in curly braces, +/// parses it as JSON, and converts it to regular CLR dictionaries, lists, and +/// primitive values so request builders and feature detectors can inspect it. +/// +public static class AdditionalApiParametersParser +{ + /// + /// Try to parse an additional-API-parameters JSON fragment into a dictionary. + /// + /// The JSON object body without the surrounding curly braces. + /// The parsed parameters if parsing succeeds; otherwise an empty dictionary. + /// The JSON parsing error message if parsing fails; otherwise . + /// if the fragment is empty or valid JSON; otherwise . + public static bool TryParse(string additionalJsonApiParameters, out IDictionary parameters, out string? errorMessage) + { + parameters = new Dictionary(); + errorMessage = null; + if (string.IsNullOrWhiteSpace(additionalJsonApiParameters)) + return true; + + try + { + // The UI stores only the object body, so wrap it before parsing. + using var jsonDoc = JsonDocument.Parse($"{{{additionalJsonApiParameters}}}"); + parameters = ConvertToDictionary(jsonDoc.RootElement); + return true; + } + catch (JsonException ex) + { + errorMessage = ex.Message; + return false; + } + } + + /// + /// Remove keys from a parsed parameter dictionary using case-insensitive matching. + /// + /// The parsed parameter dictionary to mutate. + /// The parameter names that should be removed. + /// The same dictionary instance after the matching keys were removed. + public static IDictionary RemoveKeys(IDictionary parameters, IEnumerable keysToRemove) + { + var removeSet = new HashSet(keysToRemove, StringComparer.OrdinalIgnoreCase); + if (removeSet.Count is 0) + return parameters; + + foreach (var key in parameters.Keys.ToList()) + if (removeSet.Contains(key)) + parameters.Remove(key); + + return parameters; + } + + /// + /// Convert a JSON object element into a dictionary of recursively converted CLR values. + /// + /// The JSON object element to convert. + /// A dictionary containing all JSON object properties. + private static IDictionary ConvertToDictionary(JsonElement element) + { + return element.EnumerateObject() + .ToDictionary( + p => p.Name, + p => ConvertJsonValue(p.Value) ?? string.Empty + ); + } + + /// + /// Convert a JSON value to the closest CLR representation used by provider request objects. + /// + /// The JSON element to convert. + /// A string, number, boolean, dictionary, list, or empty string for unsupported/null values. + private static object? ConvertJsonValue(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt32(out var i) ? i : + element.TryGetInt64(out var l) ? l : + element.TryGetDouble(out var d) ? d : + element.GetDecimal(), + JsonValueKind.True or JsonValueKind.False => element.GetBoolean(), + JsonValueKind.Null => string.Empty, + JsonValueKind.Object => ConvertToDictionary(element), + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonValue).ToList(), + + _ => 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 68c0feee..e679f795 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1191,42 +1191,15 @@ public abstract class BaseProvider : IProvider, ISecretId protected IDictionary ParseAdditionalApiParameters( params string[] keysToRemove) { - if(string.IsNullOrWhiteSpace(this.AdditionalJsonApiParameters)) - return new Dictionary(); - - try + if (!AdditionalApiParametersParser.TryParse(this.AdditionalJsonApiParameters, out var apiParameters, out var errorMessage)) { - // Wrap the user-provided parameters in curly brackets to form a valid JSON object: - var json = $"{{{this.AdditionalJsonApiParameters}}}"; - var jsonDoc = JsonSerializer.Deserialize(json, JSON_SERIALIZER_OPTIONS); - var dict = ConvertToDictionary(jsonDoc); - - // Some keys are always removed because we set them: - 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): - if (removeSet.Count > 0) - { - foreach (var key in dict.Keys.ToList()) - { - if (removeSet.Contains(key)) - dict.Remove(key); - } - } - - return dict; - } - catch (JsonException ex) - { - this.logger.LogError("Failed to parse additional API parameters: {ExceptionMessage}", ex.Message); + this.logger.LogError("Failed to parse additional API parameters: {ExceptionMessage}", errorMessage); return new Dictionary(); } + + // Some keys are always removed because AI Studio sets them itself. + var reservedKeys = keysToRemove.Concat(["stream", "model", "messages"]); + return AdditionalApiParametersParser.RemoveKeys(apiParameters, reservedKeys); } protected static bool TryPopIntParameter(IDictionary parameters, string key, out int value) @@ -1308,27 +1281,4 @@ public abstract class BaseProvider : IProvider, ISecretId return true; } - private static IDictionary ConvertToDictionary(JsonElement element) - { - return element.EnumerateObject() - .ToDictionary( - p => p.Name, - p => ConvertJsonValue(p.Value) ?? string.Empty - ); - } - - private static object? ConvertJsonValue(JsonElement element) => element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt32(out var i) ? i : - element.TryGetInt64(out var l) ? l : - element.TryGetDouble(out var d) ? d : - element.GetDecimal(), - JsonValueKind.True or JsonValueKind.False => element.GetBoolean(), - JsonValueKind.Null => string.Empty, - JsonValueKind.Object => ConvertToDictionary(element), - JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonValue).ToList(), - - _ => string.Empty, - }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Capability.cs b/app/MindWork AI Studio/Provider/Capability.cs index 97f56de2..297605cf 100644 --- a/app/MindWork AI Studio/Provider/Capability.cs +++ b/app/MindWork AI Studio/Provider/Capability.cs @@ -71,15 +71,20 @@ public enum Capability VIDEO_OUTPUT, /// - /// The AI model can perform reasoning tasks. + /// The AI model can perform reasoning tasks. You can enable reasoning optionally, but it is disabled by default. /// OPTIONAL_REASONING, /// - /// The AI model always performs reasoning. + /// The AI model always performs reasoning. There is no option to disable reasoning. /// ALWAYS_REASONING, - + + /// + /// The AI model performs optional reasoning, but it is enabled by default. + /// + REASONING_BY_DEFAULT, + /// /// The AI model can embed information or data. /// diff --git a/app/MindWork AI Studio/Provider/ReasoningIndicatorState.cs b/app/MindWork AI Studio/Provider/ReasoningIndicatorState.cs new file mode 100644 index 00000000..5fd076ef --- /dev/null +++ b/app/MindWork AI Studio/Provider/ReasoningIndicatorState.cs @@ -0,0 +1,27 @@ +namespace AIStudio.Provider; + +/// +/// Describes whether the provider selection should show the reasoning capability icon. +/// +public enum ReasoningIndicatorState +{ + /// + /// Do not show a reasoning indicator for the configured provider. + /// + NONE, + + /// + /// Show that the selected model always performs reasoning. + /// + ALWAYS_ON, + + /// + /// Show that reasoning is enabled by the provider or model default. + /// + DEFAULT_ON, + + /// + /// Show that reasoning was explicitly enabled through the provider settings. + /// + CONFIGURED, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs index 1931bc8f..5ed3ec5b 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs @@ -10,6 +10,19 @@ public static partial class ProviderExtensions if (modelName.IndexOf("gemini-") is not -1) { + // Gemini 2.5 Flash Lite supports thinking, but the default is off: + if (modelName.IndexOf("gemini-2.5-flash-lite") is not -1) + return + [ + Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT, + Capability.SPEECH_INPUT, Capability.VIDEO_INPUT, + + Capability.TEXT_OUTPUT, + + Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING, + Capability.CHAT_COMPLETION_API, + ]; + // Reasoning models: if (modelName.IndexOf("gemini-2.5") is not -1) return diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs index b7dc39ef..41b84808 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs @@ -175,6 +175,17 @@ public static partial class ProviderExtensions Capability.WEB_SEARCH, Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API, ]; + + if(modelName is "gpt-5.5" || modelName.StartsWith("gpt-5.5-")) + return + [ + Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, + Capability.TEXT_OUTPUT, Capability.IMAGE_OUTPUT, + + Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING, Capability.REASONING_BY_DEFAULT, + Capability.WEB_SEARCH, + Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API, + ]; return [ diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs index 6c3480f7..287c2251 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs @@ -109,7 +109,7 @@ public static partial class ProviderExtensions Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.TEXT_OUTPUT, - Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING, + Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING, Capability.CHAT_COMPLETION_API, ]; @@ -121,7 +121,7 @@ public static partial class ProviderExtensions Capability.MULTIPLE_IMAGE_INPUT, Capability.TEXT_OUTPUT, - Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING, + Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING, Capability.CHAT_COMPLETION_API, ]; @@ -158,6 +158,22 @@ public static partial class ProviderExtensions Capability.CHAT_COMPLETION_API, ]; + + // Mistral medium 3.5: + if (modelName.IndexOf("mistral-medium-3.5") is not -1) + return + [ + Capability.TEXT_INPUT, + Capability.MULTIPLE_IMAGE_INPUT, + Capability.TEXT_OUTPUT, + + Capability.OPTIONAL_REASONING, + + Capability.FUNCTION_CALLING, + Capability.CHAT_COMPLETION_API, + ]; + + if (modelName.IndexOf("mistral-3") is not -1 || modelName.IndexOf("mistral-large-3") is not -1) return diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs new file mode 100644 index 00000000..2ac6b7e2 --- /dev/null +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -0,0 +1,516 @@ +using AIStudio.Provider; + +using Host = AIStudio.Provider.SelfHosted.Host; + +namespace AIStudio.Settings; + +public static partial class ProviderExtensions +{ + /// + /// The reasoning-related intent found in the configured additional API parameters. + /// + private enum ReasoningConfigurationState + { + /// + /// No recognized reasoning parameter was found. + /// + NOT_CONFIGURED, + + /// + /// A recognized reasoning parameter explicitly enables reasoning. + /// + EXPLICITLY_ENABLED, + + /// + /// A recognized reasoning parameter explicitly disables reasoning. + /// + EXPLICITLY_DISABLED, + } + + /// + /// Get the effective reasoning indicator state for the configured provider instance. + /// + /// The configured provider. + /// The effective reasoning indicator state. + /// + /// This combines static model capabilities with per-provider additional API parameters. + /// For default-on models, an explicit disabling parameter hides the icon; for optional + /// models, an explicit enabling parameter is required before the icon is shown. + /// + public static ReasoningIndicatorState GetReasoningIndicatorState(this Provider provider) + { + var capabilities = provider.GetModelCapabilities(); + if (capabilities.Contains(Capability.ALWAYS_REASONING)) + return ReasoningIndicatorState.ALWAYS_ON; + + var reasoningConfigurationState = GetReasoningConfigurationState(provider); + if (capabilities.Contains(Capability.REASONING_BY_DEFAULT)) + { + return reasoningConfigurationState switch + { + ReasoningConfigurationState.EXPLICITLY_DISABLED => ReasoningIndicatorState.NONE, + ReasoningConfigurationState.EXPLICITLY_ENABLED => ReasoningIndicatorState.CONFIGURED, + _ => ReasoningIndicatorState.DEFAULT_ON, + }; + } + + if (capabilities.Contains(Capability.OPTIONAL_REASONING) && + reasoningConfigurationState is ReasoningConfigurationState.EXPLICITLY_ENABLED) + return ReasoningIndicatorState.CONFIGURED; + + return ReasoningIndicatorState.NONE; + } + + /// + /// Parse additional API parameters and dispatch them to provider-specific reasoning detectors. + /// + /// The configured provider whose additional API parameters should be inspected. + /// The explicit reasoning configuration state, or if nothing known was found. + private static ReasoningConfigurationState GetReasoningConfigurationState(Provider provider) + { + if (!AdditionalApiParametersParser.TryParse(provider.AdditionalJsonApiParameters, out var parameters, out _)) + return ReasoningConfigurationState.NOT_CONFIGURED; + + return provider.UsedLLMProvider switch + { + LLMProviders.OPEN_AI => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetReasoningEffortState(parameters)), + + LLMProviders.ANTHROPIC => GetAnthropicReasoningState(parameters), + + LLMProviders.MISTRAL or LLMProviders.PERPLEXITY => GetReasoningEffortState(parameters), + + LLMProviders.GOOGLE => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetGoogleReasoningState(parameters)), + + LLMProviders.ALIBABA_CLOUD => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetQwenReasoningState(parameters)), + + LLMProviders.OPEN_ROUTER or + LLMProviders.X or + LLMProviders.DEEP_SEEK or + LLMProviders.GROQ or + LLMProviders.FIREWORKS or + LLMProviders.HUGGINGFACE or + LLMProviders.HELMHOLTZ or + LLMProviders.GWDG => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetReasoningEffortState(parameters), + GetQwenReasoningState(parameters), + GetGoogleReasoningState(parameters)), + + LLMProviders.SELF_HOSTED => provider.Host switch + { + Host.OLLAMA => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetOllamaReasoningState(parameters), + GetQwenReasoningState(parameters)), + + Host.LLAMA_CPP => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetLlamaCppReasoningState(parameters), + GetQwenReasoningState(parameters)), + + Host.VLLM => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetReasoningEffortState(parameters), + GetVllmReasoningState(parameters), + GetQwenReasoningState(parameters), + GetGoogleReasoningState(parameters)), + + _ => MergeReasoningStates( + GetOpenAICompatibleReasoningState(parameters), + GetReasoningEffortState(parameters), + GetQwenReasoningState(parameters), + GetGoogleReasoningState(parameters)), + }, + + _ => ReasoningConfigurationState.NOT_CONFIGURED, + }; + } + + /// + /// Detect OpenAI-compatible reasoning parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + /// + /// OpenAI-compatible providers commonly use a nested reasoning object and/or + /// a top-level reasoning_effort parameter. + /// + private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary parameters) + { + var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED; + if (TryGetParameter(parameters, "reasoning", out var reasoning)) + { + reasoningState = reasoning switch + { + IDictionary reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort), + IDictionary reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary), + IDictionary => ReasoningConfigurationState.NOT_CONFIGURED, + _ => GetLevelState(reasoning), + }; + } + + return MergeReasoningStates(reasoningState, GetReasoningEffortState(parameters)); + } + + /// + /// Detect a top-level reasoning_effort parameter. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetReasoningEffortState(IDictionary parameters) + { + return TryGetParameter(parameters, "reasoning_effort", out var reasoningEffort) + ? GetLevelState(reasoningEffort) + : ReasoningConfigurationState.NOT_CONFIGURED; + } + + /// + /// Detect Anthropic extended-thinking parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetAnthropicReasoningState(IDictionary parameters) + { + if (!TryGetParameter(parameters, "thinking", out var thinking)) + return ReasoningConfigurationState.NOT_CONFIGURED; + + return thinking switch + { + IDictionary thinkingObject when TryGetParameter(thinkingObject, "type", out var type) => GetAnthropicThinkingTypeState(type), + _ => GetLevelState(thinking), + }; + } + + /// + /// Detect Google Gemini thinking parameters across OpenAI-compatible additional parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + /// + /// Google can expose thinking options through thinking_config, + /// generation_config.thinking_config, thinking_level, and summary settings. + /// Summary settings only prove that thinking is enabled when they request summaries; + /// disabling summaries does not necessarily disable reasoning. + /// + private static ReasoningConfigurationState GetGoogleReasoningState(IDictionary parameters) + { + var states = new List(); + + if (TryGetParameter(parameters, "thinking_config", out var thinkingConfig) && + thinkingConfig is IDictionary thinkingConfigObject) + states.Add(GetGoogleThinkingConfigState(thinkingConfigObject)); + + if (TryGetParameter(parameters, "generation_config", out var generationConfig) && + generationConfig is IDictionary generationConfigObject) + { + if (TryGetParameter(generationConfigObject, "thinking_config", out var nestedThinkingConfig) && + nestedThinkingConfig is IDictionary nestedThinkingConfigObject) + states.Add(GetGoogleThinkingConfigState(nestedThinkingConfigObject)); + + if (TryGetParameter(generationConfigObject, "thinking_summaries", out var thinkingSummaries)) + states.Add(GetThinkingSummariesState(thinkingSummaries)); + + if (TryGetParameter(generationConfigObject, "thinking_level", out var thinkingLevel)) + states.Add(GetLevelState(thinkingLevel)); + } + + if (TryGetParameter(parameters, "thinking_summaries", out var topLevelThinkingSummaries)) + states.Add(GetThinkingSummariesState(topLevelThinkingSummaries)); + + if (TryGetParameter(parameters, "thinking_level", out var topLevelThinkingLevel)) + states.Add(GetLevelState(topLevelThinkingLevel)); + + return MergeReasoningStates(states); + } + + /// + /// Detect Google Gemini thinking-budget and include-thoughts settings. + /// + /// The parsed thinking_config object. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetGoogleThinkingConfigState(IDictionary thinkingConfig) + { + var states = new List(); + + if (TryGetParameter(thinkingConfig, "thinking_budget", out var thinkingBudget) || + TryGetParameter(thinkingConfig, "thinkingBudget", out thinkingBudget)) + states.Add(GetBudgetState(thinkingBudget)); + + if (TryGetParameter(thinkingConfig, "include_thoughts", out var includeThoughts) || + TryGetParameter(thinkingConfig, "includeThoughts", out includeThoughts)) + states.Add(GetLevelState(includeThoughts)); + + return MergeReasoningStates(states); + } + + /// + /// Detect Google Gemini thinking-summary values that imply reasoning is active. + /// + /// The configured thinking-summary value. + /// The detected reasoning configuration state. + /// + /// A disabled or missing summary does not prove that thinking is disabled, so only + /// known enabling values are treated as explicit reasoning configuration. + /// + private static ReasoningConfigurationState GetThinkingSummariesState(object? value) => value switch + { + string text when text.Equals("auto", StringComparison.OrdinalIgnoreCase) || + text.Equals("on", StringComparison.OrdinalIgnoreCase) || + text.Equals("summarized", StringComparison.OrdinalIgnoreCase) + => ReasoningConfigurationState.EXPLICITLY_ENABLED, + + true => ReasoningConfigurationState.EXPLICITLY_ENABLED, + _ => ReasoningConfigurationState.NOT_CONFIGURED, + }; + + /// + /// Detect Ollama's think parameter. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetOllamaReasoningState(IDictionary parameters) + { + return TryGetParameter(parameters, "think", out var think) + ? GetLevelState(think) + : ReasoningConfigurationState.NOT_CONFIGURED; + } + + /// + /// Detect llama.cpp server reasoning parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + /// + /// llama.cpp exposes runtime reasoning control through parameters such as + /// reasoning, reasoning_budget, and template-specific kwargs. + /// + private static ReasoningConfigurationState GetLlamaCppReasoningState(IDictionary parameters) + { + var states = new List(); + + if (TryGetParameter(parameters, "reasoning", out var reasoning)) + states.Add(GetLlamaCppReasoningModeState(reasoning)); + + if (TryGetParameter(parameters, "reasoning_budget", out var reasoningBudget)) + states.Add(GetBudgetState(reasoningBudget)); + + if (TryGetParameter(parameters, "chat_template_kwargs", out var chatTemplateKwargs) && + chatTemplateKwargs is IDictionary chatTemplateKwargsObject) + states.Add(GetQwenReasoningState(chatTemplateKwargsObject)); + + return MergeReasoningStates(states); + } + + /// + /// Detect vLLM reasoning parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + /// + /// vLLM supports both top-level reasoning fields and chat-template kwargs, depending + /// on model family and reasoning parser configuration. + /// + private static ReasoningConfigurationState GetVllmReasoningState(IDictionary parameters) + { + var states = new List(); + + if (TryGetParameter(parameters, "thinking_token_budget", out var thinkingTokenBudget)) + states.Add(GetBudgetState(thinkingTokenBudget)); + + if (TryGetParameter(parameters, "chat_template_kwargs", out var chatTemplateKwargs) && + chatTemplateKwargs is IDictionary chatTemplateKwargsObject) + { + states.Add(GetQwenReasoningState(chatTemplateKwargsObject)); + + if (TryGetParameter(chatTemplateKwargsObject, "thinking", out var thinking)) + states.Add(GetLevelState(thinking)); + } + + return MergeReasoningStates(states); + } + + /// + /// Detect Qwen-style enable_thinking parameters. + /// + /// The parsed additional API parameters. + /// The detected reasoning configuration state. + /// + /// Some OpenAI-compatible servers accept enable_thinking either at the + /// top level or under chat_template_kwargs. + /// + private static ReasoningConfigurationState GetQwenReasoningState(IDictionary parameters) + { + var states = new List(); + + if (TryGetParameter(parameters, "enable_thinking", out var enableThinking)) + states.Add(GetLevelState(enableThinking)); + + if (TryGetParameter(parameters, "chat_template_kwargs", out var chatTemplateKwargs) && + chatTemplateKwargs is IDictionary chatTemplateKwargsObject && + TryGetParameter(chatTemplateKwargsObject, "enable_thinking", out var nestedEnableThinking)) + states.Add(GetLevelState(nestedEnableThinking)); + + return MergeReasoningStates(states); + } + + /// + /// Interpret Anthropic's thinking.type value. + /// + /// The configured Anthropic thinking type. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetAnthropicThinkingTypeState(object? value) => value switch + { + string text when text.Equals("enabled", StringComparison.OrdinalIgnoreCase) || + text.Equals("adaptive", StringComparison.OrdinalIgnoreCase) + => ReasoningConfigurationState.EXPLICITLY_ENABLED, + + string text when IsDisabledText(text) => ReasoningConfigurationState.EXPLICITLY_DISABLED, + _ => GetLevelState(value), + }; + + /// + /// Interpret llama.cpp's reasoning mode value. + /// + /// The configured llama.cpp reasoning mode. + /// The detected reasoning configuration state. + /// + /// auto means the server decides from the model/template, so it is treated as + /// not configured by the user rather than as explicitly enabled. + /// + private static ReasoningConfigurationState GetLlamaCppReasoningModeState(object? value) => value switch + { + string text when text.Equals("on", StringComparison.OrdinalIgnoreCase) => ReasoningConfigurationState.EXPLICITLY_ENABLED, + string text when text.Equals("off", StringComparison.OrdinalIgnoreCase) => ReasoningConfigurationState.EXPLICITLY_DISABLED, + string text when text.Equals("auto", StringComparison.OrdinalIgnoreCase) => ReasoningConfigurationState.NOT_CONFIGURED, + _ => GetLevelState(value), + }; + + /// + /// Interpret token-budget style values used by several providers. + /// + /// The configured budget value. + /// The detected reasoning configuration state. + /// + /// A zero budget disables reasoning; non-zero values, including unrestricted negative + /// budgets, indicate that reasoning is available for the request. + /// + private static ReasoningConfigurationState GetBudgetState(object? value) => value switch + { + int i => i is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + long l => l is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + double d => Math.Abs(d) < double.Epsilon ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + decimal m => m is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + _ => GetLevelState(value), + }; + + /// + /// Interpret common boolean, numeric, and level-style reasoning values. + /// + /// The raw parsed parameter value. + /// The detected reasoning configuration state. + private static ReasoningConfigurationState GetLevelState(object? value) => value switch + { + bool booleanValue => booleanValue ? ReasoningConfigurationState.EXPLICITLY_ENABLED : ReasoningConfigurationState.EXPLICITLY_DISABLED, + int i => i is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + long l => l is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + double d => Math.Abs(d) < double.Epsilon ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + decimal m => m is 0 ? ReasoningConfigurationState.EXPLICITLY_DISABLED : ReasoningConfigurationState.EXPLICITLY_ENABLED, + string text when IsDisabledText(text) => ReasoningConfigurationState.EXPLICITLY_DISABLED, + string text when IsEnabledText(text) => ReasoningConfigurationState.EXPLICITLY_ENABLED, + _ => ReasoningConfigurationState.NOT_CONFIGURED, + }; + + /// + /// Determine whether a string value is a known reasoning-enabling value. + /// + /// The string value to inspect. + /// if the value should be treated as enabling reasoning. + private static bool IsEnabledText(string text) + { + return text.Equals("true", StringComparison.OrdinalIgnoreCase) || + text.Equals("yes", StringComparison.OrdinalIgnoreCase) || + text.Equals("on", StringComparison.OrdinalIgnoreCase) || + text.Equals("enabled", StringComparison.OrdinalIgnoreCase) || + text.Equals("low", StringComparison.OrdinalIgnoreCase) || + text.Equals("minimal", StringComparison.OrdinalIgnoreCase) || + text.Equals("medium", StringComparison.OrdinalIgnoreCase) || + text.Equals("high", StringComparison.OrdinalIgnoreCase) || + text.Equals("max", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determine whether a string value is a known reasoning-disabling value. + /// + /// The string value to inspect. + /// if the value should be treated as disabling reasoning. + private static bool IsDisabledText(string text) + { + return string.IsNullOrWhiteSpace(text) || + text.Equals("false", StringComparison.OrdinalIgnoreCase) || + text.Equals("no", StringComparison.OrdinalIgnoreCase) || + text.Equals("off", StringComparison.OrdinalIgnoreCase) || + text.Equals("none", StringComparison.OrdinalIgnoreCase) || + text.Equals("disabled", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Merge multiple detected reasoning states into a single state. + /// + /// The detected states from provider-specific parameter checks. + /// The merged state. + /// + /// Explicit disabling wins over enabling because user-provided off switches should + /// suppress default-on reasoning indicators. + /// + private static ReasoningConfigurationState MergeReasoningStates(IEnumerable states) + { + var result = ReasoningConfigurationState.NOT_CONFIGURED; + foreach (var state in states) + { + if (state is ReasoningConfigurationState.EXPLICITLY_DISABLED) + return ReasoningConfigurationState.EXPLICITLY_DISABLED; + + if (state is ReasoningConfigurationState.EXPLICITLY_ENABLED) + result = ReasoningConfigurationState.EXPLICITLY_ENABLED; + } + + return result; + } + + /// + /// Merge multiple detected reasoning states into a single state. + /// + /// The detected states from provider-specific parameter checks. + /// The merged state. + private static ReasoningConfigurationState MergeReasoningStates(params ReasoningConfigurationState[] states) + { + return MergeReasoningStates(states.AsEnumerable()); + } + + /// + /// Try to read a parameter from a dictionary using case-insensitive key matching. + /// + /// The parsed parameter dictionary. + /// The parameter name to find. + /// The matched parameter value, if found. + /// if a matching key was found; otherwise . + private static bool TryGetParameter(IDictionary parameters, string key, out object? value) + { + value = null; + if (parameters.Count is 0) + return false; + + var foundKey = parameters.Keys.FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase)); + if (foundKey is null) + return false; + + value = parameters[foundKey]; + return true; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md index e90be42a..c02b6991 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.3.md @@ -1,4 +1,5 @@ # v26.6.3, build 243 (2026-06-xx xx:xx UTC) +- Improved the provider selection by showing small capability icons for supported audio, image, speech, and reasoning features of the selected model. - Improved source links in chat answers when file or document names contained spaces, umlauts, or other special characters. Source entries now open much more reliably for documents with names such as PDFs from shared portals or internal knowledge bases. - Improved all assistants, so running tasks can continue when you leave the assistant and return later. - Improved the assistant overview so it shows which assistants are still running or have a result ready.