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

@@ -376,8 +376,13 @@ public abstract class BaseProvider : IProvider, ISecretId
errorCode = TryGetString(root, "code");
errorType = TryGetString(root, "type");
// Services built on FastAPI, such as Helmholtz Blablador, word their errors as "detail":
errorMessage = TryGetString(root, "message") ?? TryGetString(root, "detail");
//
// Services built on FastAPI, such as Helmholtz Blablador, word their errors as "detail".
// And some providers put the sentence straight into "error" instead of an object, e.g.
// {"error": "Model not supported by provider novita"}. The object form was handled above,
// so reading "error" here can only meet the plain sentence:
//
errorMessage = TryGetString(root, "message") ?? TryGetString(root, "detail") ?? TryGetString(root, "error");
}
/// <summary>
@@ -510,16 +515,35 @@ public abstract class BaseProvider : IProvider, ISecretId
if(nextResponse.StatusCode is HttpStatusCode.BadRequest)
{
//
// The provider explains the problem in the body, while the reason phrase says no
// more than "Bad Request". We show that explanation and fall back to the phrase
// only when the body carries none:
//
var badRequestMessage = ReadProviderErrorMessage(errorBody);
if (string.IsNullOrWhiteSpace(badRequestMessage))
badRequestMessage = nextResponse.ReasonPhrase;
//
// When we recognize what went wrong, we say what it means for the user instead of
// guessing at the message format. The classification happened above already:
//
var classifiedMessage = this.GetProviderRequestFailureUserMessage(providerRequestFailure);
if(!string.IsNullOrWhiteSpace(classifiedMessage))
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, classifiedMessage));
}
// Check if the error body contains "context" and "token" (case-insensitive),
// which indicates that the context window is likely exceeded:
if(errorBody.Contains("context", StringComparison.InvariantCultureIgnoreCase) &&
else if(errorBody.Contains("context", StringComparison.InvariantCultureIgnoreCase) &&
errorBody.Contains("token", StringComparison.InvariantCultureIgnoreCase))
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase)));
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The data of the chat, including all file attachments, is probably too large for the selected model and provider. The provider message is: '{2}'"), this.InstanceName, this.Provider, badRequestMessage)));
}
else
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The required message format might be changed. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase)));
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). The required message format might be changed. The provider message is: '{2}'"), this.InstanceName, this.Provider, badRequestMessage)));
}
this.logger.LogError("Failed request with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}').", nextResponse.StatusCode, nextResponse.ReasonPhrase, errorBody);
@@ -0,0 +1,29 @@
namespace AIStudio.Provider.HuggingFace;
/// <summary>
/// Which of the Hugging Face endpoints a provider instance talks to.
/// </summary>
/// <remarks>
/// Hugging Face serves chatting and everything else from different places. Chat completions go to
/// the router's own OpenAI-compatible endpoint, which accepts the model IDs as the hub writes them
/// and picks an inference provider from a suffix. Embeddings and transcription do not exist there
/// at all and have to be asked of one provider's own route. Because the base URL is fixed when a
/// provider instance is built, the instance has to know from the start which one it is for.
/// </remarks>
public enum HFEndpointKind
{
/// <summary>
/// The router's own endpoint, which serves chat completions.
/// </summary>
CHAT,
/// <summary>
/// The OpenAI-compatible route of one inference provider, which serves embeddings.
/// </summary>
EMBEDDING,
/// <summary>
/// The OpenAI-compatible route of one inference provider, which transcribes audio.
/// </summary>
TRANSCRIPTION,
}
@@ -3,16 +3,46 @@
/// <summary>
/// Enum for inference providers that Hugging Face supports.
/// </summary>
/// <remarks>
/// Besides the providers themselves, this enum carries the routing strategies Hugging Face offers.
/// They are no providers, but they take the same place: the router picks a provider for us instead
/// of us naming one.
///
/// NONE must stay the first value: settings are read through the tolerant enum converter, which
/// falls back to the first value whenever it meets a name we no longer know. That is what happens
/// to a configuration naming one of the providers Hugging Face stopped routing in July 2026
/// (Hyperbolic, SambaNova, Nebius, NVIDIA, Clarifai, Black Forest Labs), and to one naming the
/// Hugging Face Inference API, which serves no model we can reach: it has no chat models at all,
/// and its OpenAI-compatible routes for embeddings and transcription do not exist. Such a provider
/// has to end up on NONE, where the validation asks the user to choose again. Were a routing
/// strategy first, those configurations would silently switch to automatic routing instead.
/// </remarks>
public enum HFInferenceProvider
{
NONE,
//
// Routing strategies. Hugging Face writes them where a provider name would go:
//
AUTOMATIC,
CHEAPEST,
PREFERRED,
//
// The providers Hugging Face routes:
//
BASETEN,
CEREBRAS,
NEBIUS_AI_STUDIO,
SAMBANOVA,
NOVITA,
HYPERBOLIC,
TOGETHER_AI,
COHERE,
DEEPINFRA,
FEATHERLESS_AI,
FIREWORKS,
HF_INFERENCE_API,
GROQ,
NOVITA,
NSCALE,
OVHCLOUD,
PUBLIC_AI,
SCALEWAY,
TOGETHER_AI,
ZAI,
}
@@ -1,43 +1,158 @@
namespace AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Provider.HuggingFace;
public static class HFInferenceProviderExtensions
{
public static string Endpoints(this HFInferenceProvider provider, Model model) => provider switch
{
HFInferenceProvider.CEREBRAS => "cerebras/v1/",
HFInferenceProvider.NEBIUS_AI_STUDIO => "nebius/v1/",
HFInferenceProvider.SAMBANOVA => "sambanova/v1/",
HFInferenceProvider.NOVITA => "novita/v3/openai/",
HFInferenceProvider.HYPERBOLIC => "hyperbolic/v1/",
HFInferenceProvider.TOGETHER_AI => "together/v1/",
HFInferenceProvider.FIREWORKS => "fireworks-ai/inference/v1/",
HFInferenceProvider.HF_INFERENCE_API => $"hf-inference/models/{model.ToString()}/v1/",
_ => string.Empty,
};
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(HFInferenceProviderExtensions).Namespace, nameof(HFInferenceProviderExtensions));
/// <summary>
/// The slug Hugging Face uses for this inference provider.
/// </summary>
/// <param name="provider">The inference provider.</param>
/// <returns>The slug, or an empty string for the routing strategies, which name no provider.</returns>
public static string EndpointsId(this HFInferenceProvider provider) => provider switch
{
HFInferenceProvider.BASETEN => "baseten",
HFInferenceProvider.CEREBRAS => "cerebras",
HFInferenceProvider.NEBIUS_AI_STUDIO => "nebius",
HFInferenceProvider.SAMBANOVA => "sambanova",
HFInferenceProvider.COHERE => "cohere",
HFInferenceProvider.DEEPINFRA => "deepinfra",
HFInferenceProvider.FEATHERLESS_AI => "featherless-ai",
HFInferenceProvider.FIREWORKS => "fireworks-ai",
HFInferenceProvider.GROQ => "groq",
HFInferenceProvider.NOVITA => "novita",
HFInferenceProvider.HYPERBOLIC => "hyperbolic",
HFInferenceProvider.NSCALE => "nscale",
HFInferenceProvider.OVHCLOUD => "ovhcloud",
HFInferenceProvider.PUBLIC_AI => "publicai",
HFInferenceProvider.SCALEWAY => "scaleway",
HFInferenceProvider.TOGETHER_AI => "together",
HFInferenceProvider.FIREWORKS => "fireworks",
HFInferenceProvider.HF_INFERENCE_API => "hf-inference",
HFInferenceProvider.ZAI => "zai-org",
_ => string.Empty,
};
/// <summary>
/// The suffix which tells the router where to send the request.
/// </summary>
/// <remarks>
/// The router serves every provider through one endpoint. Which provider answers is decided by
/// a suffix on the model name, e.g. "google/gemma-4-31B-it:novita". Without a suffix, the router
/// picks the fastest provider itself.
/// </remarks>
/// <param name="provider">The inference provider.</param>
/// <returns>The suffix including its colon, or an empty string when the router should choose.</returns>
public static string ModelSuffix(this HFInferenceProvider provider) => provider switch
{
HFInferenceProvider.NONE or HFInferenceProvider.AUTOMATIC => string.Empty,
HFInferenceProvider.CHEAPEST => ":cheapest",
HFInferenceProvider.PREFERRED => ":preferred",
_ => $":{provider.EndpointsId()}",
};
/// <summary>
/// Whether this inference provider serves models to chat with.
/// </summary>
/// <param name="provider">The inference provider.</param>
/// <returns>True, when the provider serves chat models.</returns>
public static bool SupportsChat(this HFInferenceProvider provider) => provider is not HFInferenceProvider.NONE;
/// <summary>
/// Whether this inference provider creates embeddings for us.
/// </summary>
/// <remarks>
/// Embeddings are a much shorter story than chatting. The router serves them nowhere near its
/// own endpoint, only through the route of a provider, and only two of those answer the
/// OpenAI-compatible form we send. The routing strategies are out by their nature: without a
/// named provider there is no route to address.
/// </remarks>
/// <param name="provider">The inference provider.</param>
/// <returns>True, when we can create embeddings through this provider.</returns>
public static bool SupportsEmbeddings(this HFInferenceProvider provider) => provider is HFInferenceProvider.TOGETHER_AI or HFInferenceProvider.DEEPINFRA;
/// <summary>
/// Whether this inference provider transcribes audio for us.
/// </summary>
/// <remarks>
/// The same two providers as for embeddings, and for the same reason: transcription lives on a
/// provider's own route, and only these two answer the OpenAI-compatible form there. Others do
/// transcribe for Hugging Face, but not in a shape we could send an audio file to: fal-ai and
/// Replicate both turn the request down with "Model not supported by provider".
/// </remarks>
/// <param name="provider">The inference provider.</param>
/// <returns>True, when we can transcribe audio through this provider.</returns>
public static bool SupportsTranscription(this HFInferenceProvider provider) => provider is HFInferenceProvider.TOGETHER_AI or HFInferenceProvider.DEEPINFRA;
/// <summary>
/// The base URL of the provider's own OpenAI-compatible route.
/// </summary>
/// <remarks>
/// Only chatting goes through the router's own endpoint. Everything else has to address the
/// provider directly, and they do not agree on where their OpenAI-compatible API sits: DeepInfra
/// keeps it below an additional "openai" segment, and answers the path without it with
/// "Not allowed to POST /v1/embeddings for provider deepinfra".
/// </remarks>
/// <param name="provider">The inference provider.</param>
/// <returns>The base URL, or an empty string when the provider has no such route.</returns>
public static string ProviderBaseURL(this HFInferenceProvider provider) => provider switch
{
HFInferenceProvider.TOGETHER_AI => "https://router.huggingface.co/together/v1/",
HFInferenceProvider.DEEPINFRA => "https://router.huggingface.co/deepinfra/v1/openai/",
_ => string.Empty,
};
/// <summary>
/// Removes the routing suffix from a model, if it carries one.
/// </summary>
/// <remarks>
/// The suffix says where a request goes, not what the model is. Everything asking what a model
/// can do has to look at the bare name: "google/gemma-4-31B-it:novita" is the same model as
/// "google/gemma-4-31B-it", and a name detection which never heard of the suffix would miss it.
/// Model IDs on the hub are written as "org/model" and carry no colon of their own, so the last
/// colon always starts the suffix.
/// </remarks>
/// <param name="model">The model as it is configured.</param>
/// <returns>The model without its routing suffix.</returns>
public static Model WithoutRoutingSuffix(this Model model)
{
var separatorIndex = model.Id.LastIndexOf(':');
return separatorIndex is -1 ? model : model with { Id = model.Id[..separatorIndex] };
}
/// <summary>
/// The value to filter the Hugging Face model catalog by.
/// </summary>
/// <param name="provider">The inference provider.</param>
/// <returns>The provider slug, or "all" when no particular provider was chosen.</returns>
public static string CatalogFilter(this HFInferenceProvider provider)
{
var slug = provider.EndpointsId();
return string.IsNullOrEmpty(slug) ? "all" : slug;
}
public static string ToName(this HFInferenceProvider provider) => provider switch
{
HFInferenceProvider.AUTOMATIC => TB("Automatic: the fastest provider"),
HFInferenceProvider.CHEAPEST => TB("Automatic: the cheapest provider"),
HFInferenceProvider.PREFERRED => TB("Automatic: your preferred order"),
HFInferenceProvider.BASETEN => "Baseten",
HFInferenceProvider.CEREBRAS => "Cerebras",
HFInferenceProvider.NEBIUS_AI_STUDIO => "Nebius AI Studio",
HFInferenceProvider.SAMBANOVA => "Sambanova",
HFInferenceProvider.NOVITA => "Novita",
HFInferenceProvider.HYPERBOLIC => "Hyperbolic",
HFInferenceProvider.TOGETHER_AI => "Together AI",
HFInferenceProvider.COHERE => "Cohere",
HFInferenceProvider.DEEPINFRA => "DeepInfra",
HFInferenceProvider.FEATHERLESS_AI => "Featherless AI",
HFInferenceProvider.FIREWORKS => "Fireworks AI",
HFInferenceProvider.HF_INFERENCE_API => "Hugging Face Inference API",
HFInferenceProvider.GROQ => "Groq",
HFInferenceProvider.NOVITA => "Novita",
HFInferenceProvider.NSCALE => "Nscale",
HFInferenceProvider.OVHCLOUD => "OVHcloud",
HFInferenceProvider.PUBLIC_AI => "Public AI",
HFInferenceProvider.SCALEWAY => "Scaleway",
HFInferenceProvider.TOGETHER_AI => "Together AI",
HFInferenceProvider.ZAI => "Z.ai",
_ => string.Empty,
};
}
@@ -0,0 +1,37 @@
namespace AIStudio.Provider.HuggingFace;
/// <summary>
/// A data model for the response from the model endpoint of the Hugging Face router.
/// </summary>
/// <remarks>
/// The router says more about a model than the OpenAI model list does: which inference providers
/// serve it, and which kinds of input it takes. That is why this provider brings its own data model
/// instead of using the shared one.
/// </remarks>
/// <param name="Data">The models the router knows.</param>
public readonly record struct ModelsResponse(IList<HFModel> Data);
/// <summary>
/// One model as the Hugging Face router describes it.
/// </summary>
/// <param name="Id">The ID of the model, written as "org/model".</param>
/// <param name="Providers">The inference providers serving this model.</param>
public readonly record struct HFModel(string Id, IList<HFModelProvider>? Providers);
/// <summary>
/// One inference provider serving a model.
/// </summary>
/// <param name="Provider">The slug of the inference provider, e.g. "novita".</param>
/// <param name="Status">Whether the provider currently serves the model. Known value: "live".</param>
public readonly record struct HFModelProvider(string Provider, string Status);
/// <summary>
/// One model as the Hugging Face hub lists it.
/// </summary>
/// <remarks>
/// The hub answers with a plain array of models and describes each of them in far more detail than
/// we need here, from tags to download counts. We only ever ask for the models of one provider and
/// one task, so the ID is all that is left to read.
/// </remarks>
/// <param name="Id">The ID of the model, written as "org/model".</param>
public readonly record struct HubModel(string Id);
@@ -1,8 +1,10 @@
using System.Runtime.CompilerServices;
using System.Net;
using System.Runtime.CompilerServices;
using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Provider.HuggingFace;
@@ -10,11 +12,138 @@ public sealed class ProviderHuggingFace : BaseProvider
{
private static readonly ILogger<ProviderHuggingFace> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderHuggingFace>();
public ProviderHuggingFace(HFInferenceProvider hfProvider, Model model) : base(LLMProviders.HUGGINGFACE, new Uri($"https://router.huggingface.co/{hfProvider.Endpoints(model)}"), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ProviderHuggingFace).Namespace, nameof(ProviderHuggingFace));
/// <summary>
/// The OpenAI-compatible endpoint which serves every inference provider.
/// </summary>
/// <remarks>
/// Hugging Face also keeps a route per provider, such as "/novita/v3/openai/". Those expect the
/// model ID as that provider spells it, which differs from the ID on the hub: Novita knows
/// "google/gemma-4-31B-it" as "google/gemma-4-31b-it", and the router is case-sensitive. Asking
/// for the hub spelling there is answered with "Model not supported by provider novita". This
/// endpoint takes the hub spelling and translates it for us, so it is the one we use.
/// </remarks>
private const string ROUTER_BASE_URL = "https://router.huggingface.co/v1/";
/// <summary>
/// Where the models of an inference provider are listed.
/// </summary>
/// <remarks>
/// The router lists the chat models it routes, but nothing else. Which embedding models a
/// provider offers is known to the hub alone, which answers this without a token. The URL is
/// absolute on purpose: it addresses the hub, not the router this provider is built on.
/// </remarks>
private const string HUB_MODELS_URL = "https://huggingface.co/api/models?limit=100&sort=downloads&direction=-1&inference_provider=";
private readonly HFInferenceProvider hfProvider;
public ProviderHuggingFace(HFInferenceProvider hfProvider, HFEndpointKind endpointKind = HFEndpointKind.CHAT) : base(LLMProviders.HUGGINGFACE, new Uri(BuildBaseURL(hfProvider, endpointKind)), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
{
LOGGER.LogInformation($"We use the inference provider '{hfProvider}'. Thus we use the base URL 'https://router.huggingface.co/{hfProvider.Endpoints(model)}'.");
this.hfProvider = hfProvider;
LOGGER.LogInformation($"We use the inference provider '{hfProvider}' for {endpointKind}. Thus, we use the base URL '{BuildBaseURL(hfProvider, endpointKind)}'.");
}
/// <summary>
/// Determines the base URL for the endpoint this provider instance talks to.
/// </summary>
/// <remarks>
/// A provider which serves no embeddings has no route of its own to offer, and neither have the
/// routing strategies. We still have to hand a URL to the base class, so we fall back to the
/// router. A request sent there is answered with a plain "Not Found", which is the honest
/// outcome: the user selected something we told them we cannot do, and the validation of the
/// dialog says so before it ever comes to a request.
/// </remarks>
/// <param name="hfProvider">The chosen inference provider.</param>
/// <param name="endpointKind">Which endpoint this instance is built for.</param>
/// <returns>The base URL to use.</returns>
private static string BuildBaseURL(HFInferenceProvider hfProvider, HFEndpointKind endpointKind)
{
if (endpointKind is HFEndpointKind.CHAT)
return ROUTER_BASE_URL;
var providerBaseURL = hfProvider.ProviderBaseURL();
return string.IsNullOrEmpty(providerBaseURL) ? ROUTER_BASE_URL : providerBaseURL;
}
/// <summary>
/// Builds the model name to send to the router.
/// </summary>
/// <remarks>
/// The router picks the inference provider from a suffix on the model name. When the user wrote
/// a suffix themselves, we keep theirs: appending a second one would name a model nobody knows.
/// </remarks>
/// <param name="model">The model the user chose.</param>
/// <returns>The model name including the provider suffix, when one applies.</returns>
private string BuildModelIdentifier(Model model)
{
var modelId = model.Id;
if (string.IsNullOrWhiteSpace(modelId) || modelId.Contains(':'))
return modelId;
return $"{modelId}{this.hfProvider.ModelSuffix()}";
}
/// <summary>
/// Recognizes the router's answer for a model the chosen inference provider does not serve.
/// </summary>
/// <remarks>
/// Not every model is available at every inference provider, and the router says so with a bad
/// request. Without this, the user would be told that the message format might have changed,
/// which points them at something they cannot fix and away from the one thing they can: picking
/// another provider. The router words this failure as the error code "model_not_supported",
/// while the providers behind it word it as a sentence of their own.
/// </remarks>
/// <param name="value">A piece of the failed response: an error code, a message, or the body.</param>
/// <returns>True, when this text names an unsupported model.</returns>
private static bool IsModelNotSupportedError(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return value.Contains("model_not_supported", StringComparison.OrdinalIgnoreCase) ||
value.Contains("not supported by provider", StringComparison.OrdinalIgnoreCase) ||
value.Contains("not supported by any provider", StringComparison.OrdinalIgnoreCase);
}
#region Overrides of BaseProvider
/// <inheritdoc />
protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(HttpStatusCode statusCode, string responseBody)
{
if (statusCode is HttpStatusCode.BadRequest && IsModelNotSupportedError(responseBody))
return ProviderRequestFailureReason.MODEL_NOT_SUPPORTED_BY_PROVIDER;
return base.ClassifyProviderRequestFailure(statusCode, responseBody);
}
/// <inheritdoc />
protected override ProviderRequestFailureReason ClassifyProviderRequestFailure(string? errorCode, string? errorType, string? errorMessage, string responseBody)
{
if (IsModelNotSupportedError(errorCode) || IsModelNotSupportedError(errorType) || IsModelNotSupportedError(errorMessage))
return ProviderRequestFailureReason.MODEL_NOT_SUPPORTED_BY_PROVIDER;
return base.ClassifyProviderRequestFailure(errorCode, errorType, errorMessage, responseBody);
}
/// <inheritdoc />
protected override string GetProviderRequestFailureUserMessage(ProviderRequestFailureReason failureReason)
{
if (failureReason is not ProviderRequestFailureReason.MODEL_NOT_SUPPORTED_BY_PROVIDER)
return base.GetProviderRequestFailureUserMessage(failureReason);
//
// When Hugging Face chose the provider itself, naming it back to the user would help
// nobody: they never picked it, and no other choice of provider is left to try:
//
if (this.hfProvider is HFInferenceProvider.NONE or HFInferenceProvider.AUTOMATIC or HFInferenceProvider.CHEAPEST or HFInferenceProvider.PREFERRED)
return TB("No Hugging Face inference provider offers the selected model. Please check the model name and whether it is still available on Hugging Face.");
return string.Format(TB("The Hugging Face inference provider '{0}' does not offer the selected model. Please select another inference provider, or let Hugging Face choose one for you."), this.hfProvider.ToName());
}
#endregion
#region Implementation of IProvider
/// <inheritdoc />
@@ -24,7 +153,7 @@ public sealed class ProviderHuggingFace : BaseProvider
public override string InstanceName { get; set; } = "HuggingFace";
/// <inheritdoc />
public override bool HasModelLoadingCapability => false;
public override bool HasModelLoadingCapability => true;
/// <inheritdoc />
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
@@ -41,7 +170,7 @@ public sealed class ProviderHuggingFace : BaseProvider
return new ChatCompletionAPIRequest
{
Model = chatModel.Id,
Model = this.BuildModelIdentifier(chatModel),
// Build the messages:
// - First of all the system prompt
@@ -65,21 +194,66 @@ public sealed class ProviderHuggingFace : BaseProvider
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
public override async Task<TranscriptionResult> TranscribeAudioAsync(Model transcriptionModel, string audioFilePath, SettingsManager settingsManager, CancellationToken token = default)
{
return Task.FromResult(TranscriptionResult.Failure());
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER);
//
// Note that we send the model as it is: this request goes to the provider's own route,
// where a routing suffix would be part of the name and name nothing:
//
return await this.PerformStandardTranscriptionRequest(requestedSecret, transcriptionModel, audioFilePath, token: token);
}
/// <inhertidoc />
public override Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
public override async Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
return Task.FromResult<IReadOnlyList<IReadOnlyList<float>>>([]);
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER);
//
// Note that we send the model as it is: this request goes to the provider's own route,
// where a routing suffix would be part of the name and name nothing:
//
return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", this.SelectChatModels, token, apiKeyProvisional);
}
/// <summary>
/// Picks the models the user may chat with through the chosen inference provider.
/// </summary>
/// <remarks>
/// The router reports every model it knows, together with the providers serving it. When the
/// user named a provider, we show what that provider offers and nothing else. Showing more
/// would be a disservice: every model outside that list is answered with a bad request, and the
/// user would only learn about it once they try to chat.
/// </remarks>
/// <param name="response">The response of the model endpoint.</param>
/// <returns>The models to offer.</returns>
private IEnumerable<Model> SelectChatModels(ModelsResponse response)
{
var chatModels = response.Data.Where(hfModel => new Model(hfModel.Id, null).IsChatModel());
var providerSlug = this.hfProvider.EndpointsId();
if (string.IsNullOrEmpty(providerSlug))
return ToModels(chatModels);
return ToModels(chatModels.Where(hfModel => IsServedBy(hfModel, providerSlug)));
}
private static IEnumerable<Model> ToModels(IEnumerable<HFModel> hfModels) => hfModels.Select(hfModel => new Model(hfModel.Id, null));
private static bool IsServedBy(HFModel hfModel, string providerSlug)
{
if (hfModel.Providers is null)
return false;
return hfModel.Providers.Any(provider =>
string.Equals(provider.Provider, providerSlug, StringComparison.OrdinalIgnoreCase) &&
string.Equals(provider.Status, "live", StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc />
@@ -91,14 +265,34 @@ public sealed class ProviderHuggingFace : BaseProvider
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
if (!this.hfProvider.SupportsEmbeddings())
return Task.FromResult(ModelLoadResult.FromModels([]));
return this.LoadHubModels(SecretStoreType.EMBEDDING_PROVIDER, "feature-extraction", apiKeyProvisional, token);
}
/// <summary>
/// Loads the models one inference provider offers for a task, as the hub lists them.
/// </summary>
/// <param name="storeType">Which stored API key to use.</param>
/// <param name="pipelineTag">The task to ask for, as the hub names it.</param>
/// <param name="apiKeyProvisional">An API key which is not stored yet.</param>
/// <param name="token">The cancellation token to use.</param>
/// <returns>The models of that provider for that task.</returns>
private Task<ModelLoadResult> LoadHubModels(SecretStoreType storeType, string pipelineTag, string? apiKeyProvisional, CancellationToken token)
{
var requestURL = $"{HUB_MODELS_URL}{this.hfProvider.EndpointsId()}&pipeline_tag={pipelineTag}";
return this.LoadModelsResponse<IList<HubModel>>(storeType, requestURL, hubModels => hubModels.Select(hubModel => new Model(hubModel.Id, null)), token, apiKeyProvisional);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
if (!this.hfProvider.SupportsTranscription())
return Task.FromResult(ModelLoadResult.FromModels([]));
return this.LoadHubModels(SecretStoreType.TRANSCRIPTION_PROVIDER, "automatic-speech-recognition", apiKeyProvisional, token);
}
#endregion
}
}
@@ -206,10 +206,17 @@ public static class LLMProvidersExtensions
LLMProviders.FIREWORKS => false,
LLMProviders.X => false,
LLMProviders.DEEP_SEEK => false,
LLMProviders.HUGGINGFACE => false,
LLMProviders.PERPLEXITY => false,
LLMProviders.HETZNER => false,
//
// Hugging Face serves embeddings, but not through the router endpoint we chat with: that
// one answers "/v1/embeddings" with a plain "Not Found". They have to be asked of one
// inference provider directly, and only some of them answer the OpenAI-compatible form.
// Which ones is decided by HFInferenceProviderExtensions.SupportsEmbeddings.
//
LLMProviders.HUGGINGFACE => true,
//
// Self-hosted providers are treated as a special case anyway.
//
@@ -246,9 +253,16 @@ public static class LLMProvidersExtensions
LLMProviders.ANTHROPIC => false,
LLMProviders.X => false,
LLMProviders.DEEP_SEEK => false,
LLMProviders.HUGGINGFACE => false,
LLMProviders.PERPLEXITY => false,
//
// Hugging Face transcribes audio, but like embeddings, not through the router endpoint we
// chat with: that one answers "/v1/audio/transcriptions" with a plain "Not Found". Only
// some of the inference providers answer the OpenAI-compatible form, which
// HFInferenceProviderExtensions.SupportsTranscription decides.
//
LLMProviders.HUGGINGFACE => true,
//
// Self-hosted providers are treated as a special case anyway.
//
@@ -264,7 +278,7 @@ public static class LLMProvidersExtensions
/// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this AIStudio.Settings.Provider providerSettings)
{
return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.Model, providerSettings.HFInferenceProvider, providerSettings.Id, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration);
return providerSettings.UsedLLMProvider.CreateProvider(providerSettings.InstanceName, providerSettings.Host, providerSettings.Hostname, providerSettings.HFInferenceProvider, providerSettings.Id, providerSettings.AdditionalJsonApiParameters, providerSettings.IsEnterpriseConfiguration);
}
/// <summary>
@@ -274,7 +288,7 @@ public static class LLMProvidersExtensions
/// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this EmbeddingProvider embeddingProviderSettings)
{
return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.Model, HFInferenceProvider.NONE, configuredProviderId: embeddingProviderSettings.Id, isEnterpriseConfiguration: embeddingProviderSettings.IsEnterpriseConfiguration);
return embeddingProviderSettings.UsedLLMProvider.CreateProvider(embeddingProviderSettings.Name, embeddingProviderSettings.Host, embeddingProviderSettings.Hostname, embeddingProviderSettings.HFInferenceProvider, configuredProviderId: embeddingProviderSettings.Id, isEnterpriseConfiguration: embeddingProviderSettings.IsEnterpriseConfiguration, hfEndpointKind: HFEndpointKind.EMBEDDING);
}
/// <summary>
@@ -284,10 +298,10 @@ public static class LLMProvidersExtensions
/// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this TranscriptionProvider transcriptionProviderSettings)
{
return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.Model, HFInferenceProvider.NONE, configuredProviderId: transcriptionProviderSettings.Id, isEnterpriseConfiguration: transcriptionProviderSettings.IsEnterpriseConfiguration);
return transcriptionProviderSettings.UsedLLMProvider.CreateProvider(transcriptionProviderSettings.Name, transcriptionProviderSettings.Host, transcriptionProviderSettings.Hostname, transcriptionProviderSettings.HFInferenceProvider, configuredProviderId: transcriptionProviderSettings.Id, isEnterpriseConfiguration: transcriptionProviderSettings.IsEnterpriseConfiguration, hfEndpointKind: HFEndpointKind.TRANSCRIPTION);
}
private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, Model model, HFInferenceProvider inferenceProvider, string configuredProviderId = "", string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false)
private static IProvider CreateProvider(this LLMProviders provider, string instanceName, Host host, string hostname, HFInferenceProvider inferenceProvider, string configuredProviderId = "", string expertProviderApiParameter = "", bool isEnterpriseConfiguration = false, HFEndpointKind hfEndpointKind = HFEndpointKind.CHAT)
{
try
{
@@ -308,7 +322,7 @@ public static class LLMProvidersExtensions
LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, model) { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.HUGGINGFACE => new ProviderHuggingFace(inferenceProvider, hfEndpointKind) { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.SELF_HOSTED => new ProviderSelfHosted(host, hostname) { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
@@ -392,14 +406,13 @@ public static class LLMProvidersExtensions
public static string GetModelsOverviewURL(this LLMProviders provider, HFInferenceProvider inferenceProvider) => provider switch
{
LLMProviders.FIREWORKS => "https://fireworks.ai/models?show=Serverless",
LLMProviders.HUGGINGFACE => $"https://huggingface.co/models?inference_provider={inferenceProvider.EndpointsId()}",
LLMProviders.HUGGINGFACE => $"https://huggingface.co/models?inference_provider={inferenceProvider.CatalogFilter()}",
_ => string.Empty,
};
public static bool IsLLMModelProvidedManually(this LLMProviders provider) => provider switch
{
LLMProviders.FIREWORKS => true,
LLMProviders.HUGGINGFACE => true,
_ => false,
};
@@ -46,7 +46,16 @@ public static class ModelKindExtensions
private static readonly string[] IMAGE_GENERATION_MARKERS = ["flux", "stable-diffusion", "sdxl", "dall-e", "midjourney", "gpt-image"];
private static readonly string[] VIDEO_GENERATION_MARKERS = ["sora", "veo-", "kling", "runway"];
private static readonly string[] VIDEO_GENERATION_MARKERS = ["sora", "veo-", "runway"];
//
// Markers which have to stand as a word of their own. "kling" is such a case: taken as a plain
// substring, it also matches the organization "Klingspor", the model "Inkling", and the
// fine-tune "Llama-2-7b-chat-klingon" -- all of them models to chat with, which would vanish
// from the user's list. The video models themselves are named "kling-v1" or "kling-video",
// where the name ends at a separator.
//
private static readonly string[] VIDEO_GENERATION_WORD_MARKERS = ["kling"];
//
// Voxtral is marketed as an audio model which understands speech, so one could expect it to work
@@ -101,7 +110,7 @@ public static class ModelKindExtensions
if (HasAnyMarker(model.Id, IMAGE_GENERATION_MARKERS))
return ModelKind.IMAGE_GENERATION;
if (HasAnyMarker(model.Id, VIDEO_GENERATION_MARKERS))
if (HasAnyMarker(model.Id, VIDEO_GENERATION_MARKERS) || HasAnyWordMarker(model.Id, VIDEO_GENERATION_WORD_MARKERS))
return ModelKind.VIDEO_GENERATION;
if (HasAnyMarker(model.Id, REALTIME_MARKERS))
@@ -158,4 +167,53 @@ public static class ModelKindExtensions
return false;
}
/// <summary>
/// Checks whether the model name contains one of the markers as a word of its own.
/// </summary>
/// <remarks>
/// A short marker which is also a common syllable cannot be looked for as a plain substring:
/// it would match names which have nothing to do with it, and the model would be sorted into
/// the wrong kind. Such a marker counts only where a name segment begins and ends with it.
/// </remarks>
/// <param name="modelId">The ID of the model.</param>
/// <param name="markers">The markers to look for.</param>
/// <returns>True, when one of the markers stands as a word of its own.</returns>
private static bool HasAnyWordMarker(string modelId, string[] markers)
{
foreach (var marker in markers)
{
var searchIndex = 0;
while (searchIndex <= modelId.Length - marker.Length)
{
var markerIndex = modelId.IndexOf(marker, searchIndex, StringComparison.OrdinalIgnoreCase);
if (markerIndex is -1)
break;
if (IsWholeWord(modelId, marker, markerIndex))
return true;
// The same marker may appear again later in the name, so we keep looking:
searchIndex = markerIndex + 1;
}
}
return false;
}
private static bool IsWholeWord(string modelId, string marker, int markerIndex)
{
if (markerIndex > 0 && !IsSeparator(modelId[markerIndex - 1]))
return false;
var endIndex = markerIndex + marker.Length;
return endIndex >= modelId.Length || IsSeparator(modelId[endIndex]);
}
/// <summary>
/// The characters which separate the parts of a model name, such as in "fal-ai/kling-video".
/// </summary>
/// <param name="character">The character to check.</param>
/// <returns>True, when the character separates two parts of a name.</returns>
private static bool IsSeparator(char character) => character is '/' or '-' or '_' or '.' or ' ' or ':';
}
@@ -5,4 +5,13 @@ public enum ProviderRequestFailureReason
NONE,
INSUFFICIENT_QUOTA,
TOO_MANY_REQUESTS,
/// <summary>
/// The provider does not serve the requested model.
/// </summary>
/// <remarks>
/// This applies to gateways which route to other providers: the model exists, but the one
/// meant to answer for it does not offer it.
/// </remarks>
MODEL_NOT_SUPPORTED_BY_PROVIDER,
}