Added IONOS as a provider and unified how providers sort their model lists (#941)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-29 20:45:39 +02:00
1 parent edbb75dd9f
commit 53da222562
29 files changed
+410 -50

No files matched your search

@@ -105,7 +105,9 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
var result = await this.LoadModels(["q"], SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
return result with
{
Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)]
// The API is the authority: when it reports a model we also keep as a fallback above,
// its entry comes first and the fallback is dropped.
Models = [..result.Models.Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id)]
};
}
@@ -127,7 +129,9 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C
var result = await this.LoadModels(["text-embedding-"], SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
return result with
{
Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)]
// The API is the authority: when it reports a model we also keep as a fallback above,
// its entry comes first and the fallback is dropped.
Models = [..result.Models.Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id)]
};
}
@@ -143,7 +143,9 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
return result with
{
Models = [..result.Models.Concat(additionalModels).OrderBy(x => x.Id)]
// The API is the authority: when it reports a model we also keep as a fallback above,
// its entry comes first and the fallback is dropped.
Models = [..result.Models.Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id)]
};
}
@@ -102,7 +102,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data,
modelResponse => modelResponse.Data.Where(model => model.IsChatModel()),
token,
apiKeyProvisional);
}
@@ -78,7 +78,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, token, apiKeyProvisional);
return result with
{
Models = [..result.Models.Where(model => !model.Id.StartsWith("e5-mistral-7b-instruct", StringComparison.InvariantCultureIgnoreCase))]
Models = [..result.Models.Where(model => model.IsChatModel())]
};
}
@@ -94,7 +94,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, token, apiKeyProvisional);
return result with
{
Models = [..result.Models.Where(model => model.Id.StartsWith("e5-", StringComparison.InvariantCultureIgnoreCase))]
Models = [..result.Models.Where(model => model.IsEmbeddingModel())]
};
}
@@ -124,4 +124,4 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht
return result;
}
}
}
@@ -82,9 +82,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
{
Models =
[
..result.Models.Where(model => !model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) &&
!model.Id.Contains("-embedding", StringComparison.InvariantCultureIgnoreCase)
)
..result.Models.Where(model => model.IsChatModel())
]
};
}
@@ -103,10 +101,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n
{
Models =
[
..result.Models.Where(model =>
model.Id.Contains("-embedding", StringComparison.InvariantCultureIgnoreCase) ||
model.Id.StartsWith("text-", StringComparison.InvariantCultureIgnoreCase) ||
model.Id.Contains("gritlm", StringComparison.InvariantCultureIgnoreCase))
..result.Models.Where(model => model.IsEmbeddingModel())
]
};
}
@@ -68,7 +68,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data, token, apiKeyProvisional);
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel()), token, apiKeyProvisional);
}
/// <inheritdoc />
@@ -0,0 +1,133 @@
using System.Runtime.CompilerServices;
using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
namespace AIStudio.Provider.IONOS;
public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("https://openai.inference.de-txl.ionos.com/v1/"), ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY, LOGGER)
{
/// <summary>
/// IONOS keeps an alias of some embedding models around, so that customers can migrate away from
/// the previous naming. Those aliases point to the very same models we already offer, which is
/// why we hide them instead of listing every embedding model twice.
/// </summary>
private const string MIGRATION_ALIAS_SUFFIX = "-migration";
private static readonly ILogger<ProviderIONOS> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ProviderIONOS>();
#region Implementation of IProvider
/// <inheritdoc />
public override string Id => LLMProviders.IONOS.ToSecretId();
/// <inheritdoc />
public override string InstanceName { get; set; } = "IONOS";
/// <inheritdoc />
public override bool HasModelLoadingCapability => true;
/// <inheritdoc />
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
{
await foreach (var content in this.StreamOpenAICompatibleChatCompletion<ChatCompletionAPIRequest, ChatCompletionDeltaStreamLine, NoChatCompletionAnnotationStreamLine>(
"IONOS",
chatModel,
chatThread,
settingsManager,
async (systemPrompt, apiParameters) =>
{
// Build the list of messages:
var messages = await chatThread.Blocks.BuildMessagesUsingNestedImageUrlAsync(this.Provider, chatModel);
return new ChatCompletionAPIRequest
{
Model = chatModel.Id,
// Build the messages:
// - First of all the system prompt
// - Then none-empty user and AI messages
Messages = [systemPrompt, ..messages],
// Right now, we only support streaming completions:
Stream = true,
AdditionalApiParameters = apiParameters
};
},
token: token))
yield return content;
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public override async IAsyncEnumerable<ImageURL> StreamImageCompletion(Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
{
yield break;
}
#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)
{
return Task.FromResult(TranscriptionResult.Failure());
}
/// <inhertidoc />
public override async Task<IReadOnlyList<IReadOnlyList<float>>> EmbedTextAsync(Model embeddingModel, SettingsManager settingsManager, CancellationToken token = default, params List<string> texts)
{
var requestedSecret = await Program.RUST_SERVICE.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER);
return await this.PerformStandardTextEmbeddingRequest(requestedSecret, embeddingModel, token: token, texts: texts);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(), token, apiKeyProvisional);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(), token, apiKeyProvisional);
}
/// <inheritdoc />
public override Task<ModelLoadResult> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(ModelLoadResult.FromModels([]));
}
#endregion
/// <summary>
/// Loads the models of one kind from IONOS.
/// </summary>
/// <remarks>
/// IONOS serves chat, embedding, reranking, OCR, and image models through one endpoint, and its
/// response tells us nothing but the model's name. We therefore let the shared model kind
/// detection sort them apart.
/// </remarks>
/// <param name="storeType">The secret store to read the API key from.</param>
/// <param name="isWantedKind">Decides whether a model belongs to the requested kind.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="apiKeyProvisional">An API key which was not stored yet.</param>
/// <returns>The models of the requested kind.</returns>
private Task<ModelLoadResult> LoadModels(SecretStoreType storeType, Func<Model, bool> isWantedKind, CancellationToken token, string? apiKeyProvisional = null)
{
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data
.Where(model => !model.Id.EndsWith(MIGRATION_ALIAS_SUFFIX, StringComparison.OrdinalIgnoreCase))
.Where(isWantedKind),
token,
apiKeyProvisional);
}
}
@@ -17,6 +17,7 @@ public enum LLMProviders
PERPLEXITY = 14,
OPEN_ROUTER = 15,
HETZNER = 16,
IONOS = 17,
FIREWORKS = 5,
GROQ = 6,
@@ -8,6 +8,7 @@ using AIStudio.Provider.GWDG;
using AIStudio.Provider.Helmholtz;
using AIStudio.Provider.Hetzner;
using AIStudio.Provider.HuggingFace;
using AIStudio.Provider.IONOS;
using AIStudio.Provider.Mistral;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.OpenRouter;
@@ -58,6 +59,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => "Perplexity",
LLMProviders.OPEN_ROUTER => "OpenRouter",
LLMProviders.HETZNER => "Hetzner (Experimental)",
LLMProviders.IONOS => "IONOS",
LLMProviders.GROQ => "Groq",
LLMProviders.FIREWORKS => "Fireworks.ai",
@@ -94,6 +96,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => "Perplexity",
LLMProviders.OPEN_ROUTER => "OpenRouter",
LLMProviders.HETZNER => "Hetzner",
LLMProviders.IONOS => "IONOS",
LLMProviders.GROQ => "Groq",
LLMProviders.FIREWORKS => "Fireworks.ai",
@@ -153,6 +156,11 @@ public static class LLMProvidersExtensions
"https://www.hetzner.com/legal/terms-and-conditions/"
).WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.IONOS => Confidence.GDPR_NO_TRAINING.WithRegion("Europe, Germany").WithSources(
"https://docs.ionos.com/cloud/ai/ai-model-hub/governance-and-compliance/data-handling",
"https://www.ionos.com/terms-gtc/privacy-policy/"
).WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.SELF_HOSTED => Confidence.SELF_HOSTED.WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
LLMProviders.HELMHOLTZ => Confidence.GDPR_NO_TRAINING.WithRegion("Europe, Germany").WithSources("https://helmholtz.cloud/services/?serviceID=d7d5c597-a2f6-4bd1-b71e-4d6499d98570").WithLevel(settingsManager.GetConfiguredConfidenceLevel(llmProvider)),
@@ -176,6 +184,7 @@ public static class LLMProvidersExtensions
LLMProviders.GOOGLE => true,
LLMProviders.HELMHOLTZ => true,
LLMProviders.ALIBABA_CLOUD => true,
LLMProviders.IONOS => true,
//
// Providers that do not support embeddings:
@@ -220,6 +229,7 @@ public static class LLMProvidersExtensions
//
LLMProviders.OPEN_ROUTER => false,
LLMProviders.HETZNER => false,
LLMProviders.IONOS => false,
LLMProviders.GROQ => false,
LLMProviders.ANTHROPIC => false,
LLMProviders.X => false,
@@ -283,6 +293,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => new ProviderPerplexity { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.OPEN_ROUTER => new ProviderOpenRouter { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.HETZNER => new ProviderHetzner { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.IONOS => new ProviderIONOS { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.GROQ => new ProviderGroq { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
LLMProviders.FIREWORKS => new ProviderFireworks { InstanceName = instanceName, ConfiguredProviderId = configuredProviderId, AdditionalJsonApiParameters = expertProviderApiParameter, IsEnterpriseConfiguration = isEnterpriseConfiguration },
@@ -315,6 +326,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => "https://www.perplexity.ai/account/api",
LLMProviders.OPEN_ROUTER => "https://openrouter.ai/keys",
LLMProviders.HETZNER => "https://experiments.hetzner.com",
LLMProviders.IONOS => "https://cloud.ionos.com/compute/sign-up",
LLMProviders.GROQ => "https://console.groq.com/",
LLMProviders.FIREWORKS => "https://fireworks.ai/login",
@@ -341,6 +353,7 @@ public static class LLMProvidersExtensions
LLMProviders.OPEN_ROUTER => "https://openrouter.ai/activity",
LLMProviders.HUGGINGFACE => "https://huggingface.co/settings/billing",
LLMProviders.HETZNER => "https://experiments.hetzner.com",
LLMProviders.IONOS => "https://dcd.ionos.com/latest/?page=dcd-ai-model-hub",
_ => string.Empty,
};
@@ -360,6 +373,7 @@ public static class LLMProvidersExtensions
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HUGGINGFACE => true,
LLMProviders.HETZNER => true,
LLMProviders.IONOS => true,
_ => false,
};
@@ -438,6 +452,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => true,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HETZNER => true,
LLMProviders.IONOS => true,
LLMProviders.GROQ => true,
LLMProviders.FIREWORKS => true,
@@ -462,6 +477,7 @@ public static class LLMProvidersExtensions
LLMProviders.PERPLEXITY => true,
LLMProviders.OPEN_ROUTER => true,
LLMProviders.HETZNER => true,
LLMProviders.IONOS => true,
LLMProviders.GROQ => true,
LLMProviders.FIREWORKS => true,
@@ -29,6 +29,7 @@ public static class LLMProvidersIconExtensions
LLMProviders.PERPLEXITY => $"{ICON_ROOT}/perplexity.svg",
LLMProviders.OPEN_ROUTER => $"{ICON_ROOT}/openrouter.svg",
LLMProviders.HETZNER => $"{ICON_ROOT}/hetzner.svg",
LLMProviders.IONOS => $"{ICON_ROOT}/ionos.svg",
LLMProviders.GROQ => $"{ICON_ROOT}/groq.svg",
LLMProviders.FIREWORKS => $"{ICON_ROOT}/fireworks.svg",
LLMProviders.HUGGINGFACE => $"{ICON_ROOT}/hugging-face.svg",
@@ -91,10 +91,12 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U
{
Models =
[
// Codestral is a fill-in-the-middle model, which we cannot use for chats. That is
// specific to Mistral's catalog, which is why it is not part of the shared model
// kind detection:
..modelResponse.Models.Where(n =>
!n.Id.StartsWith("code", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("embed", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase))
n.IsChatModel())
]
};
}
@@ -108,7 +110,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U
return modelResponse with
{
Models = [..modelResponse.Models.Where(n => n.Id.Contains("embed", StringComparison.InvariantCulture))]
Models = [..modelResponse.Models.Where(n => n.IsEmbeddingModel())]
};
}
+26 -8
View File
@@ -42,15 +42,33 @@ public readonly record struct Model(string Id, string? DisplayName)
#endregion
#region Implementation of IEquatable<Model?>
#region Implementation of IEquatable<Model>
public bool Equals(Model? other)
{
if(other is null)
return false;
return this.Id == other.Value.Id;
}
/// <summary>
/// Two models are the same model when they carry the same ID.
/// </summary>
/// <remarks>
/// The display name is decoration. A provider may report a model under a display name of its own,
/// while we know the very same model as a hardcoded fallback under a different one. Comparing the
/// ID alone keeps those two the same model, so that removing duplicates works.
///
/// Note that this overload is the one the runtime uses, for example for Distinct(). The overload
/// taking a nullable model below is a separate one and never gets called on its behalf, which is
/// why the hash code has to follow this one.
/// </remarks>
/// <param name="other">The model to compare with.</param>
/// <returns>True, when both models carry the same ID.</returns>
public bool Equals(Model other) => string.Equals(this.Id, other.Id, StringComparison.Ordinal);
/// <summary>
/// Two models are the same model when they carry the same ID.
/// </summary>
/// <param name="other">The model to compare with, which may be null.</param>
/// <returns>True, when the other model exists and carries the same ID.</returns>
public bool Equals(Model? other) => other is not null && this.Equals(other.Value);
/// <inheritdoc />
public override int GetHashCode() => this.Id?.GetHashCode(StringComparison.Ordinal) ?? 0;
#endregion
}
@@ -0,0 +1,69 @@
namespace AIStudio.Provider;
/// <summary>
/// The kind of an AI model, i.e. what the model is made for.
/// </summary>
/// <remarks>
/// This describes what kind of model we are dealing with. It answers a different question than the
/// Capability enum: capabilities describe what a chat model is able to do, for example whether it
/// accepts images or performs reasoning. Note that Capability.EMBEDDING marks a chat model which is
/// able to create embeddings as well, whereas ModelKind.EMBEDDING marks a model whose only purpose
/// is creating embeddings.
/// </remarks>
public enum ModelKind
{
/// <summary>
/// The model is used for chat completions.
/// </summary>
/// <remarks>
/// This is the fallback: we report a model as a chat model whenever we do not recognize any
/// other kind. Providers keep adding models we have never heard of, and a model we fail to
/// recognize must stay visible to the user instead of silently disappearing from their list.
/// </remarks>
CHAT,
/// <summary>
/// The model continues a text instead of answering in a conversation.
/// </summary>
/// <remarks>
/// These are the models from the era before chat completions, such as OpenAI's text-davinci-003.
/// Some providers still offer them, but they only work through the completions endpoint. Asking
/// them for a chat completion fails, so they must not show up as chat models.
/// </remarks>
TEXT_COMPLETION,
/// <summary>
/// The model maps text or images into a vector space.
/// </summary>
EMBEDDING,
/// <summary>
/// The model scores documents against a query to reorder search results.
/// </summary>
RERANKING,
/// <summary>
/// The model generates or edits images.
/// </summary>
IMAGE_GENERATION,
/// <summary>
/// The model transcribes audio into text.
/// </summary>
TRANSCRIPTION,
/// <summary>
/// The model synthesizes speech from text.
/// </summary>
SPEECH_SYNTHESIS,
/// <summary>
/// The model extracts text from images or scanned documents.
/// </summary>
OCR,
/// <summary>
/// The model classifies content for policy violations.
/// </summary>
MODERATION,
}
@@ -0,0 +1,116 @@
namespace AIStudio.Provider;
/// <summary>
/// Determines what kind of model we are dealing with, based on its name.
/// </summary>
/// <remarks>
/// Many providers serve every kind of model through one models endpoint, without telling us what
/// kind each model is. Before this class existed, every provider carried its own list of name
/// fragments to sort those models apart. Those lists disagreed with each other: a model like
/// nomic-embed-text was recognized as an embedding model by some providers, while others offered it
/// as a chat model. The knowledge about model families is the same for all providers, so it lives
/// here now.
///
/// This class recognizes what a model is NOT made for. Everything we do not recognize is reported as
/// a chat model. That direction matters: when a provider adds a model family we have never seen, the
/// user still gets to use it. Getting it wrong the other way around would hide a model the user is
/// paying for.
///
/// What this class must not become is a place for provider-specific knowledge. That a model called
/// "codestral" is a fill-in-the-middle model at Mistral, or that Alibaba's chat models all start
/// with a "q", is true for that one provider only. Such rules stay in the provider.
/// </remarks>
public static class ModelKindExtensions
{
//
// Reranking is checked before embedding: rerankers are commonly named after the embedding model
// they belong to, e.g. Qwen3-VL-Reranker-8B next to Qwen3-VL-Embedding-8B.
//
private static readonly string[] RERANKING_MARKERS = ["rerank"];
private static readonly string[] EMBEDDING_MARKERS = ["embed", "bge", "mpnet", "paraphrase", "sentence-transformers", "gte-", "e5-", "gritlm"];
//
// The models from before chat completions existed. Providers keep offering some of them, and
// Helmholtz Blablador still reports 'text-davinci-003', but asking any of them for a chat
// completion fails. We deliberately do not look for 'ada' here: three letters appear in far too
// many unrelated model names, and losing a chat model weighs heavier than keeping a dead one.
//
private static readonly string[] TEXT_COMPLETION_MARKERS = ["davinci", "babbage", "curie", "gpt-3.5-turbo-instruct"];
private static readonly string[] IMAGE_GENERATION_MARKERS = ["flux", "stable-diffusion", "sdxl", "dall-e", "midjourney", "gpt-image"];
//
// Voxtral is marketed as an audio model which understands speech, so one could expect it to work
// in a chat as well. It does not: asking Mistral for a chat completion with 'voxtral-mini-latest'
// is answered with 'Invalid model'. Voxtral therefore belongs here, next to the models which do
// nothing but transcribe.
//
private static readonly string[] TRANSCRIPTION_MARKERS = ["whisper", "-transcribe", "wav2vec", "parakeet", "voxtral"];
private static readonly string[] SPEECH_SYNTHESIS_MARKERS = ["-tts", "tts-", "-speech", "speech-"];
private static readonly string[] OCR_MARKERS = ["ocr"];
private static readonly string[] MODERATION_MARKERS = ["moderation", "guard"];
/// <summary>
/// Determines what kind of model this is, based on its name.
/// </summary>
/// <param name="model">The model to inspect.</param>
/// <returns>The recognized kind, or ModelKind.CHAT when we recognize no other kind.</returns>
public static ModelKind DetermineKind(this Model model)
{
if (string.IsNullOrWhiteSpace(model.Id) || model.IsSystemModel)
return ModelKind.CHAT;
if (HasAnyMarker(model.Id, RERANKING_MARKERS))
return ModelKind.RERANKING;
if (HasAnyMarker(model.Id, EMBEDDING_MARKERS))
return ModelKind.EMBEDDING;
if (HasAnyMarker(model.Id, TEXT_COMPLETION_MARKERS))
return ModelKind.TEXT_COMPLETION;
if (HasAnyMarker(model.Id, IMAGE_GENERATION_MARKERS))
return ModelKind.IMAGE_GENERATION;
if (HasAnyMarker(model.Id, TRANSCRIPTION_MARKERS))
return ModelKind.TRANSCRIPTION;
if (HasAnyMarker(model.Id, SPEECH_SYNTHESIS_MARKERS))
return ModelKind.SPEECH_SYNTHESIS;
if (HasAnyMarker(model.Id, OCR_MARKERS))
return ModelKind.OCR;
if (HasAnyMarker(model.Id, MODERATION_MARKERS))
return ModelKind.MODERATION;
return ModelKind.CHAT;
}
/// <summary>
/// Checks whether this model can be used for chatting.
/// </summary>
/// <param name="model">The model to check.</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) => model.DetermineKind() is ModelKind.CHAT;
/// <summary>
/// Checks whether this model creates embeddings.
/// </summary>
/// <param name="model">The model to check.</param>
/// <returns>True, when the model is an embedding model.</returns>
public static bool IsEmbeddingModel(this Model model) => model.DetermineKind() is ModelKind.EMBEDDING;
private static bool HasAnyMarker(string modelId, string[] markers)
{
foreach (var marker in markers)
if (modelId.Contains(marker, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
@@ -115,16 +115,8 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER
storeType,
"models",
modelResponse => modelResponse.Data
.Where(n =>
!n.Id.Contains("whisper", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("dall-e", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("tts", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("embedding", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("moderation", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("stable-diffusion", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("flux", StringComparison.OrdinalIgnoreCase) &&
!n.Id.Contains("midjourney", StringComparison.OrdinalIgnoreCase))
.Select(n => new Model(n.Id, n.Name)),
.Select(n => new Model(n.Id, n.Name))
.Where(model => model.IsChatModel()),
token,
apiKeyProvisional,
requestConfigurator: (request, secretKey) =>