Skip to content
Open
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
32 changes: 16 additions & 16 deletions src/OpenClaw.Chat/ChatModelChoice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ namespace OpenClaw.Chat;
/// the model is usable.
/// </param>
/// <param name="IsDefault">True when the gateway marks this model as the default.</param>
/// <param name="HasConfiguredFlag">True when the gateway explicitly reported configuration state.</param>
public sealed record ChatModelChoice(
string Id,
string DisplayName,
Expand All @@ -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)
{
/// <summary>
/// Provider-qualified identity used for picker tags and <c>sessions.patch</c>
Expand All @@ -40,11 +42,11 @@ public sealed record ChatModelChoice(

/// <summary>
/// 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.
/// </summary>
public bool IsSelectable => IsAvailable;
public bool IsSelectable =>
IsAvailable && (!HasConfiguredFlag || IsConfigured || RequiresAuth);

/// <summary>
/// Maps gateway models into ordered, selection-deduplicated picker entries.
Expand All @@ -58,9 +60,6 @@ public static IReadOnlyList<ChatModelChoice> 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,
Expand All @@ -70,7 +69,8 @@ public static IReadOnlyList<ChatModelChoice> 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);
}
Expand Down Expand Up @@ -253,14 +253,14 @@ public static string BuildMetaSegment(ChatModelChoice choice)

/// <summary>
/// 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 <see cref="ChatModelChoice.IsConfigured"/> flag is
/// not treated as "auth needed" because the gateway's <c>configured</c> 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.
/// </summary>
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";
Expand All @@ -269,8 +269,8 @@ public static string BuildStateMarker(ChatModelChoice choice)

/// <summary>
/// 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.
/// </summary>
public static string BuildMenuLabel(ChatModelChoice choice)
{
Expand Down
193 changes: 156 additions & 37 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, ModelInfo>(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)
Expand Down
7 changes: 4 additions & 3 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2455,16 +2455,17 @@ 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<ChatModelChoice> choices)
{
if (choices.Count == 0) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var ids = new List<string>(choices.Count);
foreach (var choice in choices)
{
if (!choice.IsSelectable) continue;
if (seen.Add(choice.Id))
ids.Add(choice.Id);
}
Expand Down
Loading
Loading