diff --git a/src/Netclaw.Cli.Tests/Doctor/ContextWindowDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/ContextWindowDoctorCheckTests.cs index cdf89e8f8..26c6c9f2f 100644 --- a/src/Netclaw.Cli.Tests/Doctor/ContextWindowDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/ContextWindowDoctorCheckTests.cs @@ -159,6 +159,60 @@ public async Task NoExplicitContextWindow_DaemonOffline_ProviderReturnsNull_Retu Assert.Contains("provider returned no context window", result.Message); } + [Fact] + public async Task CatalogOverlay_ContextWindow_Passes() + { + // C2 regression: pre-fix the doctor only read Models.Main.ContextWindow + // directly from JSON, ignoring the catalog overlay that the daemon + // honors. An operator with Catalog['p/m'].ContextWindow set (and no + // inline value) saw the doctor fall through to auto-detection and + // print a misleading "set Models.Main.ContextWindow to pin a + // specific value" tip — for a value they had already pinned. + WriteConfig(new + { + configVersion = 1, + Models = new + { + Main = new { Provider = "p", ModelId = "m" }, + Catalog = new Dictionary + { + ["p/m"] = new { ContextWindow = 200000 } + } + } + }); + var check = CreateCheck(CreateOfflineDaemonApi()); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("200,000", result.Message); + } + + [Fact] + public async Task InlineContextWindow_WinsOver_CatalogOverlay_InDoctor() + { + // Mirrors ApplyCatalogOverlays' inline-wins semantics so the doctor's + // diagnostic always matches what the daemon will actually use. + WriteConfig(new + { + configVersion = 1, + Models = new + { + Main = new { Provider = "p", ModelId = "m", ContextWindow = 100000 }, + Catalog = new Dictionary + { + ["p/m"] = new { ContextWindow = 200000 } + } + } + }); + var check = CreateCheck(CreateOfflineDaemonApi()); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Pass, result.Severity); + Assert.Contains("100,000", result.Message); + } + // ── Helpers ────────────────────────────────────────────────────────── private ContextWindowDoctorCheck CreateCheck( diff --git a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs index 2d159bfa8..a2b93ff60 100644 --- a/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Model/ModelCommandTests.cs @@ -92,7 +92,19 @@ public async Task Set_MainModel_WritesConfig() Assert.Equal("my-ollama", main.GetProperty("Provider").GetString()); Assert.Equal("qwen3:30b", main.GetProperty("ModelId").GetString()); Assert.Equal("Manual", main.GetProperty("Provenance").GetString()); - Assert.Equal(32768, main.GetProperty("ContextWindow").GetInt32()); + + // Role records are identity-only; --context-window writes to Catalog + // keyed by "{provider}/{modelId}" so the override survives later role + // swaps. Catalog key contains ':' (Ollama's `qwen3:30b`) — this + // assertion also pins down the IConfiguration colon-split fix, since + // LoadModelSelection reads back via JsonSerializer. + Assert.False(main.TryGetProperty("ContextWindow", out _)); + var catalog = models.GetProperty("Catalog"); + var entry = catalog.GetProperty("my-ollama/qwen3:30b"); + Assert.Equal(32768, entry.GetProperty("ContextWindow").GetInt32()); + + var selection = ModelCommand.LoadModelSelection(_paths)!; + Assert.Equal(32768, selection.Main.ContextWindow); } [Fact] @@ -278,6 +290,124 @@ public async Task Set_FallbackModel_WritesCorrectRole() Assert.Equal("qwen3:8b", fallback.GetProperty("ModelId").GetString()); } + [Fact] + public async Task Set_ContextWindowFlag_WritesToCatalog_NotInlineRole() + { + // The role record stays a pure identity pointer; explicit override + // intent (--context-window) lands in Models.Catalog so it survives + // role-pointer changes (the #1127 contract). + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["p"] = new Dictionary { ["Type"] = "ollama" } + } + }); + + await ModelCommand.RunAsync( + ["model", "set", "main", "p", "m", "--context-window", "200000"], + _paths, output: _output); + + var config = ReadConfigFile(_paths.NetclawConfigPath); + var models = config.RootElement.GetProperty("Models"); + var main = models.GetProperty("Main"); + Assert.False(main.TryGetProperty("ContextWindow", out _), + "Role record should not carry inline ContextWindow — overrides live in Catalog."); + + var entry = models.GetProperty("Catalog").GetProperty("p/m"); + Assert.Equal(200_000, entry.GetProperty("ContextWindow").GetInt32()); + + // Effective via overlay merge: the role sees ContextWindow=200000. + var selection = ModelCommand.LoadModelSelection(_paths)!; + Assert.Equal(200_000, selection.Main.ContextWindow); + } + + [Fact] + public async Task Set_SwitchingRole_PointerDoesNotTouchCatalog() + { + // #1127 core invariant: changing Main from p/m1 to p/m2 does NOT + // disturb Models.Catalog. The hand-set override on p/m1 stays in + // place and re-applies the next time Main points back at p/m1. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["p"] = new Dictionary { ["Type"] = "ollama" } + } + }); + + // Operator pins ContextWindow=200000 on p/m1. + await ModelCommand.RunAsync( + ["model", "set", "main", "p", "m1", "--context-window", "200000"], + _paths, output: _output); + + // Operator switches Main to p/m2 (no override on m2). + await ModelCommand.RunAsync( + ["model", "set", "main", "p", "m2"], + _paths, output: _output); + + var afterSwitch = ModelCommand.LoadModelSelection(_paths)!; + Assert.Equal("m2", afterSwitch.Main.ModelId); + Assert.Null(afterSwitch.Main.ContextWindow); // no override for m2 + + // Catalog still holds the m1 override untouched. + var config = ReadConfigFile(_paths.NetclawConfigPath); + var entry = config.RootElement + .GetProperty("Models").GetProperty("Catalog").GetProperty("p/m1"); + Assert.Equal(200_000, entry.GetProperty("ContextWindow").GetInt32()); + + // Switching Main back to p/m1 re-applies the saved override. + await ModelCommand.RunAsync( + ["model", "set", "main", "p", "m1"], + _paths, output: _output); + + var afterReturn = ModelCommand.LoadModelSelection(_paths)!; + Assert.Equal(200_000, afterReturn.Main.ContextWindow); + } + + [Fact] + public async Task Clear_LeavesCatalogIntact() + { + // Clearing a role removes the pointer; saved overrides survive. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["p"] = new Dictionary { ["Type"] = "ollama" } + }, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "p", + ["ModelId"] = "main-model" + }, + ["Fallback"] = new Dictionary + { + ["Provider"] = "p", + ["ModelId"] = "fallback-model" + }, + ["Catalog"] = new Dictionary + { + ["p/fallback-model"] = new Dictionary + { + ["ContextWindow"] = 65536L + } + } + } + }); + + await ModelCommand.RunAsync(["model", "clear", "fallback"], _paths, output: _output); + await ModelCommand.RunAsync( + ["model", "set", "fallback", "p", "fallback-model"], _paths, output: _output); + + var selection = ModelCommand.LoadModelSelection(_paths)!; + Assert.Equal(65_536, selection.Fallback!.ContextWindow); + } + private void WriteConfig(Dictionary data) { File.WriteAllText(_paths.NetclawConfigPath, diff --git a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs index a1bd61020..6a6d46d44 100644 --- a/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs +++ b/src/Netclaw.Cli.Tests/Provider/ProviderRenamerTests.cs @@ -295,6 +295,50 @@ public void Rename_TrimsWhitespaceOnNewName() Assert.True(providers.TryGetProperty("lab-a100", out _)); } + [Fact] + public void Rename_RewritesCatalogKeys_KeyedByOldProvider() + { + // C3 regression: pre-fix, Models.Catalog kept its keys keyed by the + // OLD provider name after a rename. The renamed role's + // ApplyCatalogOverlays lookup misses, and the operator's saved + // override silently disappears. + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["old-p"] = new Dictionary { ["Type"] = "openai-compatible" } + }, + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary + { + ["Provider"] = "old-p", + ["ModelId"] = "m" + }, + ["Catalog"] = new Dictionary + { + ["old-p/m"] = new Dictionary { ["ContextWindow"] = 200000L }, + ["unrelated/k"] = new Dictionary { ["ContextWindow"] = 50000L } + } + } + }); + + var result = ProviderRenamer.Rename(_paths, "old-p", "new-p"); + + Assert.True(result.Success); + + using var config = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var catalog = config.RootElement.GetProperty("Models").GetProperty("Catalog"); + Assert.False(catalog.TryGetProperty("old-p/m", out _), + "old key should have been rewritten"); + Assert.True(catalog.TryGetProperty("new-p/m", out var renamedEntry), + "renamed key should be present"); + Assert.Equal(200_000, renamedEntry.GetProperty("ContextWindow").GetInt32()); + Assert.True(catalog.TryGetProperty("unrelated/k", out _), + "catalog entries for other providers should be untouched"); + } + private void WriteConfig(Dictionary data) { File.WriteAllText(_paths.NetclawConfigPath, diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index 9cb7d36eb..e5f75c42e 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -121,4 +121,26 @@ internal static string DecryptIfEncrypted(Configuration.NetclawPaths paths, stri var protector = SecretsProtection.CreateProtector(paths); return protector.Unprotect(value); } + + /// + /// Write an operator-set field + /// into Models.Catalog["{provider}/{modelId}"]. Used by CLI flags + /// (e.g. --context-window) that represent explicit override intent. + /// The catalog is the persistent override layer — values written here + /// survive role-pointer changes (picker swaps, role clears, role + /// re-selection) because the catalog key is independent of which role + /// currently references the model. + /// + internal static void SetCatalogOverride( + Dictionary modelsSection, + string provider, + string modelId, + string fieldName, + object value) + { + var catalog = GetOrCreateSection(modelsSection, "Catalog"); + var key = Configuration.ModelSelection.CatalogKey(provider, modelId); + var entry = GetOrCreateSection(catalog, key); + entry[fieldName] = value; + } } diff --git a/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs b/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs index ceeb040a6..2cd292a5e 100644 --- a/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/ContextWindowDoctorCheck.cs @@ -50,25 +50,77 @@ public async Task RunAsync(CancellationToken cancellationToke "Add a Models.Main section with ContextWindow to netclaw.json."); } - var contextWindow = main["ContextWindow"]; + var modelId = main["ModelId"]?.GetValue() ?? "unknown"; + var providerName = main["Provider"]?.GetValue() ?? "local-ollama"; + + // Effective ContextWindow follows ModelSelection.ApplyCatalogOverlays: + // inline on the role wins; otherwise fall back to the catalog + // overlay keyed by "{provider}/{modelId}" (case-insensitive). The + // runtime daemon uses exactly this precedence, so the doctor must + // match it or it will report "no explicit setting" while the + // daemon honors an override. + var contextWindow = main["ContextWindow"] + ?? TryReadCatalogContextWindow(models!, providerName, modelId); + if (contextWindow is null) - { - var modelId = main["ModelId"]?.GetValue() ?? "unknown"; - var providerName = main["Provider"]?.GetValue() ?? "local-ollama"; return await ResolveEffectiveContextWindowAsync(modelId, providerName, cancellationToken); - } - if (contextWindow.GetValue() is var cw and > 0) + if (!TryReadPositiveInt(contextWindow, out var cw)) { - return DoctorCheckResult.Pass( + return DoctorCheckResult.Error( "Context Window", - $"Context window explicitly set to {cw:N0} tokens."); + $"ContextWindow for {providerName}/{modelId} is not a positive integer (got {DescribeJsonValue(contextWindow)}).", + "Edit netclaw.json so the ContextWindow value is a positive integer literal (no quotes, no decimals)."); } - return DoctorCheckResult.Error( + return DoctorCheckResult.Pass( "Context Window", - "Models.Main.ContextWindow must be a positive integer.", - "Set Models.Main.ContextWindow to the effective runtime context window size in tokens."); + $"Context window explicitly set to {cw:N0} tokens."); + } + + private static JsonNode? TryReadCatalogContextWindow( + JsonObject models, string providerName, string modelId) + { + var catalog = models["Catalog"] as JsonObject; + if (catalog is null) return null; + var key = ModelSelection.CatalogKey(providerName, modelId); + // Case-insensitive scan: ApplyCatalogOverlays does the same when the + // exact-case lookup misses, so the doctor must agree. + if (catalog[key] is JsonObject direct) return direct["ContextWindow"]; + foreach (var kvp in catalog) + { + if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase) + && kvp.Value is JsonObject entry) + return entry["ContextWindow"]; + } + return null; + } + + private static bool TryReadPositiveInt(JsonNode node, out int value) + { + value = 0; + try + { + // JsonValue.TryGetValue avoids the InvalidOperationException + // GetValue() throws on strings, doubles, etc. — exactly the + // shapes a hand-edited Catalog override is prone to. + if (node is JsonValue jv && jv.TryGetValue(out var parsed) && parsed > 0) + { + value = parsed; + return true; + } + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException) + { + // fall through to false + } + return false; + } + + private static string DescribeJsonValue(JsonNode node) + { + var raw = node.ToJsonString(); + return raw.Length > 40 ? raw[..37] + "..." : raw; } private async Task ResolveEffectiveContextWindowAsync( diff --git a/src/Netclaw.Cli/Model/ModelCommand.cs b/src/Netclaw.Cli/Model/ModelCommand.cs index fd12da153..df67a4f21 100644 --- a/src/Netclaw.Cli/Model/ModelCommand.cs +++ b/src/Netclaw.Cli/Model/ModelCommand.cs @@ -138,7 +138,12 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) } } - // Write to config + // Write to config. Role records are pure identity pointers + // (Provider, ModelId, Provenance). Operator-set overrides live + // independently in Models.Catalog, keyed by "{provider}/{modelId}", + // and survive every role-pointer change (this set, future sets, + // picker swaps, clears) because the catalog is decoupled from + // which role currently references the model. var (config, _) = ConfigFileHelper.LoadConfigFiles(paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); @@ -148,11 +153,18 @@ private static int RunSet(string[] args, NetclawPaths paths, TextWriter writer) ["ModelId"] = modelId, ["Provenance"] = ModelDiscoverySource.Manual.ToString() }; + modelsSection[roleKey] = modelEntry; + // --context-window is explicit override intent → persist to catalog + // so it applies regardless of which role currently points at this + // (provider, modelId). To remove a previously-set override, the + // operator hand-edits Models.Catalog["{provider}/{modelId}"]. if (contextWindow.HasValue) - modelEntry["ContextWindow"] = contextWindow.Value; + { + ConfigFileHelper.SetCatalogOverride( + modelsSection, providerName, modelId, "ContextWindow", contextWindow.Value); + } - modelsSection[roleKey] = modelEntry; ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); writer.WriteLine($"Set {role} model to {providerName}/{modelId}"); @@ -259,6 +271,10 @@ private static int RunClear(string[] args, NetclawPaths paths, TextWriter writer return 0; } + // Clearing a role removes the identity pointer only. Any override + // entries in Models.Catalog stay in place, so re-binding this role + // to the same (provider, modelId) later still picks up the saved + // overrides via ApplyCatalogOverlays. modelsSection.Remove(roleKey); ConfigFileHelper.WriteConfigFile(paths.NetclawConfigPath, config); @@ -267,7 +283,8 @@ private static int RunClear(string[] args, NetclawPaths paths, TextWriter writer } /// - /// Load model selection from config file. + /// Load model selection from config file. Applies catalog overlays so + /// callers see the effective per-role view, not the raw on-disk shape. /// internal static ModelSelection? LoadModelSelection(NetclawPaths paths) { @@ -278,7 +295,9 @@ private static int RunClear(string[] args, NetclawPaths paths, TextWriter writer if (!doc.RootElement.TryGetProperty("Models", out var modelsElement)) return null; - return JsonSerializer.Deserialize(modelsElement.GetRawText(), JsonDefaults.EnumAware); + var models = JsonSerializer.Deserialize(modelsElement.GetRawText(), JsonDefaults.EnumAware); + models?.ApplyCatalogOverlays(); + return models; } private static int WriteHelp(TextWriter writer) diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 9a22c1c55..e5f5b2242 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -185,8 +185,8 @@ static async Task RunAsync(string[] args) .AddEnvironmentVariables("NETCLAW_"); var initConfig = configBuilder.Build(); - var models = initConfig.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelSelection.LoadFromConfiguration( + initConfig.GetSection("Models"), initPaths.NetclawConfigPath); var contextWindow = ContextWindowResolution.ResolveAsync( models.Main.ContextWindow, @@ -981,7 +981,7 @@ static async Task RunAsync(string[] args) webBuilder.WebHost.UseUrls("http://127.0.0.1:0"); var sharedPaths = ConfigureConfigServices(webBuilder.Services, webBuilder.Configuration); - ConfigureCliChatServices(webBuilder.Services, webBuilder.Configuration); + ConfigureCliChatServices(webBuilder.Services, webBuilder.Configuration, sharedPaths); // Shared navigation state for passing resume session ID to ChatViewModel var navState = new ChatNavigationState { ResumeSessionId = resumeSessionId }; @@ -1797,11 +1797,11 @@ static IConfigurationRoot BuildCliConfig() // Daemon-backed CLI services (SignalR thin client) // ═══════════════════════════════════════════════════════════════════════ -static void ConfigureCliChatServices(IServiceCollection services, IConfigurationManager configuration) +static void ConfigureCliChatServices(IServiceCollection services, IConfigurationManager configuration, NetclawPaths paths) { // Resolve models for session config - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelSelection.LoadFromConfiguration( + configuration.GetSection("Models"), paths.NetclawConfigPath); // Session config: bind operator-facing settings var sessionConfig = SessionConfig.BindFromConfiguration(configuration.GetSection("Session")); diff --git a/src/Netclaw.Cli/Provider/ProviderRenamer.cs b/src/Netclaw.Cli/Provider/ProviderRenamer.cs index 07741eb22..bbd5e1ef1 100644 --- a/src/Netclaw.Cli/Provider/ProviderRenamer.cs +++ b/src/Netclaw.Cli/Provider/ProviderRenamer.cs @@ -120,9 +120,52 @@ private static List CascadeRenameModelRoles( } } + // Catalog keys embed the provider name as the first segment of + // "{provider}/{modelId}". Rewrite any key whose provider segment + // matches oldName (case-insensitive, mirroring the role cascade) so + // the override remains reachable from the renamed Provider field. + RenameCatalogProviderSegment(models, oldName, newName); + return reassigned; } + private static void RenameCatalogProviderSegment( + Dictionary models, string oldName, string newName) + { + var catalog = ConfigFileHelper.GetSectionOrNull(models, "Catalog"); + if (catalog is null || catalog.Count == 0) return; + + // Two-pass: collect rewrites first, then mutate. Iterating and + // mutating a Dictionary's keys simultaneously throws. + List<(string OldKey, string NewKey)>? rewrites = null; + foreach (var key in catalog.Keys) + { + var slash = key.IndexOf('/'); + if (slash <= 0) continue; + var providerSegment = key.AsSpan(0, slash); + if (!providerSegment.Equals(oldName, StringComparison.OrdinalIgnoreCase)) + continue; + var modelIdSegment = key.Substring(slash + 1); + var newKey = Configuration.ModelSelection.CatalogKey(newName, modelIdSegment); + rewrites ??= new List<(string, string)>(); + rewrites.Add((key, newKey)); + } + + if (rewrites is null) return; + + foreach (var (oldKey, newKey) in rewrites) + { + var entry = catalog[oldKey]; + catalog.Remove(oldKey); + // If a same-spelled rename collides with an unrelated catalog + // entry under newKey (rare — would require a pre-existing + // duplicate), keep the freshly-renamed one — that's the entry + // the active role just rebound to, so it represents the + // operator's live intent. + catalog[newKey] = entry; + } + } + private static bool HasCollision( Dictionary section, string oldName, string newName) { diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index 0c2ee64f8..298bdc3b1 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -171,6 +171,9 @@ public void ConfirmAssignment() var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); var modelsSection = ConfigFileHelper.GetOrCreateSection(config, "Models"); + // Role records are pure identity pointers; operator overrides live + // in Models.Catalog keyed by "{provider}/{modelId}" and survive + // picker swaps because they are decoupled from the role record. var modelEntry = new Dictionary { ["Provider"] = SelectedProvider, @@ -211,6 +214,9 @@ public void ClearRole(string role) var modelsSection = ConfigFileHelper.GetSectionOrNull(config, "Models"); if (modelsSection?.Remove(roleKey) == true) { + // Clearing a role removes the identity pointer only. Any saved + // overrides in Models.Catalog stay in place and re-apply if the + // role is later re-bound to the same (provider, modelId). ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); Refresh(); StatusMessage.Value = $"Cleared {role} role. Restart daemon for changes to take effect."; diff --git a/src/Netclaw.Configuration.Tests/ModelSelectionCatalogOverlayTests.cs b/src/Netclaw.Configuration.Tests/ModelSelectionCatalogOverlayTests.cs new file mode 100644 index 000000000..dcac4a90f --- /dev/null +++ b/src/Netclaw.Configuration.Tests/ModelSelectionCatalogOverlayTests.cs @@ -0,0 +1,165 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Netclaw.Configuration.Tests; + +/// +/// Locks in the contract for #1127: operator overrides persisted in +/// overlay onto matching role records +/// (Main / Fallback / Compaction) when +/// runs, with inline values winning over the catalog entry. +/// +public sealed class ModelSelectionCatalogOverlayTests +{ + [Fact] + public void CatalogOverlay_AppliesContextWindow_WhenRoleHasNoInlineValue() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Models:Main:Provider"] = "spark-362c", + ["Models:Main:ModelId"] = "Qwen/Qwen3.6-35B-A3B-FP8", + ["Models:Catalog:spark-362c/Qwen/Qwen3.6-35B-A3B-FP8:ContextWindow"] = "200000", + }) + .Build(); + + var selection = config.GetSection("Models").Get()!; + selection.ApplyCatalogOverlays(); + + Assert.Equal(200_000, selection.Main.ContextWindow); + } + + [Fact] + public void InlineRoleValue_WinsOver_CatalogOverlay() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Models:Main:Provider"] = "p", + ["Models:Main:ModelId"] = "m", + ["Models:Main:ContextWindow"] = "1000", + ["Models:Catalog:p/m:ContextWindow"] = "9999", + }) + .Build(); + + var selection = config.GetSection("Models").Get()!; + selection.ApplyCatalogOverlays(); + + Assert.Equal(1000, selection.Main.ContextWindow); + } + + [Fact] + public void CatalogOverlay_AppliesIndependentlyTo_AllThreeRoles() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Models:Main:Provider"] = "p", + ["Models:Main:ModelId"] = "main-model", + ["Models:Fallback:Provider"] = "p", + ["Models:Fallback:ModelId"] = "fallback-model", + ["Models:Compaction:Provider"] = "p", + ["Models:Compaction:ModelId"] = "compaction-model", + ["Models:Catalog:p/main-model:InputModalities"] = "Text, Image", + ["Models:Catalog:p/fallback-model:ContextWindow"] = "65536", + ["Models:Catalog:p/compaction-model:OutputModalities"] = "Text", + }) + .Build(); + + var selection = config.GetSection("Models").Get()!; + selection.ApplyCatalogOverlays(); + + Assert.Equal(ModelModality.Text | ModelModality.Image, selection.Main.InputModalities); + Assert.Equal(65_536, selection.Fallback!.ContextWindow); + Assert.Equal(ModelModality.Text, selection.Compaction!.OutputModalities); + } + + [Fact] + public void CatalogEntry_WithoutMatchingRole_IsIgnored() + { + // Operator switched Main from "old-model" to "new-model"; the old + // model's overrides remain in the catalog. They should NOT leak onto + // the unrelated new selection. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Models:Main:Provider"] = "p", + ["Models:Main:ModelId"] = "new-model", + ["Models:Catalog:p/old-model:ContextWindow"] = "12345", + }) + .Build(); + + var selection = config.GetSection("Models").Get()!; + selection.ApplyCatalogOverlays(); + + Assert.Null(selection.Main.ContextWindow); + } + + [Fact] + public void ApplyCatalogOverlays_IsNoOp_WhenCatalogAbsent() + { + var selection = new ModelSelection { Main = new ModelReference { Provider = "p", ModelId = "m" } }; + selection.ApplyCatalogOverlays(); // does not throw, leaves fields null + Assert.Null(selection.Main.ContextWindow); + Assert.Null(selection.Main.InputModalities); + } + + [Fact] + public void LoadFromConfiguration_PreservesColonInCatalogKey_ForOllamaStyleModelIds() + { + // Microsoft.Extensions.Configuration treats ':' as a path separator + // and applies that splitting to dictionary keys, so a Catalog key + // like "local-ollama/qwen3:30b" gets bound as + // Catalog["local-ollama/qwen3"] with the "30b" suffix dropped as a + // stray sub-property. Default ModelId in ModelReference is qwen3:30b + // so this is the most common path. LoadFromConfiguration re-parses + // Catalog directly from the raw JSON via System.Text.Json (which + // treats keys as opaque strings) to side-step the binder. + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, """ + { + "Models": { + "Main": { "Provider": "local-ollama", "ModelId": "qwen3:30b" }, + "Catalog": { + "local-ollama/qwen3:30b": { "ContextWindow": 200000 } + } + } + } + """); + + // Use InMemoryCollection for the Main pointer side (avoids + // depending on Microsoft.Extensions.Configuration.Json in the + // test project); LoadFromConfiguration reads Catalog directly + // from the file regardless of the IConfiguration source. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Models:Main:Provider"] = "local-ollama", + ["Models:Main:ModelId"] = "qwen3:30b", + }) + .Build(); + + var selection = ModelSelection.LoadFromConfiguration( + config.GetSection("Models"), tempFile); + + // Catalog round-trips the colon-laden key intact. + Assert.NotNull(selection.Catalog); + Assert.True(selection.Catalog!.ContainsKey("local-ollama/qwen3:30b")); + Assert.Equal(200_000, selection.Catalog["local-ollama/qwen3:30b"].ContextWindow); + + // Overlay applies to Main, so the daemon sees the pinned value. + Assert.Equal(200_000, selection.Main.ContextWindow); + } + finally + { + File.Delete(tempFile); + } + } +} diff --git a/src/Netclaw.Configuration/ModelOverride.cs b/src/Netclaw.Configuration/ModelOverride.cs new file mode 100644 index 000000000..514f96da2 --- /dev/null +++ b/src/Netclaw.Configuration/ModelOverride.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Configuration; + +/// +/// Persisted operator overrides for a single (provider, modelId) pair. +/// Lives in ; merged into matching +/// role records at read time. Only fields the operator has explicitly +/// customized are stored — auto-detected values stay ephemeral and are +/// re-resolved at every daemon startup, so a default install has no +/// catalog entries at all. +/// +public sealed class ModelOverride +{ + /// Clamps the runtime session budget; same semantics as . + public int? ContextWindow { get; set; } + + /// Manual input-modality override; same semantics as . + public ModelModality? InputModalities { get; set; } + + /// Manual output-modality override; same semantics as . + public ModelModality? OutputModalities { get; set; } +} diff --git a/src/Netclaw.Configuration/ModelSelection.cs b/src/Netclaw.Configuration/ModelSelection.cs index f39ede885..2db9d54b4 100644 --- a/src/Netclaw.Configuration/ModelSelection.cs +++ b/src/Netclaw.Configuration/ModelSelection.cs @@ -1,8 +1,12 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; + namespace Netclaw.Configuration; /// @@ -20,4 +24,119 @@ public sealed class ModelSelection /// Cheaper/faster model for compaction. Falls back to Main if not set. public ModelReference? Compaction { get; set; } + + /// + /// Optional map of operator overrides keyed by "{provider}/{modelId}". + /// Merged into matching role records by ; + /// inline values on a role win over the catalog entry. Typically empty — + /// auto-detection covers most operators' setups and only persists when an + /// override is explicitly set (e.g. a context-window cap or a forced + /// modality). + /// + public Dictionary? Catalog { get; set; } + + /// + /// Builds the catalog key for a (provider, modelId) pair. Provider keys + /// are user-defined identifiers and conventionally slash-free; model ids + /// may contain slashes (e.g. HF-shaped org/model), so the first + /// slash is the only meaningful delimiter. + /// + public static string CatalogKey(string provider, string modelId) => $"{provider}/{modelId}"; + + /// + /// Folds entries into matching role records, + /// preserving inline values (inline wins). Idempotent: a second call is + /// a no-op because inline already mirrors the catalog after the first + /// merge. Safe to call when is null. + /// + public void ApplyCatalogOverlays() + { + if (Catalog is null || Catalog.Count == 0) + return; + + Merge(Main); + if (Fallback is not null) Merge(Fallback); + if (Compaction is not null) Merge(Compaction); + } + + private void Merge(ModelReference role) + { + var key = CatalogKey(role.Provider, role.ModelId); + var overlay = FindOverlay(key); + if (overlay is null) return; + + role.ContextWindow ??= overlay.ContextWindow; + role.InputModalities ??= overlay.InputModalities; + role.OutputModalities ??= overlay.OutputModalities; + } + + private ModelOverride? FindOverlay(string key) + { + // Fast path: exact-case match against the operator-written key. + if (Catalog!.TryGetValue(key, out var direct)) return direct; + // Tolerate provider-name casing drift between role.Provider and the + // catalog key (operator hand-edits, picker re-writes, the Providers + // dictionary which is case-sensitive vs. ProviderRenamer which is + // not). Catalog is typically empty or single-digit entries so the + // O(n) scan is negligible. + foreach (var kvp in Catalog) + { + if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase)) + return kvp.Value; + } + return null; + } + + /// + /// Bind a from configuration, then re-load + /// directly from the raw config file so dictionary + /// keys containing : survive intact, and apply overlays. Use this + /// instead of calling Get<ModelSelection>() directly. + /// + /// Microsoft.Extensions.Configuration uses : as its hierarchical + /// path separator and applies that splitting to dictionary keys too, so + /// the binder turns Catalog["local-ollama/qwen3:30b"] into + /// Catalog["local-ollama/qwen3"] with a stray 30b + /// sub-property — silently dropping the operator's override. Ollama's + /// default ModelId is qwen3:30b, so the broken path is the most + /// common one. Re-parsing the raw JSON via System.Text.Json (which + /// treats keys as opaque strings) is the cleanest fix that preserves + /// the rest of the M.E.Configuration pipeline for everything else. + /// + /// + public static ModelSelection LoadFromConfiguration( + IConfiguration modelsSection, string netclawConfigPath) + { + var selection = modelsSection.Get() ?? new ModelSelection(); + selection.Catalog = LoadCatalogFromFile(netclawConfigPath) ?? selection.Catalog; + selection.ApplyCatalogOverlays(); + return selection; + } + + /// + /// Parse Models.Catalog from + /// directly via System.Text.Json (bypassing + /// Microsoft.Extensions.Configuration's path-splitting binder). Returns + /// null when the file is absent, has no Models section, has no Catalog + /// property, or Catalog is not a JSON object — callers can keep + /// whatever binder-produced value they already have in that case. + /// + public static Dictionary? LoadCatalogFromFile(string netclawConfigPath) + { + if (!File.Exists(netclawConfigPath)) return null; + + using var doc = JsonDocument.Parse(File.ReadAllText(netclawConfigPath)); + if (!doc.RootElement.TryGetProperty("Models", out var models)) return null; + if (!models.TryGetProperty("Catalog", out var catalog)) return null; + if (catalog.ValueKind != JsonValueKind.Object) return null; + + return JsonSerializer.Deserialize>( + catalog.GetRawText(), + CatalogJsonOptions); + } + + private static readonly JsonSerializerOptions CatalogJsonOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + }; } diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 975cfa9d7..be0875492 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -335,7 +335,12 @@ "properties": { "Main": { "$ref": "#/$defs/ModelReference" }, "Fallback": { "$ref": "#/$defs/ModelReference" }, - "Compaction": { "$ref": "#/$defs/ModelReference" } + "Compaction": { "$ref": "#/$defs/ModelReference" }, + "Catalog": { + "type": "object", + "description": "Optional operator overrides keyed by \"{provider}/{modelId}\". Merged into matching role records at read time; inline values on a role win. Typically empty — present only when an operator has hand-set a value (e.g. a context-window cap) they want to survive model-picker swaps.", + "additionalProperties": { "$ref": "#/$defs/ModelOverride" } + } }, "additionalProperties": false }, @@ -854,6 +859,24 @@ }, "additionalProperties": false }, + "ModelOverride": { + "type": "object", + "description": "Persisted operator override for a single (provider, modelId). Subset of ModelReference fields: identity is encoded in the catalog key.", + "properties": { + "ContextWindow": { "type": "integer", "description": "Effective runtime context window in tokens. When set, clamps the detected provider value." }, + "InputModalities": { + "type": "string", + "description": "Manual override for input modalities. Comma-separated ModelModality flags.", + "pattern": "^(Text|Image|Audio|Video)(\\s*,\\s*(Text|Image|Audio|Video))*$" + }, + "OutputModalities": { + "type": "string", + "description": "Manual override for output modalities. Comma-separated ModelModality flags.", + "pattern": "^(Text|Image|Audio|Video)(\\s*,\\s*(Text|Image|Audio|Video))*$" + } + }, + "additionalProperties": false + }, "NotificationTarget": { "type": "object", "properties": { diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 2bc907cc4..386bd8aea 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -329,8 +329,8 @@ static NetclawPaths ConfigureConfigServices(IServiceCollection services, IConfig var providers = configuration.GetSection("Providers") .Get>() ?? new() { ["local-ollama"] = new ProviderEntry() }; - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelSelection.LoadFromConfiguration( + configuration.GetSection("Models"), paths.NetclawConfigPath); services.AddDaemonLlmProviders(providers, models); @@ -359,6 +359,17 @@ static void ConfigureDaemonServices( services .AddOptions() .Bind(configuration.GetSection("Models")) + // Re-load Catalog directly from the config file (bypassing + // Microsoft.Extensions.Configuration's path-splitting binder, which + // mangles keys containing ':' such as the default Ollama + // `qwen3:30b`) and fold overlays into role records so a ContextWindow + // set only via Catalog still gets checked by ModelSelectionValidator. + .PostConfigure(models => + { + models.Catalog = ModelSelection.LoadCatalogFromFile(paths.NetclawConfigPath) + ?? models.Catalog; + models.ApplyCatalogOverlays(); + }) .ValidateOnStart(); services.AddSingleton, ModelSelectionValidator>(); services @@ -376,8 +387,8 @@ static void ConfigureDaemonServices( }); // Resolve models for session config - var models = configuration.GetSection("Models") - .Get() ?? new ModelSelection(); + var models = ModelSelection.LoadFromConfiguration( + configuration.GetSection("Models"), paths.NetclawConfigPath); services.AddSingleton(models); // Auto-detect model capabilities via the runtime IModelCapabilityResolver