diff --git a/src/OpenClaw.Chat/ChatModelChoice.cs b/src/OpenClaw.Chat/ChatModelChoice.cs index 885a0fca1..03d793d54 100644 --- a/src/OpenClaw.Chat/ChatModelChoice.cs +++ b/src/OpenClaw.Chat/ChatModelChoice.cs @@ -21,6 +21,7 @@ namespace OpenClaw.Chat; /// the model is usable. /// /// True when the gateway marks this model as the default. +/// True when the gateway explicitly reported configuration state. public sealed record ChatModelChoice( string Id, string DisplayName, @@ -30,7 +31,8 @@ public sealed record ChatModelChoice( bool IsConfigured = true, bool IsAvailable = true, bool RequiresAuth = false, - bool IsDefault = false) + bool IsDefault = false, + bool HasConfiguredFlag = false) { /// /// Provider-qualified identity used for picker tags and sessions.patch @@ -40,11 +42,11 @@ public sealed record ChatModelChoice( /// /// True when the user may switch the session to this model. Auth-needed - /// models remain selectable (selecting one routes the user toward the - /// gateway's provider-auth flow); only explicitly unavailable models are - /// blocked. + /// models remain selectable so the provider-auth flow can run. Catalog-only + /// and explicitly unavailable models remain visible but disabled. /// - public bool IsSelectable => IsAvailable; + public bool IsSelectable => + IsAvailable && (!HasConfiguredFlag || IsConfigured || RequiresAuth); /// /// Maps gateway models into ordered, selection-deduplicated picker entries. @@ -58,9 +60,6 @@ public static IReadOnlyList FromModelsList(ModelsListInfo? info foreach (var m in info.Models) { if (m is null || string.IsNullOrEmpty(m.Id)) continue; - // Hide explicitly unconfigured models unless the gateway reports an - // auth flow for them; auth-needed rows are useful picker actions. - if (m.HasConfiguredFlag && !m.IsConfigured && !m.RequiresAuth) continue; var choice = new ChatModelChoice( Id: m.Id, DisplayName: m.DisplayName, @@ -70,7 +69,8 @@ public static IReadOnlyList FromModelsList(ModelsListInfo? info IsConfigured: m.IsConfigured, IsAvailable: m.IsAvailable, RequiresAuth: m.RequiresAuth, - IsDefault: m.IsDefault); + IsDefault: m.IsDefault, + HasConfiguredFlag: m.HasConfiguredFlag); if (!seen.Add(choice.SelectionId)) continue; list.Add(choice); } @@ -253,14 +253,14 @@ public static string BuildMetaSegment(ChatModelChoice choice) /// /// Trailing state marker for a model: "default", "auth needed", - /// "unavailable", or empty. Unavailable takes precedence over auth-needed, - /// which takes precedence over default. Only explicit gateway signals drive - /// the markers — a missing flag is - /// not treated as "auth needed" because the gateway's configured view - /// often omits the field entirely. + /// "not configured", "unavailable", or empty. Explicit configuration state + /// is shown first so catalog-only rows explain why they are disabled. Missing + /// configuration metadata remains neutral for older gateways. /// public static string BuildStateMarker(ChatModelChoice choice) { + if (choice.HasConfiguredFlag && !choice.IsConfigured && !choice.RequiresAuth) + return "not configured"; if (!choice.IsAvailable) return "unavailable"; if (choice.RequiresAuth) return "auth needed"; if (choice.IsDefault) return "default"; @@ -269,8 +269,8 @@ public static string BuildStateMarker(ChatModelChoice choice) /// /// Full menu/combo label, e.g. "Claude Opus 4.8 · Anthropic · 200K · default". - /// State marker is appended last so default/auth-needed/unavailable reads at - /// the end of the row. + /// State marker is appended last so default/auth-needed/not-configured/unavailable + /// reads at the end of the row. /// public static string BuildMenuLabel(ChatModelChoice choice) { diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index acd798092..5a8273836 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -1028,7 +1028,47 @@ public async Task RequestAgentFileGetAsync(string agentId, string name) public async Task RequestModelsListAsync() { if (_modelsListUnsupported) return; - await SendTrackedRequestAsync("models.list", new { view = "configured" }); + + ModelsListInfo configured; + try + { + var configuredPayload = await SendWizardRequestAsync( + "models.list", + new { view = "configured" }, + timeoutMs: 10000); + configured = ParseModelsListPayload(configuredPayload); + } + catch (Exception ex) + { + // Preserve the legacy fire-and-forget path for older gateways and + // transient response-aware request failures. + _logger.Warn($"Configured models.list request failed; using legacy request path: {ex.Message}"); + await SendTrackedRequestAsync("models.list", new { view = "configured" }); + return; + } + + // Populate the existing safe choices immediately, then enhance the picker + // when the broader catalog response arrives. + ModelsListUpdated?.Invoke(this, configured); + + ModelsListInfo catalog; + try + { + var catalogPayload = await SendWizardRequestAsync( + "models.list", + new { view = "all" }, + timeoutMs: 10000); + catalog = ParseModelsListPayload(catalogPayload); + } + catch (Exception ex) + { + // A full catalog is an enhancement. The configured list remains a + // safe, selectable fallback when discovery is unsupported or slow. + _logger.Warn($"Full models.list catalog request failed; keeping configured models only: {ex.Message}"); + return; + } + + ModelsListUpdated?.Invoke(this, MergeModelCatalog(configured, catalog)); } // Node/Device pairing @@ -4420,49 +4460,128 @@ private void ParseModelsList(JsonElement payload) { try { - var info = new ModelsListInfo(); - // Gateway returns { models: [...] } or just an array - var modelsArray = payload.ValueKind == JsonValueKind.Array - ? payload - : payload.TryGetProperty("models", out var m) ? m : default; + ModelsListUpdated?.Invoke(this, ParseModelsListPayload(payload)); + } + catch (Exception ex) + { + _logger.Warn($"Failed to parse models.list: {ex.Message}"); + } + } + + internal static ModelsListInfo ParseModelsListPayload(JsonElement payload) + { + var info = new ModelsListInfo(); + // Gateway returns { models: [...] } or just an array. + var modelsArray = payload.ValueKind == JsonValueKind.Array + ? payload + : payload.TryGetProperty("models", out var m) ? m : default; + + if (modelsArray.ValueKind != JsonValueKind.Array) + return info; + + foreach (var item in modelsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; - if (modelsArray.ValueKind == JsonValueKind.Array) + // Read readiness flags defensively; older gateways may omit + // them, and the UI only uses them for labels/selectability. + var hasConfiguredFlag = item.TryGetProperty("configured", out var cfg) + && (cfg.ValueKind == JsonValueKind.True || cfg.ValueKind == JsonValueKind.False); + bool available = true; + if (TryReadBool(item, out var av, "available")) available = av; + else if (TryReadBool(item, out var un, "unavailable")) available = !un; + + var model = new ModelInfo { - foreach (var item in modelsArray.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Object) continue; + Id = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", + Name = item.TryGetProperty("name", out var name) ? name.GetString() : null, + Provider = item.TryGetProperty("provider", out var prov) ? prov.GetString() : null, + ContextWindow = ReadPositiveInt32(item, "contextWindow"), + ContextTokens = ReadPositiveInt32(item, "contextTokens"), + IsConfigured = hasConfiguredFlag && cfg.ValueKind == JsonValueKind.True, + HasConfiguredFlag = hasConfiguredFlag, + IsDefault = ReadBool(item, "default", "isDefault"), + IsAvailable = available, + RequiresAuth = ReadBool(item, "requiresAuth", "authRequired", "needsAuth", "authNeeded") + }; + if (!string.IsNullOrEmpty(model.Id)) + info.Models.Add(model); + } - // Read readiness flags defensively; older gateways may omit - // them, and the UI only uses them for labels/selectability. - var hasConfiguredFlag = item.TryGetProperty("configured", out var cfg) - && (cfg.ValueKind == JsonValueKind.True || cfg.ValueKind == JsonValueKind.False); - bool available = true; - if (TryReadBool(item, out var av, "available")) available = av; - else if (TryReadBool(item, out var un, "unavailable")) available = !un; + return info; + } - var model = new ModelInfo - { - Id = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "", - Name = item.TryGetProperty("name", out var name) ? name.GetString() : null, - Provider = item.TryGetProperty("provider", out var prov) ? prov.GetString() : null, - ContextWindow = ReadPositiveInt32(item, "contextWindow"), - ContextTokens = ReadPositiveInt32(item, "contextTokens"), - IsConfigured = hasConfiguredFlag && cfg.ValueKind == JsonValueKind.True, - HasConfiguredFlag = hasConfiguredFlag, - IsDefault = ReadBool(item, "default", "isDefault"), - IsAvailable = available, - RequiresAuth = ReadBool(item, "requiresAuth", "authRequired", "needsAuth", "authNeeded") - }; - if (!string.IsNullOrEmpty(model.Id)) - info.Models.Add(model); - } - } - ModelsListUpdated?.Invoke(this, info); + internal static ModelsListInfo MergeModelCatalog(ModelsListInfo configured, ModelsListInfo catalog) + { + var result = new ModelsListInfo(); + var byIdentity = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var source in configured.Models) + { + if (source is null || string.IsNullOrWhiteSpace(source.Id)) continue; + + var identity = GetModelIdentity(source); + if (!byIdentity.TryAdd(identity, CloneModelInfo(source))) continue; + + var configuredModel = byIdentity[identity]; + configuredModel.HasConfiguredFlag = true; + configuredModel.IsConfigured = true; + result.Models.Add(configuredModel); } - catch (Exception ex) + + foreach (var source in catalog.Models) { - _logger.Warn($"Failed to parse models.list: {ex.Message}"); + if (source is null || string.IsNullOrWhiteSpace(source.Id)) continue; + if (!source.IsAvailable && !source.RequiresAuth) continue; + + var identity = GetModelIdentity(source); + if (byIdentity.TryGetValue(identity, out var configuredModel)) + { + configuredModel.Name ??= source.Name; + configuredModel.Provider ??= source.Provider; + configuredModel.ContextWindow ??= source.ContextWindow; + configuredModel.ContextTokens ??= source.ContextTokens; + configuredModel.IsDefault |= source.IsDefault; + configuredModel.RequiresAuth |= source.RequiresAuth; + continue; + } + + var catalogModel = CloneModelInfo(source); + catalogModel.HasConfiguredFlag = true; + catalogModel.IsConfigured = false; + // Catalog-only entries stay visible for discovery, while the explicit + // configuration flag keeps them disabled until they are allowlisted. + byIdentity.Add(identity, catalogModel); + result.Models.Add(catalogModel); } + + return result; + } + + private static ModelInfo CloneModelInfo(ModelInfo source) => new() + { + Id = source.Id, + Name = source.Name, + Provider = source.Provider, + ContextWindow = source.ContextWindow, + ContextTokens = source.ContextTokens, + IsConfigured = source.IsConfigured, + HasConfiguredFlag = source.HasConfiguredFlag, + IsDefault = source.IsDefault, + IsAvailable = source.IsAvailable, + RequiresAuth = source.RequiresAuth + }; + + private static string GetModelIdentity(ModelInfo model) + { + var id = model.Id.Trim(); + var provider = model.Provider?.Trim(); + if (string.IsNullOrEmpty(provider)) return id; + + var prefix = provider + "/"; + return id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? id + : prefix + id; } private static int? ReadPositiveInt32(JsonElement obj, string key) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index b7f90dbdc..439b86875 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -2455,9 +2455,9 @@ private void OnModelsListUpdated(object? sender, ModelsListInfo info) Publish(snapshot); } - // Wire ids (e.g. "claude-opus-4.5") in gateway order, used by the composer - // to match against SessionInfo.Model. Kept as a parallel string[] for - // back-compat with callers/persistence that only need the id list. + // Selectable wire ids (e.g. "claude-opus-4.5") in gateway order, used by + // the composer to match against SessionInfo.Model. Kept as a parallel + // string[] for back-compat and safe reconnect persistence. private static string[] ModelIdsFromChoices(IReadOnlyList choices) { if (choices.Count == 0) return Array.Empty(); @@ -2465,6 +2465,7 @@ private static string[] ModelIdsFromChoices(IReadOnlyList choic var ids = new List(choices.Count); foreach (var choice in choices) { + if (!choice.IsSelectable) continue; if (seen.Add(choice.Id)) ids.Add(choice.Id); } diff --git a/tests/OpenClaw.Shared.Tests/ModelCatalogMergeTests.cs b/tests/OpenClaw.Shared.Tests/ModelCatalogMergeTests.cs new file mode 100644 index 000000000..fcb7c30d8 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ModelCatalogMergeTests.cs @@ -0,0 +1,105 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public class ModelCatalogMergeTests +{ + [Fact] + public void MergeModelCatalog_KeepsConfiguredFirstAndAddsAvailableCatalogModels() + { + var configured = new ModelsListInfo + { + Models = + { + new ModelInfo + { + Id = "openai/gpt-5.6-sol", + Name = "GPT-5.6 Sol", + IsAvailable = true + } + } + }; + var catalog = new ModelsListInfo + { + Models = + { + new ModelInfo + { + Id = "gpt-5.6-sol", + Name = "GPT-5.6 Sol", + Provider = "openai", + ContextWindow = 1_050_000, + IsDefault = true, + IsAvailable = true + }, + new ModelInfo + { + Id = "gpt-5.5", + Name = "GPT-5.5", + Provider = "openai", + IsAvailable = true + }, + new ModelInfo + { + Id = "claude-opus-4-8", + Name = "Claude Opus 4.8", + Provider = "anthropic", + IsAvailable = false + } + } + }; + + var merged = OpenClawGatewayClient.MergeModelCatalog(configured, catalog); + + Assert.Collection( + merged.Models, + model => + { + Assert.Equal("openai/gpt-5.6-sol", model.Id); + Assert.True(model.IsConfigured); + Assert.True(model.HasConfiguredFlag); + Assert.True(model.IsAvailable); + Assert.Equal(1_050_000, model.ContextWindow); + Assert.True(model.IsDefault); + }, + model => + { + Assert.Equal("gpt-5.5", model.Id); + Assert.Equal("openai", model.Provider); + Assert.False(model.IsConfigured); + Assert.True(model.HasConfiguredFlag); + Assert.True(model.IsAvailable); + }); + } + + [Fact] + public void MergeModelCatalog_DedupesProviderQualifiedIdentities() + { + var configured = new ModelsListInfo + { + Models = + { + new ModelInfo { Id = "openai/gpt-5.4" } + } + }; + var catalog = new ModelsListInfo + { + Models = + { + new ModelInfo + { + Id = "gpt-5.4", + Provider = "openai", + ContextWindow = 1_000_000, + IsAvailable = true + } + } + }; + + var merged = OpenClawGatewayClient.MergeModelCatalog(configured, catalog); + + var model = Assert.Single(merged.Models); + Assert.Equal("openai/gpt-5.4", model.Id); + Assert.Equal(1_000_000, model.ContextWindow); + } +} \ No newline at end of file diff --git a/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs index df219fd61..76906a127 100644 --- a/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs @@ -82,40 +82,46 @@ public void FromModelsList_FallsBackToIdWhenNameMissing() } [Fact] - public void FromModelsList_HidesExplicitlyUnconfiguredModels() + public void FromModelsList_ShowsExplicitlyUnconfiguredModelsAsDisabled() { var info = new ModelsListInfo { Models = { - // Provider explicitly reported as not configured with no auth path → hidden. new ModelInfo { Id = "unconfigured", HasConfiguredFlag = true, IsConfigured = false }, - // Auth-needed rows stay visible so users can choose the provider-auth path. new ModelInfo { Id = "needs-key", HasConfiguredFlag = true, IsConfigured = false, RequiresAuth = true }, - // Configured → kept. new ModelInfo { Id = "ready", HasConfiguredFlag = true, IsConfigured = true }, - // Flag omitted entirely → kept (we don't know, so don't hide). new ModelInfo { Id = "unknown" }, } }; var choices = ChatModelChoice.FromModelsList(info); - Assert.Equal(new[] { "needs-key", "ready", "unknown" }, choices.Select(c => c.Id).ToArray()); - Assert.True(choices[0].RequiresAuth); - Assert.True(choices[0].IsSelectable); - } - // ── Selectability ──────────────────────────────────────────────────── + Assert.Equal( + new[] { "unconfigured", "needs-key", "ready", "unknown" }, + choices.Select(c => c.Id).ToArray()); + + var unconfigured = choices[0]; + Assert.False(unconfigured.IsSelectable); + Assert.Equal("not configured", ChatModelLabels.BuildStateMarker(unconfigured)); + Assert.True(choices[1].RequiresAuth); + Assert.True(choices[1].IsSelectable); + } + + // Selectability [Fact] - public void IsSelectable_FalseOnlyWhenUnavailable() + public void IsSelectable_BlocksExplicitlyUnconfiguredModels() { Assert.True(new ChatModelChoice("x", "X").IsSelectable); - // Auth-needed stays selectable (routes to provider auth). Assert.True(new ChatModelChoice("x", "X", RequiresAuth: true).IsSelectable); + Assert.False(new ChatModelChoice( + "x", + "X", + IsConfigured: false, + HasConfiguredFlag: true).IsSelectable); Assert.False(new ChatModelChoice("x", "X", IsAvailable: false).IsSelectable); } - [Theory] [InlineData("gpt-5.4", "openai", "openai/gpt-5.4")] [InlineData("openai/gpt-5.4", "openai", "openai/gpt-5.4")] @@ -307,6 +313,16 @@ public void BuildStateMarker_AuthNeeded_BeforeDefault() Assert.Equal("auth needed", ChatModelLabels.BuildStateMarker(c)); } + [Fact] + public void BuildStateMarker_NotConfigured() + { + var c = new ChatModelChoice( + "x", + "X", + IsConfigured: false, + HasConfiguredFlag: true); + Assert.Equal("not configured", ChatModelLabels.BuildStateMarker(c)); + } [Fact] public void BuildStateMarker_Default() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index d53040255..74b55c88b 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -6195,7 +6195,7 @@ public async Task ModelsListUpdated_PopulatesAvailableModelsInSnapshot() } [Fact] - public async Task ModelsListUpdated_FiltersExplicitlyUnconfiguredModels() + public async Task ModelsListUpdated_KeepsExplicitlyUnconfiguredModelsDisabled() { var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); await provider.LoadAsync(); @@ -6215,9 +6215,9 @@ public async Task ModelsListUpdated_FiltersExplicitlyUnconfiguredModels() Assert.Equal( new[] { "gpt-5.4", "needs-auth", "legacy-gateway-model" }, snapshots[^1].AvailableModels); - Assert.True(snapshots[^1].ModelChoices!.Single(c => c.Id == "needs-auth").RequiresAuth); + Assert.False(snapshots[^1].ModelChoices!.Single(c => c.Id == "gpt-5.5").IsSelectable); + Assert.True(snapshots[^1].ModelChoices!.Single(c => c.Id == "needs-auth").IsSelectable); } - [Fact] public async Task ModelsListUpdated_DedupesDisplayNames() { @@ -6557,8 +6557,8 @@ public async Task ModelsListUpdated_PopulatesProviderRichChoices() Assert.False(choices[2].IsAvailable); Assert.False(choices[2].IsSelectable); - // AvailableModels stays a parallel id list for back-compat. - Assert.Equal(new[] { "claude-opus-4.8", "gemini-3.1-pro", "local-llama" }, snapshots[^1].AvailableModels); + // AvailableModels stays a selectable id list for safe reconnect persistence. + Assert.Equal(new[] { "claude-opus-4.8", "gemini-3.1-pro" }, snapshots[^1].AvailableModels); } [Fact]