Answer the model kind from the registry

This commit is contained in:
Thorsten Sommer 2026-09-12 14:22:21 +02:00
parent 51613e3748
commit 849f072f3e
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
18 changed files with 77 additions and 310 deletions

View File

@ -103,7 +103,7 @@ public sealed class ProviderDeepSeek() : BaseProvider(LLMProviders.DEEP_SEEK, ne
return this.LoadModelsResponse<ModelsResponse>(
storeType,
"models",
modelResponse => modelResponse.Data.Where(model => model.IsChatModel()),
modelResponse => modelResponse.Data.Where(model => model.IsChatModel(this.Provider)),
apiKeyProvisional, token: token);
}
}

View File

@ -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);

View File

@ -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)
]
};

View File

@ -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))]
};
}

View File

@ -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))
]
};
}

View File

@ -69,7 +69,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.Where(model => model.IsChatModel()), apiKeyProvisional, token: token);
return this.LoadModelsResponse<ModelsResponse>(SecretStoreType.LLM_PROVIDER, "models", modelResponse => modelResponse.Data.Where(model => model.IsChatModel(this.Provider)), apiKeyProvisional, token: token);
}
/// <inheritdoc />

View File

@ -237,7 +237,7 @@ public sealed class ProviderHuggingFace : BaseProvider
/// <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 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);

View File

@ -84,7 +84,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
@ -96,7 +96,7 @@ public sealed class ProviderIONOS() : BaseProvider(LLMProviders.IONOS, new Uri("
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />

View File

@ -77,7 +77,7 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
@ -89,13 +89,13 @@ public sealed class ProviderLiteLLM(string hostname) : BaseProvider(LLMProviders
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
public override Task<ModelLoadResult> 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

View File

@ -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))]
};
}

View File

@ -1,228 +0,0 @@
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
{
//
// 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"];
/// <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, 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;
}
/// <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;
/// <summary>
/// Checks whether this model transcribes audio.
/// </summary>
/// <param name="model">The model to check.</param>
/// <returns>True, when the model is a transcription model.</returns>
public static bool IsTranscriptionModel(this Model model) => model.DetermineKind() is ModelKind.TRANSCRIPTION;
/// <summary>
/// Checks whether this model generates images.
/// </summary>
/// <param name="model">The model to check.</param>
/// <returns>True, when the model is an image generation model.</returns>
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;
}
/// <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 ':';
}

View File

@ -367,25 +367,25 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
public override Task<ModelLoadResult> 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);
}
/// <inheritdoc />
public override Task<ModelLoadResult> 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

View File

@ -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) =>
{

View File

@ -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))]
};
}

View File

@ -65,4 +65,47 @@ public static partial class ProviderExtensions
/// <param name="provider">The configured provider.</param>
/// <returns><c>true</c> when the model accepts image input.</returns>
public static bool SupportsImageInput(this Provider provider) => provider.GetModelProfile().HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT);
/// <summary>
/// Checks whether this model can be used for chatting.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="model">The model to check.</param>
/// <param name="provider">The provider serving it.</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, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.CHAT;
/// <summary>
/// Checks whether this model creates embeddings.
/// </summary>
/// <param name="model">The model to check.</param>
/// <param name="provider">The provider serving it.</param>
/// <returns>True, when the model is an embedding model.</returns>
public static bool IsEmbeddingModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.EMBEDDING;
/// <summary>
/// Checks whether this model transcribes audio.
/// </summary>
/// <param name="model">The model to check.</param>
/// <param name="provider">The provider serving it.</param>
/// <returns>True, when the model is a transcription model.</returns>
public static bool IsTranscriptionModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.TRANSCRIPTION;
/// <summary>
/// Checks whether this model generates images.
/// </summary>
/// <param name="model">The model to check.</param>
/// <param name="provider">The provider serving it.</param>
/// <returns>True, when the model is an image generation model.</returns>
public static bool IsImageModel(this Model model, LLMProviders provider) => provider.GetModelProfile(model).Kind is ModelKind.IMAGE_GENERATION;
}

View File

@ -209,13 +209,4 @@ public static class ModelKindCorpus
..STILL_CHAT_MODELS,
];
/// <summary>
/// What the markers being replaced answer for a name, where that is not what the rules answer.
/// </summary>
/// <param name="provider">Who serves the model.</param>
/// <param name="modelId">The model ID as that provider reports it.</param>
/// <returns>The old answer, or null when nobody recorded a difference for this name.</returns>
public static ModelKind? AnsweredTodayAs(LLMProviders provider, string modelId) => ENTRIES
.FirstOrDefault(example => example.Provider == provider && string.Equals(example.ModelId, modelId, StringComparison.Ordinal))
?.AnsweredTodayAs;
}

View File

@ -8,6 +8,6 @@ namespace AIStudio.Tests.Models.Corpus;
/// <param name="Provider">The provider the model is reached through.</param>
/// <param name="ModelId">The model ID exactly as that provider reports it, before any normalization.</param>
/// <param name="Kind">What the model is made for.</param>
/// <param name="AnsweredTodayAs">What the markers being replaced answer, where that is something else.</param>
/// <param name="AnsweredTodayAs">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.</param>
/// <param name="Reason">Why the two differ, which is only filled in when they do.</param>
public sealed record ModelKindExample(LLMProviders Provider, string ModelId, ModelKind Kind, ModelKind? AnsweredTodayAs = null, string Reason = "");

View File

@ -8,14 +8,15 @@ namespace AIStudio.Tests.Models;
/// Holds the rules to what a model is made for.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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()
{