Fixing some things left over

This commit is contained in:
Peer Hogeterp 2026-09-10 17:02:25 +02:00
parent bbbcdddc91
commit b28f2bcf6c
7 changed files with 147 additions and 18 deletions

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
namespace AIStudio.Provider; namespace AIStudio.Provider;
@ -20,6 +22,12 @@ public readonly record struct Model(string Id, string? DisplayName)
/// </summary> /// </summary>
public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null); public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null);
/// <summary>
/// The provider-reported default reasoning behavior, when the model catalog supplies it.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ModelReasoningBehavior ReasoningBehavior { get; init; }
/// <summary> /// <summary>
/// Checks if this model is the system-configured placeholder. /// Checks if this model is the system-configured placeholder.
/// </summary> /// </summary>
@ -72,3 +80,14 @@ public readonly record struct Model(string Id, string? DisplayName)
#endregion #endregion
} }
/// <summary>
/// Describes the default reasoning behavior reported by a provider's model catalog.
/// </summary>
public enum ModelReasoningBehavior
{
UNKNOWN,
OPTIONAL,
DEFAULT_ON,
ALWAYS_ON,
}

View File

@ -15,6 +15,8 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{ {
private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content";
private readonly List<object> internalItems = []; private readonly List<object> internalItems = [];
private ResponsesResponse? lastResponse; private ResponsesResponse? lastResponse;
@ -48,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Stream = false, Stream = false,
Store = false, Store = false,
Tools = includeTools ? this.effectiveProviderTools : [], Tools = includeTools ? this.effectiveProviderTools : [],
AdditionalApiParameters = apiParameters, AdditionalApiParameters = IncludeEncryptedReasoning(apiParameters),
}, token); }, token);
if (response is null) if (response is null)
@ -92,6 +94,37 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Output = content, Output = content,
}); });
/// <summary>
/// Request encrypted reasoning content without replacing any additional output data selected by the user.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static IDictionary<string, object> IncludeEncryptedReasoning(IDictionary<string, object> apiParameters)
{
var result = new Dictionary<string, object>(apiParameters);
var includeKey = result.Keys.FirstOrDefault(key => key.Equals("include", StringComparison.OrdinalIgnoreCase));
if (includeKey is null)
{
result["include"] = new List<object> { ENCRYPTED_REASONING_INCLUDE };
return result;
}
var includedOutput = result[includeKey] switch
{
IEnumerable<object> values => values.ToList(),
string value => new List<object> { 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<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) private static IList<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
{ {
var localFunctionNames = runnableTools var localFunctionNames = runnableTools

View File

@ -9,10 +9,10 @@ internal static class ThinkingContent
{ {
public static string Get(string? reasoningContent, string? reasoning, IEnumerable<JsonElement>? reasoningDetails) public static string Get(string? reasoningContent, string? reasoning, IEnumerable<JsonElement>? reasoningDetails)
{ {
if (!string.IsNullOrWhiteSpace(reasoningContent)) if (!string.IsNullOrEmpty(reasoningContent))
return reasoningContent; return reasoningContent;
if (!string.IsNullOrWhiteSpace(reasoning)) if (!string.IsNullOrEmpty(reasoning))
return reasoning; return reasoning;
if (reasoningDetails is null) if (reasoningDetails is null)

View File

@ -5,4 +5,24 @@ namespace AIStudio.Provider.OpenRouter;
/// </summary> /// </summary>
/// <param name="Id">The model's ID.</param> /// <param name="Id">The model's ID.</param>
/// <param name="Name">The model's human-readable display name.</param> /// <param name="Name">The model's human-readable display name.</param>
public readonly record struct OpenRouterModel(string Id, string? Name); /// <param name="Reasoning">The model's provider-reported reasoning behavior.</param>
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,
},
};
}
/// <summary>
/// The reasoning defaults returned for a model by OpenRouter's model catalog.
/// </summary>
/// <param name="DefaultEnabled">Whether reasoning is enabled when the request does not configure it.</param>
/// <param name="Mandatory">Whether reasoning cannot be disabled for the model.</param>
public readonly record struct OpenRouterReasoning(bool DefaultEnabled, bool Mandatory);

View File

@ -116,7 +116,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
storeType, storeType,
"models", "models",
modelResponse => modelResponse.Data modelResponse => modelResponse.Data
.Select(n => new Model(n.Id, n.Name)) .Select(n => n.ToModel())
.Where(model => model.IsChatModel()), .Where(model => model.IsChatModel()),
apiKeyProvisional, apiKeyProvisional,
requestConfigurator: (request, secretKey) => requestConfigurator: (request, secretKey) =>
@ -133,7 +133,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
return this.LoadModelsResponse<OpenRouterModelsResponse>( return this.LoadModelsResponse<OpenRouterModelsResponse>(
SecretStoreType.EMBEDDING_PROVIDER, SecretStoreType.EMBEDDING_PROVIDER,
"embeddings/models", "embeddings/models",
modelResponse => modelResponse.Data.Select(n => new Model(n.Id, n.Name)), modelResponse => modelResponse.Data.Select(n => n.ToModel()),
apiKeyProvisional, apiKeyProvisional,
requestConfigurator: (request, secretKey) => requestConfigurator: (request, secretKey) =>
{ {

View File

@ -57,7 +57,29 @@ public static partial class ProviderExtensions
_ => GetModelCapabilitiesOpenSource(bareModel), _ => GetModelCapabilitiesOpenSource(bareModel),
}; };
return NormalizeForGateway(capabilities); return ApplyGatewayReasoningBehavior(NormalizeForGateway(capabilities), model.ReasoningBehavior);
}
/// <summary>
/// Applies provider-reported reasoning behavior in preference to model-name heuristics.
/// </summary>
private static List<Capability> ApplyGatewayReasoningBehavior(List<Capability> 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;
} }
/// <summary> /// <summary>

View File

@ -146,21 +146,55 @@ public static partial class ProviderExtensions
/// </remarks> /// </remarks>
private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters) private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters)
{ {
var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED; var states = new List<ReasoningConfigurationState>();
if (TryGetParameter(parameters, "reasoning", out var reasoning)) if (TryGetParameter(parameters, "reasoning", out var reasoning))
{ {
reasoningState = reasoning switch if (reasoning is IDictionary<string, object> reasoningObject)
{ {
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort), if (TryGetParameter(reasoningObject, "effort", out var effort))
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary), states.Add(GetLevelState(effort));
IDictionary<string, object> => ReasoningConfigurationState.NOT_CONFIGURED,
_ => GetLevelState(reasoning), 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);
} }
/// <summary>
/// Detect summary settings that imply reasoning is enabled.
/// </summary>
/// <remarks>
/// Turning a summary off controls visibility only and does not disable the model's reasoning.
/// </remarks>
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,
};
/// <summary> /// <summary>
/// Detect a top-level <c>reasoning_effort</c> parameter. /// Detect a top-level <c>reasoning_effort</c> parameter.
/// </summary> /// </summary>
@ -444,6 +478,7 @@ public static partial class ProviderExtensions
text.Equals("minimal", StringComparison.OrdinalIgnoreCase) || text.Equals("minimal", StringComparison.OrdinalIgnoreCase) ||
text.Equals("medium", StringComparison.OrdinalIgnoreCase) || text.Equals("medium", StringComparison.OrdinalIgnoreCase) ||
text.Equals("high", StringComparison.OrdinalIgnoreCase) || text.Equals("high", StringComparison.OrdinalIgnoreCase) ||
text.Equals("xhigh", StringComparison.OrdinalIgnoreCase) ||
text.Equals("max", StringComparison.OrdinalIgnoreCase); text.Equals("max", StringComparison.OrdinalIgnoreCase);
} }