Skip to content

Commit 50b62df

Browse files
committed
feat(ui): show provider cost breakdown
1 parent f19b9c3 commit 50b62df

7 files changed

Lines changed: 192 additions & 13 deletions

File tree

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
228228
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
229229
- Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
230230
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker, including `provider/*` entries that keep provider-scoped catalogs dynamic. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`.
231-
- When fresh Gateway session usage reports include current context tokens, the chat composer toolbar shows a small context usage ring with the used percentage; full token detail lives in its tooltip. The ring switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
231+
- When fresh Gateway session usage reports include current context tokens, the chat composer toolbar shows a small context usage ring with the used percentage. Open the ring for the current context window, latest-run token counts and estimated total cost, provider/model identity, and the latest provider response's input/output/cache cost breakdown when reported. The ring switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
232232

233233
</Accordion>
234234
<Accordion title="Talk mode (browser realtime)">

src/gateway/chat-display-projection.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,19 @@ function toFiniteNumber(x: unknown): number | undefined {
398398
return asFiniteNumber(x);
399399
}
400400

401-
function sanitizeCost(raw: unknown): { total?: number } | undefined {
401+
function sanitizeCost(raw: unknown): Record<string, number> | undefined {
402402
if (!raw || typeof raw !== "object") {
403403
return undefined;
404404
}
405405
const c = raw as Record<string, unknown>;
406-
const total = toFiniteNumber(c.total);
407-
return total !== undefined ? { total } : undefined;
406+
const out: Record<string, number> = {};
407+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
408+
const value = toFiniteNumber(c[key]);
409+
if (value !== undefined) {
410+
out[key] = value;
411+
}
412+
}
413+
return Object.keys(out).length > 0 ? out : undefined;
408414
}
409415

410416
function sanitizeUsage(raw: unknown): Record<string, number> | undefined {

src/gateway/server.chat.gateway-server-chat-b.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3616,8 +3616,13 @@ describe("gateway server chat", () => {
36163616
role: "assistant",
36173617
timestamp: Date.now(),
36183618
content: [{ type: "text", text: "hello" }],
3619-
usage: { input: 12, output: 5, totalTokens: 17 },
3620-
cost: { total: 0.0123 },
3619+
usage: {
3620+
input: 12,
3621+
output: 5,
3622+
totalTokens: 17,
3623+
cost: { input: 0.002, output: 0.01, cacheRead: 0.0003, cacheWrite: 0, total: 0.0123 },
3624+
},
3625+
cost: { input: 0.002, output: 0.01, cacheRead: 0.0003, cacheWrite: 0, total: 0.0123 },
36213626
details: { debug: true },
36223627
},
36233628
}),
@@ -3627,13 +3632,32 @@ describe("gateway server chat", () => {
36273632
expect(messages).toHaveLength(1);
36283633
const message = messages[0] as {
36293634
role?: string;
3630-
usage?: { input?: number; output?: number; totalTokens?: number };
3631-
cost?: { total?: number };
3635+
usage?: {
3636+
input?: number;
3637+
output?: number;
3638+
totalTokens?: number;
3639+
cost?: Record<string, number>;
3640+
};
3641+
cost?: Record<string, number>;
36323642
};
36333643
expect(message.role).toBe("assistant");
36343644
expect(message.usage?.input).toBe(12);
36353645
expect(message.usage?.output).toBe(5);
36363646
expect(message.usage?.totalTokens).toBe(17);
3647+
expect(message.usage?.cost).toEqual({
3648+
input: 0.002,
3649+
output: 0.01,
3650+
cacheRead: 0.0003,
3651+
cacheWrite: 0,
3652+
total: 0.0123,
3653+
});
3654+
expect(message.cost).toEqual({
3655+
input: 0.002,
3656+
output: 0.01,
3657+
cacheRead: 0.0003,
3658+
cacheWrite: 0,
3659+
total: 0.0123,
3660+
});
36373661
expect(message.cost?.total).toBe(0.0123);
36383662
expect(messages[0]).not.toHaveProperty("details");
36393663
});

ui/src/e2e/chat-flow.e2e.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
252252
});
253253
const page = await context.newPage();
254254
await installMockGateway(page, {
255+
historyMessages: [
256+
{ role: "user", content: "Show current usage", timestamp: Date.now() - 1_000 },
257+
{
258+
role: "assistant",
259+
content: "Usage ready.",
260+
cost: {
261+
input: 0.003456,
262+
output: 0.018,
263+
cacheRead: 0.0015,
264+
cacheWrite: 0.0005,
265+
total: 0.023456,
266+
},
267+
model: "gpt-5.5",
268+
provider: "openai",
269+
timestamp: Date.now(),
270+
},
271+
],
255272
methodResponses: {
256273
"sessions.list": {
257274
count: 1,
@@ -269,6 +286,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
269286
key: "main",
270287
kind: "direct",
271288
model: "gpt-5.5",
289+
modelProvider: "openai",
272290
outputTokens: 42_300,
273291
totalTokens: 46_000,
274292
updatedAt: Date.now(),
@@ -292,6 +310,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
292310
await expect.poll(() => popover.textContent()).toContain("42.3k");
293311
await expect.poll(() => popover.textContent()).toContain("Est. cost");
294312
await expect.poll(() => popover.textContent()).toContain("$0.023");
313+
await expect.poll(() => popover.textContent()).toContain("Cost by Type");
314+
await expect.poll(() => popover.textContent()).toContain("$0.0035");
315+
await expect.poll(() => popover.textContent()).toContain("$0.018");
316+
await expect.poll(() => popover.textContent()).toContain("$0.0015");
317+
await expect.poll(() => popover.textContent()).toContain("$0.0005");
318+
await expect.poll(() => popover.textContent()).toContain("openai");
295319
await expect.poll(() => popover.textContent()).toContain("gpt-5.5");
296320

297321
await page.keyboard.press("Escape");

ui/src/pages/chat/chat-composer.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,19 @@ describe("context notice", () => {
347347
totalTokens: 46_000,
348348
contextTokens: 200_000,
349349
estimatedCostUsd: 0.007725,
350+
model: "gpt-5.5",
351+
modelProvider: "openai",
350352
};
353+
const providerCostMessages = [
354+
{ role: "assistant", cost: { input: 99, output: 99 } },
355+
{ role: "user", content: "Current turn" },
356+
{
357+
role: "assistant",
358+
model: "openrouter/auto",
359+
responseModel: "gpt-5.5",
360+
cost: { input: 0.001225, output: 0.006, cacheRead: 0.0005, cacheWrite: 0 },
361+
},
362+
];
351363
const lowUsage = getContextNoticeViewModel(lowUsageSession, 200_000);
352364
if (!lowUsage) {
353365
throw new Error("expected low usage context notice");
@@ -359,7 +371,10 @@ describe("context notice", () => {
359371
expect(lowUsage.cost).toBe(0.007725);
360372
expect(lowUsage.warning).toBe(false);
361373
expect(lowUsage.compactRecommended).toBe(false);
362-
render(renderContextNotice(lowUsageSession, 200_000), container);
374+
render(
375+
renderContextNotice(lowUsageSession, 200_000, { messages: providerCostMessages }),
376+
container,
377+
);
363378
const lowNotice = container.querySelector<HTMLElement>(".context-ring");
364379
expect(lowNotice).toBeInstanceOf(HTMLElement);
365380
expect([...lowNotice!.classList]).toEqual(["context-ring"]);
@@ -373,9 +388,19 @@ describe("context notice", () => {
373388
expect(
374389
container.querySelector(".context-usage__popover")?.textContent?.replace(/\s+/gu, " ").trim(),
375390
).toBe(
376-
"Context window 46k / 200k · 23% Latest run tokens Input 757.3k Output — Est. cost $0.0077",
391+
"Context window 46k / 200k · 23% Latest run tokens Input 757.3k Output — Est. cost $0.0077 Cost by Type Input $0.0012 Output $0.0060 Cache Read $0.0005 Cache Write $0.00 Provider openai Model gpt-5.5",
392+
);
393+
expect(
394+
container.querySelectorAll(".context-usage__stats:not(.context-usage__stats--cost) > div"),
395+
).toHaveLength(3);
396+
expect(container.querySelectorAll(".context-usage__stats--cost > div")).toHaveLength(4);
397+
render(
398+
renderContextNotice(lowUsageSession, 200_000, {
399+
messages: [...providerCostMessages, { role: "user", content: "Steer before response" }],
400+
}),
401+
container,
377402
);
378-
expect(container.querySelectorAll(".context-usage__stats > div")).toHaveLength(3);
403+
expect(container.querySelector(".context-usage__stats--cost")).toBeNull();
379404
const lowFill = lowNotice!.querySelector(".context-ring__fill");
380405
expect(lowFill?.tagName.toLowerCase()).toBe("circle");
381406
// 23% of the 40.84 circumference stays hidden via dashoffset.

ui/src/pages/chat/components/chat-composer.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,9 +1173,67 @@ export function renderFallbackIndicator(status: FallbackStatus | null | undefine
11731173
export type ContextNoticeOptions = {
11741174
compactBusy?: boolean;
11751175
compactDisabled?: boolean;
1176+
messages?: unknown[];
11761177
onCompact?: () => void | Promise<void>;
11771178
};
11781179

1180+
type ProviderCostStats = {
1181+
input?: number;
1182+
output?: number;
1183+
cacheRead?: number;
1184+
cacheWrite?: number;
1185+
provider: string | null;
1186+
model: string | null;
1187+
};
1188+
1189+
function readCostRecord(value: unknown): Record<string, unknown> | null {
1190+
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
1191+
}
1192+
1193+
function readCostValue(
1194+
cost: Record<string, unknown> | null,
1195+
key: "input" | "output" | "cacheRead" | "cacheWrite",
1196+
) {
1197+
const value = cost?.[key];
1198+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
1199+
}
1200+
1201+
function latestProviderCostStats(messages: unknown[] | undefined): ProviderCostStats | null {
1202+
if (!messages?.length) {
1203+
return null;
1204+
}
1205+
for (let index = messages.length - 1; index >= 0; index -= 1) {
1206+
const message = readCostRecord(messages[index]);
1207+
if (message?.role === "user") {
1208+
return null;
1209+
}
1210+
if (message?.role !== "assistant") {
1211+
continue;
1212+
}
1213+
const directCost = readCostRecord(message.cost);
1214+
const usageCost = readCostRecord(readCostRecord(message.usage)?.cost);
1215+
const stats: ProviderCostStats = {
1216+
provider: typeof message.provider === "string" ? message.provider.trim() || null : null,
1217+
model:
1218+
(typeof message.responseModel === "string" ? message.responseModel.trim() : "") ||
1219+
(typeof message.model === "string" ? message.model.trim() : "") ||
1220+
null,
1221+
};
1222+
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
1223+
const cost = readCostValue(directCost, key) ?? readCostValue(usageCost, key);
1224+
if (cost !== undefined) {
1225+
stats[key] = cost;
1226+
}
1227+
}
1228+
if (
1229+
[stats.input, stats.output, stats.cacheRead, stats.cacheWrite].some((value) => value != null)
1230+
) {
1231+
return stats;
1232+
}
1233+
}
1234+
return null;
1235+
}
1236+
11791237
function parseHexRgb(hex: string): [number, number, number] | null {
11801238
const h = hex.trim().replace(/^#/, "");
11811239
if (!/^[0-9a-fA-F]{6}$/.test(h)) {
@@ -1225,6 +1283,7 @@ export function getContextNoticeViewModel(
12251283
input: number | null;
12261284
output: number | null;
12271285
cost: number | null;
1286+
provider: string | null;
12281287
model: string | null;
12291288
detail: string;
12301289
color: string;
@@ -1258,6 +1317,7 @@ export function getContextNoticeViewModel(
12581317
input,
12591318
output,
12601319
cost,
1320+
provider: session?.modelProvider?.trim() || null,
12611321
model: session?.model?.trim() || null,
12621322
};
12631323
if (!warning) {
@@ -1312,8 +1372,20 @@ export function renderContextNotice(
13121372
pct: String(model.pct),
13131373
});
13141374
const dashOffset = RING_CIRCUMFERENCE * (1 - model.pct / 100);
1375+
const providerCosts = latestProviderCostStats(options.messages);
1376+
const provider = providerCosts?.provider ?? model.provider;
1377+
const responseModel = providerCosts?.model ?? model.model;
13151378
const formatStat = (value: number | null) =>
13161379
value === null ? t("usage.common.emptyValue") : formatCompactTokenCount(value);
1380+
const renderCostStat = (label: string, value: number | undefined) =>
1381+
value === undefined
1382+
? nothing
1383+
: html`
1384+
<div>
1385+
<dt>${label}</dt>
1386+
<dd>${formatCost(value)}</dd>
1387+
</div>
1388+
`;
13171389
return html`
13181390
<div class="context-usage" style="--ctx-color:${model.color};--ctx-bg:${model.bg}">
13191391
<details>
@@ -1379,11 +1451,30 @@ export function renderContextNotice(
13791451
</div>
13801452
`}
13811453
</dl>
1382-
${model.model
1454+
${providerCosts
1455+
? html`
1456+
<div class="context-usage__section-label">${t("usage.breakdown.costByType")}</div>
1457+
<dl class="context-usage__stats context-usage__stats--cost">
1458+
${renderCostStat(t("usage.breakdown.input"), providerCosts.input)}
1459+
${renderCostStat(t("usage.breakdown.output"), providerCosts.output)}
1460+
${renderCostStat(t("usage.breakdown.cacheRead"), providerCosts.cacheRead)}
1461+
${renderCostStat(t("usage.breakdown.cacheWrite"), providerCosts.cacheWrite)}
1462+
</dl>
1463+
`
1464+
: nothing}
1465+
${provider
1466+
? html`
1467+
<div class="context-usage__model">
1468+
<span>${t("sessionsView.provider")}</span>
1469+
<strong>${provider}</strong>
1470+
</div>
1471+
`
1472+
: nothing}
1473+
${responseModel
13831474
? html`
13841475
<div class="context-usage__model">
13851476
<span>${t("sessionsView.model")}</span>
1386-
<strong>${model.model}</strong>
1477+
<strong>${responseModel}</strong>
13871478
</div>
13881479
`
13891480
: nothing}
@@ -1964,6 +2055,7 @@ export function renderChatComposer(props: ChatComposerProps) {
19642055
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
19652056
compactBusy,
19662057
compactDisabled: !canCompose || isBusy || showAbortableUi,
2058+
messages: props.messages,
19672059
onCompact: props.onCompact,
19682060
})}
19692061
${renderChatRunControls({

ui/src/styles/chat/layout.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ openclaw-chat-page {
262262
margin: 10px 0 0;
263263
}
264264

265+
.context-usage__stats--cost {
266+
grid-template-columns: repeat(2, minmax(0, 1fr));
267+
}
268+
265269
.context-usage__stats div {
266270
min-width: 0;
267271
padding: 9px 10px;
@@ -294,6 +298,10 @@ openclaw-chat-page {
294298
font-size: 11px;
295299
}
296300

301+
.context-usage__model + .context-usage__model {
302+
margin-top: 6px;
303+
}
304+
297305
.context-usage__model strong {
298306
max-width: 70%;
299307
overflow: hidden;

0 commit comments

Comments
 (0)