From 954012936f0ca2d985e2eec29482d8c0715a40dd Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 10 Sep 2026 21:47:36 +0200 Subject: [PATCH] Normalize model IDs before matching capabilities --- .../Settings/ProviderExtensions.Alibaba.cs | 2 +- .../Settings/ProviderExtensions.Anthropic.cs | 2 +- .../Settings/ProviderExtensions.DeepSeek.cs | 2 +- .../Settings/ProviderExtensions.Gateway.cs | 2 +- .../Settings/ProviderExtensions.Google.cs | 2 +- .../Settings/ProviderExtensions.Mistral.cs | 2 +- .../Settings/ProviderExtensions.OpenAI.cs | 2 +- .../Settings/ProviderExtensions.OpenSource.cs | 31 +++++---- .../Settings/ProviderExtensions.Perplexity.cs | 2 +- .../Settings/ProviderExtensions.cs | 64 +++++++++++++++++++ 10 files changed, 92 insertions(+), 19 deletions(-) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Alibaba.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Alibaba.cs index 3553ccb6..cb8eb76f 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Alibaba.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Alibaba.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesAlibaba(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); // Qwen models: if (modelName.StartsWith("qwen")) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Anthropic.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Anthropic.cs index 602c6f1f..95ad0d46 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Anthropic.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Anthropic.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesAnthropic(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); // Claude Fable 5 and Mythos 5 always use adaptive thinking: if(modelName.StartsWith("claude-fable-5") || modelName.StartsWith("claude-mythos-5")) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.DeepSeek.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.DeepSeek.cs index f74a3130..2bc1681b 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.DeepSeek.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.DeepSeek.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesDeepSeek(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); // The reasoner alias points to the thinking mode of the current flash model: if(modelName.IndexOf("reasoner") is not -1) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs index c1123381..bd2edbca 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Gateway.cs @@ -32,7 +32,7 @@ public static partial class ProviderExtensions var separatorIndex = model.Id.IndexOf('/'); var vendor = separatorIndex is -1 ? string.Empty : model.Id[..separatorIndex].ToLowerInvariant(); var bareModel = separatorIndex is -1 ? model : model with { Id = model.Id[(separatorIndex + 1)..] }; - var bareModelName = bareModel.Id.ToLowerInvariant().AsSpan(); + var bareModelName = NormalizeModelId(bareModel.Id).AsSpan(); var capabilities = vendor switch { diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs index dcaa6db7..451605b0 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Google.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesGoogle(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); if (modelName.IndexOf("gemini-") is not -1) { diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Mistral.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Mistral.cs index e233942d..60b61f15 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Mistral.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Mistral.cs @@ -64,7 +64,7 @@ public static partial class ProviderExtensions private static List GetModelCapabilitiesMistral(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); // Pixtral models are able to do process images: if (modelName.IndexOf("pixtral") is not -1) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs index bf1f1a04..eff57469 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenAI.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesOpenAI(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); if (modelName is "gpt-4o-search-preview") return diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs index 37f3eed7..86613635 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.OpenSource.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesOpenSource(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); // // Checking for names in the case of open source models is a hard task. @@ -19,6 +19,13 @@ public static partial class ProviderExtensions // - LM Studio: llama-3.1-405b-instruct // - Helmholtz Blablador: 1 - Llama3 405 the best general model // - GWDG: Llama 3.1 405B Instruct + // - Ollama: llama3.1:405b + // + // The name arrives here already normalized by NormalizeModelId: lowercase, with every + // separator written as a single hyphen. That is why the checks below no longer carry a + // variant with a space or a colon. What normalization cannot do is insert a separator + // where a provider left it out, or remove one where it added it, so a family which is + // written both as "llama3" and as "llama-3" still needs both spellings. // // @@ -27,7 +34,6 @@ public static partial class ProviderExtensions if (modelName.IndexOf("llama") is not -1) { if (modelName.IndexOf("llama4") is not -1 || - modelName.IndexOf("llama 4") is not -1 || modelName.IndexOf("llama-4") is not -1 || modelName.IndexOf("llama-v4") is not -1) return @@ -52,7 +58,6 @@ public static partial class ProviderExtensions // All models >= 3.1 are able to do function calling: // if (modelName.IndexOf("llama3.") is not -1 || - modelName.IndexOf("llama 3.") is not -1 || modelName.IndexOf("llama-3.") is not -1 || modelName.IndexOf("llama-v3p") is not -1) return @@ -101,8 +106,7 @@ public static partial class ProviderExtensions Capability.CHAT_COMPLETION_API, ]; - if(modelName.IndexOf("deepseek-r1") is not -1 || - modelName.IndexOf("deepseek r1") is not -1) + if(modelName.IndexOf("deepseek-r1") is not -1) return [ Capability.TEXT_INPUT, Capability.TEXT_OUTPUT, Capability.ALWAYS_REASONING, @@ -150,8 +154,15 @@ public static partial class ProviderExtensions Capability.CHAT_COMPLETION_API, ]; - // Check for the multimodal Qwen 3.8 27B checkpoint: - if(modelName.IndexOf("qwen3.8-27b") is not -1) + // + // Check for the multimodal Qwen 3.8 27B checkpoint. Blablador writes this one in two + // further ways, which no normalization can turn into the canonical name: it separates + // the family from the version ("Qwen 3.8-27B with DFlash on haicluster"), and its short + // alias drops the dot ("alias-qwen38-27b"). + // + if(modelName.IndexOf("qwen3.8-27b") is not -1 || + modelName.IndexOf("qwen-3.8-27b") is not -1 || + modelName.IndexOf("qwen38-27b") is not -1) return [ Capability.TEXT_INPUT, Capability.MULTIPLE_IMAGE_INPUT, Capability.VIDEO_INPUT, @@ -475,8 +486,7 @@ public static partial class ProviderExtensions // template keeps the thinking channel closed by default. // if (modelName.IndexOf("gemma-4") is not -1 || - modelName.IndexOf("gemma4") is not -1 || - modelName.IndexOf("gemma 4") is not -1) + modelName.IndexOf("gemma4") is not -1) { if (modelName.IndexOf("e2b") is not -1 || modelName.IndexOf("e4b") is not -1 || @@ -508,8 +518,7 @@ public static partial class ProviderExtensions // than "1b", so that a name such as gemma-3-31b does not match it. // if (modelName.IndexOf("gemma-3") is not -1 || - modelName.IndexOf("gemma3") is not -1 || - modelName.IndexOf("gemma 3") is not -1) + modelName.IndexOf("gemma3") is not -1) { if (modelName.IndexOf("-1b") is not -1) return diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.Perplexity.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.Perplexity.cs index d73ba8c5..50510323 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.Perplexity.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.Perplexity.cs @@ -6,7 +6,7 @@ public static partial class ProviderExtensions { private static List GetModelCapabilitiesPerplexity(Model model) { - var modelName = model.Id.ToLowerInvariant().AsSpan(); + var modelName = NormalizeModelId(model.Id).AsSpan(); if(modelName.IndexOf("reasoning") is not -1 || modelName.IndexOf("deep-research") is not -1) diff --git a/app/MindWork AI Studio/Settings/ProviderExtensions.cs b/app/MindWork AI Studio/Settings/ProviderExtensions.cs index c1f6502a..a281a00c 100644 --- a/app/MindWork AI Studio/Settings/ProviderExtensions.cs +++ b/app/MindWork AI Studio/Settings/ProviderExtensions.cs @@ -5,6 +5,70 @@ namespace AIStudio.Settings; public static partial class ProviderExtensions { + /// + /// The longest model ID we normalize without going to the heap. + /// + private const int MAX_STACK_ALLOCATED_MODEL_ID_LENGTH = 256; + + /// + /// Brings a model ID into the form the capability rules are written in. + /// + /// + /// Every provider names the same model differently, and the difference is rarely in the words: + /// it is in what sits between them. Ollama separates the variant with a colon + /// ("qwen3.8:27b-mlx"), Blablador answers with a whole sentence ("10 - Muse Glimmer 30b - the + /// newest META model"), Fireworks puts a path in front + /// ("accounts/fireworks/models/llama-v3p1-405b-instruct"), and the hubs use hyphens. Without + /// this, every rule would have to spell out each of those writings, which is what the Llama + /// block used to do with four variants of one check. + /// + /// The dots stay. They carry the version boundary: llama3 and llama3.1 are different models, + /// and only the latter calls functions. Dropping them would merge the two. + /// + /// The patterns in the rules are written in this normalized form already, which is why they + /// use lowercase and hyphens throughout. + /// + /// The model ID as the provider reports it. + /// The model ID in lowercase, with every separator written as a single hyphen. + private static string NormalizeModelId(string modelId) + { + if (string.IsNullOrWhiteSpace(modelId)) + return string.Empty; + + // + // Normalizing never makes a name longer, so the original length is always enough room. + // Model IDs are short, which is why the buffer lives on the stack: the longest ones we + // know of are the descriptive names Blablador answers with, at around 75 characters. A + // provider reporting something longer still gets a correct answer, just from the heap. + // + Span normalized = modelId.Length <= MAX_STACK_ALLOCATED_MODEL_ID_LENGTH + ? stackalloc char[modelId.Length] + : new char[modelId.Length]; + + var length = 0; + foreach (var character in modelId) + { + if (char.IsAsciiLetterOrDigit(character) || character is '.') + { + normalized[length++] = char.ToLowerInvariant(character); + continue; + } + + // Anything else separates two parts of the name. A leading separator, and a repeated + // one, say nothing and would only get in the way of the patterns: + if (length is 0 || normalized[length - 1] is '-') + continue; + + normalized[length++] = '-'; + } + + // A trailing separator carries no meaning either: + if (length > 0 && normalized[length - 1] is '-') + length--; + + return new string(normalized[..length]); + } + /// /// Get the capabilities of the model used by the configured provider. ///