diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/AnthropicThinkingDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/AnthropicThinkingDialect.cs
new file mode 100644
index 00000000..ad5f237f
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/AnthropicThinkingDialect.cs
@@ -0,0 +1,45 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// Anthropic's extended thinking, written as a "thinking" object.
+///
+///
+/// The object carries a type, and the two types which switch thinking on are named outright:
+/// "enabled" and "adaptive". Everything else falls through to the ordinary reading of a value, so
+/// that a person writing "thinking": false is understood as well.
+///
+public sealed class AnthropicThinkingDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.ANTHROPIC_THINKING;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters)
+ {
+ if (!ReasoningParameters.TryGet(parameters, "thinking", out var thinking))
+ return ReasoningConfigurationState.NOT_CONFIGURED;
+
+ return thinking switch
+ {
+ IDictionary thinkingObject when ReasoningParameters.TryGet(thinkingObject, "type", out var type) => TypeOf(type),
+
+ _ => ReasoningParameters.LevelOf(thinking),
+ };
+ }
+
+ ///
+ /// Reads the "type" of an Anthropic thinking object.
+ ///
+ /// The configured thinking type.
+ /// What it says.
+ private static ReasoningConfigurationState TypeOf(object? value) => value switch
+ {
+ string text when text.Equals("enabled", StringComparison.OrdinalIgnoreCase) ||
+ text.Equals("adaptive", StringComparison.OrdinalIgnoreCase)
+ => ReasoningConfigurationState.EXPLICITLY_ENABLED,
+
+ string text when ReasoningParameters.IsDisabledText(text) => ReasoningConfigurationState.EXPLICITLY_DISABLED,
+
+ _ => ReasoningParameters.LevelOf(value),
+ };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/GoogleThinkingDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/GoogleThinkingDialect.cs
new file mode 100644
index 00000000..8ab9683e
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/GoogleThinkingDialect.cs
@@ -0,0 +1,87 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// Google's thinking config, thinking level, and thought summaries.
+///
+///
+/// Google offers the same settings in several places at once: directly, under "generation_config",
+/// and in both spellings of each key, because their own libraries write snake case while the REST
+/// API answers in camel case. All of them are read, and the answers put together.
+///
+/// Summaries are the one setting which only ever says yes. Asking for thought summaries proves that
+/// thinking is on; switching them off proves nothing, because a model can think without showing it.
+///
+public sealed class GoogleThinkingDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.GOOGLE_THINKING;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters)
+ {
+ var states = new List();
+
+ if (ReasoningParameters.TryGet(parameters, "thinking_config", out var thinkingConfig) &&
+ thinkingConfig is IDictionary thinkingConfigObject)
+ states.Add(ConfigOf(thinkingConfigObject));
+
+ if (ReasoningParameters.TryGet(parameters, "generation_config", out var generationConfig) &&
+ generationConfig is IDictionary generationConfigObject)
+ {
+ if (ReasoningParameters.TryGet(generationConfigObject, "thinking_config", out var nestedThinkingConfig) &&
+ nestedThinkingConfig is IDictionary nestedThinkingConfigObject)
+ states.Add(ConfigOf(nestedThinkingConfigObject));
+
+ if (ReasoningParameters.TryGet(generationConfigObject, "thinking_summaries", out var thinkingSummaries))
+ states.Add(SummariesOf(thinkingSummaries));
+
+ if (ReasoningParameters.TryGet(generationConfigObject, "thinking_level", out var thinkingLevel))
+ states.Add(ReasoningParameters.LevelOf(thinkingLevel));
+ }
+
+ if (ReasoningParameters.TryGet(parameters, "thinking_summaries", out var topLevelThinkingSummaries))
+ states.Add(SummariesOf(topLevelThinkingSummaries));
+
+ if (ReasoningParameters.TryGet(parameters, "thinking_level", out var topLevelThinkingLevel))
+ states.Add(ReasoningParameters.LevelOf(topLevelThinkingLevel));
+
+ return ReasoningParameters.Merge(states);
+ }
+
+ ///
+ /// Reads a thinking config, in either spelling of its keys.
+ ///
+ /// The parsed thinking config object.
+ /// What it says.
+ private static ReasoningConfigurationState ConfigOf(IDictionary thinkingConfig)
+ {
+ var states = new List();
+
+ if (ReasoningParameters.TryGet(thinkingConfig, "thinking_budget", out var thinkingBudget) ||
+ ReasoningParameters.TryGet(thinkingConfig, "thinkingBudget", out thinkingBudget))
+ states.Add(ReasoningParameters.BudgetOf(thinkingBudget));
+
+ if (ReasoningParameters.TryGet(thinkingConfig, "include_thoughts", out var includeThoughts) ||
+ ReasoningParameters.TryGet(thinkingConfig, "includeThoughts", out includeThoughts))
+ states.Add(ReasoningParameters.LevelOf(includeThoughts));
+
+ return ReasoningParameters.Merge(states);
+ }
+
+ ///
+ /// Reads a thought summary setting, which can only ever say yes.
+ ///
+ /// The configured summary setting.
+ /// Yes, when it asks for summaries; nothing otherwise.
+ private static ReasoningConfigurationState SummariesOf(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,
+ };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/LlamaCppReasoningDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/LlamaCppReasoningDialect.cs
new file mode 100644
index 00000000..82fa68c5
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/LlamaCppReasoningDialect.cs
@@ -0,0 +1,47 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// The reasoning mode and budget of the llama.cpp server.
+///
+///
+/// Its "reasoning" key is a mode rather than an object, and one of its three values means neither
+/// yes nor no: "auto" hands the decision to the model's own template, which is exactly the case
+/// where nobody has decided anything.
+///
+public sealed class LlamaCppReasoningDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.LLAMA_CPP;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters)
+ {
+ var states = new List();
+
+ if (ReasoningParameters.TryGet(parameters, "reasoning", out var reasoning))
+ states.Add(ModeOf(reasoning));
+
+ if (ReasoningParameters.TryGet(parameters, "reasoning_budget", out var reasoningBudget))
+ states.Add(ReasoningParameters.BudgetOf(reasoningBudget));
+
+ if (ReasoningParameters.TryGet(parameters, "chat_template_kwargs", out var chatTemplateKwargs) &&
+ chatTemplateKwargs is IDictionary chatTemplateKwargsObject)
+ states.Add(QwenThinkingDialect.In(chatTemplateKwargsObject));
+
+ return ReasoningParameters.Merge(states);
+ }
+
+ ///
+ /// Reads the reasoning mode.
+ ///
+ /// The configured mode.
+ /// What it says, which for "auto" is nothing.
+ private static ReasoningConfigurationState ModeOf(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,
+
+ _ => ReasoningParameters.LevelOf(value),
+ };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/OllamaThinkDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/OllamaThinkDialect.cs
new file mode 100644
index 00000000..4f62dc1a
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/OllamaThinkDialect.cs
@@ -0,0 +1,20 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// Ollama's "think" parameter.
+///
+///
+/// One key, and it takes a boolean as readily as a level, which is why it needs no reading of its
+/// own beyond the ordinary one.
+///
+public sealed class OllamaThinkDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.OLLAMA_THINK;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters) =>
+ ReasoningParameters.TryGet(parameters, "think", out var think)
+ ? ReasoningParameters.LevelOf(think)
+ : ReasoningConfigurationState.NOT_CONFIGURED;
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/OpenAICompatibleDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/OpenAICompatibleDialect.cs
new file mode 100644
index 00000000..c155750c
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/OpenAICompatibleDialect.cs
@@ -0,0 +1,31 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// The nested "reasoning" object almost every OpenAI-compatible server accepts.
+///
+///
+/// The object may carry an effort or a summary setting, and it may be written as a plain value
+/// instead. An object carrying neither says nothing: somebody who wrote "reasoning": {} has not
+/// asked for anything yet.
+///
+public sealed class OpenAICompatibleDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.OPEN_AI_COMPATIBLE;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters)
+ {
+ if (!ReasoningParameters.TryGet(parameters, "reasoning", out var reasoning))
+ return ReasoningConfigurationState.NOT_CONFIGURED;
+
+ return reasoning switch
+ {
+ IDictionary reasoningObject when ReasoningParameters.TryGet(reasoningObject, "effort", out var effort) => ReasoningParameters.LevelOf(effort),
+ IDictionary reasoningObject when ReasoningParameters.TryGet(reasoningObject, "summary", out var summary) => ReasoningParameters.LevelOf(summary),
+ IDictionary => ReasoningConfigurationState.NOT_CONFIGURED,
+
+ _ => ReasoningParameters.LevelOf(reasoning),
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/QwenThinkingDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/QwenThinkingDialect.cs
new file mode 100644
index 00000000..23b9dd2c
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/QwenThinkingDialect.cs
@@ -0,0 +1,38 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// The "enable_thinking" switch Qwen introduced and other servers took over.
+///
+///
+/// It is accepted at the top level and inside "chat_template_kwargs", because it is really an
+/// argument to the chat template rather than to the API -- which is also why two other dialects ask
+/// this one about their own kwargs object instead of repeating the two keys.
+///
+public sealed class QwenThinkingDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.QWEN_THINKING;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters) => In(parameters);
+
+ ///
+ /// Reads the switch out of any parameter object, which need not be the top-level one.
+ ///
+ /// The object to look in.
+ /// What it says.
+ public static ReasoningConfigurationState In(IDictionary parameters)
+ {
+ var states = new List();
+
+ if (ReasoningParameters.TryGet(parameters, "enable_thinking", out var enableThinking))
+ states.Add(ReasoningParameters.LevelOf(enableThinking));
+
+ if (ReasoningParameters.TryGet(parameters, "chat_template_kwargs", out var chatTemplateKwargs) &&
+ chatTemplateKwargs is IDictionary chatTemplateKwargsObject &&
+ ReasoningParameters.TryGet(chatTemplateKwargsObject, "enable_thinking", out var nestedEnableThinking))
+ states.Add(ReasoningParameters.LevelOf(nestedEnableThinking));
+
+ return ReasoningParameters.Merge(states);
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/ReasoningEffortDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/ReasoningEffortDialect.cs
new file mode 100644
index 00000000..fec393e0
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/ReasoningEffortDialect.cs
@@ -0,0 +1,21 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// The top-level "reasoning_effort" parameter.
+///
+///
+/// A dialect of its own although it is one key, because it travels on its own: providers accept it
+/// without the nested object next to it, and the code this replaces had to remember to check for it
+/// separately at every one of them. Here it is one line in the table instead.
+///
+public sealed class ReasoningEffortDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.REASONING_EFFORT;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters) =>
+ ReasoningParameters.TryGet(parameters, "reasoning_effort", out var effort)
+ ? ReasoningParameters.LevelOf(effort)
+ : ReasoningConfigurationState.NOT_CONFIGURED;
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/Dialects/VllmReasoningDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/Dialects/VllmReasoningDialect.cs
new file mode 100644
index 00000000..f65015fa
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/Dialects/VllmReasoningDialect.cs
@@ -0,0 +1,34 @@
+namespace AIStudio.Provider.Reasoning.Dialects;
+
+///
+/// The thinking token budget and chat template kwargs of vLLM.
+///
+///
+/// What vLLM accepts depends on the model family it was pointed at and on which reasoning parser
+/// the operator started it with, so both the budget and the template arguments are read.
+///
+public sealed class VllmReasoningDialect : IReasoningDialect
+{
+ ///
+ public ReasoningDialect Dialect => ReasoningDialect.VLLM;
+
+ ///
+ public ReasoningConfigurationState Detect(IDictionary parameters)
+ {
+ var states = new List();
+
+ if (ReasoningParameters.TryGet(parameters, "thinking_token_budget", out var thinkingTokenBudget))
+ states.Add(ReasoningParameters.BudgetOf(thinkingTokenBudget));
+
+ if (ReasoningParameters.TryGet(parameters, "chat_template_kwargs", out var chatTemplateKwargs) &&
+ chatTemplateKwargs is IDictionary chatTemplateKwargsObject)
+ {
+ states.Add(QwenThinkingDialect.In(chatTemplateKwargsObject));
+
+ if (ReasoningParameters.TryGet(chatTemplateKwargsObject, "thinking", out var thinking))
+ states.Add(ReasoningParameters.LevelOf(thinking));
+ }
+
+ return ReasoningParameters.Merge(states);
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/IReasoningDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/IReasoningDialect.cs
new file mode 100644
index 00000000..3040f7a3
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/IReasoningDialect.cs
@@ -0,0 +1,24 @@
+namespace AIStudio.Provider.Reasoning;
+
+///
+/// One way of asking a request to think, and how to recognize it.
+///
+///
+/// A dialect reads parameters and says nothing else. It does not know which provider it is being
+/// asked for, it keeps no state, and it never looks at the model -- what a model is able to do comes
+/// from the rules, and mixing the two is what made the code this replaces hard to follow.
+///
+public interface IReasoningDialect
+{
+ ///
+ /// Which dialect this is, which is also where it stands in the order.
+ ///
+ ReasoningDialect Dialect { get; }
+
+ ///
+ /// Reads what these parameters say about reasoning.
+ ///
+ /// The parsed additional API parameters.
+ /// What they say, which is usually nothing.
+ ReasoningConfigurationState Detect(IDictionary parameters);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/ReasoningConfigurationState.cs b/app/MindWork AI Studio/Provider/Reasoning/ReasoningConfigurationState.cs
new file mode 100644
index 00000000..6d081a1d
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/ReasoningConfigurationState.cs
@@ -0,0 +1,28 @@
+namespace AIStudio.Provider.Reasoning;
+
+///
+/// What the additional API parameters of a provider say about reasoning.
+///
+///
+/// This answers a different question than ReasoningSupport does. That one says what a model is able
+/// to do, and it comes from the rules. This one says what the person asked their provider for, in
+/// the free-text parameters they wrote themselves -- and most of the time it says nothing at all,
+/// which is a statement of its own rather than a missing answer.
+///
+public 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,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/ReasoningDialect.cs b/app/MindWork AI Studio/Provider/Reasoning/ReasoningDialect.cs
new file mode 100644
index 00000000..4c44b349
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/ReasoningDialect.cs
@@ -0,0 +1,53 @@
+namespace AIStudio.Provider.Reasoning;
+
+///
+/// The ways a request can be asked to think, one per way of writing it down.
+///
+///
+/// Every provider speaks one or more of these, and which ones is stated in the dispatcher rather
+/// than worked out from anything. The order here is the order they are asked in: the answer does not
+/// depend on it -- a "no" wins wherever it stands -- but a report which named them in whatever order
+/// a container handed them over would read differently on another machine.
+///
+public enum ReasoningDialect
+{
+ ///
+ /// The nested "reasoning" object most OpenAI-compatible servers accept.
+ ///
+ OPEN_AI_COMPATIBLE,
+
+ ///
+ /// The top-level "reasoning_effort" parameter.
+ ///
+ REASONING_EFFORT,
+
+ ///
+ /// Anthropic's extended thinking, written as a "thinking" object.
+ ///
+ ANTHROPIC_THINKING,
+
+ ///
+ /// Google's thinking config, thinking level, and thought summaries.
+ ///
+ GOOGLE_THINKING,
+
+ ///
+ /// The "enable_thinking" switch Qwen introduced and other servers took over.
+ ///
+ QWEN_THINKING,
+
+ ///
+ /// Ollama's "think" parameter.
+ ///
+ OLLAMA_THINK,
+
+ ///
+ /// The reasoning mode and budget of the llama.cpp server.
+ ///
+ LLAMA_CPP,
+
+ ///
+ /// The thinking token budget and chat template kwargs of vLLM.
+ ///
+ VLLM,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/ReasoningDispatcher.cs b/app/MindWork AI Studio/Provider/Reasoning/ReasoningDispatcher.cs
new file mode 100644
index 00000000..1dc9006b
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/ReasoningDispatcher.cs
@@ -0,0 +1,147 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+
+using AIStudio.Provider.Reasoning.Dialects;
+
+using Host = AIStudio.Provider.SelfHosted.Host;
+
+namespace AIStudio.Provider.Reasoning;
+
+///
+/// Decides which dialects a provider speaks, and reads its parameters in all of them.
+///
+///
+/// Which dialect answers for which provider is a table here rather than a chain of checks spread
+/// through the reading itself. That is the whole point of the split: adding a provider means adding
+/// a line, and reading what one accepts means reading one line.
+///
+/// The answer is worked out once per provider setting. The question is asked from the provider list,
+/// which re-renders whenever anything on the page changes, and the old code parsed the JSON a person
+/// typed into their expert settings on every one of those renders. Nothing here reaches for
+/// application state, so a test can ask it without the app having started.
+///
+public static class ReasoningDispatcher
+{
+ ///
+ /// Every dialect there is, in the order the enum names them.
+ ///
+ private static readonly FrozenDictionary DIALECTS = new IReasoningDialect[]
+ {
+ new OpenAICompatibleDialect(),
+ new ReasoningEffortDialect(),
+ new AnthropicThinkingDialect(),
+ new GoogleThinkingDialect(),
+ new QwenThinkingDialect(),
+ new OllamaThinkDialect(),
+ new LlamaCppReasoningDialect(),
+ new VllmReasoningDialect(),
+ }.ToFrozenDictionary(dialect => dialect.Dialect);
+
+ ///
+ /// Every dialect there is, in the order the enum names them.
+ ///
+ public static IReadOnlyList Dialects { get; } = DIALECTS.Values.OrderBy(dialect => dialect.Dialect).ToList();
+
+ ///
+ /// What an OpenAI-compatible server understands when nothing more is known about it.
+ ///
+ ///
+ /// The gateways and resellers serve everybody's models, so they are asked in every dialect a
+ /// model of any vendor might answer to. Reading one dialect too many costs a dictionary lookup;
+ /// reading one too few hides a switch the person has set.
+ ///
+ private static readonly ReasoningDialect[] EVERYTHING_A_GATEWAY_MIGHT_SERVE =
+ [
+ ReasoningDialect.OPEN_AI_COMPATIBLE,
+ ReasoningDialect.REASONING_EFFORT,
+ ReasoningDialect.QWEN_THINKING,
+ ReasoningDialect.GOOGLE_THINKING,
+ ];
+
+ private static readonly ReasoningDialect[] NOTHING = [];
+
+ ///
+ /// The answers already worked out, so that the same settings are read once.
+ ///
+ private static readonly ConcurrentDictionary<(LLMProviders Provider, Host Host, string Parameters), ReasoningConfigurationState> ANSWERED = new();
+
+ ///
+ /// Reads what a provider's additional API parameters say about reasoning.
+ ///
+ /// The LLM provider.
+ /// The engine behind it, which only matters for self-hosted providers.
+ /// The additional API parameters, as the person wrote them.
+ /// What they say, which is usually nothing.
+ public static ReasoningConfigurationState WhatTheParametersSay(LLMProviders provider, Host host, string? additionalParameters)
+ {
+ if (string.IsNullOrWhiteSpace(additionalParameters))
+ return ReasoningConfigurationState.NOT_CONFIGURED;
+
+ return ANSWERED.GetOrAdd((provider, host, additionalParameters), static key => Read(key.Provider, key.Host, key.Parameters));
+ }
+
+ ///
+ /// Which dialects this provider speaks.
+ ///
+ ///
+ /// The commercial providers are asked only in their own dialect plus whatever their API
+ /// documents, because a parameter they do not accept says nothing about what they will do. The
+ /// self-hosted engines are the other case: the operator picked the engine, so what it accepts is
+ /// known, and it is the engine rather than the model which decides.
+ ///
+ /// The LLM provider.
+ /// The engine behind it.
+ /// The dialects to read the parameters in.
+ public static IReadOnlyList DialectsOf(LLMProviders provider, Host host) => provider switch
+ {
+ LLMProviders.OPEN_AI => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT],
+
+ LLMProviders.ANTHROPIC => [ReasoningDialect.ANTHROPIC_THINKING],
+
+ LLMProviders.MISTRAL or LLMProviders.PERPLEXITY => [ReasoningDialect.REASONING_EFFORT],
+
+ LLMProviders.GOOGLE => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.GOOGLE_THINKING],
+
+ LLMProviders.ALIBABA_CLOUD => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING],
+
+ LLMProviders.OPEN_ROUTER or
+ LLMProviders.HETZNER or
+ LLMProviders.IONOS or
+ LLMProviders.LITE_LLM or
+ LLMProviders.X or
+ LLMProviders.DEEP_SEEK or
+ LLMProviders.GROQ or
+ LLMProviders.FIREWORKS or
+ LLMProviders.HUGGINGFACE or
+ LLMProviders.HELMHOLTZ or
+ LLMProviders.GWDG => EVERYTHING_A_GATEWAY_MIGHT_SERVE,
+
+ LLMProviders.SELF_HOSTED => host switch
+ {
+ Host.OLLAMA => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING, ReasoningDialect.OLLAMA_THINK],
+
+ Host.LLAMA_CPP => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING, ReasoningDialect.LLAMA_CPP],
+
+ Host.VLLM => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING, ReasoningDialect.GOOGLE_THINKING, ReasoningDialect.VLLM],
+
+ _ => EVERYTHING_A_GATEWAY_MIGHT_SERVE,
+ },
+
+ _ => NOTHING,
+ };
+
+ ///
+ /// Parses the parameters and asks every dialect this provider speaks.
+ ///
+ /// The LLM provider.
+ /// The engine behind it.
+ /// The additional API parameters.
+ /// What they say.
+ private static ReasoningConfigurationState Read(LLMProviders provider, Host host, string additionalParameters)
+ {
+ if (!AdditionalApiParametersParser.TryParse(additionalParameters, out var parameters, out _))
+ return ReasoningConfigurationState.NOT_CONFIGURED;
+
+ return ReasoningParameters.Merge(DialectsOf(provider, host).Select(key => DIALECTS[key].Detect(parameters)));
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Provider/Reasoning/ReasoningParameters.cs b/app/MindWork AI Studio/Provider/Reasoning/ReasoningParameters.cs
new file mode 100644
index 00000000..f1a3683d
--- /dev/null
+++ b/app/MindWork AI Studio/Provider/Reasoning/ReasoningParameters.cs
@@ -0,0 +1,131 @@
+namespace AIStudio.Provider.Reasoning;
+
+///
+/// Reading the values a person wrote into their additional API parameters.
+///
+///
+/// Every dialect ends up asking the same two questions: is this key there, and does this value mean
+/// yes or no. The answers are the same whoever asks them -- "off" is off at every provider -- so
+/// they live here rather than once per dialect.
+///
+public static class ReasoningParameters
+{
+ ///
+ /// Try to read a parameter, matching the key regardless of how it was capitalized.
+ ///
+ /// The parsed parameter dictionary.
+ /// The parameter name to find.
+ /// The matched parameter value, if found.
+ /// True, when a matching key was found.
+ public static bool TryGet(IDictionary parameters, string key, out object? value)
+ {
+ value = null;
+ if (parameters.Count is 0)
+ return false;
+
+ var foundKey = parameters.Keys.FirstOrDefault(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase));
+ if (foundKey is null)
+ return false;
+
+ value = parameters[foundKey];
+ return true;
+ }
+
+ ///
+ /// Reads a value which is written as a boolean, a number, or a level.
+ ///
+ /// The raw parsed parameter value.
+ /// What the value says.
+ public static ReasoningConfigurationState LevelOf(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,
+ };
+
+ ///
+ /// Reads a token budget, which several providers use to say the same thing with a number.
+ ///
+ ///
+ /// A budget of zero switches thinking off. Everything else, negative budgets included, leaves it
+ /// available -- a negative one usually means "as much as it takes".
+ ///
+ /// The configured budget value.
+ /// What the budget says.
+ public static ReasoningConfigurationState BudgetOf(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,
+
+ _ => LevelOf(value),
+ };
+
+ ///
+ /// Puts several answers together into one.
+ ///
+ ///
+ /// A "no" wins over a "yes", wherever the two stand. Somebody who switched thinking off in one
+ /// place meant to switch it off, and an indicator lighting up anyway because another parameter
+ /// could be read as a yes would be the app arguing with them.
+ ///
+ /// What the dialects found.
+ /// The one answer.
+ public static ReasoningConfigurationState Merge(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;
+ }
+
+ ///
+ /// Puts several answers together into one.
+ ///
+ /// What the dialects found.
+ /// The one answer.
+ public static ReasoningConfigurationState Merge(params ReasoningConfigurationState[] states) => Merge(states.AsEnumerable());
+
+ ///
+ /// Whether a text means yes.
+ ///
+ /// The string value to inspect.
+ /// True, when the value switches reasoning on.
+ public static bool IsEnabledText(string text) =>
+ 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);
+
+ ///
+ /// Whether a text means no.
+ ///
+ /// The string value to inspect.
+ /// True, when the value switches reasoning off.
+ public static bool IsDisabledText(string text) =>
+ 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);
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs
index b1c37f0a..475e6bd5 100644
--- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs
+++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs
@@ -1,520 +1,43 @@
using AIStudio.Models;
using AIStudio.Provider;
-
-using Host = AIStudio.Provider.SelfHosted.Host;
+using AIStudio.Provider.Reasoning;
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.
///
+ ///
+ /// Two answers meet here, and they answer different questions. What a model is able to do comes
+ /// from the rules; what this person asked for comes from the parameters they wrote into their
+ /// own provider. A model which thinks unless told otherwise stops showing the indicator when a
+ /// parameter turns it off, and a model which can be asked to think shows it only once one does.
+ ///
/// 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 reasoning = provider.GetModelProfile().Reasoning;
if (reasoning is ReasoningSupport.ALWAYS)
return ReasoningIndicatorState.ALWAYS_ON;
- var reasoningConfigurationState = GetReasoningConfigurationState(provider);
+ var configured = ReasoningDispatcher.WhatTheParametersSay(provider.UsedLLMProvider, provider.Host, provider.AdditionalJsonApiParameters);
if (reasoning is ReasoningSupport.ON_BY_DEFAULT)
{
- return reasoningConfigurationState switch
+ return configured switch
{
ReasoningConfigurationState.EXPLICITLY_DISABLED => ReasoningIndicatorState.NONE,
ReasoningConfigurationState.EXPLICITLY_ENABLED => ReasoningIndicatorState.CONFIGURED,
+
_ => ReasoningIndicatorState.DEFAULT_ON,
};
}
- if (reasoning is ReasoningSupport.OPTIONAL &&
- reasoningConfigurationState is ReasoningConfigurationState.EXPLICITLY_ENABLED)
+ if (reasoning is ReasoningSupport.OPTIONAL && configured 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.HETZNER or
- LLMProviders.IONOS or
- LLMProviders.LITE_LLM 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/Tests/Provider/Reasoning/ReasoningDispatcherTests.cs b/app/Tests/Provider/Reasoning/ReasoningDispatcherTests.cs
new file mode 100644
index 00000000..f9dfe2f0
--- /dev/null
+++ b/app/Tests/Provider/Reasoning/ReasoningDispatcherTests.cs
@@ -0,0 +1,128 @@
+using AIStudio.Provider;
+using AIStudio.Provider.Reasoning;
+
+using Host = AIStudio.Provider.SelfHosted.Host;
+
+namespace AIStudio.Tests.Provider.Reasoning;
+
+///
+/// Checks what the app makes of the API parameters a person wrote themselves.
+///
+///
+/// This is the first test this reading has ever had. Five hundred lines interpreted a dozen ways of
+/// saying "think" across nine providers and three engines, and the only way to find out whether any
+/// of it was right was to configure a provider and watch an icon.
+///
+/// The parameters are stored the way the settings dialog stores them: the body of a JSON object,
+/// without the braces around it. That is why every fragment below starts with a quoted key.
+///
+[TestFixture]
+public sealed class ReasoningDispatcherTests
+{
+ [TestCase(LLMProviders.OPEN_AI, """ "reasoning_effort": "high" """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(LLMProviders.OPEN_AI, """ "reasoning": { "effort": "none" } """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(LLMProviders.OPEN_AI, """ "reasoning": { } """, ReasoningConfigurationState.NOT_CONFIGURED, Description = "An empty object is somebody who has not asked for anything yet.")]
+ [TestCase(LLMProviders.OPEN_AI, """ "temperature": 0.5 """, ReasoningConfigurationState.NOT_CONFIGURED)]
+ [TestCase(LLMProviders.ANTHROPIC, """ "thinking": { "type": "enabled" } """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(LLMProviders.ANTHROPIC, """ "thinking": { "type": "adaptive" } """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(LLMProviders.ANTHROPIC, """ "thinking": { "type": "disabled" } """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(LLMProviders.GOOGLE, """ "thinking_config": { "thinking_budget": 0 } """, ReasoningConfigurationState.EXPLICITLY_DISABLED, Description = "A budget of nothing is the way Google switches thinking off.")]
+ [TestCase(LLMProviders.GOOGLE, """ "generation_config": { "thinking_config": { "thinkingBudget": 1024 } } """, ReasoningConfigurationState.EXPLICITLY_ENABLED, Description = "Nested, and in the other spelling their own libraries write.")]
+ [TestCase(LLMProviders.GOOGLE, """ "thinking_summaries": "auto" """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(LLMProviders.GOOGLE, """ "thinking_summaries": "off" """, ReasoningConfigurationState.NOT_CONFIGURED, Description = "A model can think without showing it, so switching summaries off proves nothing.")]
+ [TestCase(LLMProviders.GOOGLE, """ "reasoning_effort": "minimal" """, ReasoningConfigurationState.EXPLICITLY_ENABLED, Description = "Google's OpenAI-compatible endpoint takes the effort too, which the table has to say out loud now that the dialect no longer smuggles it in.")]
+ [TestCase(LLMProviders.ALIBABA_CLOUD, """ "enable_thinking": false """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(LLMProviders.GROQ, """ "chat_template_kwargs": { "enable_thinking": true } """, ReasoningConfigurationState.EXPLICITLY_ENABLED, Description = "A gateway serves everybody's models, so it is asked in everybody's dialect.")]
+ public void TheParametersOfAProviderAreReadInTheDialectsItSpeaks(LLMProviders provider, string parameters, ReasoningConfigurationState wanted)
+ {
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(provider, Host.NONE, parameters), Is.EqualTo(wanted));
+ }
+
+ [TestCase(Host.OLLAMA, """ "think": true """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(Host.OLLAMA, """ "think": "off" """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(Host.LLAMA_CPP, """ "reasoning": "on" """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(Host.LLAMA_CPP, """ "reasoning": "off" """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(Host.LLAMA_CPP, """ "reasoning": "auto" """, ReasoningConfigurationState.NOT_CONFIGURED, Description = "Auto hands the decision to the model's own template, which means nobody decided.")]
+ [TestCase(Host.LLAMA_CPP, """ "reasoning_budget": 0 """, ReasoningConfigurationState.EXPLICITLY_DISABLED)]
+ [TestCase(Host.VLLM, """ "thinking_token_budget": 2048 """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ [TestCase(Host.VLLM, """ "chat_template_kwargs": { "thinking": true } """, ReasoningConfigurationState.EXPLICITLY_ENABLED)]
+ public void EachSelfHostedEngineIsReadInItsOwn(Host host, string parameters, ReasoningConfigurationState wanted)
+ {
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.SELF_HOSTED, host, parameters), Is.EqualTo(wanted));
+ }
+
+ [Test]
+ public void AProviderIsNotReadInADialectItDoesNotSpeak()
+ {
+ //
+ // The reason the table exists. Mistral accepts an effort and nothing else, so writing Qwen's
+ // switch into a Mistral provider says nothing -- and claiming it did would light an indicator
+ // for a request which will never carry that parameter anywhere.
+ //
+ Assert.Multiple(() =>
+ {
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.MISTRAL, Host.NONE, """ "enable_thinking": true """), Is.EqualTo(ReasoningConfigurationState.NOT_CONFIGURED));
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.ANTHROPIC, Host.NONE, """ "reasoning_effort": "high" """), Is.EqualTo(ReasoningConfigurationState.NOT_CONFIGURED));
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.MISTRAL, Host.NONE, """ "reasoning_effort": "high" """), Is.EqualTo(ReasoningConfigurationState.EXPLICITLY_ENABLED), "And the one it does speak still counts.");
+ });
+ }
+
+ [Test]
+ public void ANoWinsOverAYesWhereverTheTwoStand()
+ {
+ //
+ // Somebody who switched thinking off in one place meant to switch it off. An indicator
+ // lighting up because another parameter could be read as a yes would be the app arguing
+ // with them about their own settings.
+ //
+ var state = ReasoningDispatcher.WhatTheParametersSay(LLMProviders.SELF_HOSTED, Host.OLLAMA, """ "think": true, "enable_thinking": false """);
+
+ Assert.That(state, Is.EqualTo(ReasoningConfigurationState.EXPLICITLY_DISABLED));
+ }
+
+ [TestCase("", Description = "Nothing configured at all.")]
+ [TestCase(" ")]
+ [TestCase(""" "reasoning_effort": """, Description = "A fragment somebody is still typing.")]
+ [TestCase("not json at all")]
+ public void ParametersNobodyCanReadSayNothing(string parameters)
+ {
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.OPEN_AI, Host.NONE, parameters), Is.EqualTo(ReasoningConfigurationState.NOT_CONFIGURED));
+ }
+
+ [Test]
+ public void WithoutAProviderNothingIsRead()
+ {
+ Assert.That(ReasoningDispatcher.WhatTheParametersSay(LLMProviders.NONE, Host.NONE, """ "reasoning_effort": "high" """), Is.EqualTo(ReasoningConfigurationState.NOT_CONFIGURED));
+ }
+
+ [Test]
+ public void EveryDialectThereIsCanBeAsked()
+ {
+ //
+ // Adding a way of saying "think" means adding a member to the enum and a class next to it.
+ // Forgetting the second half would make the first half a name nothing answers to, and the
+ // provider naming it in its table would quietly read one dialect less.
+ //
+ var registered = ReasoningDispatcher.Dialects.Select(dialect => dialect.Dialect).ToList();
+
+ Assert.That(registered, Is.EquivalentTo(Enum.GetValues()));
+ }
+
+ [Test]
+ public void EveryDialectAProviderNamesIsOneThatExists()
+ {
+ var known = ReasoningDispatcher.Dialects.Select(dialect => dialect.Dialect).ToHashSet();
+
+ Assert.Multiple(() =>
+ {
+ foreach (var provider in Enum.GetValues())
+ foreach (var host in Enum.GetValues())
+ {
+ var named = ReasoningDispatcher.DialectsOf(provider, host);
+
+ Assert.That(named, Is.SubsetOf(known), $"{provider} on {host} names a dialect nothing answers to.");
+ Assert.That(named, Is.Unique, $"{provider} on {host} names a dialect twice.");
+ }
+ });
+ }
+}
\ No newline at end of file