mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:53:36 +00:00
Sort the self-hosted model lists by what a model is made for
This commit is contained in:
parent
ca2f5a9331
commit
c6e36a11d3
@ -97,12 +97,16 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
switch (host)
|
||||
{
|
||||
case Host.LLAMA_CPP:
|
||||
return await this.LoadLlamaCppTextModels(["embed"], [], apiKeyProvisional, token);
|
||||
|
||||
return await this.LoadLlamaCppTextModels(apiKeyProvisional, token);
|
||||
|
||||
case Host.LM_STUDIO:
|
||||
case Host.OLLAMA:
|
||||
case Host.VLLM:
|
||||
return await this.LoadModels( SecretStoreType.LLM_PROVIDER, ["embed"], [], apiKeyProvisional, token);
|
||||
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsChatModel(this.Provider))]
|
||||
};
|
||||
}
|
||||
|
||||
return ModelLoadResult.FromModels([]);
|
||||
@ -129,14 +133,18 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
case Host.LM_STUDIO:
|
||||
case Host.OLLAMA:
|
||||
case Host.VLLM:
|
||||
return await this.LoadModels( SecretStoreType.EMBEDDING_PROVIDER, [], ["embed"], apiKeyProvisional, token);
|
||||
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider))]
|
||||
};
|
||||
}
|
||||
|
||||
return ModelLoadResult.FromModels([]);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOGGER.LogError($"Failed to load text models from self-hosted provider: {e.Message}");
|
||||
LOGGER.LogError($"Failed to load embedding models from self-hosted provider: {e.Message}");
|
||||
return ModelLoadResult.Failure(ModelLoadFailureReason.UNKNOWN, e.Message);
|
||||
}
|
||||
}
|
||||
@ -154,10 +162,22 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
new Provider.Model("loaded-model", TB("Model as configured by whisper.cpp")),
|
||||
]);
|
||||
|
||||
//
|
||||
// These two answer the models endpoint with everything they serve, and nothing in
|
||||
// that answer says which of them listens. Asking what each model is made for is the
|
||||
// only thing standing between this list and every chat and embedding model of the
|
||||
// installation, which is what it used to hold. An engine running no speech model at
|
||||
// all therefore offers nothing here, and says so, rather than offering models which
|
||||
// would fail the moment audio reaches them.
|
||||
//
|
||||
case Host.OLLAMA:
|
||||
case Host.VLLM:
|
||||
return await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], apiKeyProvisional, token);
|
||||
|
||||
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token);
|
||||
return result with
|
||||
{
|
||||
Models = [..result.Models.Where(model => model.IsTranscriptionModel(this.Provider))]
|
||||
};
|
||||
|
||||
default:
|
||||
return ModelLoadResult.FromModels([]);
|
||||
}
|
||||
@ -171,7 +191,21 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, string? apiKeyProvisional, CancellationToken token)
|
||||
/// <summary>
|
||||
/// Everything the engine lists, in the order it listed it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What kind of model each of these is stays unanswered here. It used to be answered right in
|
||||
/// this method, by looking for the word "embed" in the name: the text models were the ones
|
||||
/// without it, the embedding models the ones with it. That reading lost bge-m3 and all-minilm,
|
||||
/// which say what they are through another word, and handed them to the chat list instead. The
|
||||
/// callers ask the shared model kind detection now, the way every other provider does.
|
||||
/// </remarks>
|
||||
/// <param name="storeType">Which key to send along.</param>
|
||||
/// <param name="apiKeyProvisional">A key from a dialog which has not stored it yet.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The models the engine named, unsorted and unfiltered.</returns>
|
||||
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string? apiKeyProvisional, CancellationToken token)
|
||||
{
|
||||
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, isTryingSecret: true);
|
||||
|
||||
@ -211,10 +245,8 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
//
|
||||
ListedModels.Shared.Report(this.ConfiguredProviderId, ListingsOf(models));
|
||||
|
||||
return SuccessfulModelLoadResult(models.
|
||||
Where(model => !string.IsNullOrWhiteSpace(model.Id) &&
|
||||
!ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCulture)) &&
|
||||
filterPhrases.All( filter => model.Id.Contains(filter, StringComparison.InvariantCulture)))
|
||||
return SuccessfulModelLoadResult(models
|
||||
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
|
||||
.Select(n => new Provider.Model(n.Id, null)));
|
||||
}
|
||||
catch (Exception e) when (this.IsTimeoutException(e, token))
|
||||
@ -229,7 +261,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
if (host is not Host.LLAMA_CPP || !chatModel.IsSystemModel)
|
||||
return chatModel;
|
||||
|
||||
var modelLoadResult = await this.LoadLlamaCppTextModels(["embed"], [], null, token);
|
||||
var modelLoadResult = await this.LoadLlamaCppTextModels(null, token);
|
||||
if (!modelLoadResult.Success)
|
||||
return chatModel;
|
||||
|
||||
@ -266,7 +298,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
return chatModel;
|
||||
}
|
||||
|
||||
private async Task<ModelLoadResult> LoadLlamaCppTextModels(string[] ignorePhrases, string[] filterPhrases, string? apiKeyProvisional, CancellationToken token)
|
||||
private async Task<ModelLoadResult> LoadLlamaCppTextModels(string? apiKeyProvisional, CancellationToken token)
|
||||
{
|
||||
var secretKey = await this.GetModelLoadingSecretKey(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, true);
|
||||
|
||||
@ -298,7 +330,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
return LlamaCppLegacyModelResult();
|
||||
|
||||
var models = responseModels
|
||||
.Where(model => IsMatchingLlamaCppTextModel(model, ignorePhrases, filterPhrases))
|
||||
.Where(this.IsMatchingLlamaCppTextModel)
|
||||
.Select(model => new Provider.Model(model.Id, null))
|
||||
.ToList();
|
||||
|
||||
@ -330,15 +362,23 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
|
||||
/// <returns>One listing per model, which says nothing for the models the engine was silent about.</returns>
|
||||
private static IEnumerable<ModelListing> ListingsOf(IEnumerable<Model> models) => models.Select(model => ModelListing.For(model.Id, model.ContextWindowTokens));
|
||||
|
||||
private static bool IsMatchingLlamaCppTextModel(Model model, string[] ignorePhrases, string[] filterPhrases)
|
||||
/// <summary>
|
||||
/// Whether this is a model somebody can chat with, as far as llama.cpp and the rules say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two sources, and both have to agree. What a model is made for comes from the shared rules,
|
||||
/// the same answer the other engines get. What the running build of it puts out comes from
|
||||
/// llama.cpp itself, which states the modalities on this route: an engine serving a model that
|
||||
/// answers in something other than text knows that before any rule about the name could.
|
||||
/// </remarks>
|
||||
/// <param name="model">The model as llama.cpp listed it.</param>
|
||||
/// <returns>True when both agree that it answers a chat in text.</returns>
|
||||
private bool IsMatchingLlamaCppTextModel(Model model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.Id))
|
||||
return false;
|
||||
|
||||
if (ignorePhrases.Any(ignorePhrase => model.Id.Contains(ignorePhrase, StringComparison.InvariantCultureIgnoreCase)))
|
||||
return false;
|
||||
|
||||
if (!filterPhrases.All(filter => model.Id.Contains(filter, StringComparison.InvariantCultureIgnoreCase)))
|
||||
if (!new Provider.Model(model.Id, null).IsChatModel(this.Provider))
|
||||
return false;
|
||||
|
||||
var outputModalities = model.Architecture?.OutputModalities;
|
||||
|
||||
135
app/Tests/Provider/SelfHostedModelListTests.cs
Normal file
135
app/Tests/Provider/SelfHostedModelListTests.cs
Normal file
@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
|
||||
using SelfHostedModelsResponse = AIStudio.Provider.SelfHosted.ModelsResponse;
|
||||
|
||||
namespace AIStudio.Tests.Provider;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the models of somebody's own server are sorted into the three lists they are offered in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The engines answer one route with everything they serve, and that answer says nothing about what
|
||||
/// any of it is made for: an ID, the word "model", and at Ollama a timestamp. Which list a model
|
||||
/// ends up in is therefore decided afterwards, and for a long time it was decided by looking for
|
||||
/// the word "embed" in the name -- the chat list was everything without it, the embedding list
|
||||
/// everything with it, and the transcription list was not filtered at all.
|
||||
///
|
||||
/// The body below is the real answer of a local Ollama, copied off the route rather than written
|
||||
/// from memory, and it holds the two names which that reading got wrong. What it costs is visible
|
||||
/// in the assertions: an embedding model in the chat list is one somebody picks and then waits for
|
||||
/// an answer which never comes.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class SelfHostedModelListTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions AS_THE_PROVIDERS_READ_IT = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What "GET /v1/models" answers on a local Ollama, shortened to the fields it sends.
|
||||
/// </summary>
|
||||
private const string WHAT_A_LOCAL_OLLAMA_ANSWERS =
|
||||
"""
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{ "id": "all-minilm:latest", "object": "model", "created": 1789809739, "owned_by": "library" },
|
||||
{ "id": "bge-m3:latest", "object": "model", "created": 1789809702, "owned_by": "library" },
|
||||
{ "id": "qwen3-embedding:0.6b", "object": "model", "created": 1788699716, "owned_by": "library" },
|
||||
{ "id": "qwen3-embedding:4b", "object": "model", "created": 1788699586, "owned_by": "library" },
|
||||
{ "id": "qwen3-embedding:latest", "object": "model", "created": 1788699253, "owned_by": "library" },
|
||||
{ "id": "qwen3.8:latest", "object": "model", "created": 1788698492, "owned_by": "library" },
|
||||
{ "id": "gpt-oss:latest", "object": "model", "created": 1756645805, "owned_by": "library" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static IReadOnlyList<Model> TheModelsTheEngineListed()
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<SelfHostedModelsResponse>(WHAT_A_LOCAL_OLLAMA_ANSWERS, AS_THE_PROVIDERS_READ_IT);
|
||||
Assert.That(response.Data, Is.Not.Null, "The answer has to be readable before anything can be sorted out of it.");
|
||||
|
||||
return response.Data!
|
||||
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
|
||||
.Select(model => new Model(model.Id, null))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheChatListHoldsWhatSomebodyCanTalkTo()
|
||||
{
|
||||
var chatModels = TheModelsTheEngineListed()
|
||||
.Where(model => model.IsChatModel(LLMProviders.SELF_HOSTED))
|
||||
.Select(model => model.Id)
|
||||
.ToList();
|
||||
|
||||
Assert.That(chatModels, Is.EquivalentTo(new[] { "qwen3.8:latest", "gpt-oss:latest" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheEmbeddingListHoldsTheModelsWhichSayNothingAboutEmbedding()
|
||||
{
|
||||
var embeddingModels = TheModelsTheEngineListed()
|
||||
.Where(model => model.IsEmbeddingModel(LLMProviders.SELF_HOSTED))
|
||||
.Select(model => model.Id)
|
||||
.ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(embeddingModels, Does.Contain("bge-m3:latest"), "Named after the family which built it, with no word about what it does.");
|
||||
Assert.That(embeddingModels, Does.Contain("all-minilm:latest"), "The same, and without the organization which used to be the only marker.");
|
||||
Assert.That(embeddingModels, Has.Count.EqualTo(5), "The three Qwen embedding tags belong here as well, and nothing else does.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NothingIsInTwoListsAtOnce()
|
||||
{
|
||||
//
|
||||
// The two lists were cut from one name with one word, so a model could only ever be in one
|
||||
// of them. They are cut by two questions now, and two questions can both say yes.
|
||||
//
|
||||
var models = TheModelsTheEngineListed();
|
||||
var inBothLists = models
|
||||
.Where(model => model.IsChatModel(LLMProviders.SELF_HOSTED) && model.IsEmbeddingModel(LLMProviders.SELF_HOSTED))
|
||||
.Select(model => model.Id)
|
||||
.ToList();
|
||||
|
||||
Assert.That(inBothLists, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEngineWithoutASpeechModelOffersNoneForTranscription()
|
||||
{
|
||||
//
|
||||
// Ollama serves no speech-to-text model of its own, and the list said otherwise: it was
|
||||
// handed through unfiltered, so all seven of these stood there to be picked.
|
||||
//
|
||||
var transcriptionModels = TheModelsTheEngineListed()
|
||||
.Where(model => model.IsTranscriptionModel(LLMProviders.SELF_HOSTED))
|
||||
.ToList();
|
||||
|
||||
Assert.That(transcriptionModels, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASpeechModelOnSuchAServerIsOfferedForTranscription()
|
||||
{
|
||||
//
|
||||
// The other half of the one above: the empty list has to come from there being no speech
|
||||
// model, not from the question never saying yes on this provider.
|
||||
//
|
||||
var models = new[] { "whisper-large-v3", "faster-whisper-large-v3", "canary-1b-flash" }
|
||||
.Select(id => new Model(id, null))
|
||||
.Where(model => model.IsTranscriptionModel(LLMProviders.SELF_HOSTED))
|
||||
.Select(model => model.Id)
|
||||
.ToList();
|
||||
|
||||
Assert.That(models, Has.Count.EqualTo(3));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user