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
243 changes: 243 additions & 0 deletions src/OpenClaw.Chat/ChatModelChoice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
namespace OpenClaw.Chat;

using OpenClaw.Shared;

/// <summary>
/// Provider-rich description of a model exposed by <c>models.list</c>.
/// </summary>
/// <param name="Id">Wire model id (e.g. <c>claude-opus-4.8</c>).</param>
/// <param name="DisplayName">Human-friendly label (falls back to <paramref name="Id"/>).</param>
/// <param name="Provider">Owning provider (e.g. <c>OpenAI</c>, <c>Anthropic</c>), when known.</param>
/// <param name="ContextWindow">Context-window size in tokens, when known.</param>
/// <param name="IsConfigured">True when the provider is configured on the gateway.</param>
/// <param name="IsAvailable">
/// True when the model can be selected right now. When false the picker shows it
/// but does not let the user switch to it.
/// </param>
/// <param name="RequiresAuth">
/// True when the model's provider still needs authentication/credentials before
/// the model is usable.
/// </param>
/// <param name="IsDefault">True when the gateway marks this model as the default.</param>
public sealed record ChatModelChoice(
string Id,
string DisplayName,
string? Provider = null,
int? ContextWindow = null,
bool IsConfigured = true,
bool IsAvailable = true,
bool RequiresAuth = false,
bool IsDefault = false)
{
/// <summary>
/// Provider-qualified identity used for picker tags and <c>sessions.patch</c>
/// model refs. Already-qualified ids are preserved.
/// </summary>
public string SelectionId => BuildSelectionId(Id, Provider);

/// <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.
/// </summary>
public bool IsSelectable => IsAvailable;

/// <summary>
/// Maps gateway models into ordered, selection-deduplicated picker entries.
/// </summary>
public static IReadOnlyList<ChatModelChoice> FromModelsList(ModelsListInfo? info)
{
if (info?.Models is not { Count: > 0 }) return Array.Empty<ChatModelChoice>();

var seen = new HashSet<string>(StringComparer.Ordinal);
var list = new List<ChatModelChoice>(info.Models.Count);
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,
Provider: m.Provider,
ContextWindow: m.ContextWindow,
IsConfigured: m.IsConfigured,
IsAvailable: m.IsAvailable,
RequiresAuth: m.RequiresAuth,
IsDefault: m.IsDefault);
if (!seen.Add(choice.SelectionId)) continue;
list.Add(choice);
}
return list;
}

public bool MatchesModel(string? modelId, string? provider = null)
{
var normalizedModel = NormalizeId(modelId);
if (normalizedModel is null) return false;

if (NormalizeId(provider) is { } normalizedProvider)
{
var providerQualified = BuildSelectionId(normalizedModel, normalizedProvider);
return string.Equals(SelectionId, providerQualified, StringComparison.Ordinal)
|| (string.Equals(Id, normalizedModel, StringComparison.Ordinal)
&& string.Equals(NormalizeId(Provider), normalizedProvider, StringComparison.OrdinalIgnoreCase));
}

return string.Equals(Id, normalizedModel, StringComparison.Ordinal)
|| string.Equals(SelectionId, normalizedModel, StringComparison.Ordinal);
}

public static string BuildSelectionId(string modelId, string? provider)
{
var normalizedModel = NormalizeId(modelId) ?? string.Empty;
if (normalizedModel.Length == 0) return string.Empty;
var normalizedProvider = NormalizeId(provider);
if (normalizedProvider is null) return normalizedModel;

var prefix = normalizedProvider + "/";
return normalizedModel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? normalizedModel
: $"{normalizedProvider}/{normalizedModel}";
}

public static string? ResolveSelectionId(
string? modelId,
string? provider,
IReadOnlyList<ChatModelChoice> choices)
{
var normalizedModel = NormalizeId(modelId);
if (normalizedModel is null) return null;

if (NormalizeId(provider) is not null)
{
var match = choices.FirstOrDefault(c => c.MatchesModel(normalizedModel, provider));
if (match is not null) return match.SelectionId;

var bareRawMatches = choices
.Where(c => c.Id == normalizedModel && string.IsNullOrWhiteSpace(c.Provider))
.Take(2)
.ToArray();
if (bareRawMatches.Length == 1) return bareRawMatches[0].SelectionId;

return BuildSelectionId(normalizedModel, provider);
}

var direct = choices.FirstOrDefault(c => c.SelectionId == normalizedModel);
if (direct is not null) return direct.SelectionId;

var rawMatches = choices.Where(c => c.Id == normalizedModel).Take(2).ToArray();
return rawMatches.Length == 1 ? rawMatches[0].SelectionId : normalizedModel;
}

private static string? NormalizeId(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
}

/// <summary>
/// Pure label/formatting helpers for the model picker. Lives in
/// <c>OpenClaw.Chat</c> (no WinUI dependency) so the display strings can be unit
/// tested without spinning up the composer.
/// </summary>
public static class ChatModelLabels
{
/// <summary>
/// True when <paramref name="modelId"/> represents "no explicit model
/// override" — i.e. the session is tracking the gateway/agent default.
/// This predicate only describes the <em>current</em> state, derived from an
/// empty/absent session model. Clearing an override (so a session tracks the
/// default again) is performed via the tri-state <c>SessionPatch.Clear</c>
/// (explicit JSON null), not by sending an empty model string.
/// </summary>
public static bool IsTrackingDefault(string? modelId) => string.IsNullOrEmpty(modelId);

/// <summary>
/// Compact token-count label: 272000 → "272K", 1_048_576 → "1M",
/// 200000 → "200K". Falls back to the raw number for small values.
/// </summary>
public static string FormatContextWindow(int contextWindow)
{
if (contextWindow <= 0) return string.Empty;
if (contextWindow >= 1_000_000)
{
var millions = contextWindow / 1_000_000.0;
// Trim a trailing ".0" so 2_000_000 → "2M" not "2.0M".
return millions == Math.Floor(millions)
? $"{(int)millions}M"
: $"{millions:0.#}M";
}
if (contextWindow >= 1_000)
{
var thousands = contextWindow / 1_000.0;
return thousands == Math.Floor(thousands)
? $"{(int)thousands}K"
: $"{thousands:0.#}K";
}
return contextWindow.ToString();
}

/// <summary>
/// Builds the secondary metadata segment ("OpenAI · 272K") from provider and
/// context window, or an empty string when neither is known.
/// </summary>
public static string BuildMetaSegment(ChatModelChoice choice)
{
var hasProvider = !string.IsNullOrWhiteSpace(choice.Provider);
var ctx = choice.ContextWindow is { } cw ? FormatContextWindow(cw) : string.Empty;
var hasCtx = ctx.Length > 0;

if (hasProvider && hasCtx) return $"{choice.Provider} · {ctx}";
if (hasProvider) return choice.Provider!;
if (hasCtx) return ctx;
return string.Empty;
}

/// <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.
/// </summary>
public static string BuildStateMarker(ChatModelChoice choice)
{
if (!choice.IsAvailable) return "unavailable";
if (choice.RequiresAuth) return "auth needed";
if (choice.IsDefault) return "default";
return string.Empty;
}

/// <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.
/// </summary>
public static string BuildMenuLabel(ChatModelChoice choice)
{
var label = choice.DisplayName;
var meta = BuildMetaSegment(choice);
if (meta.Length > 0) label = $"{label} · {meta}";
var marker = BuildStateMarker(choice);
if (marker.Length > 0) label = $"{label} · {marker}";
return label;
}

/// <summary>
/// Label for the "clear to gateway default" picker entry. Selecting it clears
/// the session's explicit model override (the gateway falls back to its
/// agent/default model). When the default model is known its name is
/// surfaced, e.g. "Default (Claude Opus 4.8)".
/// </summary>
public static string BuildDefaultEntryLabel(ChatModelChoice? defaultChoice)
{
if (defaultChoice is not null && !string.IsNullOrWhiteSpace(defaultChoice.DisplayName))
return $"Default ({defaultChoice.DisplayName})";
return "Default";
}
}
11 changes: 10 additions & 1 deletion src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public record ChatThread
public string? Compute { get; init; }
public string? ProfileName { get; init; }
public string? Model { get; init; }
public string? ModelProvider { get; init; }
public string? ThinkingLevel { get; init; }
public long InputTokens { get; init; }
public long OutputTokens { get; init; }
Expand Down Expand Up @@ -190,7 +191,8 @@ public record ChatDataSnapshot(
string? DefaultThreadId,
string? ConnectionStatus,
string[] AvailableModels,
ChatComposeTarget ComposeTarget);
ChatComposeTarget ComposeTarget,
IReadOnlyList<ChatModelChoice>? ModelChoices = null);

/// <summary>
/// Describes where the UI may send the next chat message. Distinct from
Expand Down Expand Up @@ -257,6 +259,13 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell
Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default);
Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default);
Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default);
/// <summary>
/// Clears the session's explicit model override so it tracks the gateway's
/// agent/default model again. Providers that don't support clearing leave
/// the default no-op. Distinct from <see cref="SetModelAsync"/> because the
/// gateway models this as an explicit null (tri-state), not an empty string.
/// </summary>
Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask;
Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default);
Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default);
Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default);
Expand Down
19 changes: 19 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2075,9 +2075,28 @@ public class ModelInfo
public string? Name { get; set; }
public string? Provider { get; set; }
public int? ContextWindow { get; set; }

/// <summary>True when the model's provider is configured on the gateway.</summary>
public bool IsConfigured { get; set; }
public bool HasConfiguredFlag { get; set; }

/// <summary>True when the gateway marks this model as the default choice.</summary>
public bool IsDefault { get; set; }

/// <summary>
/// True when the model can be selected/used right now. Defaults to
/// <c>true</c> so models lists from gateways that don't report availability
/// are treated as usable. The gateway may report <c>available:false</c> or
/// <c>unavailable:true</c> to mark a model as not selectable.
/// </summary>
public bool IsAvailable { get; set; } = true;

/// <summary>
/// True when the model's provider needs authentication/credentials before
/// it can be used (e.g. an API key has not been configured yet).
/// </summary>
public bool RequiresAuth { get; set; }

public string DisplayName => Name ?? Id;
}

Expand Down
32 changes: 31 additions & 1 deletion src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4126,16 +4126,27 @@ private void ParseModelsList(JsonElement payload)
{
foreach (var item in modelsArray.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;

// 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
{
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 = item.TryGetProperty("contextWindow", out var cw) && cw.ValueKind == JsonValueKind.Number ? cw.GetInt32() : null,
IsConfigured = hasConfiguredFlag && cfg.ValueKind == JsonValueKind.True,
HasConfiguredFlag = hasConfiguredFlag
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);
Expand All @@ -4149,6 +4160,25 @@ private void ParseModelsList(JsonElement payload)
}
}

// Reads the first present boolean property among <paramref name="keys"/>.
// Returns false when none are present (or are non-boolean).
private static bool ReadBool(JsonElement obj, params string[] keys) =>
TryReadBool(obj, out var value, keys) && value;

private static bool TryReadBool(JsonElement obj, out bool value, params string[] keys)
{
foreach (var key in keys)
{
if (obj.TryGetProperty(key, out var prop))
{
if (prop.ValueKind == JsonValueKind.True) { value = true; return true; }
if (prop.ValueKind == JsonValueKind.False) { value = false; return true; }
}
}
value = false;
return false;
}

private void ParseNodePairList(JsonElement payload)
{
try
Expand Down
14 changes: 12 additions & 2 deletions src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ public interface IChatGatewayBridge : IDisposable
Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
Task PatchSessionModelAsync(string sessionKey, string model);
/// <summary>
/// Clears the session's model override (tri-state <c>sessions.patch</c> with
/// an explicit JSON null), reverting the session to the gateway/agent
/// default. Distinct from <see cref="PatchSessionModelAsync"/>, which sets a
/// concrete model.
/// </summary>
Task ClearSessionModelAsync(string sessionKey);
Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel);
Task<ChatHistoryInfo> RequestChatHistoryAsync(string? sessionKey);
Task SendChatAbortAsync(string runId, string? sessionKey = null);
Expand Down Expand Up @@ -160,10 +167,13 @@ public Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? s
_client.SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments);

public Task PatchSessionModelAsync(string sessionKey, string model) =>
_client.PatchSessionAsync(sessionKey, model: model);
_client.PatchSessionAsync(sessionKey, new SessionPatch { Model = model });

public Task ClearSessionModelAsync(string sessionKey) =>
_client.PatchSessionAsync(sessionKey, new SessionPatch { Model = SessionPatch.Clear });

public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) =>
_client.PatchSessionAsync(sessionKey, thinkingLevel: thinkingLevel);
_client.PatchSessionAsync(sessionKey, new SessionPatch { ThinkingLevel = thinkingLevel });

public Task<ChatHistoryInfo> RequestChatHistoryAsync(string? sessionKey) =>
_client.RequestChatHistoryAsync(sessionKey);
Expand Down
Loading
Loading