Skip to content

Commit 8a18744

Browse files
authored
feat(ui): show provider costs in context popover (#100379)
* feat(ui): show estimated run cost * feat(ui): show provider cost breakdown
1 parent d84314d commit 8a18744

66 files changed

Lines changed: 375 additions & 130 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ export type GatewaySessionRow = {
500500
outputTokens?: number;
501501
totalTokens?: number;
502502
totalTokensFresh?: boolean;
503+
estimatedCostUsd?: number;
503504
status?: SessionRunStatus;
504505
hasActiveRun?: boolean;
505506
subagentRunState?: SubagentRunState;

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

Lines changed: 27 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,
@@ -264,10 +281,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
264281
sessions: [
265282
{
266283
contextTokens: 200_000,
284+
estimatedCostUsd: 0.023456,
267285
inputTokens: 757_300,
268286
key: "main",
269287
kind: "direct",
270288
model: "gpt-5.5",
289+
modelProvider: "openai",
271290
outputTokens: 42_300,
272291
totalTokens: 46_000,
273292
updatedAt: Date.now(),
@@ -289,6 +308,14 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
289308
await expect.poll(() => popover.textContent()).toContain("46k / 200k · 23%");
290309
await expect.poll(() => popover.textContent()).toContain("757.3k");
291310
await expect.poll(() => popover.textContent()).toContain("42.3k");
311+
await expect.poll(() => popover.textContent()).toContain("Est. cost");
312+
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");
292319
await expect.poll(() => popover.textContent()).toContain("gpt-5.5");
293320

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

ui/src/i18n/.i18n/ar.meta.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/i18n/.i18n/de.meta.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/i18n/.i18n/de.tm.jsonl

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/i18n/.i18n/es.meta.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/i18n/.i18n/fa.meta.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)