From b28f2bcf6c3e88fcd15c5ab8aaeb2411f2945c34 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp <20603780+peerschuett@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:02:25 +0200 Subject: [PATCH] Fixing some things left over --- app/MindWork AI Studio/Provider/Model.cs | 21 +++++++- .../OpenAI/ResponsesToolCallingAdapter.cs | 35 +++++++++++- .../Provider/OpenAI/ThinkingContent.cs | 4 +- .../Provider/OpenRouter/OpenRouterModel.cs | 22 +++++++- .../Provider/OpenRouter/ProviderOpenRouter.cs | 4 +- .../Settings/ProviderExtensions.Gateway.cs | 26 ++++++++- .../Settings/ProviderExtensions.Reasoning.cs | 53 +++++++++++++++---- 7 files changed, 147 insertions(+), 18 deletions(-) diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 97ca3bbf..5961a2a6 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using AIStudio.Tools.PluginSystem; namespace AIStudio.Provider; @@ -20,6 +22,12 @@ public readonly record struct Model(string Id, string? DisplayName) /// public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null); + /// + /// The provider-reported default reasoning behavior, when the model catalog supplies it. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public ModelReasoningBehavior ReasoningBehavior { get; init; } + /// /// Checks if this model is the system-configured placeholder. /// @@ -71,4 +79,15 @@ public readonly record struct Model(string Id, string? DisplayName) public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0; #endregion -} \ No newline at end of file +} + +/// +/// Describes the default reasoning behavior reported by a provider's model catalog. +/// +public enum ModelReasoningBehavior +{ + UNKNOWN, + OPTIONAL, + DEFAULT_ON, + ALWAYS_ON, +} diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs index 5d30a623..f039a14b 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -15,6 +15,8 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> executeRequestAsync) : IToolCallingProviderAdapter { + private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content"; + private readonly List internalItems = []; private ResponsesResponse? lastResponse; @@ -48,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Stream = false, Store = false, Tools = includeTools ? this.effectiveProviderTools : [], - AdditionalApiParameters = apiParameters, + AdditionalApiParameters = IncludeEncryptedReasoning(apiParameters), }, token); if (response is null) @@ -92,6 +94,37 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Output = content, }); + /// + /// Request encrypted reasoning content without replacing any additional output data selected by the user. + /// + /// + /// Tool rounds use stateless Responses requests. OpenAI requires encrypted reasoning items in that mode + /// so that the complete output can be passed back with the tool result on the next round. + /// + private static IDictionary IncludeEncryptedReasoning(IDictionary apiParameters) + { + var result = new Dictionary(apiParameters); + var includeKey = result.Keys.FirstOrDefault(key => key.Equals("include", StringComparison.OrdinalIgnoreCase)); + if (includeKey is null) + { + result["include"] = new List { ENCRYPTED_REASONING_INCLUDE }; + return result; + } + + var includedOutput = result[includeKey] switch + { + IEnumerable values => values.ToList(), + string value => new List { value }, + _ => [], + }; + + if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal))) + includedOutput.Add(ENCRYPTED_REASONING_INCLUDE); + + result[includeKey] = includedOutput; + return result; + } + private static IList BuildEffectiveProviderTools(IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) { var localFunctionNames = runnableTools diff --git a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs index 114258cd..ab40f996 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ThinkingContent.cs @@ -9,10 +9,10 @@ internal static class ThinkingContent { public static string Get(string? reasoningContent, string? reasoning, IEnumerable? reasoningDetails) { - if (!string.IsNullOrWhiteSpace(reasoningContent)) + if (!string.IsNullOrEmpty(reasoningContent)) return reasoningContent; - if (!string.IsNullOrWhiteSpace(reasoning)) + if (!string.IsNullOrEmpty(reasoning)) return reasoning; if (reasoningDetails is null) diff --git a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs index 7cd47a59..c05d2475 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/OpenRouterModel.cs @@ -5,4 +5,24 @@ namespace AIStudio.Provider.OpenRouter; /// /// The model's ID. /// The model's human-readable display name. -public readonly record struct OpenRouterModel(string Id, string? Name); +/// The model's provider-reported reasoning behavior. +public readonly record struct OpenRouterModel(string Id, string? Name, OpenRouterReasoning? Reasoning) +{ + public Model ToModel() => new(this.Id, this.Name) + { + ReasoningBehavior = this.Reasoning switch + { + { Mandatory: true } => ModelReasoningBehavior.ALWAYS_ON, + { DefaultEnabled: true } => ModelReasoningBehavior.DEFAULT_ON, + not null => ModelReasoningBehavior.OPTIONAL, + _ => ModelReasoningBehavior.UNKNOWN, + }, + }; +} + +/// +/// The reasoning defaults returned for a model by OpenRouter's model catalog. +/// +/// Whether reasoning is enabled when the request does not configure it. +/// Whether reasoning cannot be disabled for the model. +public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory); diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 842b9fc6..995d3463 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -116,7 +116,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER storeType, "models", modelResponse => modelResponse.Data - .Select(n => new Model(n.Id, n.Name)) + .Select(n => n.ToModel()) .Where(model => model.IsChatModel()), apiKeyProvisional, requestConfigurator: (request, secretKey) => @@ -133,7 +133,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER return this.LoadModelsResponse( SecretStoreType.EMBEDDING_PROVIDER, "embeddings/models", - modelResponse => modelResponse.Data.Select(n => new Model(n.Id, n.Name)), + modelResponse => modelResponse.Data.Select(n => n.ToModel()), apiKeyProvisional, requestConfigurator: (request, secretKey) => { diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs index c1123381..ac7898ea 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs @@ -57,7 +57,29 @@ public static partial class ProviderExtensions _ => GetModelCapabilitiesOpenSource(bareModel), }; - return NormalizeForGateway(capabilities); + return ApplyGatewayReasoningBehavior(NormalizeForGateway(capabilities), model.ReasoningBehavior); + } + + /// + /// Applies provider-reported reasoning behavior in preference to model-name heuristics. + /// + private static List ApplyGatewayReasoningBehavior(List capabilities, ModelReasoningBehavior reasoningBehavior) + { + if (reasoningBehavior is ModelReasoningBehavior.UNKNOWN) + return capabilities; + + capabilities.Remove(Capability.OPTIONAL_REASONING); + capabilities.Remove(Capability.REASONING_BY_DEFAULT); + capabilities.Remove(Capability.ALWAYS_REASONING); + capabilities.Add(reasoningBehavior switch + { + ModelReasoningBehavior.OPTIONAL => Capability.OPTIONAL_REASONING, + ModelReasoningBehavior.DEFAULT_ON => Capability.REASONING_BY_DEFAULT, + ModelReasoningBehavior.ALWAYS_ON => Capability.ALWAYS_REASONING, + _ => throw new ArgumentOutOfRangeException(nameof(reasoningBehavior), reasoningBehavior, null), + }); + + return capabilities; } /// @@ -78,4 +100,4 @@ public static partial class ProviderExtensions return capabilities; } -} \ 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 25d687b4..c239f257 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Reasoning.cs @@ -146,21 +146,55 @@ public static partial class ProviderExtensions /// private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary parameters) { - var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED; + var states = new List(); if (TryGetParameter(parameters, "reasoning", out var reasoning)) { - reasoningState = reasoning switch + if (reasoning is IDictionary reasoningObject) { - 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), - }; + if (TryGetParameter(reasoningObject, "effort", out var effort)) + states.Add(GetLevelState(effort)); + + if (TryGetParameter(reasoningObject, "enabled", out var enabled)) + states.Add(GetLevelState(enabled)); + + if (TryGetParameter(reasoningObject, "max_tokens", out var maxTokens)) + states.Add(GetBudgetState(maxTokens)); + + if (TryGetParameter(reasoningObject, "summary", out var summary)) + states.Add(GetReasoningSummaryState(summary)); + + if (TryGetParameter(reasoningObject, "generate_summary", out var generateSummary)) + states.Add(GetReasoningSummaryState(generateSummary)); + } + else + states.Add(GetLevelState(reasoning)); } - return MergeReasoningStates(reasoningState, GetReasoningEffortState(parameters)); + if (TryGetParameter(parameters, "include_reasoning", out var includeReasoning) && + GetLevelState(includeReasoning) is ReasoningConfigurationState.EXPLICITLY_ENABLED) + states.Add(ReasoningConfigurationState.EXPLICITLY_ENABLED); + + states.Add(GetReasoningEffortState(parameters)); + return MergeReasoningStates(states); } + /// + /// Detect summary settings that imply reasoning is enabled. + /// + /// + /// Turning a summary off controls visibility only and does not disable the model's reasoning. + /// + private static ReasoningConfigurationState GetReasoningSummaryState(object? value) => value switch + { + string text when text.Equals("auto", StringComparison.OrdinalIgnoreCase) || + text.Equals("concise", StringComparison.OrdinalIgnoreCase) || + text.Equals("detailed", StringComparison.OrdinalIgnoreCase) + => ReasoningConfigurationState.EXPLICITLY_ENABLED, + + true => ReasoningConfigurationState.EXPLICITLY_ENABLED, + _ => ReasoningConfigurationState.NOT_CONFIGURED, + }; + /// /// Detect a top-level reasoning_effort parameter. /// @@ -444,6 +478,7 @@ public static partial class ProviderExtensions text.Equals("minimal", StringComparison.OrdinalIgnoreCase) || text.Equals("medium", StringComparison.OrdinalIgnoreCase) || text.Equals("high", StringComparison.OrdinalIgnoreCase) || + text.Equals("xhigh", StringComparison.OrdinalIgnoreCase) || text.Equals("max", StringComparison.OrdinalIgnoreCase); } @@ -516,4 +551,4 @@ public static partial class ProviderExtensions value = parameters[foundKey]; return true; } -} \ No newline at end of file +}