Reworked the Hugging Face provider (#944)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-30 16:45:14 +02:00
1 parent a7228c7782
commit f14c69b938
28 files changed
+1039 -93

No files matched your search

@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
using SharedTools;
@@ -22,7 +23,8 @@ public sealed record EmbeddingProvider(
string Hostname = "http://localhost:1234",
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
string CustomIconDataUrl = "",
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<EmbeddingProvider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<EmbeddingProvider>();
@@ -103,6 +105,16 @@ public sealed record EmbeddingProvider(
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead<string>(out var hfInferenceProviderText))
{
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
{
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
hfInferenceProvider = HFInferenceProvider.NONE;
}
}
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
@@ -126,6 +138,7 @@ public sealed record EmbeddingProvider(
Host = host,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
// Handle an encrypted API key if present. When the user manages their own key for this
@@ -192,6 +205,14 @@ public sealed record EmbeddingProvider(
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection(string? encryptedApiKey = null)
{
var hfInferenceProviderLine = string.Empty;
if (this.HFInferenceProvider is not HFInferenceProvider.NONE)
{
hfInferenceProviderLine = $"""
["HFInferenceProvider"] = "{this.HFInferenceProvider}",
""";
}
var apiKeyLine = string.Empty;
if (!string.IsNullOrWhiteSpace(encryptedApiKey))
{
@@ -205,9 +226,10 @@ public sealed record EmbeddingProvider(
["Id"] = "{{Guid.NewGuid().ToString()}}",
["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}",
["UsedLLMProvider"] = "{{this.UsedLLMProvider}}",
["Host"] = "{{this.Host}}",
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}",
{{hfInferenceProviderLine}}
{{apiKeyLine}}
["Model"] = {
["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}",
@@ -4,16 +4,26 @@ namespace AIStudio.Settings;
public static partial class ProviderExtensions
{
private static List<Capability> GetModelCapabilitiesOpenRouter(Model model)
/// <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)
{
//
// OpenRouter model IDs follow the pattern "vendor/model-name". Examples:
// 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
//
// OpenRouter offers the models of all the other providers. Instead of keeping a
// 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
@@ -41,26 +51,26 @@ public static partial class ProviderExtensions
"perplexity" => GetModelCapabilitiesPerplexity(bareModel),
// Everything else is open source: Qwen, Llama, GLM, Kimi, Muse, Hunyuan,
// Nemotron, Grok, and whatever OpenRouter adds next. DeepSeek belongs here
// Nemotron, Grok, and whatever a gateway adds next. DeepSeek belongs here
// as well: its own implementation covers the aliases of the DeepSeek
// platform, while OpenRouter uses the names of the open weights.
// platform, while the gateways use the names of the open weights.
_ => GetModelCapabilitiesOpenSource(bareModel),
};
return NormalizeForOpenRouter(capabilities);
return NormalizeForGateway(capabilities);
}
/// <summary>
/// Adjusts the capabilities reported by another provider for use through OpenRouter.
/// 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 OpenRouter.</returns>
/// <returns>The capabilities as they apply when using the model through a gateway.</returns>
/// <remarks>
/// OpenRouter serves every model through its OpenAI-compatible chat completion API.
/// 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.
/// </remarks>
private static List<Capability> NormalizeForOpenRouter(List<Capability> capabilities)
private static List<Capability> NormalizeForGateway(List<Capability> capabilities)
{
capabilities.Remove(Capability.RESPONSES_API);
if(!capabilities.Contains(Capability.CHAT_COMPLETION_API))
@@ -462,6 +462,84 @@ public static partial class ProviderExtensions
Capability.CHAT_COMPLETION_API,
];
//
// Google Gemma models. Gemma is the open-weights family, while Gemini is not, which is why
// Gemma is handled here and Gemini in the Google implementation.
//
if (modelName.IndexOf("gemma") is not -1)
{
//
// Every checkpoint of the Gemma 4 generation is multimodal and understands video as
// well; there is no text-only variant. Audio input is limited to the E2B, E4B, and 12B
// checkpoints. The models can think, but only when the request asks them to: their chat
// template keeps the thinking channel closed by default.
//
if (modelName.IndexOf("gemma-4") is not -1 ||
modelName.IndexOf("gemma4") is not -1 ||
modelName.IndexOf("gemma 4") is not -1)
{
if (modelName.IndexOf("e2b") is not -1 ||
modelName.IndexOf("e4b") is not -1 ||
modelName.IndexOf("12b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.AUDIO_INPUT, Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.VIDEO_INPUT,
Capability.TEXT_OUTPUT,
Capability.OPTIONAL_REASONING, Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// Gemma 3 accepts images from the 4B checkpoint upwards; the 1B one is text-only. This
// generation does not reason. The check for the small checkpoint looks for "-1b" rather
// than "1b", so that a name such as gemma-3-31b does not match it.
//
if (modelName.IndexOf("gemma-3") is not -1 ||
modelName.IndexOf("gemma3") is not -1 ||
modelName.IndexOf("gemma 3") is not -1)
{
if (modelName.IndexOf("-1b") is not -1)
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
return
[
Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT,
Capability.TEXT_OUTPUT,
Capability.FUNCTION_CALLING,
Capability.CHAT_COMPLETION_API,
];
}
//
// The earlier generations take text only and were not built for tool usage:
//
return
[
Capability.TEXT_INPUT, Capability.TEXT_OUTPUT,
Capability.CHAT_COMPLETION_API,
];
}
//
// Z AI / GLM models:
//
@@ -1,4 +1,5 @@
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
namespace AIStudio.Settings;
@@ -53,21 +54,27 @@ public static partial class ProviderExtensions
LLMProviders.DEEP_SEEK => GetModelCapabilitiesDeepSeek(model),
LLMProviders.ALIBABA_CLOUD => GetModelCapabilitiesAlibaba(model),
LLMProviders.PERPLEXITY => GetModelCapabilitiesPerplexity(model),
LLMProviders.OPEN_ROUTER => GetModelCapabilitiesOpenRouter(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
// OpenRouter detection handle it, which resolves the vendor prefix and asks 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 => GetModelCapabilitiesOpenRouter(model),
LLMProviders.LITE_LLM => GetModelCapabilitiesGateway(model),
LLMProviders.GROQ => GetModelCapabilitiesOpenSource(model),
LLMProviders.FIREWORKS => GetModelCapabilitiesOpenSource(model),
LLMProviders.HUGGINGFACE => GetModelCapabilitiesOpenSource(model),
LLMProviders.GROQ or LLMProviders.FIREWORKS => GetModelCapabilitiesOpenSource(model),
//
// 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),
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
using SharedTools;
@@ -22,7 +23,8 @@ public sealed record TranscriptionProvider(
string Hostname = "http://localhost:1234",
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
string CustomIconDataUrl = "",
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<TranscriptionProvider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<TranscriptionProvider>();
@@ -103,6 +105,16 @@ public sealed record TranscriptionProvider(
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var hfInferenceProvider = HFInferenceProvider.NONE;
if (table.TryGetValue("HFInferenceProvider", out var hfInferenceProviderValue) && hfInferenceProviderValue.TryRead<string>(out var hfInferenceProviderText))
{
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
{
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
hfInferenceProvider = HFInferenceProvider.NONE;
}
}
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
@@ -126,6 +138,7 @@ public sealed record TranscriptionProvider(
Host = host,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
HFInferenceProvider = hfInferenceProvider,
};
// Handle an encrypted API key if present. When the user manages their own key for this
@@ -192,6 +205,14 @@ public sealed record TranscriptionProvider(
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection(string? encryptedApiKey = null)
{
var hfInferenceProviderLine = string.Empty;
if (this.HFInferenceProvider is not HFInferenceProvider.NONE)
{
hfInferenceProviderLine = $"""
["HFInferenceProvider"] = "{this.HFInferenceProvider}",
""";
}
var apiKeyLine = string.Empty;
if (!string.IsNullOrWhiteSpace(encryptedApiKey))
{
@@ -208,6 +229,7 @@ public sealed record TranscriptionProvider(
["Host"] = "{{this.Host}}",
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}",
{{hfInferenceProviderLine}}
{{apiKeyLine}}
["Model"] = {
["Id"] = "{{LuaTools.EscapeLuaString(this.Model.Id)}}",