diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 77fc496..534c9e8 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -1885,34 +1885,47 @@ public class OpenClawGatewayClient : WebSocketClientBase { if (status.Providers.Count == 0) return ""; - var parts = new List(); + // At most 2 providers are shown; track them with two nullable strings to avoid + // allocating a List on every usage-status update. + string? p0 = null, p1 = null; + int included = 0; + foreach (var provider in status.Providers) { - if (parts.Count == 2) break; + if (included == 2) break; var displayName = string.IsNullOrWhiteSpace(provider.DisplayName) ? provider.Provider : provider.DisplayName; if (string.IsNullOrWhiteSpace(displayName)) displayName = "provider"; + string part; if (!string.IsNullOrWhiteSpace(provider.Error)) { - parts.Add($"{displayName}: error"); - continue; + part = $"{displayName}: error"; + } + else + { + if (provider.Windows.Count == 0) continue; + var window = provider.Windows.MaxBy(w => w.UsedPercent); + if (window is null) continue; + var remaining = Math.Clamp((int)Math.Round(100 - window.UsedPercent), 0, 100); + part = $"{displayName}: {remaining}% left"; } - if (provider.Windows.Count == 0) continue; - var window = provider.Windows.MaxBy(w => w.UsedPercent); - if (window is null) continue; - var remaining = Math.Clamp((int)Math.Round(100 - window.UsedPercent), 0, 100); - parts.Add($"{displayName}: {remaining}% left"); + if (included == 0) p0 = part; + else p1 = part; + included++; } - if (parts.Count == 0) - return ""; + if (included == 0) return ""; - if (status.Providers.Count > 2) - parts.Add($"+{status.Providers.Count - 2}"); - - return string.Join(" · ", parts); + string? overflow = status.Providers.Count > 2 ? $"+{status.Providers.Count - 2}" : null; + return (p1, overflow) switch + { + (null, null) => p0!, + (null, _) => $"{p0} · {overflow}", + (_, null) => $"{p0} · {p1}", + _ => $"{p0} · {p1} · {overflow}", + }; } private static string? FirstNonEmpty(params string?[] values) diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index d44f3fd..df596e4 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -110,6 +110,14 @@ public class OpenClawGatewayClientTests return GetUsageState(); } + public string CallBuildProviderSummary(GatewayUsageStatusInfo status) + { + var method = typeof(OpenClawGatewayClient).GetMethod( + "BuildProviderSummary", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + return (string)method!.Invoke(null, new object[] { status })!; + } + public GatewayUsageInfo ParseUsageCostPayload(string payloadJson) { InvokePrivatePayloadParser("ParseUsageCost", payloadJson); @@ -644,6 +652,177 @@ public class OpenClawGatewayClientTests Assert.Contains("left", usage.ProviderSummary!); } + // ── BuildProviderSummary tests ────────────────────────────────────────────── + + [Fact] + public void BuildProviderSummary_NoProviders_ReturnsEmpty() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo { Providers = [] }; + + Assert.Equal("", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_SingleProviderWithUsage_ShowsRemainingPercent() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo + { + DisplayName = "OpenAI", + Windows = [new GatewayUsageWindowInfo { Label = "daily", UsedPercent = 25.0 }] + } + ] + }; + + var result = helper.CallBuildProviderSummary(status); + + Assert.Equal("OpenAI: 75% left", result); + } + + [Fact] + public void BuildProviderSummary_SingleProviderWithError_ShowsErrorLabel() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo { DisplayName = "Anthropic", Error = "rate limited" } + ] + }; + + Assert.Equal("Anthropic: error", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_ProviderWithNoWindows_IsSkipped() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = [new GatewayUsageProviderInfo { DisplayName = "OpenAI" }] + }; + + Assert.Equal("", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_TwoProviders_JoinedWithSeparator() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo + { + DisplayName = "OpenAI", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 20.0 }] + }, + new GatewayUsageProviderInfo + { + DisplayName = "Anthropic", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 50.0 }] + } + ] + }; + + Assert.Equal("OpenAI: 80% left · Anthropic: 50% left", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_ThreeProviders_ShowsOverflowCount() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo + { + DisplayName = "P1", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 10.0 }] + }, + new GatewayUsageProviderInfo + { + DisplayName = "P2", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 20.0 }] + }, + new GatewayUsageProviderInfo + { + DisplayName = "P3", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 30.0 }] + } + ] + }; + + var result = helper.CallBuildProviderSummary(status); + + Assert.Equal("P1: 90% left · P2: 80% left · +1", result); + } + + [Fact] + public void BuildProviderSummary_MissingDisplayName_FallsBackToProviderField() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo + { + Provider = "openai", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 0.0 }] + } + ] + }; + + Assert.StartsWith("openai:", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_AllProvidersEmpty_ReturnsEmpty() + { + var helper = new GatewayClientTestHelper(); + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo { DisplayName = "P1" }, + new GatewayUsageProviderInfo { DisplayName = "P2" } + ] + }; + + Assert.Equal("", helper.CallBuildProviderSummary(status)); + } + + [Fact] + public void BuildProviderSummary_OverflowWithOneValidProvider_ShowsOverflow() + { + var helper = new GatewayClientTestHelper(); + // 3 providers but only the first has windows — included=1, but Providers.Count=3 > 2 → overflow shown + var status = new GatewayUsageStatusInfo + { + Providers = + [ + new GatewayUsageProviderInfo + { + DisplayName = "P1", + Windows = [new GatewayUsageWindowInfo { UsedPercent = 10.0 }] + }, + new GatewayUsageProviderInfo { DisplayName = "P2" }, + new GatewayUsageProviderInfo { DisplayName = "P3" } + ] + }; + + Assert.Equal("P1: 90% left · +1", helper.CallBuildProviderSummary(status)); + } + [Fact] public void ParseUsageCostPayload_UpdatesLegacyUsageTotals() {