diff --git a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs index 8a27acb9..78b8f92f 100644 --- a/app/MindWork AI Studio/Components/ProviderSelection.razor.cs +++ b/app/MindWork AI Studio/Components/ProviderSelection.razor.cs @@ -69,11 +69,19 @@ public partial class ProviderSelection : MSGComponentBase if (capabilities.Contains(Capability.SPEECH_INPUT)) capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible"))); - if (capabilities.Contains(Capability.ALWAYS_REASONING)) - capabilityIcons.Add(new(Icons.Material.Filled.Psychology, this.T("Supports reasoning"))); + 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 by default"), + ReasoningIndicatorState.CONFIGURED => this.T("Uses reasoning from provider settings"), + _ => this.T("Uses reasoning"), + }; [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] private IEnumerable GetAvailableProviders() 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/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.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs new file mode 100644 index 00000000..f448d579 --- /dev/null +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -0,0 +1,524 @@ +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) || UsesReasoningByDefault(provider, capabilities)) + { + 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; + } + + /// + /// Determine provider/host defaults that are not expressible by a model-name capability alone. + /// + /// The configured provider instance. + /// The static capabilities detected for the selected model. + /// if reasoning should be treated as enabled by default. + private static bool UsesReasoningByDefault(Provider provider, List capabilities) + { + return provider is { UsedLLMProvider: LLMProviders.SELF_HOSTED, Host: Host.OLLAMA } && + capabilities.Contains(Capability.OPTIONAL_REASONING); + } + + /// + /// 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