Rebuilt how AI Studio knows what a model can do (#960)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-13 14:17:25 +02:00
1 parent d21e09dd1e
commit d85b4e71b6
287 files changed
+18341 -3677

No files matched your search

@@ -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,
}
@@ -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)));
}
}
@@ -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);
}