diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs
index 8fc657df8..4d8cd99aa 100644
--- a/src/Netclaw.Cli/Program.cs
+++ b/src/Netclaw.Cli/Program.cs
@@ -989,7 +989,7 @@ static void WriteStatusResult(DaemonRuntimeStatus.Response status, string endpoi
if (status.Model is { } model)
{
- Console.WriteLine($"model: {model.ModelId} (provider: {model.Provider}, context: {model.ContextWindow:N0} tokens)");
+ Console.WriteLine($"model: {model.DisplayName ?? model.ModelId} (provider: {model.Provider}, context: {model.ContextWindow:N0} tokens)");
Console.WriteLine($" input: {model.InputModalities}");
Console.WriteLine($" output: {model.OutputModalities}");
}
diff --git a/src/Netclaw.Configuration/DaemonRuntimeStatus.cs b/src/Netclaw.Configuration/DaemonRuntimeStatus.cs
index 7da693264..20d94035c 100644
--- a/src/Netclaw.Configuration/DaemonRuntimeStatus.cs
+++ b/src/Netclaw.Configuration/DaemonRuntimeStatus.cs
@@ -93,6 +93,12 @@ public sealed class Model : IWireType
{
public required string ModelId { get; init; }
+ ///
+ /// Human-friendly model name with file-format noise stripped
+ /// (.gguf extension, quantization suffixes, Ollama tags).
+ ///
+ public string? DisplayName { get; init; }
+
public required string Provider { get; init; }
public required string InputModalities { get; init; }
diff --git a/src/Netclaw.Configuration/ModelIdNormalizer.cs b/src/Netclaw.Configuration/ModelIdNormalizer.cs
index e726a2d9f..6a984dab7 100644
--- a/src/Netclaw.Configuration/ModelIdNormalizer.cs
+++ b/src/Netclaw.Configuration/ModelIdNormalizer.cs
@@ -4,7 +4,8 @@ namespace Netclaw.Configuration;
///
/// Normalizes model IDs across provider naming conventions to enable
-/// cross-provider capability lookups. Produces candidate IDs for matching.
+/// cross-provider capability lookups. Produces candidate IDs for matching
+/// and human-friendly display names.
///
public static partial class ModelIdNormalizer
{
@@ -12,6 +13,14 @@ public static partial class ModelIdNormalizer
[GeneratedRegex(@"-\d{8}$")]
private static partial Regex DateSuffixPattern();
+ // Matches .gguf file extension (case-insensitive)
+ [GeneratedRegex(@"\.gguf$", RegexOptions.IgnoreCase)]
+ private static partial Regex GgufExtensionPattern();
+
+ // Matches GGML quantization suffixes: -Q4_0, -Q5_K_M, -IQ2_XXS, -Q4_K_XL, etc.
+ [GeneratedRegex(@"[-_]I?Q\d(?:_[A-Z0-9]{1,4})*(?:[-_]XL)?$", RegexOptions.IgnoreCase)]
+ private static partial Regex QuantizationSuffixPattern();
+
// Known provider prefixes for bare model IDs
private static readonly Dictionary KnownPrefixes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -32,47 +41,73 @@ public static partial class ModelIdNormalizer
///
/// Produces a set of candidate model IDs for cross-provider lookup.
- /// The first entry is always the original ID.
+ /// The first entry is always the original ID. Candidates are ordered
+ /// from most specific (raw) to most normalized.
///
public static IReadOnlyList GetCandidates(string modelId)
{
var candidates = new List { modelId };
var working = modelId;
- // Strip Ollama tag suffixes (:latest, :7b, :q4_0, etc.)
+ // Step 1: Strip Ollama tag suffixes (:latest, :7b, :q4_0, etc.)
var colonIndex = working.IndexOf(':', StringComparison.Ordinal);
if (colonIndex > 0)
{
- var stripped = working[..colonIndex];
- if (!candidates.Contains(stripped))
- candidates.Add(stripped);
- working = stripped;
+ working = working[..colonIndex];
+ AddIfNew(candidates, working);
}
- // Strip date suffixes (-20250514)
- var withoutDate = DateSuffixPattern().Replace(working, "");
- if (withoutDate != working && !candidates.Contains(withoutDate))
+ // Step 2: Strip .gguf file extension
+ var afterGguf = GgufExtensionPattern().Replace(working, "");
+ if (afterGguf != working)
+ {
+ AddIfNew(candidates, afterGguf);
+ working = afterGguf;
+ }
+
+ // Step 3: Strip quantization suffix (-Q5_K_M, -q4_0, -IQ2_XXS, etc.)
+ var afterQuant = QuantizationSuffixPattern().Replace(working, "");
+ if (afterQuant != working)
+ {
+ AddIfNew(candidates, afterQuant);
+ working = afterQuant;
+ }
+
+ // Step 4: Lowercase normalization (GGUF PascalCase → catalog lowercase)
+ var lowered = working.ToLowerInvariant();
+ if (lowered != working)
{
- candidates.Add(withoutDate);
+ AddIfNew(candidates, lowered);
+ working = lowered;
}
- // If no slash (not already prefixed), try adding known provider prefixes
+ // Step 5: Trailing-segment stripping (catches build variant tags like -UD, -BPW4)
+ var lastDash = working.LastIndexOf('-');
+ if (lastDash > 0)
+ {
+ var withoutTrailing = working[..lastDash];
+ AddIfNew(candidates, withoutTrailing);
+ }
+
+ // Step 6: Strip date suffixes (-20250514)
+ var withoutDate = DateSuffixPattern().Replace(working, "");
+ if (withoutDate != working)
+ AddIfNew(candidates, withoutDate);
+
+ // Step 7: Add known provider prefixes for unprefixed forms
if (!working.Contains('/', StringComparison.Ordinal))
{
foreach (var (prefix, provider) in KnownPrefixes)
{
if (working.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
- var prefixed = $"{provider}/{working}";
- if (!candidates.Contains(prefixed))
- candidates.Add(prefixed);
-
- // Also try prefixed + date-stripped
- if (withoutDate != working)
+ // Prefix all unique unprefixed candidates
+ var unprefixed = candidates
+ .Where(c => !c.Contains('/', StringComparison.Ordinal))
+ .ToList();
+ foreach (var form in unprefixed)
{
- var prefixedNoDate = $"{provider}/{withoutDate}";
- if (!candidates.Contains(prefixedNoDate))
- candidates.Add(prefixedNoDate);
+ AddIfNew(candidates, $"{provider}/{form}");
}
break;
@@ -83,10 +118,39 @@ public static IReadOnlyList GetCandidates(string modelId)
{
// Already has a prefix — try date-stripped version with prefix
var prefixedWithoutDate = DateSuffixPattern().Replace(modelId, "");
- if (prefixedWithoutDate != modelId && !candidates.Contains(prefixedWithoutDate))
- candidates.Add(prefixedWithoutDate);
+ if (prefixedWithoutDate != modelId)
+ AddIfNew(candidates, prefixedWithoutDate);
}
return candidates;
}
+
+ ///
+ /// Produces a human-friendly display name by stripping file-format noise
+ /// (.gguf extension, quantization suffixes, Ollama tags) while preserving
+ /// the original casing and meaningful name segments.
+ ///
+ public static string GetDisplayName(string modelId)
+ {
+ var name = modelId;
+
+ // Strip Ollama tag
+ var colonIdx = name.IndexOf(':', StringComparison.Ordinal);
+ if (colonIdx > 0)
+ name = name[..colonIdx];
+
+ // Strip .gguf extension
+ name = GgufExtensionPattern().Replace(name, "");
+
+ // Strip quantization suffix
+ name = QuantizationSuffixPattern().Replace(name, "");
+
+ return name;
+ }
+
+ private static void AddIfNew(List candidates, string value)
+ {
+ if (!candidates.Contains(value))
+ candidates.Add(value);
+ }
}
diff --git a/src/Netclaw.Daemon.Tests/Providers/ModelIdNormalizerTests.cs b/src/Netclaw.Daemon.Tests/Providers/ModelIdNormalizerTests.cs
index 45b25a337..6ba850ab7 100644
--- a/src/Netclaw.Daemon.Tests/Providers/ModelIdNormalizerTests.cs
+++ b/src/Netclaw.Daemon.Tests/Providers/ModelIdNormalizerTests.cs
@@ -5,6 +5,8 @@ namespace Netclaw.Daemon.Tests.Providers;
public sealed class ModelIdNormalizerTests
{
+ // === Existing regression tests ===
+
[Fact]
public void OriginalId_AlwaysFirst()
{
@@ -64,4 +66,117 @@ public void NoTransformNeeded()
var candidates = ModelIdNormalizer.GetCandidates("anthropic/claude-sonnet-4");
Assert.Equal("anthropic/claude-sonnet-4", candidates[0]);
}
+
+ // === GGUF extension stripping ===
+
+ [Fact]
+ public void StripGgufExtension()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Model-Q5_K_M.gguf");
+ Assert.Contains("Model-Q5_K_M", candidates);
+ }
+
+ [Fact]
+ public void StripGgufExtension_CaseInsensitive()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Model-Q5_K_M.GGUF");
+ Assert.Contains("Model-Q5_K_M", candidates);
+ }
+
+ // === Quantization suffix stripping ===
+
+ [Theory]
+ [InlineData("model-Q4_0", "model")]
+ [InlineData("model-Q5_K_M", "model")]
+ [InlineData("model-Q8_0", "model")]
+ [InlineData("model-IQ2_XXS", "model")]
+ [InlineData("model-Q4_K_XL", "model")]
+ [InlineData("model-IQ3_M", "model")]
+ [InlineData("model-q4_0", "model")]
+ public void StripQuantizationSuffix(string input, string expected)
+ {
+ var candidates = ModelIdNormalizer.GetCandidates(input);
+ Assert.Contains(expected, candidates);
+ }
+
+ // === Combined GGUF + quant ===
+
+ [Fact]
+ public void CombinedGgufAndQuant()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Qwen2.5-Coder-32B-Instruct-Q5_K_M.gguf");
+ Assert.Contains("Qwen2.5-Coder-32B-Instruct", candidates);
+ }
+
+ // === Lowercase normalization ===
+
+ [Fact]
+ public void LowercaseNormalization()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Qwen2.5-Coder-32B-Instruct");
+ Assert.Contains("qwen2.5-coder-32b-instruct", candidates);
+ }
+
+ // === Full pipeline ===
+
+ [Fact]
+ public void FullPipeline_GgufToLowercase()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Qwen2.5-Coder-32B-Instruct-Q5_K_M.gguf");
+ Assert.Contains("qwen2.5-coder-32b-instruct", candidates);
+ }
+
+ // === Trailing segment stripping ===
+
+ [Fact]
+ public void TrailingSegmentStrip()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("qwen3.5-35b-a3b-ud");
+ Assert.Contains("qwen3.5-35b-a3b", candidates);
+ }
+
+ // === Current running model: Qwen3.5-35B-A3B-UD-Q4_K_XL.gguf ===
+
+ [Fact]
+ public void CurrentRunningModel_ProducesNormalizedCandidate()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Qwen3.5-35B-A3B-UD-Q4_K_XL.gguf");
+ Assert.Contains("qwen3.5-35b-a3b", candidates);
+ }
+
+ // === Simple quant with existing prefix ===
+
+ [Fact]
+ public void SimpleQuant_WithExistingPrefix()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("llama-3.1-8b-q4_0");
+ Assert.Contains("llama-3.1-8b", candidates);
+ Assert.Contains("meta-llama/llama-3.1-8b", candidates);
+ }
+
+ // === False positive guard ===
+
+ [Fact]
+ public void FalsePositiveGuard_NoQuantStrip()
+ {
+ var candidates = ModelIdNormalizer.GetCandidates("Qwen3-30B");
+ // "30B" is not a quantization pattern — should not be stripped
+ Assert.Contains("Qwen3-30B", candidates);
+ Assert.DoesNotContain("Qwen3", candidates.Where(c =>
+ !c.Contains('/', StringComparison.Ordinal) &&
+ c == "Qwen3").ToList());
+ }
+
+ // === GetDisplayName tests ===
+
+ [Theory]
+ [InlineData("Qwen3.5-35B-A3B-UD-Q4_K_XL.gguf", "Qwen3.5-35B-A3B-UD")]
+ [InlineData("qwen3:30b", "qwen3")]
+ [InlineData("claude-sonnet-4-20250514", "claude-sonnet-4-20250514")]
+ [InlineData("aaron/custom-llama:latest", "aaron/custom-llama")]
+ [InlineData("Meta-Llama-3.1-8B-Instruct-Q4_0.gguf", "Meta-Llama-3.1-8B-Instruct")]
+ public void GetDisplayName_StripsFileFormatNoise(string input, string expected)
+ {
+ Assert.Equal(expected, ModelIdNormalizer.GetDisplayName(input));
+ }
}
diff --git a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
index da6c949eb..fd16814a3 100644
--- a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
+++ b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
@@ -76,6 +76,7 @@ await BuildSlackStatusAsync(cancellationToken)
Model = new DaemonRuntimeStatus.Model
{
ModelId = sessionConfig.ModelId,
+ DisplayName = ModelIdNormalizer.GetDisplayName(sessionConfig.ModelId),
Provider = modelSelection.Main.Provider,
InputModalities = sessionConfig.InputModalities.ToString(),
OutputModalities = sessionConfig.OutputModalities.ToString(),
diff --git a/src/Netclaw.Daemon/Providers/HuggingFaceCapabilityResolver.cs b/src/Netclaw.Daemon/Providers/HuggingFaceCapabilityResolver.cs
index ffaf33f29..d8331ab43 100644
--- a/src/Netclaw.Daemon/Providers/HuggingFaceCapabilityResolver.cs
+++ b/src/Netclaw.Daemon/Providers/HuggingFaceCapabilityResolver.cs
@@ -12,6 +12,7 @@ namespace Netclaw.Daemon.Providers;
public sealed class HuggingFaceCapabilityResolver : IModelCapabilityResolver
{
private const string BaseUrl = "https://huggingface.co/api/models/";
+ private const int MaxHttpAttempts = 3;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
@@ -25,27 +26,36 @@ public HuggingFaceCapabilityResolver(HttpClient httpClient, ILogger ResolveAsync(
string modelId, CancellationToken ct = default)
{
- // HuggingFace IDs use org/model format — skip bare names without a slash
- // unless they're already a valid HF ID
- var hfId = modelId;
+ // Use normalizer candidates, but only try those with org/model format (HF requires a slash)
+ var candidates = ModelIdNormalizer.GetCandidates(modelId)
+ .Where(c => c.Contains('/', StringComparison.Ordinal))
+ .Take(MaxHttpAttempts);
- // Strip Ollama tags
- var colonIdx = hfId.IndexOf(':', StringComparison.Ordinal);
- if (colonIdx > 0)
- hfId = hfId[..colonIdx];
+ foreach (var candidate in candidates)
+ {
+ var result = await TryResolveHfIdAsync(candidate, modelId, ct);
+ if (result is not null)
+ return result;
+ }
+ return null;
+ }
+
+ private async Task TryResolveHfIdAsync(
+ string hfId, string originalModelId, CancellationToken ct)
+ {
var url = $"{BaseUrl}{Uri.EscapeDataString(hfId)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await _httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
- _logger.LogDebug("HuggingFace returned {Status} for model {ModelId}", response.StatusCode, hfId);
+ _logger.LogDebug("HuggingFace returned {Status} for {HfId}", response.StatusCode, hfId);
return null;
}
var json = await response.Content.ReadAsStringAsync(ct);
- return ParseModelInfo(modelId, json);
+ return ParseModelInfo(originalModelId, json);
}
internal static ResolvedModelCapabilities? ParseModelInfo(string modelId, string json)
diff --git a/src/Netclaw.Daemon/Providers/OpenRouterOracleResolver.cs b/src/Netclaw.Daemon/Providers/OpenRouterOracleResolver.cs
index 54ad17872..79a57a123 100644
--- a/src/Netclaw.Daemon/Providers/OpenRouterOracleResolver.cs
+++ b/src/Netclaw.Daemon/Providers/OpenRouterOracleResolver.cs
@@ -35,13 +35,27 @@ public OpenRouterOracleResolver(
if (catalog is null)
return null;
- // Try all normalized candidates
- foreach (var candidate in ModelIdNormalizer.GetCandidates(modelId))
+ var candidates = ModelIdNormalizer.GetCandidates(modelId);
+
+ // Fast path: exact dictionary lookup for all normalized candidates
+ foreach (var candidate in candidates)
{
if (catalog.TryGetValue(candidate, out var caps))
return caps with { ModelId = modelId };
}
+ // Suffix scan: for unprefixed candidates, check if any catalog entry
+ // ends with "/{candidate}" — handles arbitrary org prefixes dynamically
+ foreach (var candidate in candidates.Where(
+ c => !c.Contains('/', StringComparison.Ordinal)))
+ {
+ var suffix = $"/{candidate}";
+ var match = catalog.Keys.FirstOrDefault(
+ k => k.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
+ if (match is not null)
+ return catalog[match] with { ModelId = modelId };
+ }
+
_logger.LogDebug("Model {ModelId} not found in OpenRouter catalog", modelId);
return null;
}