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;
namespace AIStudio.Provider;
@ -20,6 +22,12 @@ public readonly record struct Model(string Id, string? DisplayName)
/// </summary>
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>
/// Checks if this model is the system-configured placeholder.
/// </summary>
@ -71,4 +79,15 @@ public readonly record struct Model(string Id, string? DisplayName)
public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0;
#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,
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{
private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content";
private readonly List<object> internalItems = [];
private ResponsesResponse? lastResponse;
@ -48,7 +50,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> 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<object> b
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)
{
var localFunctionNames = runnableTools

View File

@ -9,10 +9,10 @@ internal static class ThinkingContent
{
public static string Get(string? reasoningContent, string? reasoning, IEnumerable<JsonElement>? 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)

View File

@ -5,4 +5,24 @@ namespace AIStudio.Provider.OpenRouter;
/// </summary>
/// <param name="Id">The model's ID.</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,
"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<OpenRouterModelsResponse>(
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) =>
{

View File

@ -57,7 +57,29 @@ public static partial class ProviderExtensions
_ => 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>
@ -78,4 +100,4 @@ public static partial class ProviderExtensions
return capabilities;
}
}
}

View File

@ -146,21 +146,55 @@ public static partial class ProviderExtensions
/// </remarks>
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))
{
reasoningState = reasoning switch
if (reasoning is IDictionary<string, object> reasoningObject)
{
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "effort", out var effort) => GetLevelState(effort),
IDictionary<string, object> reasoningObject when TryGetParameter(reasoningObject, "summary", out var summary) => GetLevelState(summary),
IDictionary<string, object> => ReasoningConfigurationState.NOT_CONFIGURED,
_ => GetLevelState(reasoning),
};
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);
}
/// <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>
/// Detect a top-level <c>reasoning_effort</c> parameter.
/// </summary>
@ -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;
}
}
}