mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 00:53:37 +00:00
Split the reasoning parameters into dialects
This commit is contained in:
parent
849f072f3e
commit
f4078856d1
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
147
app/MindWork AI Studio/Provider/Reasoning/ReasoningDispatcher.cs
Normal file
147
app/MindWork AI Studio/Provider/Reasoning/ReasoningDispatcher.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
131
app/MindWork AI Studio/Provider/Reasoning/ReasoningParameters.cs
Normal file
131
app/MindWork AI Studio/Provider/Reasoning/ReasoningParameters.cs
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
128
app/Tests/Provider/Reasoning/ReasoningDispatcherTests.cs
Normal file
128
app/Tests/Provider/Reasoning/ReasoningDispatcherTests.cs
Normal 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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user