Add llmman as a self-hosted host for chat and embedding providers (#947)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Eric CurtinandThorsten Sommer authored and GitHub committed 2026-10-13 13:52:43 +02:00
1 parent 2bd3d41421
commit 88d1f4d96a
20 files changed
+270 -89

No files matched your search

@@ -557,21 +557,7 @@ public static class LLMProvidersExtensions
public static bool CanLoadModels(this LLMProviders provider, Host host, string? apiKey)
{
if (provider is LLMProviders.SELF_HOSTED)
{
switch (host)
{
case Host.NONE:
case Host.WHISPER_CPP:
default:
return false;
case Host.LLAMA_CPP:
case Host.OLLAMA:
case Host.LM_STUDIO:
case Host.VLLM:
return true;
}
}
return host.IsModelListSupported();
if(provider is LLMProviders.NONE)
return false;
@@ -125,6 +125,13 @@ public static class ReasoningDispatcher
Host.VLLM => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING, ReasoningDialect.GOOGLE_THINKING, ReasoningDialect.VLLM],
//
// llmman starts llama.cpp for GGUF models and vLLM, SGLang, or MLX for the others, and
// hands their fields through unchanged. Which engine serves a model is up to llmman, so
// it is read in everything llama.cpp and vLLM are read in:
//
Host.LLMMAN => [ReasoningDialect.OPEN_AI_COMPATIBLE, ReasoningDialect.REASONING_EFFORT, ReasoningDialect.QWEN_THINKING, ReasoningDialect.GOOGLE_THINKING, ReasoningDialect.LLAMA_CPP, ReasoningDialect.VLLM],
_ => EVERYTHING_A_GATEWAY_MIGHT_SERVE,
},
@@ -9,4 +9,5 @@ public enum Host
WHISPER_CPP,
OLLAMA,
VLLM,
LLMMAN,
}
@@ -11,6 +11,7 @@ public static class HostExtensions
Host.WHISPER_CPP => "whisper.cpp",
Host.OLLAMA => "ollama",
Host.VLLM => "vLLM",
Host.LLMMAN => "llmman",
_ => "Unknown",
};
@@ -47,6 +48,7 @@ public static class HostExtensions
case Host.VLLM:
case Host.LM_STUDIO:
case Host.LLAMA_CPP:
case Host.LLMMAN:
return true;
}
}
@@ -58,6 +60,7 @@ public static class HostExtensions
case Host.LM_STUDIO:
case Host.OLLAMA:
case Host.VLLM:
case Host.LLMMAN:
return true;
default:
@@ -73,12 +76,44 @@ public static class HostExtensions
case Host.OLLAMA:
case Host.VLLM:
case Host.WHISPER_CPP:
case Host.LLMMAN:
return true;
default:
case Host.LM_STUDIO:
case Host.LLAMA_CPP:
return false;
}
}
/// <summary>
/// Whether the engine answers the models route with the models it serves.
/// </summary>
/// <remarks>
/// This is the only place which says so. The provider asks it before it loads the models of a
/// chat, embedding, or transcription list, and the dialogs ask it before they offer to reload
/// them; a new engine therefore needs no more than its line here. whisper.cpp is not among
/// them: it serves the one model it was started with and has no route to list it, so the
/// provider offers that model under a fixed name instead. llama.cpp is, even though builds
/// older than the route answer it with 404; the provider handles those builds itself.
/// </remarks>
/// <param name="host">The engine.</param>
/// <returns>True when the models can be loaded from the engine; otherwise, false.</returns>
public static bool IsModelListSupported(this Host host)
{
switch (host)
{
case Host.LLAMA_CPP:
case Host.OLLAMA:
case Host.LM_STUDIO:
case Host.VLLM:
case Host.LLMMAN:
return true;
default:
case Host.NONE:
case Host.WHISPER_CPP:
return false;
}
}
}
@@ -8,8 +8,8 @@ namespace AIStudio.Provider.SelfHosted;
/// <remarks>
/// The context window is vLLM's addition to that route: it reports the window the operator started
/// the engine with, which is the one number no rule about the weights could ever know. Ollama,
/// LM Studio, and llama.cpp answer the same route without it, so it stays unknown there instead of
/// being guessed.
/// LM Studio, llama.cpp, and llmman answer the same route without it, so it stays unknown there
/// instead of being guessed.
///
/// vLLM calls that field max_model_len, which reads like a limit on the model rather than on a
/// conversation. The wire keeps their spelling, and this record says what the number means, so that
@@ -25,7 +25,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
public override string InstanceName { get; set; } = "Self-hosted";
/// <inheritdoc />
public override bool HasModelLoadingCapability => host is Host.OLLAMA or Host.LM_STUDIO or Host.VLLM or Host.LLAMA_CPP;
public override bool HasModelLoadingCapability => host.IsModelListSupported();
/// <inheritdoc />
public override async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletion(Provider.Model chatModel, ChatThread chatThread, SettingsManager settingsManager, [EnumeratorCancellation] CancellationToken token = default)
@@ -40,7 +40,7 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
{
// Build the list of messages. The image format depends on the host:
// - Ollama uses the direct image URL format: { "type": "image_url", "image_url": "data:..." }
// - LM Studio, vLLM, and llama.cpp use the nested image URL format: { "type": "image_url", "image_url": { "url": "data:..." } }
// - LM Studio, vLLM, llama.cpp, and llmman use the nested image URL format: { "type": "image_url", "image_url": { "url": "data:..." } }
var messages = host switch
{
Host.OLLAMA => await chatThread.Blocks.BuildMessagesUsingDirectImageUrlAsync(this.CreateSettingsProvider(effectiveChatModel)),
@@ -94,22 +94,17 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
{
try
{
switch (host)
if (host is Host.LLAMA_CPP)
return await this.LoadLlamaCppTextModels(apiKeyProvisional, token);
if (!host.IsChatSupported() || !host.IsModelListSupported())
return ModelLoadResult.FromModels([]);
var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token);
return result with
{
case Host.LLAMA_CPP:
return await this.LoadLlamaCppTextModels(apiKeyProvisional, token);
case Host.LM_STUDIO:
case Host.OLLAMA:
case Host.VLLM:
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([]);
Models = [..result.Models.Where(model => model.IsChatModel(this.Provider))]
};
}
catch(Exception e)
{
@@ -128,19 +123,14 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
{
try
{
switch (host)
{
case Host.LM_STUDIO:
case Host.OLLAMA:
case Host.VLLM:
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider))]
};
}
if (!host.IsEmbeddingSupported() || !host.IsModelListSupported())
return ModelLoadResult.FromModels([]);
return ModelLoadResult.FromModels([]);
var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider))]
};
}
catch(Exception e)
{
@@ -154,33 +144,28 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
{
try
{
switch (host)
{
case Host.WHISPER_CPP:
return ModelLoadResult.FromModels(
[
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:
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsTranscriptionModel(this.Provider))]
};
if (host is Host.WHISPER_CPP)
return ModelLoadResult.FromModels(
[
new Provider.Model("loaded-model", TB("Model as configured by whisper.cpp")),
]);
default:
return ModelLoadResult.FromModels([]);
}
if (!host.IsTranscriptionSupported() || !host.IsModelListSupported())
return ModelLoadResult.FromModels([]);
//
// The engines which get here 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.
//
var result = await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, apiKeyProvisional, token);
return result with
{
Models = [..result.Models.Where(model => model.IsTranscriptionModel(this.Provider))]
};
}
catch (Exception e)
{