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]