From 849f072f3ecf8597a3ba221eccffcced72b561ba Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 12 Sep 2026 14:22:21 +0200 Subject: [PATCH] Answer the model kind from the registry --- .../Provider/DeepSeek/ProviderDeepSeek.cs | 2 +- .../Provider/GWDG/ProviderGWDG.cs | 4 +- .../Provider/Google/ProviderGoogle.cs | 4 +- .../Provider/Groq/ProviderGroq.cs | 4 +- .../Provider/Helmholtz/ProviderHelmholtz.cs | 6 +- .../Provider/Hetzner/ProviderHetzner.cs | 2 +- .../HuggingFace/ProviderHuggingFace.cs | 2 +- .../Provider/IONOS/ProviderIONOS.cs | 4 +- .../Provider/LiteLLM/ProviderLiteLLM.cs | 6 +- .../Provider/Mistral/ProviderMistral.cs | 4 +- .../Provider/ModelKindExtensions.cs | 228 ------------------ .../Provider/OpenAI/ProviderOpenAI.cs | 8 +- .../Provider/OpenRouter/ProviderOpenRouter.cs | 2 +- .../Provider/X/ProviderX.cs | 2 +- .../Settings/ProviderExtensions.cs | 43 ++++ app/Tests/Models/Corpus/ModelKindCorpus.cs | 9 - app/Tests/Models/Corpus/ModelKindExample.cs | 2 +- app/Tests/Models/ModelKindTests.cs | 55 +---- 18 files changed, 77 insertions(+), 310 deletions(-) delete mode 100644 app/MindWork AI Studio/Provider/ModelKindExtensions.cs diff --git a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs index 8addbdb4..b830684f 100644 --- a/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs +++ b/app/MindWork AI Studio/Provider/DeepSeek/ProviderDeepSeek.cs @@ -103,7 +103,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne return this.LoadModelsResponse( storeType, "models", - modelResponse => modelResponse.Data.Where(model => model.IsChatModel()), + modelResponse => modelResponse.Data.Where(model => model.IsChatModel(this.Provider)), apiKeyProvisional, token: token); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs index 1536b4e6..54b32e57 100644 --- a/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs +++ b/app/MindWork AI Studio/Provider/GWDG/ProviderGWDG.cs @@ -88,7 +88,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token); return result with { - Models = [..result.Models.Where(model => model.IsChatModel())] + Models = [..result.Models.Where(model => model.IsChatModel(this.Provider))] }; } @@ -112,7 +112,7 @@ public sealed class ProviderGWDG() : BaseProvider(LLMProviders.GWDG, new Uri("ht if (!result.Success) return result; - var embeddingModels = result.Models.Where(model => model.IsEmbeddingModel()).ToList(); + var embeddingModels = result.Models.Where(model => model.IsEmbeddingModel(this.Provider)).ToList(); if (embeddingModels.Count is 0) return ModelLoadResult.FromModels(KNOWN_EMBEDDING_MODELS); diff --git a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs index e4a0e87e..17363e1d 100644 --- a/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs +++ b/app/MindWork AI Studio/Provider/Google/ProviderGoogle.cs @@ -176,7 +176,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https // ..result.Models.Where(model => model.Id.StartsWith("gemini-", StringComparison.OrdinalIgnoreCase) && - model.IsChatModel()) + model.IsChatModel(this.Provider)) .Select(this.WithDisplayNameFallback) ] }; @@ -195,7 +195,7 @@ public class ProviderGoogle() : BaseProvider(LLMProviders.GOOGLE, new Uri("https { Models = [ - ..result.Models.Where(model => model.IsEmbeddingModel()) + ..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)) .Select(this.WithDisplayNameFallback) ] }; diff --git a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs index 243596f8..0544a5d6 100644 --- a/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs +++ b/app/MindWork AI Studio/Provider/Groq/ProviderGroq.cs @@ -83,7 +83,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token); return result with { - Models = [..result.Models.Where(model => model.IsChatModel())] + Models = [..result.Models.Where(model => model.IsChatModel(this.Provider))] }; } @@ -105,7 +105,7 @@ public class ProviderGroq() : BaseProvider(LLMProviders.GROQ, new Uri("https://a var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token); return result with { - Models = [..result.Models.Where(model => model.IsTranscriptionModel())] + Models = [..result.Models.Where(model => model.IsTranscriptionModel(this.Provider))] }; } diff --git a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs index b9dff0a1..b40f77c1 100644 --- a/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs +++ b/app/MindWork AI Studio/Provider/Helmholtz/ProviderHelmholtz.cs @@ -84,7 +84,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n { Models = [ - ..result.Models.Where(model => model.IsChatModel()) + ..result.Models.Where(model => model.IsChatModel(this.Provider)) ] }; } @@ -103,7 +103,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n { Models = [ - ..result.Models.Where(model => model.IsEmbeddingModel()) + ..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)) ] }; } @@ -116,7 +116,7 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, n { Models = [ - ..result.Models.Where(model => model.IsTranscriptionModel()) + ..result.Models.Where(model => model.IsTranscriptionModel(this.Provider)) ] }; } diff --git a/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs b/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs index 231fc85e..59080ada 100644 --- a/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs +++ b/app/MindWork AI Studio/Provider/Hetzner/ProviderHetzner.cs @@ -69,7 +69,7 @@ public sealed class ProviderHetzner() : BaseProvider(LLMProviders.HETZNER, new U /// public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModelsResponse(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel()), apiKeyProvisional, token: token); + return this.LoadModelsResponse(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel(this.Provider)), apiKeyProvisional, token: token); } /// diff --git a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs index af324010..35e64e52 100644 --- a/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs +++ b/app/MindWork AI Studio/Provider/HuggingFace/ProviderHuggingFace.cs @@ -237,7 +237,7 @@ public sealed class ProviderHuggingFace : BaseProvider /// The models to offer. private IEnumerable SelectChatModels(ModelsResponse response) { - var chatModels = response.Data.Where(hfModel => new Model(hfModel.Id, null).IsChatModel()); + var chatModels = response.Data.Where(hfModel => new Model(hfModel.Id, null).IsChatModel(this.Provider)); var providerSlug = this.hfProvider.EndpointsId(); if (string.IsNullOrEmpty(providerSlug)) return ToModels(chatModels); diff --git a/app/MindWork AI Studio/Provider/IONOS/ProviderIONOS.cs b/app/MindWork AI Studio/Provider/IONOS/ProviderIONOS.cs index 5a9ac32b..8bfe5806 100644 --- a/app/MindWork AI Studio/Provider/IONOS/ProviderIONOS.cs +++ b/app/MindWork AI Studio/Provider/IONOS/ProviderIONOS.cs @@ -84,7 +84,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri(" /// public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(this.Provider), apiKeyProvisional, token); } /// @@ -96,7 +96,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri(" /// public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(this.Provider), apiKeyProvisional, token); } /// diff --git a/app/MindWork AI Studio/Provider/LiteLLM/ProviderLiteLLM.cs b/app/MindWork AI Studio/Provider/LiteLLM/ProviderLiteLLM.cs index 96f9486b..dbcafa4c 100644 --- a/app/MindWork AI Studio/Provider/LiteLLM/ProviderLiteLLM.cs +++ b/app/MindWork AI Studio/Provider/LiteLLM/ProviderLiteLLM.cs @@ -77,7 +77,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders /// public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(this.Provider), apiKeyProvisional, token); } /// @@ -89,13 +89,13 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders /// public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(this.Provider), apiKeyProvisional, token); } /// public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, model => model.IsTranscriptionModel(this.Provider), apiKeyProvisional, token); } #endregion diff --git a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs index e82f8d5c..11f436e4 100644 --- a/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs +++ b/app/MindWork AI Studio/Provider/Mistral/ProviderMistral.cs @@ -97,7 +97,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U // kind detection: ..modelResponse.Models.Where(n => !n.Id.StartsWith("code", StringComparison.OrdinalIgnoreCase) && - n.IsChatModel()) + n.IsChatModel(this.Provider)) ] }; } @@ -111,7 +111,7 @@ public sealed class ProviderMistral() : BaseProvider(LLMProviders.MISTRAL, new U return modelResponse with { - Models = [..modelResponse.Models.Where(n => n.IsEmbeddingModel())] + Models = [..modelResponse.Models.Where(n => n.IsEmbeddingModel(this.Provider))] }; } diff --git a/app/MindWork AI Studio/Provider/ModelKindExtensions.cs b/app/MindWork AI Studio/Provider/ModelKindExtensions.cs deleted file mode 100644 index d3e3535f..00000000 --- a/app/MindWork AI Studio/Provider/ModelKindExtensions.cs +++ /dev/null @@ -1,228 +0,0 @@ -namespace AIStudio.Provider; - -/// -/// Determines what kind of model we are dealing with, based on its name. -/// -/// -/// 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. -/// -public static class ModelKindExtensions -{ - // - // Checked first, because these entries are no models at all: whatever else their name might - // suggest, none of the other kinds applies to them. - // - private static readonly string[] OTHER_MARKERS = ["container"]; - - // - // 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"]; - - // - // Google names its image models after the chat model they grew out of and appends "image": - // gemini-3-pro-image, gemini-3.1-flash-image, gemini-2.5-flash-image. Read as a plain substring, - // that word is too greedy -- it also sits inside "imagenet" and "reimagined", and a chat model - // carrying such a word would disappear from the user's list. It therefore counts only where a - // name segment begins and ends with it. - // - private static readonly string[] IMAGE_GENERATION_WORD_MARKERS = ["image"]; - - private static readonly string[] VIDEO_GENERATION_MARKERS = ["sora", "veo-", "runway", "hailuo"]; - - // - // 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 - // 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"]; - - // - // Besides the pure text-to-speech models, this covers the models which answer in audio, such as - // 'gpt-audio' and 'gpt-4o-audio-preview'. Those do accept a text-only request, but they are made - // for spoken conversations, and the providers offering them directly keep them out of their chat - // model lists as well. - // - private static readonly string[] SPEECH_SYNTHESIS_MARKERS = ["-tts", "tts-", "-speech", "speech-", "-audio", "audio-"]; - - // - // The models for spoken conversations over a live connection. They speak their own protocol, - // usually a WebSocket, and answer a chat completion request with an error. Checked before - // transcription, because some of them carry the name of a transcription model, such as - // OpenAI's 'gpt-realtime-whisper'. Those still need the live connection. - // - private static readonly string[] REALTIME_MARKERS = ["realtime"]; - - private static readonly string[] OCR_MARKERS = ["ocr"]; - - private static readonly string[] MODERATION_MARKERS = ["moderation", "guard"]; - - /// - /// Determines what kind of model this is, based on its name. - /// - /// The model to inspect. - /// The recognized kind, or ModelKind.CHAT when we recognize no other kind. - public static ModelKind DetermineKind(this Model model) - { - if (string.IsNullOrWhiteSpace(model.Id) || model.IsSystemModel) - return ModelKind.CHAT; - - if (HasAnyMarker(model.Id, OTHER_MARKERS)) - return ModelKind.OTHER; - - 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) || HasAnyWordMarker(model.Id, IMAGE_GENERATION_WORD_MARKERS)) - return ModelKind.IMAGE_GENERATION; - - if (HasAnyMarker(model.Id, VIDEO_GENERATION_MARKERS) || HasAnyWordMarker(model.Id, VIDEO_GENERATION_WORD_MARKERS)) - return ModelKind.VIDEO_GENERATION; - - if (HasAnyMarker(model.Id, REALTIME_MARKERS)) - return ModelKind.REALTIME; - - 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; - } - - /// - /// Checks whether this model can be used for chatting. - /// - /// The model to check. - /// True, when the model is a chat model or when we recognize no other kind. - public static bool IsChatModel(this Model model) => model.DetermineKind() is ModelKind.CHAT; - - /// - /// Checks whether this model creates embeddings. - /// - /// The model to check. - /// True, when the model is an embedding model. - public static bool IsEmbeddingModel(this Model model) => model.DetermineKind() is ModelKind.EMBEDDING; - - /// - /// Checks whether this model transcribes audio. - /// - /// The model to check. - /// True, when the model is a transcription model. - public static bool IsTranscriptionModel(this Model model) => model.DetermineKind() is ModelKind.TRANSCRIPTION; - - /// - /// Checks whether this model generates images. - /// - /// The model to check. - /// True, when the model is an image generation model. - public static bool IsImageModel(this Model model) => model.DetermineKind() is ModelKind.IMAGE_GENERATION; - - private static bool HasAnyMarker(string modelId, string[] markers) - { - foreach (var marker in markers) - if (modelId.Contains(marker, StringComparison.OrdinalIgnoreCase)) - return true; - - return false; - } - - /// - /// Checks whether the model name contains one of the markers as a word of its own. - /// - /// - /// 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. - /// - /// The ID of the model. - /// The markers to look for. - /// True, when one of the markers stands as a word of its own. - 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]); - } - - /// - /// The characters which separate the parts of a model name, such as in "fal-ai/kling-video". - /// - /// The character to check. - /// True, when the character separates two parts of a name. - private static bool IsSeparator(char character) => character is '/' or '-' or '_' or '.' or ' ' or ':'; -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index d999510a..faa40eb6 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -367,25 +367,25 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur /// public override Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.LLM_PROVIDER, static model => model.IsChatModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.LLM_PROVIDER, model => model.IsChatModel(this.Provider), apiKeyProvisional, token); } /// public override Task GetImageModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.IMAGE_PROVIDER, static model => model.IsImageModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.IMAGE_PROVIDER, model => model.IsImageModel(this.Provider), apiKeyProvisional, token); } /// public override Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, static model => model.IsEmbeddingModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, model => model.IsEmbeddingModel(this.Provider), apiKeyProvisional, token); } /// public override Task GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { - return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, static model => model.IsTranscriptionModel(), apiKeyProvisional, token); + return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, model => model.IsTranscriptionModel(this.Provider), apiKeyProvisional, token); } #endregion diff --git a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs index 9fc90fd9..a5844ee7 100644 --- a/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs +++ b/app/MindWork AI Studio/Provider/OpenRouter/ProviderOpenRouter.cs @@ -117,7 +117,7 @@ public sealed class ProviderOpenRouter() : BaseProvider(LLMProviders.OPEN_ROUTER "models", modelResponse => modelResponse.Data .Select(n => new Model(n.Id, n.Name)) - .Where(model => model.IsChatModel()), + .Where(model => model.IsChatModel(this.Provider)), apiKeyProvisional, requestConfigurator: (request, secretKey) => { diff --git a/app/MindWork AI Studio/Provider/X/ProviderX.cs b/app/MindWork AI Studio/Provider/X/ProviderX.cs index 5a86efed..46d96be8 100644 --- a/app/MindWork AI Studio/Provider/X/ProviderX.cs +++ b/app/MindWork AI Studio/Provider/X/ProviderX.cs @@ -84,7 +84,7 @@ public sealed class ProviderX() : BaseProvider(LLMProviders.X, new Uri("https:// // "-image", which said nothing about grok-imagine-video: that one made films and stood // in the list of things to chat with. // - Models = [..result.Models.Where(model => model.IsChatModel())] + Models = [..result.Models.Where(model => model.IsChatModel(this.Provider))] }; } diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.cs index 2d1d2c19..9c033a9e 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.cs @@ -65,4 +65,47 @@ public static partial class ProviderExtensions /// The configured provider. /// true when the model accepts image input. public static bool SupportsImageInput(this Provider provider) => provider.GetModelProfile().HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT); + + /// + /// Checks whether this model can be used for chatting. + /// + /// + /// 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. + /// + /// The model to check. + /// The provider serving it. + /// True, when the model is a chat model or when we recognize no other kind. + public static bool IsChatModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.CHAT; + + /// + /// Checks whether this model creates embeddings. + /// + /// The model to check. + /// The provider serving it. + /// True, when the model is an embedding model. + public static bool IsEmbeddingModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.EMBEDDING; + + /// + /// Checks whether this model transcribes audio. + /// + /// The model to check. + /// The provider serving it. + /// True, when the model is a transcription model. + public static bool IsTranscriptionModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.TRANSCRIPTION; + + /// + /// Checks whether this model generates images. + /// + /// The model to check. + /// The provider serving it. + /// True, when the model is an image generation model. + public static bool IsImageModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.IMAGE_GENERATION; } \ No newline at end of file diff --git a/app/Tests/Models/Corpus/ModelKindCorpus.cs b/app/Tests/Models/Corpus/ModelKindCorpus.cs index 00f3de7b..0ed736f3 100644 --- a/app/Tests/Models/Corpus/ModelKindCorpus.cs +++ b/app/Tests/Models/Corpus/ModelKindCorpus.cs @@ -209,13 +209,4 @@ public static class ModelKindCorpus ..STILL_CHAT_MODELS, ]; - /// - /// What the markers being replaced answer for a name, where that is not what the rules answer. - /// - /// Who serves the model. - /// The model ID as that provider reports it. - /// The old answer, or null when nobody recorded a difference for this name. - public static ModelKind? AnsweredTodayAs(LLMProviders provider, string modelId) => ENTRIES - .FirstOrDefault(example => example.Provider == provider && string.Equals(example.ModelId, modelId, StringComparison.Ordinal)) - ?.AnsweredTodayAs; } \ No newline at end of file diff --git a/app/Tests/Models/Corpus/ModelKindExample.cs b/app/Tests/Models/Corpus/ModelKindExample.cs index 8f5fae09..18d2acd0 100644 --- a/app/Tests/Models/Corpus/ModelKindExample.cs +++ b/app/Tests/Models/Corpus/ModelKindExample.cs @@ -8,6 +8,6 @@ namespace AIStudio.Tests.Models.Corpus; /// The provider the model is reached through. /// The model ID exactly as that provider reports it, before any normalization. /// What the model is made for. -/// What the markers being replaced answer, where that is something else. +/// What the markers that used to answer this question said, where they said something else. History now: the code that said it is gone, so nothing checks this any more. It stays because a decision without the thing it decided against reads like an arbitrary statement. /// Why the two differ, which is only filled in when they do. public sealed record ModelKindExample(LLMProviders Provider, string ModelId, ModelKind Kind, ModelKind? AnsweredTodayAs = null, string Reason = ""); \ No newline at end of file diff --git a/app/Tests/Models/ModelKindTests.cs b/app/Tests/Models/ModelKindTests.cs index 7c715a14..44b4437f 100644 --- a/app/Tests/Models/ModelKindTests.cs +++ b/app/Tests/Models/ModelKindTests.cs @@ -8,14 +8,15 @@ namespace AIStudio.Tests.Models; /// Holds the rules to what a model is made for. /// /// -/// What a model can do and what it is for are two questions, and until now two pieces of code -/// answered them, each walking the same name with rules of its own. This is the test which says the -/// second answer did not change when it moved: the marker list is still there and still answers, so -/// every example can be put to both and the two have to agree. +/// What a model can do and what it is for are two questions, and they used to be answered by two +/// pieces of code, each walking the same name with rules of its own. While both existed, the tests +/// here held one against the other. The marker list is gone now, and with it the comparison: the +/// corpus-wide check moved into the snapshot, which carries the kind of every model in a column of +/// its own. /// -/// The day the call sites move to the profile, the marker list goes and the test below which asks -/// it goes with it. What stays is the first test: the examples say what each name is, in words a -/// person can check against a model card. +/// What is left says what a name is, in words a person can check against a model card -- and holds +/// the handful of decisions where the rules deliberately answer something else than the markers did. +/// Those stand in the corpus next to the name, with the reason. /// [TestFixture] public sealed class ModelKindTests @@ -34,46 +35,6 @@ public sealed class ModelKindTests }); } - [Test] - public void TheMarkersBeingReplacedAnswerEveryExampleTheSameWay() - { - Assert.Multiple(() => - { - foreach (var example in ModelKindCorpus.ENTRIES) - { - var today = new Model(example.ModelId, null).DetermineKind(); - var wanted = example.AnsweredTodayAs ?? example.Kind; - var because = example.AnsweredTodayAs is null - ? $"{example.Provider} \"{example.ModelId}\" is sorted differently by the rules than by the markers they replace." - : $"{example.Provider} \"{example.ModelId}\": {example.Reason}"; - - Assert.That(today, Is.EqualTo(wanted), because); - } - }); - } - - [Test] - public void EveryModelOfTheCapabilityCorpusKeepsTheKindItHasToday() - { - // - // The examples above are names chosen to reach a rule. This asks the other way round: the - // corpus is full of models nobody wants sorted anywhere but into a chat, and a word inside - // one of those names claiming a kind would take the model out of the user's list without - // anything else going wrong. - // - Assert.Multiple(() => - { - foreach (var entry in ModelCorpus.ENTRIES) - { - var today = new Model(entry.ModelId, null).DetermineKind(); - var rebuilt = ModelRegistry.Shared.Profile(entry.Provider, entry.ModelId).Kind; - var wanted = ModelKindCorpus.AnsweredTodayAs(entry.Provider, entry.ModelId) ?? rebuilt; - - Assert.That(today, Is.EqualTo(wanted), $"{entry.Provider} \"{entry.ModelId}\" is sorted as {rebuilt} by the rules and as {today} by the markers they replace."); - } - }); - } - [Test] public void AModelWhichIsNoKindOfItsOwnIsAChatModel() {