Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Netclaw.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Expand Down
6 changes: 6 additions & 0 deletions src/Netclaw.Configuration/DaemonRuntimeStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ public sealed class Model : IWireType
{
public required string ModelId { get; init; }

/// <summary>
/// Human-friendly model name with file-format noise stripped
/// (.gguf extension, quantization suffixes, Ollama tags).
/// </summary>
public string? DisplayName { get; init; }

public required string Provider { get; init; }

public required string InputModalities { get; init; }
Expand Down
110 changes: 87 additions & 23 deletions src/Netclaw.Configuration/ModelIdNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ namespace Netclaw.Configuration;

/// <summary>
/// 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.
/// </summary>
public static partial class ModelIdNormalizer
{
// Matches date suffixes like -20250514, -20260101
[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<string, string> KnownPrefixes = new(StringComparer.OrdinalIgnoreCase)
{
Expand All @@ -32,47 +41,73 @@ public static partial class ModelIdNormalizer

/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<string> GetCandidates(string modelId)
{
var candidates = new List<string> { 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;
Expand All @@ -83,10 +118,39 @@ public static IReadOnlyList<string> 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;
}

/// <summary>
/// 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.
/// </summary>
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<string> candidates, string value)
{
if (!candidates.Contains(value))
candidates.Add(value);
}
}
115 changes: 115 additions & 0 deletions src/Netclaw.Daemon.Tests/Providers/ModelIdNormalizerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace Netclaw.Daemon.Tests.Providers;

public sealed class ModelIdNormalizerTests
{
// === Existing regression tests ===

[Fact]
public void OriginalId_AlwaysFirst()
{
Expand Down Expand Up @@ -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));
}
}
1 change: 1 addition & 0 deletions src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
28 changes: 19 additions & 9 deletions src/Netclaw.Daemon/Providers/HuggingFaceCapabilityResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HuggingFaceCapabilityResolver> _logger;
Expand All @@ -25,27 +26,36 @@ public HuggingFaceCapabilityResolver(HttpClient httpClient, ILogger<HuggingFaceC
public async Task<ResolvedModelCapabilities?> 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<ResolvedModelCapabilities?> 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)
Expand Down
Loading
Loading