mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-10 16:53:47 +00:00
Rebuilt how AI Studio knows what a model can do (#960)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
1 parent
d21e09dd1e
commit
d85b4e71b6
287 files changed
+18341
-3677
No files matched your search
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Provider;
|
||||
|
||||
using Lua;
|
||||
@@ -10,11 +12,69 @@ using LuaTable = Lua.LuaTable;
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Optional expert capability overrides for a configured LLM provider.
|
||||
/// Missing values keep the automatic capability detection result.
|
||||
/// What a person stated about the model of their own provider instance, against what the rules
|
||||
/// worked out. Anything left unsaid keeps the automatic answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The name says capabilities because that is all this could hold when it was written, and renaming
|
||||
/// it now would break every settings file and every rolled-out configuration which spells the word.
|
||||
/// What it holds is everything a person can say about the model behind their own provider: what it
|
||||
/// can do, how it reasons, how much it reads, and how many pictures it takes.
|
||||
///
|
||||
/// The numbers carry the same key names a model plugin uses for the same questions, down to the
|
||||
/// spelling. The two surfaces answer different questions -- a plugin describes a model, this
|
||||
/// describes one installation of it -- but an administrator writing both should not have to learn
|
||||
/// two vocabularies to say the same thing twice.
|
||||
/// </remarks>
|
||||
public sealed record ProviderCapabilityOverrides
|
||||
{
|
||||
/// <summary>
|
||||
/// How wide the window of this installation is, in tokens.
|
||||
/// </summary>
|
||||
private const string CONTEXT_WINDOW_KEY = "CONTEXT_WINDOW";
|
||||
|
||||
/// <summary>
|
||||
/// How many images one message may carry here.
|
||||
/// </summary>
|
||||
private const string MAX_IMAGES_PER_MESSAGE_KEY = "MAX_IMAGES_PER_MESSAGE";
|
||||
|
||||
/// <summary>
|
||||
/// How many images one request may carry here.
|
||||
/// </summary>
|
||||
private const string MAX_IMAGES_PER_REQUEST_KEY = "MAX_IMAGES_PER_REQUEST";
|
||||
|
||||
/// <summary>
|
||||
/// The keys which name a number rather than a capability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They share the table with the capability words, so the parser has to ask which sort of key
|
||||
/// it is looking at before it asks what the value should be: a number where a switch belongs is
|
||||
/// as wrong as a switch where a number belongs, and neither may quietly become the other.
|
||||
/// </remarks>
|
||||
private static readonly IReadOnlyList<string> NUMERIC_KEYS =
|
||||
[
|
||||
CONTEXT_WINDOW_KEY,
|
||||
MAX_IMAGES_PER_MESSAGE_KEY,
|
||||
MAX_IMAGES_PER_REQUEST_KEY,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The capabilities a person switches on or off directly, without the reasoning words.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// How a model reasons is one answer out of four, not three flags which can contradict each
|
||||
/// other, so it is resolved on its own below. The three words stay in the list above because
|
||||
/// that is the vocabulary a settings file and a configuration plugin are written in.
|
||||
/// </remarks>
|
||||
private static readonly IReadOnlyList<Capability> DIRECTLY_SETTABLE_CAPABILITIES =
|
||||
[
|
||||
Capability.AUDIO_INPUT,
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.SPEECH_INPUT,
|
||||
Capability.VIDEO_INPUT,
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<Capability> SUPPORTED_CAPABILITIES =
|
||||
[
|
||||
Capability.AUDIO_INPUT,
|
||||
@@ -59,6 +119,34 @@ public sealed record ProviderCapabilityOverrides
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? ReasoningByDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many tokens this installation reads and writes, or null to keep the automatic answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One number, where the rules know two. What a model card calls "raisable to" is a statement
|
||||
/// about the model: somebody could configure the engine that way. A person filling this in has
|
||||
/// already configured it, or has not, and either way says what their installation does today.
|
||||
/// Stating a ceiling next to it would be describing a possibility they are the only one able to
|
||||
/// realize.
|
||||
/// </remarks>
|
||||
[JsonPropertyName(CONTEXT_WINDOW_KEY)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? ContextWindowTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many images one message may carry, or null to keep the automatic answer.
|
||||
/// </summary>
|
||||
[JsonPropertyName(MAX_IMAGES_PER_MESSAGE_KEY)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? MaxImagesPerMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many images one request may carry, or null to keep the automatic answer.
|
||||
/// </summary>
|
||||
[JsonPropertyName(MAX_IMAGES_PER_REQUEST_KEY)]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? MaxImagesPerRequest { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasOverrides =>
|
||||
this.AudioInput is not null ||
|
||||
@@ -68,7 +156,10 @@ public sealed record ProviderCapabilityOverrides
|
||||
this.VideoInput is not null ||
|
||||
this.OptionalReasoning is not null ||
|
||||
this.AlwaysReasoning is not null ||
|
||||
this.ReasoningByDefault is not null;
|
||||
this.ReasoningByDefault is not null ||
|
||||
this.ContextWindowTokens is not null ||
|
||||
this.MaxImagesPerMessage is not null ||
|
||||
this.MaxImagesPerRequest is not null;
|
||||
|
||||
public bool? GetOverride(Capability capability) => capability switch
|
||||
{
|
||||
@@ -96,51 +187,157 @@ public sealed record ProviderCapabilityOverrides
|
||||
_ => this
|
||||
};
|
||||
|
||||
public List<Capability> ApplyTo(IEnumerable<Capability> automaticCapabilities)
|
||||
/// <summary>
|
||||
/// Reads the number a key stands for.
|
||||
/// </summary>
|
||||
/// <param name="key">One of the numeric keys.</param>
|
||||
/// <returns>The number, or null when nobody stated it.</returns>
|
||||
private int? GetNumber(string key) => key switch
|
||||
{
|
||||
var mergedCapabilities = automaticCapabilities.Distinct().ToList();
|
||||
foreach (var capability in SUPPORTED_CAPABILITIES)
|
||||
{
|
||||
var overrideValue = this.GetOverride(capability);
|
||||
if (overrideValue == true && !mergedCapabilities.Contains(capability))
|
||||
mergedCapabilities.Add(capability);
|
||||
else if (overrideValue == false)
|
||||
mergedCapabilities.Remove(capability);
|
||||
}
|
||||
CONTEXT_WINDOW_KEY => this.ContextWindowTokens,
|
||||
MAX_IMAGES_PER_MESSAGE_KEY => this.MaxImagesPerMessage,
|
||||
MAX_IMAGES_PER_REQUEST_KEY => this.MaxImagesPerRequest,
|
||||
|
||||
this.NormalizeReasoningCapabilities(mergedCapabilities);
|
||||
return mergedCapabilities;
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// States the number a key stands for.
|
||||
/// </summary>
|
||||
/// <param name="key">One of the numeric keys.</param>
|
||||
/// <param name="value">The number, or null to keep the automatic answer.</param>
|
||||
/// <returns>The overrides with that number in them.</returns>
|
||||
private ProviderCapabilityOverrides SetNumber(string key, int? value) => key switch
|
||||
{
|
||||
CONTEXT_WINDOW_KEY => this with { ContextWindowTokens = value },
|
||||
MAX_IMAGES_PER_MESSAGE_KEY => this with { MaxImagesPerMessage = value },
|
||||
MAX_IMAGES_PER_REQUEST_KEY => this with { MaxImagesPerRequest = value },
|
||||
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies what a person said about their own installation to what the rules worked out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The topmost link of the chain: an explicit statement about one's own provider wins over
|
||||
/// everything the rules could know, because the person can see the installation and the rules
|
||||
/// cannot.
|
||||
/// </remarks>
|
||||
/// <param name="profile">What the rules worked out.</param>
|
||||
/// <returns>The profile as this provider instance was told it is.</returns>
|
||||
public ModelProfile ApplyTo(in ModelProfile profile) => profile with
|
||||
{
|
||||
Capabilities = this.ApplyToCapabilities(profile.Capabilities),
|
||||
Reasoning = this.ResolveReasoning(profile.Reasoning),
|
||||
Context = this.ResolveContext(profile.Context),
|
||||
Images = this.ResolveImages(profile.Images),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Works out how wide the window is, out of what the rules say and what a person said.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A stated number replaces the window whole, the ceiling included. Keeping "raisable to
|
||||
/// 131,072" next to a person's own 16,384 would be reporting a possibility as a property of
|
||||
/// their installation, and whoever reads that number is asking what fits, not what could be
|
||||
/// made to fit.
|
||||
///
|
||||
/// A number which is not a width at all is ignored rather than repaired. Both places a person
|
||||
/// can write one refuse it with a message, so one arriving here came out of a settings file
|
||||
/// somebody edited by hand, and the honest answer to that is the one nobody made up.
|
||||
/// </remarks>
|
||||
/// <param name="stated">What the rules worked out.</param>
|
||||
/// <returns>The window after the overrides.</returns>
|
||||
private ContextWindow ResolveContext(ContextWindow stated) => this.ContextWindowTokens is { } tokens and > 0 ? ContextWindow.Of(tokens) : stated;
|
||||
|
||||
/// <summary>
|
||||
/// Works out how many images fit, out of what the rules say and what a person said.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each of the two numbers stands for itself, the way each switch above does: stating one says
|
||||
/// nothing about the other, and the one left unsaid keeps whatever the rules worked out. The
|
||||
/// smaller of the two still decides what fits into a message, so a person who states the larger
|
||||
/// number alone may well see no change -- which is the correct answer, not a bug: they have not
|
||||
/// contradicted the limit that is actually in the way.
|
||||
/// </remarks>
|
||||
/// <param name="stated">What the rules worked out.</param>
|
||||
/// <returns>The limits after the overrides.</returns>
|
||||
private ImageLimits ResolveImages(ImageLimits stated) => new(CountOfImages(this.MaxImagesPerMessage) ?? stated.MaxPerMessage, CountOfImages(this.MaxImagesPerRequest) ?? stated.MaxPerRequest);
|
||||
|
||||
/// <summary>
|
||||
/// Takes a stated image limit, where it is one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zero is a real limit here: an engine can be configured to take no pictures at all. A
|
||||
/// negative number is not a limit at all, and is ignored for the same reason a window of zero
|
||||
/// tokens is.
|
||||
/// </remarks>
|
||||
/// <param name="limit">What was stated.</param>
|
||||
/// <returns>The limit, or null when nothing usable was stated.</returns>
|
||||
private static int? CountOfImages(int? limit) => limit >= 0 ? limit : null;
|
||||
|
||||
/// <summary>
|
||||
/// Switches the plain capabilities on and off.
|
||||
/// </summary>
|
||||
/// <param name="stated">What the rules worked out.</param>
|
||||
/// <returns>The capabilities after the overrides.</returns>
|
||||
private Capability ApplyToCapabilities(Capability stated)
|
||||
{
|
||||
var capabilities = stated;
|
||||
foreach (var capability in DIRECTLY_SETTABLE_CAPABILITIES)
|
||||
switch (this.GetOverride(capability))
|
||||
{
|
||||
case true:
|
||||
capabilities |= capability;
|
||||
break;
|
||||
|
||||
case false:
|
||||
capabilities &= ~capability;
|
||||
break;
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private void NormalizeReasoningCapabilities(List<Capability> capabilities)
|
||||
/// <summary>
|
||||
/// Works out how a model reasons, out of what the rules say and what a person said.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This replaced thirty lines which repaired states that cannot exist -- a model both always
|
||||
/// reasoning and reasoning on request -- by an answer which cannot be in two of them at once.
|
||||
/// The expert dialog writes all three words together, and every combination it produces means
|
||||
/// exactly what it meant before.
|
||||
///
|
||||
/// One thing did change, and it is a defect going away. A word nobody said anything about used
|
||||
/// to destroy the answer: a provider carrying any override at all, say tool calling turned off,
|
||||
/// lost "reasoning on by default" on the way through, because the repair took the word away
|
||||
/// unless "reasoning on request" stood next to it -- which no rule ever states. Here a "no" only
|
||||
/// takes away what it names.
|
||||
/// </remarks>
|
||||
/// <param name="stated">How the rules say the model reasons.</param>
|
||||
/// <returns>How it reasons after the overrides.</returns>
|
||||
private ReasoningSupport ResolveReasoning(ReasoningSupport stated)
|
||||
{
|
||||
if (this.AlwaysReasoning == true ||
|
||||
this.AlwaysReasoning is not false &&
|
||||
this.OptionalReasoning is not true &&
|
||||
this.ReasoningByDefault is not true &&
|
||||
capabilities.Contains(Capability.ALWAYS_REASONING))
|
||||
// A "yes" is the whole answer, whatever else is written next to it:
|
||||
if (this.AlwaysReasoning is true)
|
||||
return ReasoningSupport.ALWAYS;
|
||||
|
||||
if (this.ReasoningByDefault is true)
|
||||
return ReasoningSupport.ON_BY_DEFAULT;
|
||||
|
||||
if (this.OptionalReasoning is true)
|
||||
return ReasoningSupport.OPTIONAL;
|
||||
|
||||
// A "no" only contradicts the state it names:
|
||||
return stated switch
|
||||
{
|
||||
capabilities.Remove(Capability.OPTIONAL_REASONING);
|
||||
capabilities.Remove(Capability.REASONING_BY_DEFAULT);
|
||||
return;
|
||||
}
|
||||
ReasoningSupport.ALWAYS => this.AlwaysReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.ALWAYS,
|
||||
ReasoningSupport.ON_BY_DEFAULT => this.ReasoningByDefault is false || this.OptionalReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.ON_BY_DEFAULT,
|
||||
ReasoningSupport.OPTIONAL => this.OptionalReasoning is false ? ReasoningSupport.NONE : ReasoningSupport.OPTIONAL,
|
||||
|
||||
if (this.AlwaysReasoning == false ||
|
||||
this.OptionalReasoning == true ||
|
||||
this.ReasoningByDefault == true)
|
||||
capabilities.Remove(Capability.ALWAYS_REASONING);
|
||||
|
||||
if (this.OptionalReasoning == false)
|
||||
{
|
||||
capabilities.Remove(Capability.REASONING_BY_DEFAULT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ReasoningByDefault == true && !capabilities.Contains(Capability.OPTIONAL_REASONING))
|
||||
capabilities.Add(Capability.OPTIONAL_REASONING);
|
||||
|
||||
if (!capabilities.Contains(Capability.OPTIONAL_REASONING))
|
||||
capabilities.Remove(Capability.REASONING_BY_DEFAULT);
|
||||
_ => ReasoningSupport.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
public string ExportAsLuaTable(string indentation)
|
||||
@@ -159,6 +356,14 @@ public sealed record ProviderCapabilityOverrides
|
||||
builder.AppendLine($@"{indentation} [""{capability}""] = {overrideValue.Value.ToString().ToLowerInvariant()},");
|
||||
}
|
||||
|
||||
foreach (var key in NUMERIC_KEYS)
|
||||
{
|
||||
if (this.GetNumber(key) is not { } number)
|
||||
continue;
|
||||
|
||||
builder.AppendLine($@"{indentation} [""{key}""] = {number.ToString(CultureInfo.InvariantCulture)},");
|
||||
}
|
||||
|
||||
builder.Append($@"{indentation}}},");
|
||||
return builder.ToString();
|
||||
}
|
||||
@@ -186,9 +391,21 @@ public sealed record ProviderCapabilityOverrides
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryMatchNumericKey(keyText, out var numericKey))
|
||||
{
|
||||
if (!TryReadNumber(pair.Value, numericKey, out var number))
|
||||
{
|
||||
logger.LogWarning("The configured provider {ProviderIndex} states a '{OverrideKey}' which is not {Expectation}. The automatic answer will be used for it. (Plugin ID: {PluginId})", idx, numericKey, ExpectationOf(numericKey), configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
result = result.SetNumber(numericKey, number);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseSupportedCapability(keyText, out var capability))
|
||||
{
|
||||
logger.LogWarning("The configured provider {ProviderIndex} contains an unsupported capability override '{CapabilityKey}'. The entry will be ignored. (Plugin ID: {PluginId})", idx, keyText, configPluginId);
|
||||
logger.LogWarning("The configured provider {ProviderIndex} contains an unsupported override '{OverrideKey}'. The entry will be ignored. (Plugin ID: {PluginId})", idx, keyText, configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -204,6 +421,58 @@ public sealed record ProviderCapabilityOverrides
|
||||
return result.HasOverrides ? result : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes a key which names a number, whichever way it was spelled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Spelled loosely for the same reason the capability words are: a table written by hand is
|
||||
/// read by the app, not by a compiler, and rejecting "context_window" over its letters would be
|
||||
/// a riddle rather than a message. What comes back is the canonical spelling, so everything
|
||||
/// after this point deals with one name per question.
|
||||
/// </remarks>
|
||||
/// <param name="key">The key as it was written.</param>
|
||||
/// <param name="numericKey">The canonical spelling of that key.</param>
|
||||
/// <returns>True when the key names a number.</returns>
|
||||
private static bool TryMatchNumericKey(string key, out string numericKey)
|
||||
{
|
||||
foreach (var candidate in NUMERIC_KEYS)
|
||||
if (string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
numericKey = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
numericKey = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a number, where it is one this key accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A window has to be a width, so zero token is refused: nothing fits into it, and a provider
|
||||
/// which can hold nothing is not what anybody meant to state. A picture count of zero is a
|
||||
/// different matter and allowed because an engine really can be told to take no pictures.
|
||||
/// </remarks>
|
||||
/// <param name="value">The value as it stands in the table.</param>
|
||||
/// <param name="numericKey">The canonical key it stands under.</param>
|
||||
/// <param name="number">The number read.</param>
|
||||
/// <returns>True, when the value is a number, this key accepts.</returns>
|
||||
private static bool TryReadNumber(LuaValue value, string numericKey, out int number)
|
||||
{
|
||||
if (!value.TryRead(out number))
|
||||
return false;
|
||||
|
||||
return numericKey is CONTEXT_WINDOW_KEY ? number > 0 : number >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a key accepts, said in the words of a warning.
|
||||
/// </summary>
|
||||
/// <param name="numericKey">The canonical key.</param>
|
||||
/// <returns>The expectation.</returns>
|
||||
private static string ExpectationOf(string numericKey) => numericKey is CONTEXT_WINDOW_KEY ? "a number of tokens greater than zero" : "a number of images of zero or more";
|
||||
|
||||
private static bool TryParseSupportedCapability(string capabilityKey, out Capability capability)
|
||||
{
|
||||
capability = Capability.NONE;
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesAlibaba(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
// Qwen models:
|
||||
if (modelName.StartsWith("qwen"))
|
||||
{
|
||||
// Check for omni models. Alibaba lists the Qwen3 and Qwen3.5 Omni series among the
|
||||
// models which call functions; the older qwen-omni ones are not on that list, which
|
||||
// is what the version check separates here:
|
||||
if (modelName.IndexOf("omni") is not -1)
|
||||
{
|
||||
if (modelName.StartsWith("qwen3"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.AUDIO_INPUT, Capability.SPEECH_INPUT,
|
||||
Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.SPEECH_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.AUDIO_INPUT, Capability.SPEECH_INPUT,
|
||||
Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.SPEECH_OUTPUT,
|
||||
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Qwen 3.5:
|
||||
if(modelName.StartsWith("qwen3.5"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Check for Qwen 3.6 family:
|
||||
if(modelName.StartsWith("qwen3.6"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.VIDEO_INPUT,
|
||||
Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Check for the Qwen 3.7 family. Thinking is optional here and switched on by
|
||||
// default, except for the two preview snapshots, which do nothing else:
|
||||
if(modelName.StartsWith("qwen3.7"))
|
||||
{
|
||||
if(modelName.IndexOf("-preview") is not -1 ||
|
||||
modelName.IndexOf("-2026-05-17") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Vision arrived in the middle of the series. The rolling qwen3.7-max alias
|
||||
// still answers as the text-only May snapshot, so only the June one may be
|
||||
// told that it reads images and video:
|
||||
if(modelName.IndexOf("-2026-06-08") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for the Qwen 3.8 family:
|
||||
if(modelName.StartsWith("qwen3.8"))
|
||||
{
|
||||
// Flash thinks by default, but thinking can be turned off:
|
||||
if(modelName.StartsWith("qwen3.8-flash"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Unlike the open-weight checkpoint, the Max model keeps its vision
|
||||
// capabilities when used through Alibaba Cloud:
|
||||
if(modelName.StartsWith("qwen3.8-max"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// All other 3.8 models, such as the 27B one:
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for the VL models. Alibaba names the Qwen3-VL Plus and Flash series as
|
||||
// function callers; the older qwen-vl models are absent from that list:
|
||||
if(modelName.IndexOf("-vl-") is not -1)
|
||||
{
|
||||
if(modelName.StartsWith("qwen3"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for Qwen 3:
|
||||
if(modelName.StartsWith("qwen3"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
//
|
||||
// QwQ models. What Model Studio serves under this name is qwq-plus, a commercial
|
||||
// thinking-only model built on Qwen2.5. It is not the same model as the open-weight
|
||||
// QwQ-32B, which the rules for open source models cover; the two only share a family
|
||||
// name. Neither of them appears in Alibaba's list of models which call functions, and
|
||||
// the model card of the open weights does not mention tools at all, which is why this
|
||||
// states no such ability. Anybody who knows better can turn it on in the expert settings.
|
||||
//
|
||||
if (modelName.StartsWith("qwq"))
|
||||
{
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// QVQ models:
|
||||
if (modelName.StartsWith("qvq"))
|
||||
{
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Default to text input and output:
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesAnthropic(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
// Claude Fable 5 and Mythos 5 always use adaptive thinking:
|
||||
if(modelName.StartsWith("claude-fable-5") || modelName.StartsWith("claude-mythos-5"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Claude Opus 5 and Sonnet 5 think adaptively unless thinking is turned off:
|
||||
if(modelName.StartsWith("claude-opus-5") || modelName.StartsWith("claude-sonnet-5"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.REASONING_BY_DEFAULT, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Claude Haiku 4.5 needs an explicit thinking budget to reason:
|
||||
if(modelName.StartsWith("claude-haiku-4-5"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Claude 4.x models:
|
||||
if(modelName.StartsWith("claude-opus-4") || modelName.StartsWith("claude-sonnet-4"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Claude 3.7 is able to do reasoning:
|
||||
if(modelName.StartsWith("claude-3-7"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// All other 3.x models are able to process text and images as input:
|
||||
if(modelName.StartsWith("claude-3-"))
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Any other model. Every current Claude model accepts images, so we assume the
|
||||
// same for models we do not know yet:
|
||||
return [
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesDeepSeek(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
// The reasoner alias points to the thinking mode of the current flash model:
|
||||
if(modelName.IndexOf("reasoner") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// The chat alias points to the non-thinking mode of the same model:
|
||||
if(modelName.IndexOf("chat") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// DeepSeek publishes its models as open weights and offers them under the same
|
||||
// names here. Instead of maintaining a second copy of those rules, we reuse the
|
||||
// ones for open source models:
|
||||
return GetModelCapabilitiesOpenSource(model);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the capabilities of a model offered through a gateway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A gateway serves the models of many other providers rather than models of its own. OpenRouter,
|
||||
/// LiteLLM, and the Hugging Face router all work that way, and all three name their models the
|
||||
/// same: "vendor/model-name".
|
||||
/// </remarks>
|
||||
/// <param name="model">The model as the gateway names it.</param>
|
||||
/// <returns>The capabilities of the model when reached through a gateway.</returns>
|
||||
private static List<Capability> GetModelCapabilitiesGateway(Model model)
|
||||
{
|
||||
//
|
||||
// Model IDs follow the pattern "vendor/model-name". Examples:
|
||||
// - openai/gpt-5.6
|
||||
// - anthropic/claude-opus-5
|
||||
// - google/gemini-3.7-flash
|
||||
// - qwen/qwen3.8-flash-next
|
||||
//
|
||||
// A gateway offers the models of all the other providers. Instead of keeping a
|
||||
// second set of rules here, which would always lag behind, we hand the model
|
||||
// over to the provider implementation which already knows it. The vendor prefix
|
||||
// has to be removed first: some of those implementations match the beginning of
|
||||
// the model name and would not recognize a prefixed ID.
|
||||
//
|
||||
var separatorIndex = model.Id.IndexOf('/');
|
||||
var vendor = separatorIndex is -1 ? string.Empty : model.Id[..separatorIndex].ToLowerInvariant();
|
||||
var bareModel = separatorIndex is -1 ? model : model with { Id = model.Id[(separatorIndex + 1)..] };
|
||||
var bareModelName = NormalizeModelId(bareModel.Id).AsSpan();
|
||||
|
||||
var capabilities = vendor switch
|
||||
{
|
||||
// The gpt-oss models are open weights. The OpenAI implementation does not
|
||||
// know them, because they are not part of the OpenAI cloud offering:
|
||||
"openai" when bareModelName.IndexOf("gpt-oss") is not -1 => GetModelCapabilitiesOpenSource(bareModel),
|
||||
"openai" => GetModelCapabilitiesOpenAI(bareModel),
|
||||
|
||||
"anthropic" => GetModelCapabilitiesAnthropic(bareModel),
|
||||
|
||||
// Gemma is open weights, Gemini is not:
|
||||
"google" when bareModelName.IndexOf("gemma") is not -1 => GetModelCapabilitiesOpenSource(bareModel),
|
||||
"google" => GetModelCapabilitiesGoogle(bareModel),
|
||||
|
||||
"mistralai" => GetModelCapabilitiesMistral(bareModel),
|
||||
"perplexity" => GetModelCapabilitiesPerplexity(bareModel),
|
||||
|
||||
// Everything else is open source: Qwen, Llama, GLM, Kimi, Muse, Hunyuan,
|
||||
// Nemotron, Grok, and whatever a gateway adds next. DeepSeek belongs here
|
||||
// as well: its own implementation covers the aliases of the DeepSeek
|
||||
// platform, while the gateways use the names of the open weights.
|
||||
_ => GetModelCapabilitiesOpenSource(bareModel),
|
||||
};
|
||||
|
||||
return NormalizeForGateway(capabilities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts the capabilities reported by another provider for use through a gateway.
|
||||
/// </summary>
|
||||
/// <param name="capabilities">The capabilities as reported by the provider implementation.</param>
|
||||
/// <returns>The capabilities as they apply when using the model through a gateway.</returns>
|
||||
/// <remarks>
|
||||
/// A gateway serves every model through its OpenAI-compatible chat completion API.
|
||||
/// The Responses API is not available there, no matter which API the original
|
||||
/// provider offers.
|
||||
///
|
||||
/// The same holds for a provider which resells a model under its plain name instead of
|
||||
/// prefixing it with the vendor, such as GWDG. Those go through the open source rules, which
|
||||
/// call this for the very same reason.
|
||||
/// </remarks>
|
||||
private static List<Capability> NormalizeForGateway(List<Capability> capabilities)
|
||||
{
|
||||
capabilities.Remove(Capability.RESPONSES_API);
|
||||
if(!capabilities.Contains(Capability.CHAT_COMPLETION_API))
|
||||
capabilities.Add(Capability.CHAT_COMPLETION_API);
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesGoogle(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
if (modelName.IndexOf("gemini-") is not -1)
|
||||
{
|
||||
//
|
||||
// Image generation models. They carry a version number like every other model and
|
||||
// have to be asked about first, or gemini-3-pro-image would be read as a chat model
|
||||
// of the 3.x line and be promised function calling. No image model of the family
|
||||
// offers that; what they do offer, and the chat models do not, is writing images.
|
||||
//
|
||||
if (modelName.IndexOf("-image") is not -1)
|
||||
{
|
||||
// Of the image models, only the 3.1 Flash ones read video. They think about
|
||||
// complex prompts, and, as with the 3.x chat models, thinking cannot be
|
||||
// switched off:
|
||||
if (modelName.IndexOf("gemini-3.1-flash") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.IMAGE_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Every other Gemini 3 image model thinks as well, it just does not read video:
|
||||
if (modelName.IndexOf("gemini-3") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.IMAGE_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// The older image models, such as the 2.5 Flash one, do not think:
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.IMAGE_OUTPUT,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Chat-compatible Gemini 3.x reasoning models. We match the entire 3.x line
|
||||
// so that new releases are covered as well: they all reason, and the
|
||||
// thinking level can only be lowered, never turned off. That holds for the
|
||||
// Flash Lite models of this line too, which is what sets them apart from
|
||||
// Gemini 2.5 Flash Lite below: there, thinking is off until it is asked for,
|
||||
// while here the lowest level still thinks. The two rolling aliases carry no
|
||||
// version number and are listed separately:
|
||||
if (modelName.IndexOf("gemini-3") is not -1 ||
|
||||
modelName is "gemini-flash-latest" ||
|
||||
modelName is "gemini-pro-latest")
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT,
|
||||
Capability.SPEECH_INPUT, Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Gemini 2.5 Flash Lite supports thinking, but the default is off:
|
||||
if (modelName.IndexOf("gemini-2.5-flash-lite") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT,
|
||||
Capability.SPEECH_INPUT, Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Reasoning models:
|
||||
if (modelName.IndexOf("gemini-2.5") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT,
|
||||
Capability.SPEECH_INPUT, Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Realtime model:
|
||||
if(modelName.IndexOf("-2.0-flash-live-") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.AUDIO_INPUT, Capability.SPEECH_INPUT,
|
||||
Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT, Capability.SPEECH_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
//
|
||||
// There used to be a branch here which withheld function calling from the 2.0 Flash
|
||||
// models. It said the wrong thing about them, and it only ever caught the dated IDs
|
||||
// because it asked for a trailing hyphen: the plain gemini-2.0-flash alias walked
|
||||
// past it and got a different answer than gemini-2.0-flash-001, which is the same
|
||||
// model. Both questions are moot now, because Google shut the 2.0 Flash chat models
|
||||
// down on 1 June 2026. Anything still asking for one of those names gets the default
|
||||
// below. The live model above keeps its branch: it belongs to a different API whose
|
||||
// retirement Google announces separately.
|
||||
//
|
||||
|
||||
// The old 1.0 pro vision model:
|
||||
if(modelName.IndexOf("pro-vision") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Default to all other Gemini models:
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.AUDIO_INPUT,
|
||||
Capability.SPEECH_INPUT, Capability.VIDEO_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
|
||||
// Default for all other models:
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
//
|
||||
// Mistral names its models after the month they were released: mistral-large-2512 is
|
||||
// Mistral Large 3 from December 2025. The version number lives in the marketing name only,
|
||||
// so matching on it misses nearly every model the API actually serves. The constants below
|
||||
// read as YYMM and say from which release on a family gained a capability.
|
||||
//
|
||||
private const int MISTRAL_LARGE_VISION_SINCE = 2512; // Mistral Large 3
|
||||
private const int MISTRAL_LARGE_REASONING_SINCE = 2512; // Mistral Large 3
|
||||
private const int MISTRAL_MEDIUM_VISION_SINCE = 2505; // Mistral Medium 3
|
||||
private const int MISTRAL_MEDIUM_REASONING_SINCE = 2604; // Mistral Medium 3.5
|
||||
private const int MISTRAL_SMALL_VISION_SINCE = 2503; // Mistral Small 3.1
|
||||
private const int MISTRAL_SMALL_REASONING_SINCE = 2603; // Mistral Small 4
|
||||
private const int MINISTRAL_VISION_SINCE = 2512; // Ministral 3
|
||||
|
||||
/// <summary>
|
||||
/// Used for families which have no reasoning at all. No release date can ever reach it.
|
||||
/// </summary>
|
||||
private const int MISTRAL_REASONING_NEVER = int.MaxValue;
|
||||
|
||||
//
|
||||
// Where the "latest" aliases point to. Mistral moves them on with every release, so they
|
||||
// have to behave like the release they resolve to instead of carrying their own rules.
|
||||
//
|
||||
private const int MISTRAL_LARGE_LATEST = 2512;
|
||||
private const int MISTRAL_MEDIUM_LATEST = 2604;
|
||||
private const int MISTRAL_SMALL_LATEST = 2603;
|
||||
private const int MINISTRAL_LATEST = 2512;
|
||||
|
||||
/// <summary>
|
||||
/// Mistral released its first date-named model in 2023. Anything below that is not a release
|
||||
/// date but a parameter count or a context size which happens to have four digits.
|
||||
/// </summary>
|
||||
private const int MISTRAL_FIRST_RELEASE_YEAR = 23;
|
||||
|
||||
//
|
||||
// Mistral serves some models under their marketing version as well, and it writes the version
|
||||
// separator both ways: mistral-medium-3.5 and mistral-medium-3-5 are the same model. Those
|
||||
// names carry no release date, so we map them onto the release they stand for. The order
|
||||
// matters: the more specific version has to come first, otherwise "3" would swallow "3.5".
|
||||
//
|
||||
private static readonly (string VersionName, int ReleaseDate)[] MISTRAL_VERSION_NAMES =
|
||||
[
|
||||
("mistral-large-3", 2512),
|
||||
|
||||
("mistral-medium-3.5", 2604),
|
||||
("mistral-medium-3-5", 2604),
|
||||
("mistral-medium-3.1", 2508),
|
||||
("mistral-medium-3-1", 2508),
|
||||
("mistral-medium-3", 2505),
|
||||
|
||||
("mistral-small-4", 2603),
|
||||
("mistral-small-3.2", 2506),
|
||||
("mistral-small-3-2", 2506),
|
||||
("mistral-small-3.1", 2503),
|
||||
("mistral-small-3-1", 2503),
|
||||
("mistral-small-3", 2501),
|
||||
];
|
||||
|
||||
private static List<Capability> GetModelCapabilitiesMistral(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
// Pixtral models are able to do process images:
|
||||
if (modelName.IndexOf("pixtral") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
// Mistral saba:
|
||||
if (modelName.IndexOf("mistral-saba-") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
//
|
||||
// The four families Mistral versions by release date. Ministral has to be matched before
|
||||
// the others, although its name does not contain "mistral" as a substring: keeping the
|
||||
// families together makes the block easier to read.
|
||||
//
|
||||
if (modelName.IndexOf("ministral") is not -1)
|
||||
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MINISTRAL_LATEST), MINISTRAL_VISION_SINCE, MISTRAL_REASONING_NEVER);
|
||||
|
||||
if (modelName.IndexOf("mistral-large") is not -1)
|
||||
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_LARGE_LATEST), MISTRAL_LARGE_VISION_SINCE, MISTRAL_LARGE_REASONING_SINCE);
|
||||
|
||||
if (modelName.IndexOf("mistral-medium") is not -1)
|
||||
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_MEDIUM_LATEST), MISTRAL_MEDIUM_VISION_SINCE, MISTRAL_MEDIUM_REASONING_SINCE);
|
||||
|
||||
if (modelName.IndexOf("mistral-small") is not -1)
|
||||
return BuildMistralCapabilities(GetMistralReleaseDate(modelName, MISTRAL_SMALL_LATEST), MISTRAL_SMALL_VISION_SINCE, MISTRAL_SMALL_REASONING_SINCE);
|
||||
|
||||
// Default:
|
||||
return GetModelCapabilitiesOpenSource(model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the release date a Mistral model belongs to.
|
||||
/// </summary>
|
||||
/// <param name="modelName">The lowercase model name to inspect.</param>
|
||||
/// <param name="latestReleaseDate">The release the family's "latest" alias points to.</param>
|
||||
/// <returns>The release date as YYMM, or 0 when the name carries none.</returns>
|
||||
private static int GetMistralReleaseDate(ReadOnlySpan<char> modelName, int latestReleaseDate)
|
||||
{
|
||||
// The "latest" alias always points to the newest release of its family:
|
||||
if (modelName.IndexOf("-latest") is not -1)
|
||||
return latestReleaseDate;
|
||||
|
||||
foreach (var (versionName, releaseDate) in MISTRAL_VERSION_NAMES)
|
||||
if (modelName.IndexOf(versionName) is not -1)
|
||||
return releaseDate;
|
||||
|
||||
return ReadMistralReleaseDate(modelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the four-digit release date out of a Mistral model name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The block has to be exactly four digits long and has to read as a plausible year and month.
|
||||
/// Without that, the size of a model would be mistaken for its release: ministral-14b-2512
|
||||
/// must resolve to 2512 and not to anything the "14b" part could be read as.
|
||||
/// </remarks>
|
||||
/// <param name="modelName">The lowercase model name to inspect.</param>
|
||||
/// <returns>The release date as YYMM, or 0 when the name carries none.</returns>
|
||||
private static int ReadMistralReleaseDate(ReadOnlySpan<char> modelName)
|
||||
{
|
||||
for (var index = 0; index + 4 <= modelName.Length; index++)
|
||||
{
|
||||
// A digit next to the block means the block is longer than four digits:
|
||||
if (index > 0 && char.IsAsciiDigit(modelName[index - 1]))
|
||||
continue;
|
||||
|
||||
if (index + 4 < modelName.Length && char.IsAsciiDigit(modelName[index + 4]))
|
||||
continue;
|
||||
|
||||
var candidate = modelName.Slice(index, 4);
|
||||
if (!char.IsAsciiDigit(candidate[0]) || !char.IsAsciiDigit(candidate[1]) ||
|
||||
!char.IsAsciiDigit(candidate[2]) || !char.IsAsciiDigit(candidate[3]))
|
||||
continue;
|
||||
|
||||
var releaseDate = int.Parse(candidate);
|
||||
var year = releaseDate / 100;
|
||||
var month = releaseDate % 100;
|
||||
if (year < MISTRAL_FIRST_RELEASE_YEAR || month is < 1 or > 12)
|
||||
continue;
|
||||
|
||||
return releaseDate;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the capabilities of a Mistral model from its release date.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model whose release date we cannot read gets neither image input nor reasoning. That is
|
||||
/// the safe direction: offering an ability the model does not have would fail the request,
|
||||
/// whereas a missing one can be added by hand through the capability overrides.
|
||||
/// </remarks>
|
||||
/// <param name="releaseDate">The release date of the model as YYMM, or 0 when unknown.</param>
|
||||
/// <param name="visionSince">The release from which this family accepts images.</param>
|
||||
/// <param name="reasoningSince">The release from which this family can reason.</param>
|
||||
/// <returns>The capabilities of the model.</returns>
|
||||
private static List<Capability> BuildMistralCapabilities(int releaseDate, int visionSince, int reasoningSince)
|
||||
{
|
||||
List<Capability> capabilities = [Capability.TEXT_INPUT, Capability.FUNCTION_CALLING, Capability.CHAT_COMPLETION_API, Capability.TEXT_OUTPUT];
|
||||
|
||||
if (releaseDate >= visionSince)
|
||||
capabilities.Add(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
|
||||
if (releaseDate >= reasoningSince)
|
||||
capabilities.Add(Capability.OPTIONAL_REASONING);
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesOpenAI(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
if (modelName is "gpt-4o-search-preview")
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if (modelName is "gpt-4o-mini-search-preview")
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if (modelName.StartsWith("o1-mini"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-3.5-turbo")
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if(modelName.StartsWith("gpt-3.5"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if (modelName.StartsWith("o3-mini"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if (modelName.StartsWith("o4-mini") || modelName.StartsWith("o3"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if (modelName.StartsWith("o1"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING, Capability.FUNCTION_CALLING,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if(modelName.StartsWith("gpt-4-turbo"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-4" || modelName.StartsWith("gpt-4-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if(modelName.StartsWith("gpt-5-nano"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.ALWAYS_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5" || modelName.StartsWith("gpt-5-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.ALWAYS_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API,
|
||||
];
|
||||
|
||||
//
|
||||
// None of the GPT-5 models writes images itself. They can ask for one through the
|
||||
// image generation tool, which is a tool call like any other and produces a picture
|
||||
// from a separate model. That is a different thing from an output modality, and we
|
||||
// must not report it as one: the chat would then offer to receive images which never
|
||||
// arrive.
|
||||
//
|
||||
if(modelName is "gpt-5.1" || modelName.StartsWith("gpt-5.1-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5.2" || modelName.StartsWith("gpt-5.2-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5.3" || modelName.StartsWith("gpt-5.3-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5.4" || modelName.StartsWith("gpt-5.4-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.OPTIONAL_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5.5" || modelName.StartsWith("gpt-5.5-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.REASONING_BY_DEFAULT,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
if(modelName is "gpt-5.6" || modelName.StartsWith("gpt-5.6-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.REASONING_BY_DEFAULT,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
//
|
||||
// GPT-6 Astra. Unlike the 5.5 and 5.6 models, it reasons on every request: the effort
|
||||
// reaches from low to max, and there is no setting which switches thinking off.
|
||||
//
|
||||
if(modelName is "gpt-6-astra" || modelName.StartsWith("gpt-6-astra-"))
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING, Capability.ALWAYS_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.RESPONSES_API, Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.FUNCTION_CALLING,
|
||||
Capability.RESPONSES_API,
|
||||
Capability.WEB_SEARCH,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,41 +0,0 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
private static List<Capability> GetModelCapabilitiesPerplexity(Model model)
|
||||
{
|
||||
var modelName = NormalizeModelId(model.Id).AsSpan();
|
||||
|
||||
//
|
||||
// No Sonar model writes images. What looked like it does is the option to have the
|
||||
// answer come with images: those are pictures the search found on the pages it read,
|
||||
// handed back as links, not something the model drew.
|
||||
//
|
||||
if(modelName.IndexOf("reasoning") is not -1 ||
|
||||
modelName.IndexOf("deep-research") is not -1)
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.ALWAYS_REASONING,
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
|
||||
return
|
||||
[
|
||||
Capability.TEXT_INPUT,
|
||||
Capability.MULTIPLE_IMAGE_INPUT,
|
||||
|
||||
Capability.TEXT_OUTPUT,
|
||||
|
||||
Capability.WEB_SEARCH,
|
||||
Capability.CHAT_COMPLETION_API,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,519 +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
|
||||
{
|
||||
/// <summary>
|
||||
/// The reasoning-related intent found in the configured additional API parameters.
|
||||
/// </summary>
|
||||
private enum ReasoningConfigurationState
|
||||
{
|
||||
/// <summary>
|
||||
/// No recognized reasoning parameter was found.
|
||||
/// </summary>
|
||||
NOT_CONFIGURED,
|
||||
|
||||
/// <summary>
|
||||
/// A recognized reasoning parameter explicitly enables reasoning.
|
||||
/// </summary>
|
||||
EXPLICITLY_ENABLED,
|
||||
|
||||
/// <summary>
|
||||
/// A recognized reasoning parameter explicitly disables reasoning.
|
||||
/// </summary>
|
||||
EXPLICITLY_DISABLED,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the effective reasoning indicator state for the configured provider instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="provider">The configured provider.</param>
|
||||
/// <returns>The effective reasoning indicator state.</returns>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static ReasoningIndicatorState GetReasoningIndicatorState(this Provider provider)
|
||||
{
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
||||
var reasoning = provider.GetModelProfile().Reasoning;
|
||||
if (reasoning is ReasoningSupport.ALWAYS)
|
||||
return ReasoningIndicatorState.ALWAYS_ON;
|
||||
|
||||
var reasoningConfigurationState = GetReasoningConfigurationState(provider);
|
||||
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
|
||||
|
||||
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 (capabilities.Contains(Capability.OPTIONAL_REASONING) &&
|
||||
reasoningConfigurationState is ReasoningConfigurationState.EXPLICITLY_ENABLED)
|
||||
if (reasoning is ReasoningSupport.OPTIONAL && configured is ReasoningConfigurationState.EXPLICITLY_ENABLED)
|
||||
return ReasoningIndicatorState.CONFIGURED;
|
||||
|
||||
return ReasoningIndicatorState.NONE;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse additional API parameters and dispatch them to provider-specific reasoning detectors.
|
||||
/// </summary>
|
||||
/// <param name="provider">The configured provider whose additional API parameters should be inspected.</param>
|
||||
/// <returns>The explicit reasoning configuration state, or <see cref="ReasoningConfigurationState.NOT_CONFIGURED"/> if nothing known was found.</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect OpenAI-compatible reasoning parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// OpenAI-compatible providers commonly use a nested <c>reasoning</c> object and/or
|
||||
/// a top-level <c>reasoning_effort</c> parameter.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetOpenAICompatibleReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
var reasoningState = ReasoningConfigurationState.NOT_CONFIGURED;
|
||||
if (TryGetParameter(parameters, "reasoning", out var reasoning))
|
||||
{
|
||||
reasoningState = reasoning switch
|
||||
{
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
return MergeReasoningStates(reasoningState, GetReasoningEffortState(parameters));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect a top-level <c>reasoning_effort</c> parameter.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
private static ReasoningConfigurationState GetReasoningEffortState(IDictionary<string, object> parameters)
|
||||
{
|
||||
return TryGetParameter(parameters, "reasoning_effort", out var reasoningEffort)
|
||||
? GetLevelState(reasoningEffort)
|
||||
: ReasoningConfigurationState.NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect Anthropic extended-thinking parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
private static ReasoningConfigurationState GetAnthropicReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
if (!TryGetParameter(parameters, "thinking", out var thinking))
|
||||
return ReasoningConfigurationState.NOT_CONFIGURED;
|
||||
|
||||
return thinking switch
|
||||
{
|
||||
IDictionary<string, object> thinkingObject when TryGetParameter(thinkingObject, "type", out var type) => GetAnthropicThinkingTypeState(type),
|
||||
_ => GetLevelState(thinking),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect Google Gemini thinking parameters across OpenAI-compatible additional parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// Google can expose thinking options through <c>thinking_config</c>,
|
||||
/// <c>generation_config.thinking_config</c>, <c>thinking_level</c>, and summary settings.
|
||||
/// Summary settings only prove that thinking is enabled when they request summaries;
|
||||
/// disabling summaries does not necessarily disable reasoning.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetGoogleReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
var states = new List<ReasoningConfigurationState>();
|
||||
|
||||
if (TryGetParameter(parameters, "thinking_config", out var thinkingConfig) &&
|
||||
thinkingConfig is IDictionary<string, object> thinkingConfigObject)
|
||||
states.Add(GetGoogleThinkingConfigState(thinkingConfigObject));
|
||||
|
||||
if (TryGetParameter(parameters, "generation_config", out var generationConfig) &&
|
||||
generationConfig is IDictionary<string, object> generationConfigObject)
|
||||
{
|
||||
if (TryGetParameter(generationConfigObject, "thinking_config", out var nestedThinkingConfig) &&
|
||||
nestedThinkingConfig is IDictionary<string, object> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect Google Gemini thinking-budget and include-thoughts settings.
|
||||
/// </summary>
|
||||
/// <param name="thinkingConfig">The parsed <c>thinking_config</c> object.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
private static ReasoningConfigurationState GetGoogleThinkingConfigState(IDictionary<string, object> thinkingConfig)
|
||||
{
|
||||
var states = new List<ReasoningConfigurationState>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect Google Gemini thinking-summary values that imply reasoning is active.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured thinking-summary value.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// A disabled or missing summary does not prove that thinking is disabled, so only
|
||||
/// known enabling values are treated as explicit reasoning configuration.
|
||||
/// </remarks>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Detect Ollama's <c>think</c> parameter.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
private static ReasoningConfigurationState GetOllamaReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
return TryGetParameter(parameters, "think", out var think)
|
||||
? GetLevelState(think)
|
||||
: ReasoningConfigurationState.NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect llama.cpp server reasoning parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// llama.cpp exposes runtime reasoning control through parameters such as
|
||||
/// <c>reasoning</c>, <c>reasoning_budget</c>, and template-specific kwargs.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetLlamaCppReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
var states = new List<ReasoningConfigurationState>();
|
||||
|
||||
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<string, object> chatTemplateKwargsObject)
|
||||
states.Add(GetQwenReasoningState(chatTemplateKwargsObject));
|
||||
|
||||
return MergeReasoningStates(states);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect vLLM reasoning parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// vLLM supports both top-level reasoning fields and chat-template kwargs, depending
|
||||
/// on model family and reasoning parser configuration.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetVllmReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
var states = new List<ReasoningConfigurationState>();
|
||||
|
||||
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<string, object> chatTemplateKwargsObject)
|
||||
{
|
||||
states.Add(GetQwenReasoningState(chatTemplateKwargsObject));
|
||||
|
||||
if (TryGetParameter(chatTemplateKwargsObject, "thinking", out var thinking))
|
||||
states.Add(GetLevelState(thinking));
|
||||
}
|
||||
|
||||
return MergeReasoningStates(states);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect Qwen-style <c>enable_thinking</c> parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed additional API parameters.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// Some OpenAI-compatible servers accept <c>enable_thinking</c> either at the
|
||||
/// top level or under <c>chat_template_kwargs</c>.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState GetQwenReasoningState(IDictionary<string, object> parameters)
|
||||
{
|
||||
var states = new List<ReasoningConfigurationState>();
|
||||
|
||||
if (TryGetParameter(parameters, "enable_thinking", out var enableThinking))
|
||||
states.Add(GetLevelState(enableThinking));
|
||||
|
||||
if (TryGetParameter(parameters, "chat_template_kwargs", out var chatTemplateKwargs) &&
|
||||
chatTemplateKwargs is IDictionary<string, object> chatTemplateKwargsObject &&
|
||||
TryGetParameter(chatTemplateKwargsObject, "enable_thinking", out var nestedEnableThinking))
|
||||
states.Add(GetLevelState(nestedEnableThinking));
|
||||
|
||||
return MergeReasoningStates(states);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpret Anthropic's <c>thinking.type</c> value.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured Anthropic thinking type.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Interpret llama.cpp's <c>reasoning</c> mode value.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured llama.cpp reasoning mode.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// <c>auto</c> means the server decides from the model/template, so it is treated as
|
||||
/// not configured by the user rather than as explicitly enabled.
|
||||
/// </remarks>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Interpret token-budget style values used by several providers.
|
||||
/// </summary>
|
||||
/// <param name="value">The configured budget value.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
/// <remarks>
|
||||
/// A zero budget disables reasoning; non-zero values, including unrestricted negative
|
||||
/// budgets, indicate that reasoning is available for the request.
|
||||
/// </remarks>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Interpret common boolean, numeric, and level-style reasoning values.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw parsed parameter value.</param>
|
||||
/// <returns>The detected reasoning configuration state.</returns>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determine whether a string value is a known reasoning-enabling value.
|
||||
/// </summary>
|
||||
/// <param name="text">The string value to inspect.</param>
|
||||
/// <returns><see langword="true"/> if the value should be treated as enabling reasoning.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine whether a string value is a known reasoning-disabling value.
|
||||
/// </summary>
|
||||
/// <param name="text">The string value to inspect.</param>
|
||||
/// <returns><see langword="true"/> if the value should be treated as disabling reasoning.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge multiple detected reasoning states into a single state.
|
||||
/// </summary>
|
||||
/// <param name="states">The detected states from provider-specific parameter checks.</param>
|
||||
/// <returns>The merged state.</returns>
|
||||
/// <remarks>
|
||||
/// Explicit disabling wins over enabling because user-provided off switches should
|
||||
/// suppress default-on reasoning indicators.
|
||||
/// </remarks>
|
||||
private static ReasoningConfigurationState MergeReasoningStates(IEnumerable<ReasoningConfigurationState> states)
|
||||
{
|
||||
var result = ReasoningConfigurationState.NOT_CONFIGURED;
|
||||
foreach (var state in states)
|
||||
{
|
||||
if (state is ReasoningConfigurationState.EXPLICITLY_DISABLED)
|
||||
return ReasoningConfigurationState.EXPLICITLY_DISABLED;
|
||||
|
||||
if (state is ReasoningConfigurationState.EXPLICITLY_ENABLED)
|
||||
result = ReasoningConfigurationState.EXPLICITLY_ENABLED;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge multiple detected reasoning states into a single state.
|
||||
/// </summary>
|
||||
/// <param name="states">The detected states from provider-specific parameter checks.</param>
|
||||
/// <returns>The merged state.</returns>
|
||||
private static ReasoningConfigurationState MergeReasoningStates(params ReasoningConfigurationState[] states)
|
||||
{
|
||||
return MergeReasoningStates(states.AsEnumerable());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to read a parameter from a dictionary using case-insensitive key matching.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parsed parameter dictionary.</param>
|
||||
/// <param name="key">The parameter name to find.</param>
|
||||
/// <param name="value">The matched parameter value, if found.</param>
|
||||
/// <returns><see langword="true"/> if a matching key was found; otherwise <see langword="false"/>.</returns>
|
||||
private static bool TryGetParameter(IDictionary<string, object> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,78 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.HuggingFace;
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Models.Live;
|
||||
using AIStudio.Models.Registry;
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
public static partial class ProviderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The longest model ID we normalize without going to the heap.
|
||||
/// </summary>
|
||||
private const int MAX_STACK_ALLOCATED_MODEL_ID_LENGTH = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Brings a model ID into the form the capability rules are written in.
|
||||
/// Everything the app knows about the model this provider instance is configured with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every provider names the same model differently, and the difference is rarely in the words:
|
||||
/// it is in what sits between them. Ollama separates the variant with a colon
|
||||
/// ("qwen3.8:27b-mlx"), Blablador answers with a whole sentence ("10 - Muse Glimmer 30b - the
|
||||
/// newest META model"), Fireworks puts a path in front
|
||||
/// ("accounts/fireworks/models/llama-v3p1-405b-instruct"), and the hubs use hyphens. Without
|
||||
/// this, every rule would have to spell out each of those writings, which is what the Llama
|
||||
/// block used to do with four variants of one check.
|
||||
///
|
||||
/// The dots stay. They carry the version boundary: llama3 and llama3.1 are different models,
|
||||
/// and only the latter calls functions. Dropping them would merge the two.
|
||||
///
|
||||
/// The patterns in the rules are written in this normalized form already, which is why they
|
||||
/// use lowercase and hyphens throughout.
|
||||
/// The one door to that question. Behind it stand the links of the chain, in the order they
|
||||
/// win: what the person said about their own installation, then what the installation itself
|
||||
/// reported, then what the rules worked out from the name, and last what the app assumes when
|
||||
/// nothing else said anything.
|
||||
/// </remarks>
|
||||
/// <param name="modelId">The model ID as the provider reports it.</param>
|
||||
/// <returns>The model ID in lowercase, with every separator written as a single hyphen.</returns>
|
||||
private static string NormalizeModelId(string modelId)
|
||||
/// <param name="provider">The configured provider.</param>
|
||||
/// <returns>The profile of the configured model.</returns>
|
||||
public static ModelProfile GetModelProfile(this Provider provider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(modelId))
|
||||
return string.Empty;
|
||||
|
||||
//
|
||||
// Normalizing never makes a name longer, so the original length is always enough room.
|
||||
// Model IDs are short, which is why the buffer lives on the stack: the longest ones we
|
||||
// know of are the descriptive names Blablador answers with, at around 75 characters. A
|
||||
// provider reporting something longer still gets a correct answer, just from the heap.
|
||||
//
|
||||
Span<char> normalized = modelId.Length <= MAX_STACK_ALLOCATED_MODEL_ID_LENGTH
|
||||
? stackalloc char[modelId.Length]
|
||||
: new char[modelId.Length];
|
||||
|
||||
var length = 0;
|
||||
foreach (var character in modelId)
|
||||
{
|
||||
if (char.IsAsciiLetterOrDigit(character) || character is '.')
|
||||
{
|
||||
normalized[length++] = char.ToLowerInvariant(character);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else separates two parts of the name. A leading separator, and a repeated
|
||||
// one, say nothing and would only get in the way of the patterns:
|
||||
if (length is 0 || normalized[length - 1] is '-')
|
||||
continue;
|
||||
|
||||
normalized[length++] = '-';
|
||||
}
|
||||
|
||||
// A trailing separator carries no meaning either:
|
||||
if (length > 0 && normalized[length - 1] is '-')
|
||||
length--;
|
||||
|
||||
return new string(normalized[..length]);
|
||||
var automatic = provider.GetAutomaticModelProfile();
|
||||
return provider.CapabilityOverrides?.ApplyTo(automatic) ?? automatic;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the capabilities of the model used by the configured provider.
|
||||
/// Everything known about the configured model except what the person themselves switched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what happens when somebody fills in nothing, which is why the expert dialog shows it
|
||||
/// as the automatic answer. It has to include what the provider reported: a person who leaves
|
||||
/// the window empty gets the number their own engine stated, and a placeholder showing them a
|
||||
/// different one would be a promise the app does not keep.
|
||||
/// </remarks>
|
||||
/// <param name="provider">The configured provider.</param>
|
||||
/// <returns>The capabilities of the configured model.</returns>
|
||||
public static List<Capability> GetModelCapabilities(this Provider provider)
|
||||
/// <returns>The profile of the configured model, without that provider's overrides.</returns>
|
||||
public static ModelProfile GetAutomaticModelProfile(this Provider provider)
|
||||
{
|
||||
var automaticCapabilities = provider.UsedLLMProvider.GetModelCapabilities(provider.Model);
|
||||
return provider.CapabilityOverrides?.ApplyTo(automaticCapabilities) ?? automaticCapabilities;
|
||||
var stated = provider.UsedLLMProvider.GetModelProfile(provider.Model);
|
||||
return ListedModels.Shared.Of(provider.Id, provider.Model.Id).ApplyTo(stated);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Everything the rules know about a model at a provider, without anybody's own installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The answer to the model as such, which is the same for everybody who uses that name at that
|
||||
/// provider -- and therefore the answer the registry caches. What one particular installation
|
||||
/// says about it is asked one link further up, where the instance is known.
|
||||
///
|
||||
/// The assumed profile fills in where no rule stated a single capability. It fills in the
|
||||
/// capabilities only: a modifier may well have said what the model is made for without any rule
|
||||
/// saying what it can do, and an embedding model nobody wrote a rule for stays an embedding
|
||||
/// model rather than turning into a chat model with an assumption attached.
|
||||
/// </remarks>
|
||||
/// <param name="provider">The LLM provider the model is reached through.</param>
|
||||
/// <param name="model">The model, named the way that provider names it.</param>
|
||||
/// <returns>The profile, which knows nothing when there is nothing to reach.</returns>
|
||||
public static ModelProfile GetModelProfile(this LLMProviders provider, Model model)
|
||||
{
|
||||
//
|
||||
// Without a provider there is nothing to reach the model through, and an empty name is what
|
||||
// a provider reports before anybody picked one. Neither is a model we could assume anything
|
||||
// about, so neither gets the assumption.
|
||||
//
|
||||
if (provider is LLMProviders.NONE || string.IsNullOrWhiteSpace(model.Id))
|
||||
return ModelProfile.UNKNOWN;
|
||||
|
||||
var stated = ModelRegistry.Shared.Profile(provider, model.Id);
|
||||
return stated.Capabilities is Capability.NONE
|
||||
? stated with { Capabilities = ModelProfile.ASSUMED.Capabilities }
|
||||
: stated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get whether the model used by the configured provider accepts images as input.
|
||||
/// </summary>
|
||||
@@ -91,61 +84,48 @@ public static partial class ProviderExtensions
|
||||
/// </remarks>
|
||||
/// <param name="provider">The configured provider.</param>
|
||||
/// <returns><c>true</c> when the model accepts image input.</returns>
|
||||
public static bool SupportsImageInput(this Provider provider)
|
||||
{
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
return capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) || capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
}
|
||||
public static bool SupportsImageInput(this Provider provider) => provider.GetModelProfile().HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT);
|
||||
|
||||
/// <summary>
|
||||
/// Get the capabilities of a model for a specific provider.
|
||||
/// Checks whether this model can be used for chatting.
|
||||
/// </summary>
|
||||
/// <param name="provider">The LLM provider.</param>
|
||||
/// <param name="model">The model to get the capabilities for.</param>
|
||||
/// <returns>>The capabilities of the model.</returns>
|
||||
public static List<Capability> GetModelCapabilities(this LLMProviders provider, Model model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.Id))
|
||||
return [];
|
||||
/// <remarks>
|
||||
/// What a model can do and what it is made for used to be two questions answered by two pieces
|
||||
/// of code, each walking the same name with rules of its own. They disagreed: a model like
|
||||
/// nomic-embed-text was an embedding model at one provider and a chat model at the next. Both
|
||||
/// come out of the same rules now, which is why this takes the provider -- the same name means
|
||||
/// different things depending on who serves it, and only the provider knows how to unwrap it.
|
||||
///
|
||||
/// The direction of the answer is deliberate. Everything not recognized as something else is a
|
||||
/// chat model, so a provider adding a family we have never seen keeps it visible to the person
|
||||
/// paying for it. Getting it wrong the other way would hide a model.
|
||||
/// </remarks>
|
||||
/// <param name="model">The model to check.</param>
|
||||
/// <param name="provider">The provider serving it.</param>
|
||||
/// <returns>True, when the model is a chat model or when we recognize no other kind.</returns>
|
||||
public static bool IsChatModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.CHAT;
|
||||
|
||||
return provider switch
|
||||
{
|
||||
LLMProviders.OPEN_AI => GetModelCapabilitiesOpenAI(model),
|
||||
LLMProviders.MISTRAL => GetModelCapabilitiesMistral(model),
|
||||
LLMProviders.ANTHROPIC => GetModelCapabilitiesAnthropic(model),
|
||||
LLMProviders.GOOGLE => GetModelCapabilitiesGoogle(model),
|
||||
LLMProviders.X => GetModelCapabilitiesOpenSource(model),
|
||||
LLMProviders.DEEP_SEEK => GetModelCapabilitiesDeepSeek(model),
|
||||
LLMProviders.ALIBABA_CLOUD => GetModelCapabilitiesAlibaba(model),
|
||||
LLMProviders.PERPLEXITY => GetModelCapabilitiesPerplexity(model),
|
||||
LLMProviders.OPEN_ROUTER => GetModelCapabilitiesGateway(model),
|
||||
LLMProviders.HETZNER or LLMProviders.IONOS => GetModelCapabilitiesOpenSource(model),
|
||||
|
||||
//
|
||||
// LiteLLM is a gateway just like OpenRouter, and it names its models the same way:
|
||||
// "vendor/model", e.g. "anthropic/claude-opus-5" or "azure/gpt-5.6". So we let the
|
||||
// gateway detection handle it, which resolves the vendor prefix and asks the
|
||||
// provider who really knows the model. Everything it cannot place is treated as
|
||||
// an open source model, which is the right fallback for a freely named alias:
|
||||
//
|
||||
LLMProviders.LITE_LLM => GetModelCapabilitiesGateway(model),
|
||||
/// <summary>
|
||||
/// Checks whether this model creates embeddings.
|
||||
/// </summary>
|
||||
/// <param name="model">The model to check.</param>
|
||||
/// <param name="provider">The provider serving it.</param>
|
||||
/// <returns>True, when the model is an embedding model.</returns>
|
||||
public static bool IsEmbeddingModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.EMBEDDING;
|
||||
|
||||
LLMProviders.GROQ or LLMProviders.FIREWORKS => GetModelCapabilitiesOpenSource(model),
|
||||
/// <summary>
|
||||
/// Checks whether this model transcribes audio.
|
||||
/// </summary>
|
||||
/// <param name="model">The model to check.</param>
|
||||
/// <param name="provider">The provider serving it.</param>
|
||||
/// <returns>True, when the model is a transcription model.</returns>
|
||||
public static bool IsTranscriptionModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.TRANSCRIPTION;
|
||||
|
||||
//
|
||||
// Hugging Face names its models the way the hub does, "org/model", which is the same
|
||||
// shape the other gateways use. So we let the gateway detection resolve the organization
|
||||
// and ask the provider implementation which really knows the model. The routing suffix
|
||||
// has to go first: it says which inference provider answers, not what the model is.
|
||||
//
|
||||
LLMProviders.HUGGINGFACE => GetModelCapabilitiesGateway(model.WithoutRoutingSuffix()),
|
||||
|
||||
LLMProviders.HELMHOLTZ => GetModelCapabilitiesOpenSource(model),
|
||||
LLMProviders.GWDG => GetModelCapabilitiesOpenSource(model),
|
||||
|
||||
LLMProviders.SELF_HOSTED => GetModelCapabilitiesOpenSource(model),
|
||||
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// Checks whether this model generates images.
|
||||
/// </summary>
|
||||
/// <param name="model">The model to check.</param>
|
||||
/// <param name="provider">The provider serving it.</param>
|
||||
/// <returns>True, when the model is an image generation model.</returns>
|
||||
public static bool IsImageModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.IMAGE_GENERATION;
|
||||
}
|
||||
Reference in new issue
Block a user