From 033317c61de832a1d8fb2796785dd96612f8ce6a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 13 Mar 2026 17:41:16 +0000 Subject: [PATCH] feat(providers): normalize GGUF and quantized model IDs for capability resolution (#204) Model capability detection relied on exact model ID matches, which broke for local/runtime-specific identifiers like GGUF filenames, quantized variants, and Ollama tags. Enhance ModelIdNormalizer with a multi-step pipeline that strips .gguf extensions, quantization suffixes (-Q5_K_M, -IQ2_XXS, etc.), lowercases PascalCase names, and removes trailing build variant segments (-UD, -BPW4). Add dynamic suffix scan to OpenRouterOracleResolver so unprefixed candidates match catalog entries without maintaining a static prefix map for every model family. Update HuggingFaceCapabilityResolver to use the normalizer instead of ad-hoc Ollama tag stripping. Add GetDisplayName() for human-friendly model names in status output, stripping file-format noise while preserving original casing. --- src/Netclaw.Cli/Program.cs | 2 +- .../DaemonRuntimeStatus.cs | 6 + .../ModelIdNormalizer.cs | 110 +++++++++++++---- .../Providers/ModelIdNormalizerTests.cs | 115 ++++++++++++++++++ .../Gateway/DaemonRuntimeStatusService.cs | 1 + .../HuggingFaceCapabilityResolver.cs | 28 +++-- .../Providers/OpenRouterOracleResolver.cs | 18 ++- 7 files changed, 245 insertions(+), 35 deletions(-) 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; }