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