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
14 changes: 14 additions & 0 deletions docs/CHAT_VISUAL_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ action visible without hovering. Provider promotion replaces the pending row wit
without adding queue-only rows to history. This applies to both ChatPage and
ChatWindow. See the [pending-message concept](design/reference/concepts/states/pending-chat-message.md).

Assistant messages carry a quiet metadata footer under the bubble: local time,
model name, the latest context usage (on the newest assistant message only), and
the per-message cost when the gateway reports `usage.cost.total` for that
message. The whole footer renders on the last bubble of each assistant run and
only while tool calls are visible (Settings > Show tool calls). Cost renders as
`$0.225` with the gateway Control UI precision (four decimals below $0.01, three
below $1, otherwise two); amounts that would round to zero at four decimals
(below $0.00005) render as `<$0.0001`. The amount is the gateway's estimate
from its configured per-token prices, not an invoice: for a local model it is a
counter at whatever prices were configured, and subscription or OAuth providers
may show a value that is never billed. Missing, zero, negative, or non-finite
costs are hidden. `ChatUsageFormatter` owns usage and cost text; the timeline
only joins the parts with a middle dot.

Chat responses are not read aloud by default. Enabling the Text-to-speech
capability permits playback but does not enable automatic spoken replies.
Settings > Voice > "Read responses aloud" opts in to automatic playback;
Expand Down
4 changes: 2 additions & 2 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ per-run artifacts. Existing MCP-only integration defaults remain unchanged.

### OpenClaw.Shared.Tests

- **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, channel status, and rich text helpers.
- **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, chat message cost parsing, channel status, and rich text helpers.
- **Gateway and WebSocket behavior** - gateway client parsing, session keys, WebSocket base handling, URL normalization, local gateway classification, and token sanitization.
- **Capabilities and MCP** - app/canvas/screen/camera/system capabilities, MCP auth token reset, MCP HTTP server, MCP tool bridge, MXC availability, MXC policy building, and command runners.
- **Exec approval** - legacy policy coverage plus V2 evaluator, input validation, normalization, prompt adapter, routing, coordinator, store, environment sanitizing, and shell-wrapper parsing.
Expand All @@ -60,7 +60,7 @@ per-run artifacts. Existing MCP-only integration defaults remain unchanged.

### OpenClaw.Tray.Tests

- **Tray UI and state** - app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, and chat timeline/markdown handling.
- **Tray UI and state** - app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, chat timeline/markdown handling, and assistant footer usage/cost formatting.
- **Connection and pairing** - connection manager node connector tests, connection page approval/channel metrics/row state, operator and Windows tray node pairing approval, and gateway action transport.
- **Settings and startup** - settings round-trip/isolation, consent and settings save, auto-start defaults, startup setup state, existing config guard policy, and local setup progress stage mapping.
- **Onboarding and local gateway setup** - onboarding completion/chat bootstrapper/existing config guard, wizard flow/selection/error/step parsing, setup code decoding, local gateway setup diagnostics, uninstall, WSL keep-alive, and auto-pair flags.
Expand Down
7 changes: 7 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1841,6 +1841,13 @@ public static bool IsSilentAssistantDirective(string? role, string? text) =>
/// <summary>Unix epoch milliseconds when the gateway logged this message (0 if unknown).</summary>
public long Ts { get; set; }

/// <summary>
/// Gateway-reported cost in USD from <c>usage.cost.total</c> on the
/// assistant message, or <c>null</c> when absent or not a finite
/// non-negative number.
/// </summary>
public double? CostUsd { get; set; }

/// <summary>
/// Optional cumulative input (prompt) token count for the turn this
/// message belongs to, when the gateway includes a <c>usage</c> block on
Expand Down
22 changes: 19 additions & 3 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess
CompactionTokensBefore = openClawMetadata.TokensBefore,
CompactionTokensAfter = openClawMetadata.TokensAfter,
StopReason = stopReason,
CostUsd = ExtractChatCost(m),
InputTokens = inputTokens,
OutputTokens = outputTokens,
ResponseTokens = responseTokens,
Expand All @@ -802,6 +803,17 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess
return info;
}

private static double? ExtractChatCost(JsonElement message)
{
if (message.ValueKind == JsonValueKind.Object &&
message.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object &&
usage.TryGetProperty("cost", out var cost) && cost.ValueKind == JsonValueKind.Object &&
cost.TryGetProperty("total", out var total) && total.ValueKind == JsonValueKind.Number && total.TryGetDouble(out var value) &&
double.IsFinite(value) && value >= 0)
return value;
return null;
}

private static string ExtractMessageText(JsonElement message, string role)
{
if (!message.TryGetProperty("content", out var content)) return string.Empty;
Expand Down Expand Up @@ -4079,7 +4091,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
messageOpenClawMetadata.Kind ?? payloadOpenClawMetadata.Kind,
messageOpenClawMetadata.TokensBefore ?? payloadOpenClawMetadata.TokensBefore,
messageOpenClawMetadata.TokensAfter ?? payloadOpenClawMetadata.TokensAfter,
contentParts);
contentParts,
costUsd: ExtractChatCost(message) ?? ExtractChatCost(payload));

if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -4120,7 +4133,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
openClawMetadata.Kind,
openClawMetadata.TokensBefore,
openClawMetadata.TokensAfter,
projection.ContentParts);
projection.ContentParts,
costUsd: ExtractChatCost(payload));

if (role == "assistant" &&
(string.IsNullOrWhiteSpace(state) ||
Expand Down Expand Up @@ -4200,7 +4214,8 @@ private void EmitChatMessageReceived(
string? openClawKind = null,
long? compactionTokensBefore = null,
long? compactionTokensAfter = null,
IReadOnlyList<ChatMessageContentPartInfo>? contentParts = null)
IReadOnlyList<ChatMessageContentPartInfo>? contentParts = null,
double? costUsd = null)
{
if (ChatMessageInfo.IsSilentAssistantDirective(role, text))
return;
Expand All @@ -4215,6 +4230,7 @@ private void EmitChatMessageReceived(
ContentParts = contentParts ?? Array.Empty<ChatMessageContentPartInfo>(),
State = state,
Ts = tsMs,
CostUsd = costUsd,
InputTokens = inputTokens,
OutputTokens = outputTokens,
ResponseTokens = responseTokens,
Expand Down
38 changes: 38 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2211,6 +2211,44 @@ private static bool TryGetTerminalAgentRunId(
}
}

/// <summary>
/// Attaches a gateway-reported cost to the latest assistant entry of the
/// thread. Returns a snapshot only when the stored value changed; the
/// latest rendered frame wins (identified duplicates never reach here
/// because the queue classifier drops them).
/// </summary>
internal ChatDataSnapshot? SnapshotAssistantCost(
string threadId,
double costUsd,
ChatProjectionContext context)
{
lock (_gate)
{
if (!_timelines.TryGetValue(threadId, out var timeline))
return null;
for (var i = timeline.Entries.Count - 1; i >= 0; i--)
{
var entry = timeline.Entries[i];
if (entry.Kind != ChatTimelineItemKind.Assistant)
continue;
var threadMetadata = GetOrCreateThreadMetaLocked(threadId);
threadMetadata.TryGetValue(entry.Id, out var existing);
if (existing?.CostUsd is { } current && current.Equals(costUsd))
return null;
// A reported zero on an entry without a cost renders the same
// as no cost, so skip the redundant snapshot.
if (existing?.CostUsd is null && costUsd <= 0)
return null;
threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with
{
CostUsd = costUsd,
};
return BuildSnapshotLocked(context);
}
return null;
}
}

internal ChatDataSnapshot? SnapshotAssistantUsageContribution(
string threadId,
ChatEntryMetadata metadata,
Expand Down
23 changes: 13 additions & 10 deletions src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace OpenClawTray.Chat;
/// carry — specifically the wall-clock timestamp of when the entry was
/// created, the model active at that moment, and gateway-reported usage
/// counters — so the timeline renderer can show a richer footer like
/// <c>Field · 7:54 PM · ↑1475 ↓12 R45.4k 23% ctx · gpt-5.5</c>.
/// <c>7:54 PM · gpt-5.5 · 45.4K/200.0K (23%) · $0.225</c>.
/// </summary>
/// <param name="Timestamp">
/// Local-time timestamp of when the entry was created. <c>null</c> when the
Expand All @@ -19,22 +19,24 @@ namespace OpenClawTray.Chat;
/// when the model is unknown.
/// </param>
/// <param name="InputTokens">
/// Cumulative input (prompt) tokens reported by the gateway for this turn,
/// shown in the footer with an up arrow (<c>↑</c>). <c>null</c> when not
/// reported (most live ``chat`` deltas don't carry usage info — only the
/// final summary does).
/// Cumulative input (prompt) tokens reported by the gateway for this turn.
/// <c>null</c> when not reported (most live ``chat`` deltas don't carry usage
/// info — only the final summary does).
/// </param>
/// <param name="OutputTokens">
/// Cumulative output tokens reported by the gateway for this turn, shown in
/// the footer with a down arrow (<c>↓</c>).
/// Cumulative output tokens reported by the gateway for this turn.
/// </param>
/// <param name="ResponseTokens">
/// Total tokens spent on the response (prompt + completion) — surfaces as
/// <c>R&lt;n&gt;</c> in the footer (e.g. <c>R45.4k</c>).
/// Total tokens spent on the response (prompt + completion).
/// </param>
/// <param name="ContextPercent">
/// Percentage of the model's context window consumed by the conversation
/// when this entry was generated (0–100). Shown as <c>23% ctx</c>.
/// when this entry was generated (0–100).
/// </param>
/// <param name="CostUsd">
/// Gateway-reported cost in USD for this assistant entry
/// (<c>usage.cost.total</c>), as carried on the message frame or history
/// row. <c>null</c> when the gateway reported none.
/// </param>
/// <param name="ContextTokens">
/// Total context window size captured with a session usage snapshot. Used by
Expand Down Expand Up @@ -75,6 +77,7 @@ public sealed record ChatEntryMetadata(
int? OutputTokens = null,
int? ResponseTokens = null,
int? ContextPercent = null,
double? CostUsd = null,
long? ContextTokens = null,
int? UsageContributionTokens = null,
string? GatewayMessageId = null,
Expand Down
22 changes: 22 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,24 @@ next.ActiveAssistantId is { } assistantId &&
.Select(static tool => tool.CallId)
.Where(static callId => !string.IsNullOrWhiteSpace(callId))
.ToHashSet(StringComparer.Ordinal);
// One history row can replay as several entries (text, tool, media) but
// reports a single cost. Show it once, on the row's last assistant
// entry (text or media), which is where the footer renders.
static bool RendersAssistantEntry(ChatHistoryReplayPart part) =>
part.Text.Length > 0 || part.AssistantContentParts.Count > 0;
var costCarriers = new Dictionary<ChatMessageInfo, ChatHistoryReplayPart>(
ReferenceEqualityComparer.Instance);
foreach (var part in replayParts)
{
if (part.Message.CostUsd is null)
continue;
if (!costCarriers.TryGetValue(part.Message, out var carrier) ||
RendersAssistantEntry(part) ||
!RendersAssistantEntry(carrier))
{
costCarriers[part.Message] = part;
}
}

string AllocateSyntheticToolCallId()
{
Expand Down Expand Up @@ -603,6 +621,10 @@ string AllocateSyntheticToolCallId()
message.OutputTokens,
message.ResponseTokens,
message.ContextPercent,
CostUsd: costCarriers.TryGetValue(message, out var costCarrier) &&
ReferenceEquals(costCarrier, replayPart)
? message.CostUsd
: null,
GatewayMessageId: message.OpenClawId,
OpenClawSeq: message.OpenClawSeq,
OpenClawKind: message.OpenClawKind,
Expand Down
21 changes: 21 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/ChatUsageFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ public static class ChatUsageFormatter
: null;
}

/// <summary>
/// Formats the gateway-reported per-message cost for the assistant footer
/// using the same precision as the gateway's Control UI (4 decimals below
/// $0.01, 3 below $1, otherwise 2), e.g. <c>$0.588</c>. The precision rule
/// mirrors <c>formatCost</c> in <c>ui/src/lib/format.ts</c> of
/// openclaw/openclaw (commit 0acc55cfa8a973f9d1764f7641f41e5a38e88abe).
/// Two deliberate divergences: zero is hidden instead of rendered as
/// <c>$0.00</c>, and values that would round to zero at four decimals
/// (below $0.00005) render as <c>&lt;$0.0001</c>. Missing, negative, or
/// non-finite values are hidden.
/// </summary>
public static string? FormatCost(double? costUsd)
{
if (costUsd is not { } cost || !double.IsFinite(cost) || cost <= 0)
return null;
if (cost < 0.00005)
return "<$0.0001";
var decimals = cost < 0.01 ? 4 : cost < 1 ? 3 : 2;
return "$" + cost.ToString("F" + decimals, CultureInfo.InvariantCulture);
}

private static string FormatCount(long value)
{
if (value >= 1_000_000)
Expand Down
6 changes: 6 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,12 @@ message.ResponseTokens is not null ||
Publish(usageSnapshot);
}

if (message.CostUsd is { } costUsd &&
_state.SnapshotAssistantCost(threadId, costUsd, ProjectionContext()) is { } costSnapshot)
{
Publish(costSnapshot);
}

if (!message.IsFinal)
return;
var completedRunId = _state.CompleteAssistantFinal(threadId);
Expand Down
3 changes: 2 additions & 1 deletion src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1117,9 +1117,10 @@ private static string FooterText(
&& row.Props.Timeline.ShowToolCalls
? row.Props.Timeline.DefaultUsageSummary
: null;
var cost = ChatUsageFormatter.FormatCost(metadata?.CostUsd);
return string.Join(
" · ",
new[] { time, model, usageSummary }.Where(static value => !string.IsNullOrWhiteSpace(value)));
new[] { time, model, usageSummary, cost }.Where(static value => !string.IsNullOrWhiteSpace(value)));
}

private static Element UserMetadata(
Expand Down
Loading
Loading