Split the reasoning parameters into dialects

This commit is contained in:
Thorsten Sommer 2026-09-12 14:36:56 +02:00
parent 849f072f3e
commit f4078856d1
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
15 changed files with 845 additions and 488 deletions

View File

@ -0,0 +1,45 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// Anthropic's extended thinking, written as a "thinking" object.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class AnthropicThinkingDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.ANTHROPIC_THINKING;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters)
{
if (!ReasoningParameters.TryGet(parameters, "thinking", out var thinking))
return ReasoningConfigurationState.NOT_CONFIGURED;
return thinking switch
{
IDictionary<string, object> thinkingObject when ReasoningParameters.TryGet(thinkingObject, "type", out var type) => TypeOf(type),
_ => ReasoningParameters.LevelOf(thinking),
};
}
/// <summary>
/// Reads the "type" of an Anthropic thinking object.
/// </summary>
/// <param name="value">The configured thinking type.</param>
/// <returns>What it says.</returns>
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),
};
}

View File

@ -0,0 +1,87 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// Google's thinking config, thinking level, and thought summaries.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class GoogleThinkingDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.GOOGLE_THINKING;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
if (ReasoningParameters.TryGet(parameters, "thinking_config", out var thinkingConfig) &&
thinkingConfig is IDictionary<string, object> thinkingConfigObject)
states.Add(ConfigOf(thinkingConfigObject));
if (ReasoningParameters.TryGet(parameters, "generation_config", out var generationConfig) &&
generationConfig is IDictionary<string, object> generationConfigObject)
{
if (ReasoningParameters.TryGet(generationConfigObject, "thinking_config", out var nestedThinkingConfig) &&
nestedThinkingConfig is IDictionary<string, object> 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);
}
/// <summary>
/// Reads a thinking config, in either spelling of its keys.
/// </summary>
/// <param name="thinkingConfig">The parsed thinking config object.</param>
/// <returns>What it says.</returns>
private static ReasoningConfigurationState ConfigOf(IDictionary<string, object> thinkingConfig)
{
var states = new List<ReasoningConfigurationState>();
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);
}
/// <summary>
/// Reads a thought summary setting, which can only ever say yes.
/// </summary>
/// <param name="value">The configured summary setting.</param>
/// <returns>Yes, when it asks for summaries; nothing otherwise.</returns>
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,
};
}

View File

@ -0,0 +1,47 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// The reasoning mode and budget of the llama.cpp server.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class LlamaCppReasoningDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.LLAMA_CPP;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
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<string, object> chatTemplateKwargsObject)
states.Add(QwenThinkingDialect.In(chatTemplateKwargsObject));
return ReasoningParameters.Merge(states);
}
/// <summary>
/// Reads the reasoning mode.
/// </summary>
/// <param name="value">The configured mode.</param>
/// <returns>What it says, which for "auto" is nothing.</returns>
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),
};
}

View File

@ -0,0 +1,20 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// Ollama's "think" parameter.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class OllamaThinkDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.OLLAMA_THINK;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters) =>
ReasoningParameters.TryGet(parameters, "think", out var think)
? ReasoningParameters.LevelOf(think)
: ReasoningConfigurationState.NOT_CONFIGURED;
}

View File

@ -0,0 +1,31 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// The nested "reasoning" object almost every OpenAI-compatible server accepts.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class OpenAICompatibleDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.OPEN_AI_COMPATIBLE;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters)
{
if (!ReasoningParameters.TryGet(parameters, "reasoning", out var reasoning))
return ReasoningConfigurationState.NOT_CONFIGURED;
return reasoning switch
{
IDictionary<string, object> reasoningObject when ReasoningParameters.TryGet(reasoningObject, "effort", out var effort) => ReasoningParameters.LevelOf(effort),
IDictionary<string, object> reasoningObject when ReasoningParameters.TryGet(reasoningObject, "summary", out var summary) => ReasoningParameters.LevelOf(summary),
IDictionary<string, object> => ReasoningConfigurationState.NOT_CONFIGURED,
_ => ReasoningParameters.LevelOf(reasoning),
};
}
}

View File

@ -0,0 +1,38 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// The "enable_thinking" switch Qwen introduced and other servers took over.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class QwenThinkingDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.QWEN_THINKING;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters) => In(parameters);
/// <summary>
/// Reads the switch out of any parameter object, which need not be the top-level one.
/// </summary>
/// <param name="parameters">The object to look in.</param>
/// <returns>What it says.</returns>
public static ReasoningConfigurationState In(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
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<string, object> chatTemplateKwargsObject &&
ReasoningParameters.TryGet(chatTemplateKwargsObject, "enable_thinking", out var nestedEnableThinking))
states.Add(ReasoningParameters.LevelOf(nestedEnableThinking));
return ReasoningParameters.Merge(states);
}
}

View File

@ -0,0 +1,21 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// The top-level "reasoning_effort" parameter.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class ReasoningEffortDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.REASONING_EFFORT;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters) =>
ReasoningParameters.TryGet(parameters, "reasoning_effort", out var effort)
? ReasoningParameters.LevelOf(effort)
: ReasoningConfigurationState.NOT_CONFIGURED;
}

View File

@ -0,0 +1,34 @@
namespace AIStudio.Provider.Reasoning.Dialects;
/// <summary>
/// The thinking token budget and chat template kwargs of vLLM.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class VllmReasoningDialect : IReasoningDialect
{
/// <inheritdoc />
public ReasoningDialect Dialect => ReasoningDialect.VLLM;
/// <inheritdoc />
public ReasoningConfigurationState Detect(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
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<string, object> chatTemplateKwargsObject)
{
states.Add(QwenThinkingDialect.In(chatTemplateKwargsObject));
if (ReasoningParameters.TryGet(chatTemplateKwargsObject, "thinking", out var thinking))
states.Add(ReasoningParameters.LevelOf(thinking));
}
return ReasoningParameters.Merge(states);
}
}

View File

@ -0,0 +1,24 @@
namespace AIStudio.Provider.Reasoning;
/// <summary>
/// One way of asking a request to think, and how to recognize it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IReasoningDialect
{
/// <summary>
/// Which dialect this is, which is also where it stands in the order.
/// </summary>
ReasoningDialect Dialect { get; }
/// <summary>
/// Reads what these parameters say about reasoning.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>What they say, which is usually nothing.</returns>
ReasoningConfigurationState Detect(IDictionary<string, object> parameters);
}

View File

@ -0,0 +1,28 @@
namespace AIStudio.Provider.Reasoning;
/// <summary>
/// What the additional API parameters of a provider say about reasoning.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public enum ReasoningConfigurationState
{
/// <summary>
/// No recognized reasoning parameter was found.
/// </summary>
NOT_CONFIGURED,
/// <summary>
/// A recognized reasoning parameter explicitly enables reasoning.
/// </summary>
EXPLICITLY_ENABLED,
/// <summary>
/// A recognized reasoning parameter explicitly disables reasoning.
/// </summary>
EXPLICITLY_DISABLED,
}

View File

@ -0,0 +1,53 @@
namespace AIStudio.Provider.Reasoning;
/// <summary>
/// The ways a request can be asked to think, one per way of writing it down.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public enum ReasoningDialect
{
/// <summary>
/// The nested "reasoning" object most OpenAI-compatible servers accept.
/// </summary>
OPEN_AI_COMPATIBLE,
/// <summary>
/// The top-level "reasoning_effort" parameter.
/// </summary>
REASONING_EFFORT,
/// <summary>
/// Anthropic's extended thinking, written as a "thinking" object.
/// </summary>
ANTHROPIC_THINKING,
/// <summary>
/// Google's thinking config, thinking level, and thought summaries.
/// </summary>
GOOGLE_THINKING,
/// <summary>
/// The "enable_thinking" switch Qwen introduced and other servers took over.
/// </summary>
QWEN_THINKING,
/// <summary>
/// Ollama's "think" parameter.
/// </summary>
OLLAMA_THINK,
/// <summary>
/// The reasoning mode and budget of the llama.cpp server.
/// </summary>
LLAMA_CPP,
/// <summary>
/// The thinking token budget and chat template kwargs of vLLM.
/// </summary>
VLLM,
}

View File

@ -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;
/// <summary>
/// Decides which dialects a provider speaks, and reads its parameters in all of them.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class ReasoningDispatcher
{
/// <summary>
/// Every dialect there is, in the order the enum names them.
/// </summary>
private static readonly FrozenDictionary<ReasoningDialect, IReasoningDialect> DIALECTS = new IReasoningDialect[]
{
new OpenAICompatibleDialect(),
new ReasoningEffortDialect(),
new AnthropicThinkingDialect(),
new GoogleThinkingDialect(),
new QwenThinkingDialect(),
new OllamaThinkDialect(),
new LlamaCppReasoningDialect(),
new VllmReasoningDialect(),
}.ToFrozenDictionary(dialect => dialect.Dialect);
/// <summary>
/// Every dialect there is, in the order the enum names them.
/// </summary>
public static IReadOnlyList<IReasoningDialect> Dialects { get; } = DIALECTS.Values.OrderBy(dialect => dialect.Dialect).ToList();
/// <summary>
/// What an OpenAI-compatible server understands when nothing more is known about it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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 = [];
/// <summary>
/// The answers already worked out, so that the same settings are read once.
/// </summary>
private static readonly ConcurrentDictionary<(LLMProviders Provider, Host Host, string Parameters), ReasoningConfigurationState> ANSWERED = new();
/// <summary>
/// Reads what a provider's additional API parameters say about reasoning.
/// </summary>
/// <param name="provider">The LLM provider.</param>
/// <param name="host">The engine behind it, which only matters for self-hosted providers.</param>
/// <param name="additionalParameters">The additional API parameters, as the person wrote them.</param>
/// <returns>What they say, which is usually nothing.</returns>
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));
}
/// <summary>
/// Which dialects this provider speaks.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="provider">The LLM provider.</param>
/// <param name="host">The engine behind it.</param>
/// <returns>The dialects to read the parameters in.</returns>
public static IReadOnlyList<ReasoningDialect> 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,
};
/// <summary>
/// Parses the parameters and asks every dialect this provider speaks.
/// </summary>
/// <param name="provider">The LLM provider.</param>
/// <param name="host">The engine behind it.</param>
/// <param name="additionalParameters">The additional API parameters.</param>
/// <returns>What they say.</returns>
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)));
}
}

View File

@ -0,0 +1,131 @@
namespace AIStudio.Provider.Reasoning;
/// <summary>
/// Reading the values a person wrote into their additional API parameters.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class ReasoningParameters
{
/// <summary>
/// Try to read a parameter, matching the key regardless of how it was capitalized.
/// </summary>
/// <param name="parameters">The parsed parameter dictionary.</param>
/// <param name="key">The parameter name to find.</param>
/// <param name="value">The matched parameter value, if found.</param>
/// <returns>True, when a matching key was found.</returns>
public static bool TryGet(IDictionary<string, object> 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;
}
/// <summary>
/// Reads a value which is written as a boolean, a number, or a level.
/// </summary>
/// <param name="value">The raw parsed parameter value.</param>
/// <returns>What the value says.</returns>
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,
};
/// <summary>
/// Reads a token budget, which several providers use to say the same thing with a number.
/// </summary>
/// <remarks>
/// 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".
/// </remarks>
/// <param name="value">The configured budget value.</param>
/// <returns>What the budget says.</returns>
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),
};
/// <summary>
/// Puts several answers together into one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="states">What the dialects found.</param>
/// <returns>The one answer.</returns>
public static ReasoningConfigurationState Merge(IEnumerable<ReasoningConfigurationState> 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;
}
/// <summary>
/// Puts several answers together into one.
/// </summary>
/// <param name="states">What the dialects found.</param>
/// <returns>The one answer.</returns>
public static ReasoningConfigurationState Merge(params ReasoningConfigurationState[] states) => Merge(states.AsEnumerable());
/// <summary>
/// Whether a text means yes.
/// </summary>
/// <param name="text">The string value to inspect.</param>
/// <returns>True, when the value switches reasoning on.</returns>
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);
/// <summary>
/// Whether a text means no.
/// </summary>
/// <param name="text">The string value to inspect.</param>
/// <returns>True, when the value switches reasoning off.</returns>
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);
}

View File

@ -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
{
/// <summary>
/// The reasoning-related intent found in the configured additional API parameters.
/// </summary>
private enum ReasoningConfigurationState
{
/// <summary>
/// No recognized reasoning parameter was found.
/// </summary>
NOT_CONFIGURED,
/// <summary>
/// A recognized reasoning parameter explicitly enables reasoning.
/// </summary>
EXPLICITLY_ENABLED,
/// <summary>
/// A recognized reasoning parameter explicitly disables reasoning.
/// </summary>
EXPLICITLY_DISABLED,
}
/// <summary>
/// Get the effective reasoning indicator state for the configured provider instance.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="provider">The configured provider.</param>
/// <returns>The effective reasoning indicator state.</returns>
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>
/// Parse additional API parameters and dispatch them to provider-specific reasoning detectors.
/// </summary>
/// <param name="provider">The configured provider whose additional API parameters should be inspected.</param>
/// <returns>The explicit reasoning configuration state, or <see cref="ReasoningConfigurationState.NOT_CONFIGURED"/> if nothing known was found.</returns>
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,
};
}
/// <summary>
/// Detect OpenAI-compatible reasoning parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// OpenAI-compatible providers commonly use a nested <c>reasoning</c> object and/or
/// a top-level <c>reasoning_effort</c> parameter.
/// </remarks>
private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters)
{
var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED;
if (TryGetParameter(parameters, "reasoning", out var reasoning))
{
reasoningState = reasoning switch
{
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort),
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary),
IDictionary<string, object> => ReasoningConfigurationState.NOT_CONFIGURED,
_ => GetLevelState(reasoning),
};
}
return MergeReasoningStates(reasoningState, GetReasoningEffortState(parameters));
}
/// <summary>
/// Detect a top-level <c>reasoning_effort</c> parameter.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
private static ReasoningConfigurationState GetReasoningEffortState(IDictionary<string, object> parameters)
{
return TryGetParameter(parameters, "reasoning_effort", out var reasoningEffort)
? GetLevelState(reasoningEffort)
: ReasoningConfigurationState.NOT_CONFIGURED;
}
/// <summary>
/// Detect Anthropic extended-thinking parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
private static ReasoningConfigurationState GetAnthropicReasoningState(IDictionary<string, object> parameters)
{
if (!TryGetParameter(parameters, "thinking", out var thinking))
return ReasoningConfigurationState.NOT_CONFIGURED;
return thinking switch
{
IDictionary<string, object> thinkingObject when TryGetParameter(thinkingObject, "type", out var type) => GetAnthropicThinkingTypeState(type),
_ => GetLevelState(thinking),
};
}
/// <summary>
/// Detect Google Gemini thinking parameters across OpenAI-compatible additional parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// Google can expose thinking options through <c>thinking_config</c>,
/// <c>generation_config.thinking_config</c>, <c>thinking_level</c>, and summary settings.
/// Summary settings only prove that thinking is enabled when they request summaries;
/// disabling summaries does not necessarily disable reasoning.
/// </remarks>
private static ReasoningConfigurationState GetGoogleReasoningState(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
if (TryGetParameter(parameters, "thinking_config", out var thinkingConfig) &&
thinkingConfig is IDictionary<string, object> thinkingConfigObject)
states.Add(GetGoogleThinkingConfigState(thinkingConfigObject));
if (TryGetParameter(parameters, "generation_config", out var generationConfig) &&
generationConfig is IDictionary<string, object> generationConfigObject)
{
if (TryGetParameter(generationConfigObject, "thinking_config", out var nestedThinkingConfig) &&
nestedThinkingConfig is IDictionary<string, object> 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);
}
/// <summary>
/// Detect Google Gemini thinking-budget and include-thoughts settings.
/// </summary>
/// <param name="thinkingConfig">The parsed <c>thinking_config</c> object.</param>
/// <returns>The detected reasoning configuration state.</returns>
private static ReasoningConfigurationState GetGoogleThinkingConfigState(IDictionary<string, object> thinkingConfig)
{
var states = new List<ReasoningConfigurationState>();
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);
}
/// <summary>
/// Detect Google Gemini thinking-summary values that imply reasoning is active.
/// </summary>
/// <param name="value">The configured thinking-summary value.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// A disabled or missing summary does not prove that thinking is disabled, so only
/// known enabling values are treated as explicit reasoning configuration.
/// </remarks>
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,
};
/// <summary>
/// Detect Ollama's <c>think</c> parameter.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
private static ReasoningConfigurationState GetOllamaReasoningState(IDictionary<string, object> parameters)
{
return TryGetParameter(parameters, "think", out var think)
? GetLevelState(think)
: ReasoningConfigurationState.NOT_CONFIGURED;
}
/// <summary>
/// Detect llama.cpp server reasoning parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// llama.cpp exposes runtime reasoning control through parameters such as
/// <c>reasoning</c>, <c>reasoning_budget</c>, and template-specific kwargs.
/// </remarks>
private static ReasoningConfigurationState GetLlamaCppReasoningState(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
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<string, object> chatTemplateKwargsObject)
states.Add(GetQwenReasoningState(chatTemplateKwargsObject));
return MergeReasoningStates(states);
}
/// <summary>
/// Detect vLLM reasoning parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// vLLM supports both top-level reasoning fields and chat-template kwargs, depending
/// on model family and reasoning parser configuration.
/// </remarks>
private static ReasoningConfigurationState GetVllmReasoningState(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
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<string, object> chatTemplateKwargsObject)
{
states.Add(GetQwenReasoningState(chatTemplateKwargsObject));
if (TryGetParameter(chatTemplateKwargsObject, "thinking", out var thinking))
states.Add(GetLevelState(thinking));
}
return MergeReasoningStates(states);
}
/// <summary>
/// Detect Qwen-style <c>enable_thinking</c> parameters.
/// </summary>
/// <param name="parameters">The parsed additional API parameters.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// Some OpenAI-compatible servers accept <c>enable_thinking</c> either at the
/// top level or under <c>chat_template_kwargs</c>.
/// </remarks>
private static ReasoningConfigurationState GetQwenReasoningState(IDictionary<string, object> parameters)
{
var states = new List<ReasoningConfigurationState>();
if (TryGetParameter(parameters, "enable_thinking", out var enableThinking))
states.Add(GetLevelState(enableThinking));
if (TryGetParameter(parameters, "chat_template_kwargs", out var chatTemplateKwargs) &&
chatTemplateKwargs is IDictionary<string, object> chatTemplateKwargsObject &&
TryGetParameter(chatTemplateKwargsObject, "enable_thinking", out var nestedEnableThinking))
states.Add(GetLevelState(nestedEnableThinking));
return MergeReasoningStates(states);
}
/// <summary>
/// Interpret Anthropic's <c>thinking.type</c> value.
/// </summary>
/// <param name="value">The configured Anthropic thinking type.</param>
/// <returns>The detected reasoning configuration state.</returns>
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),
};
/// <summary>
/// Interpret llama.cpp's <c>reasoning</c> mode value.
/// </summary>
/// <param name="value">The configured llama.cpp reasoning mode.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// <c>auto</c> means the server decides from the model/template, so it is treated as
/// not configured by the user rather than as explicitly enabled.
/// </remarks>
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),
};
/// <summary>
/// Interpret token-budget style values used by several providers.
/// </summary>
/// <param name="value">The configured budget value.</param>
/// <returns>The detected reasoning configuration state.</returns>
/// <remarks>
/// A zero budget disables reasoning; non-zero values, including unrestricted negative
/// budgets, indicate that reasoning is available for the request.
/// </remarks>
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),
};
/// <summary>
/// Interpret common boolean, numeric, and level-style reasoning values.
/// </summary>
/// <param name="value">The raw parsed parameter value.</param>
/// <returns>The detected reasoning configuration state.</returns>
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,
};
/// <summary>
/// Determine whether a string value is a known reasoning-enabling value.
/// </summary>
/// <param name="text">The string value to inspect.</param>
/// <returns><see langword="true"/> if the value should be treated as enabling reasoning.</returns>
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);
}
/// <summary>
/// Determine whether a string value is a known reasoning-disabling value.
/// </summary>
/// <param name="text">The string value to inspect.</param>
/// <returns><see langword="true"/> if the value should be treated as disabling reasoning.</returns>
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);
}
/// <summary>
/// Merge multiple detected reasoning states into a single state.
/// </summary>
/// <param name="states">The detected states from provider-specific parameter checks.</param>
/// <returns>The merged state.</returns>
/// <remarks>
/// Explicit disabling wins over enabling because user-provided off switches should
/// suppress default-on reasoning indicators.
/// </remarks>
private static ReasoningConfigurationState MergeReasoningStates(IEnumerable<ReasoningConfigurationState> 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;
}
/// <summary>
/// Merge multiple detected reasoning states into a single state.
/// </summary>
/// <param name="states">The detected states from provider-specific parameter checks.</param>
/// <returns>The merged state.</returns>
private static ReasoningConfigurationState MergeReasoningStates(params ReasoningConfigurationState[] states)
{
return MergeReasoningStates(states.AsEnumerable());
}
/// <summary>
/// Try to read a parameter from a dictionary using case-insensitive key matching.
/// </summary>
/// <param name="parameters">The parsed parameter dictionary.</param>
/// <param name="key">The parameter name to find.</param>
/// <param name="value">The matched parameter value, if found.</param>
/// <returns><see langword="true"/> if a matching key was found; otherwise <see langword="false"/>.</returns>
private static bool TryGetParameter(IDictionary<string, object> 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;
}
}

View File

@ -0,0 +1,128 @@
using AIStudio.Provider;
using AIStudio.Provider.Reasoning;
using Host = AIStudio.Provider.SelfHosted.Host;
namespace AIStudio.Tests.Provider.Reasoning;
/// <summary>
/// Checks what the app makes of the API parameters a person wrote themselves.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ReasoningDialect>()));
}
[Test]
public void EveryDialectAProviderNamesIsOneThatExists()
{
var known = ReasoningDispatcher.Dialects.Select(dialect => dialect.Dialect).ToHashSet();
Assert.Multiple(() =>
{
foreach (var provider in Enum.GetValues<LLMProviders>())
foreach (var host in Enum.GetValues<Host>())
{
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.");
}
});
}
}