feat(tui): replace footer hints with model telemetry

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent c320d08dfe
commit 783d381710
8 changed files with 218 additions and 70 deletions
+43 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import os import os
import time
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -932,6 +933,27 @@ class AgentRunner:
progress_state: dict[str, bool] | None = None progress_state: dict[str, bool] | None = None
active_hosted_tools: dict[str, dict[str, Any]] = {} active_hosted_tools: dict[str, dict[str, Any]] = {}
request_started_at = 0.0
first_output_at: float | None = None
generation_started_at: float | None = None
generation_elapsed_s = 0.0
def _generation_delta(delta: str) -> None:
nonlocal first_output_at, generation_started_at
if not delta:
return
now = time.perf_counter()
if first_output_at is None:
first_output_at = now
if generation_started_at is None:
generation_started_at = now
def _pause_generation() -> None:
nonlocal generation_elapsed_s, generation_started_at
if generation_started_at is None:
return
generation_elapsed_s += max(0.0, time.perf_counter() - generation_started_at)
generation_started_at = None
async def _provider_tool_event(event: dict[str, Any]) -> None: async def _provider_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") != "hosted_tool": if event.get("kind") != "hosted_tool":
@@ -950,6 +972,7 @@ class AgentRunner:
thinking_buf = "" thinking_buf = ""
async def _stream(delta: str) -> None: async def _stream(delta: str) -> None:
_generation_delta(delta)
if delta: if delta:
context.streamed_content = True context.streamed_content = True
await hook.on_stream(context, delta) await hook.on_stream(context, delta)
@@ -958,6 +981,7 @@ class AgentRunner:
nonlocal thinking_buf nonlocal thinking_buf
if not delta: if not delta:
return return
_generation_delta(delta)
prev_clean = strip_reasoning_tags(thinking_buf) prev_clean = strip_reasoning_tags(thinking_buf)
thinking_buf += delta thinking_buf += delta
new_clean = strip_reasoning_tags(thinking_buf) new_clean = strip_reasoning_tags(thinking_buf)
@@ -967,6 +991,7 @@ class AgentRunner:
await hook.emit_reasoning(incremental) await hook.emit_reasoning(incremental)
async def _stream_recover() -> None: async def _stream_recover() -> None:
_pause_generation()
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
coro = spec.runtime.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
@@ -986,6 +1011,7 @@ class AgentRunner:
nonlocal stream_buf nonlocal stream_buf
if not delta: if not delta:
return return
_generation_delta(delta)
prev_clean = strip_think(stream_buf) prev_clean = strip_think(stream_buf)
stream_buf += delta stream_buf += delta
new_clean = strip_think(stream_buf) new_clean = strip_think(stream_buf)
@@ -1027,6 +1053,7 @@ class AgentRunner:
if is_streaming_request and timeout_s is not None if is_streaming_request and timeout_s is not None
else timeout_s else timeout_s
) )
request_started_at = time.perf_counter()
try: try:
response = ( response = (
await coro if outer_timeout_s is None await coro if outer_timeout_s is None
@@ -1045,6 +1072,11 @@ class AgentRunner:
finish_reason="error", finish_reason="error",
error_kind="timeout", error_kind="timeout",
) )
_pause_generation()
if first_output_at is not None:
response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000))
if generation_elapsed_s > 0:
response.generation_ms = max(1, round(generation_elapsed_s * 1000))
# chat_stream_with_retry may recover internally, so only fail unfinished # chat_stream_with_retry may recover internally, so only fail unfinished
# hosted calls after the provider returns its final error response. # hosted calls after the provider returns its final error response.
if response.finish_reason == "error": if response.finish_reason == "error":
@@ -1288,10 +1320,18 @@ class AgentRunner:
if total > 0: if total > 0:
usage["total_tokens"] = total usage["total_tokens"] = total
usage.setdefault("provider_tokens", total) usage.setdefault("provider_tokens", total)
return usage elif response.finish_reason == "error":
if response.finish_reason == "error":
return {} return {}
return self._estimate_response_usage(spec, messages, response) else:
usage = self._estimate_response_usage(spec, messages, response)
completion = usage.get("completion_tokens", 0)
if response.generation_ms is not None and completion > 0:
usage["generation_ms"] = response.generation_ms
usage["measured_completion_tokens"] = completion
if response.ttft_ms is not None:
usage["ttft_ms"] = response.ttft_ms
usage["timed_requests"] = 1
return usage
def _estimate_response_usage( def _estimate_response_usage(
self, self,
+6
View File
@@ -258,6 +258,12 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list) tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop" finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict) usage: dict[str, int] = field(default_factory=dict)
# Locally measured streaming telemetry. ``generation_ms`` excludes time to
# first token and provider retry gaps; ``ttft_ms`` measures the first
# streamed reasoning/content delta from request start. They stay separate
# from provider usage because providers do not report these consistently.
generation_ms: int | None = None
ttft_ms: int | None = None
retry_after: float | None = None # Provider supplied retry wait in seconds. retry_after: float | None = None # Provider supplied retry wait in seconds.
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc. reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
+45 -1
View File
@@ -3,7 +3,7 @@ cached-token propagation, and hook context."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -143,6 +143,50 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal():
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_measures_stream_generation_without_time_to_first_token():
from nanobot.agent.hook import AgentHook
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
await on_content_delta("he")
await on_content_delta("llo")
return LLMResponse(
content="hello",
usage={"prompt_tokens": 100, "completion_tokens": 12},
)
provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
class StreamingHook(AgentHook):
def wants_streaming(self) -> bool:
return True
with patch(
"nanobot.agent.runner.time.perf_counter",
side_effect=[10.0, 10.2, 10.4, 10.8],
):
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=StreamingHook(),
))
assert result.usage["generation_ms"] == 600
assert result.usage["measured_completion_tokens"] == 12
assert result.usage["ttft_ms"] == 200
assert result.usage["timed_requests"] == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_length_recovery_streams_segments_once_and_returns_all_content(): async def test_runner_length_recovery_streams_segments_once_and_returns_all_content():
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
+15 -6
View File
@@ -1335,7 +1335,7 @@ describe("NanobotTui layout", () => {
expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170]) expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170])
}) })
test("keeps footer status and shortcuts visually separated", async () => { test("uses the idle footer for model telemetry instead of permanent shortcuts", async () => {
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup) const app = mount(setup)
app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "attached", chat_id: "chat" })
@@ -1343,23 +1343,32 @@ describe("NanobotTui layout", () => {
event: "turn_end", event: "turn_end",
chat_id: "chat", chat_id: "chat",
latency_ms: 1700, latency_ms: 1700,
usage: { prompt_tokens: 1200, completion_tokens: 80, cached_tokens: 900 }, usage: {
prompt_tokens: 1200,
completion_tokens: 80,
cached_tokens: 900,
generation_ms: 1600,
measured_completion_tokens: 80,
ttft_ms: 240,
timed_requests: 1,
},
context_window_tokens: 128_000, context_window_tokens: 128_000,
}) })
await setup.flush() await setup.flush()
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || "" const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
expect(footer).toContain("Ready · 1.7s") expect(footer).toContain("Ready · 1.7s")
expect(footer).toContain("50 tok/s")
expect(footer).toContain("cache 75%")
expect(footer).toContain("↑1.2k ↓80") expect(footer).toContain("↑1.2k ↓80")
expect(footer).toContain("enter send") expect(footer).not.toContain("enter send")
expect(footer).not.toContain("1.7senter")
app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" }) app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" })
await Bun.sleep(130) await Bun.sleep(130)
await setup.renderOnce() await setup.renderOnce()
const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || "" const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || ""
expect(activeFooter).toContain("ctrl+c stop") expect(activeFooter).not.toContain("ctrl+c stop")
expect(activeFooter).not.toContain("enter send") expect(activeFooter).not.toContain("enter steer")
app.accept({ event: "turn_end", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat" })
}) })
+14 -19
View File
@@ -71,6 +71,7 @@ import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RuntimeControls } from "./runtime-controls" import { RuntimeControls } from "./runtime-controls"
import { import {
contextualFooterHints, contextualFooterHints,
footerTelemetry,
type FooterMode, type FooterMode,
type FooterHintTheme, type FooterHintTheme,
} from "./footer-hints" } from "./footer-hints"
@@ -320,22 +321,6 @@ function formatElapsed(milliseconds: number): string {
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s` return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
} }
function usageStatus(usage: TokenUsage | null): string {
if (!usage) return ""
const prompt = usage.prompt_tokens
const completion = usage.completion_tokens
const tokens = typeof prompt === "number" || typeof completion === "number"
? `${formatTokenCount(prompt || 0)}${formatTokenCount(completion || 0)}`
: typeof usage.total_tokens === "number" ? `${formatTokenCount(usage.total_tokens)} tok` : ""
const cached = typeof usage.cached_tokens === "number" && usage.cached_tokens > 0
? `${formatTokenCount(usage.cached_tokens)} cached`
: ""
const cost = typeof usage.cost_usd === "number" && usage.cost_usd > 0
? `$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}`
: ""
return [tokens, cached, cost].filter(Boolean).join(" · ")
}
async function copyWithSystemClipboard(text: string): Promise<void> { async function copyWithSystemClipboard(text: string): Promise<void> {
const commands = process.platform === "darwin" const commands = process.platform === "darwin"
? [["pbcopy"]] ? [["pbcopy"]]
@@ -942,6 +927,9 @@ export class NanobotTui {
} }
this.updateTitle() this.updateTitle()
this.setActive(false) this.setActive(false)
// A synthetic/rehydrated turn may already be idle, in which case
// setActive(false) intentionally does not repaint the footer.
this.updateMeta()
this.readyDetail = typeof event.latency_ms === "number" this.readyDetail = typeof event.latency_ms === "number"
? `${(event.latency_ms / 1000).toFixed(1)}s` ? `${(event.latency_ms / 1000).toFixed(1)}s`
: "" : ""
@@ -1120,9 +1108,7 @@ export class NanobotTui {
? "New output · Ctrl+End latest" ? "New output · Ctrl+End latest"
: "History · Ctrl+End latest" : "History · Ctrl+End latest"
} }
const usage = usageStatus(this.lastUsage) if (detail) return `Ready · ${detail}`
const suffix = [detail, usage].filter(Boolean).join(" · ")
if (suffix) return `Ready · ${suffix}`
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready" return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
} }
@@ -1429,6 +1415,14 @@ export class NanobotTui {
: this.contextPanel.visible ? "context" : this.contextPanel.visible ? "context"
: this.transcriptNavigation.awayFromBottom ? "history" : this.transcriptNavigation.awayFromBottom ? "history"
: "ready" : "ready"
if (mode === "ready") {
this.meta.content = footerTelemetry(
this.lastUsage,
this.renderer.width,
footerHintTheme(this.palette),
)
return
}
this.meta.content = contextualFooterHints( this.meta.content = contextualFooterHints(
mode, mode,
this.renderer.width, this.renderer.width,
@@ -1955,6 +1949,7 @@ export class NanobotTui {
this.lastUsage = context.lastUsage || this.lastUsage this.lastUsage = context.lastUsage || this.lastUsage
this.updateTitle() this.updateTitle()
if (!this.activeTurn) this.status.content = this.readyStatus() if (!this.activeTurn) this.status.content = this.readyStatus()
this.updateMeta()
} catch { } catch {
// Keep the last known estimate; it is intentionally informational. // Keep the last known estimate; it is intentionally informational.
} }
+27 -18
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { contextualFooterHints, footerHints } from "./footer-hints" import { contextualFooterHints, footerHints, footerTelemetry } from "./footer-hints"
const theme = { const theme = {
accent: "#EF8E30", accent: "#EF8E30",
@@ -21,30 +21,39 @@ describe("footerHints", () => {
expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113]) expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113])
}) })
test("adapts the active-turn vocabulary to available width", () => { test("keeps passive composer modes free of permanent instructions", () => {
const wide = contextualFooterHints("active", 100, theme, "linux") const ready = contextualFooterHints("ready", 100, theme, "linux")
const compact = contextualFooterHints("active", 72, theme, "linux") const active = contextualFooterHints("active", 100, theme, "darwin")
expect(wide.chunks.map(({ text }) => text).join("")) expect(ready.chunks).toHaveLength(0)
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop") expect(active.chunks).toHaveLength(0)
expect(compact.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · ctrl+c stop")
}) })
test("uses the native Option symbol on macOS", () => { test("shows measured throughput, cache ratio, token counts, and TTFT", () => {
const result = contextualFooterHints("active", 100, theme, "darwin") const result = footerTelemetry({
prompt_tokens: 1200,
completion_tokens: 80,
cached_tokens: 900,
generation_ms: 1600,
measured_completion_tokens: 80,
ttft_ms: 500,
timed_requests: 2,
}, 120, theme)
expect(result.chunks.map(({ text }) => text).join("")) expect(result.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · ⌥↑ edit · ctrl+c stop") .toBe("50 tok/s · cache 75% · ↑1.2k ↓80 · TTFT 250ms")
expect(result.chunks[0]?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48])
}) })
test("advertises Shift+Enter when the terminal can distinguish it", () => { test("degrades telemetry instead of guessing missing provider metrics", () => {
const enhanced = contextualFooterHints("ready", 80, theme, "darwin", true) const compact = footerTelemetry({
const legacy = contextualFooterHints("ready", 80, theme, "darwin", false) prompt_tokens: 1000,
completion_tokens: 20,
cached_tokens: 0,
}, 60, theme)
const unsupported = footerTelemetry({ prompt_tokens: 1000, completion_tokens: 20 }, 60, theme)
expect(enhanced.chunks.map(({ text }) => text).join("")) expect(compact.chunks.map(({ text }) => text).join("")).toBe("cache 0%")
.toBe("enter send · shift+enter newline · ctrl+c stop") expect(unsupported.chunks).toHaveLength(0)
expect(legacy.chunks.map(({ text }) => text).join(""))
.toBe("enter send · ctrl+j newline · ctrl+c stop")
}) })
}) })
+60 -23
View File
@@ -1,6 +1,7 @@
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core" import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
import { optionArrowUp } from "./platform-keys" import { formatTokenCount } from "./context-panel"
import type { TokenUsage } from "./protocol"
export interface FooterHint { export interface FooterHint {
key: string key: string
@@ -30,10 +31,63 @@ export function contextualFooterHints(
mode: FooterMode, mode: FooterMode,
width: number, width: number,
theme: FooterHintTheme, theme: FooterHintTheme,
platform: string = process.platform, _platform: string = process.platform,
shiftedEnter = false, _shiftedEnter = false,
): StyledText { ): StyledText {
return footerHints(hintsFor(mode, width, platform, shiftedEnter), theme) return footerHints(hintsFor(mode, width), theme)
}
/** Last-turn model telemetry. Passive chrome reports the system, not its manual. */
export function footerTelemetry(
usage: TokenUsage | null,
width: number,
theme: FooterHintTheme,
): StyledText {
if (!usage) return new StyledText([])
const parts: string[] = []
const duration = usage.generation_ms
const measured = usage.measured_completion_tokens
if (typeof duration === "number" && duration > 0 && typeof measured === "number") {
const rate = measured * 1000 / duration
const value = rate < 10 ? rate.toFixed(1) : String(Math.round(rate))
const estimated = (usage.estimated_tokens || 0) > 0 ? "~" : ""
parts.push(`${estimated}${value} tok/s`)
}
if (
typeof usage.cached_tokens === "number"
&& typeof usage.prompt_tokens === "number"
&& usage.prompt_tokens > 0
) {
const hitRate = Math.min(100, Math.max(0, Math.round(
usage.cached_tokens * 100 / usage.prompt_tokens,
)))
parts.push(`cache ${hitRate}%`)
}
if (width >= 72) {
const prompt = usage.prompt_tokens
const completion = usage.completion_tokens
if (typeof prompt === "number" || typeof completion === "number") {
parts.push(`${formatTokenCount(prompt || 0)}${formatTokenCount(completion || 0)}`)
}
}
if (width >= 112 && typeof usage.ttft_ms === "number") {
const requests = Math.max(1, usage.timed_requests || 1)
const average = usage.ttft_ms / requests
parts.push(`TTFT ${average < 1000 ? `${Math.round(average)}ms` : `${(average / 1000).toFixed(1)}s`}`)
}
if (width >= 128 && typeof usage.cost_usd === "number" && usage.cost_usd > 0) {
parts.push(`$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}`)
}
return footerMetrics(parts, theme)
}
function footerMetrics(parts: readonly string[], theme: FooterHintTheme): StyledText {
const chunks: TextChunk[] = []
parts.forEach((text, index) => {
if (index) chunks.push(chunk(" · ", theme.separator))
chunks.push(chunk(text, index === 0 ? theme.accent : theme.muted, index === 0))
})
return new StyledText(chunks)
} }
/** Give shortcuts visual hierarchy without turning the footer into a toolbar. */ /** Give shortcuts visual hierarchy without turning the footer into a toolbar. */
@@ -51,8 +105,6 @@ export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme
function hintsFor( function hintsFor(
mode: FooterMode, mode: FooterMode,
width: number, width: number,
platform: string,
shiftedEnter: boolean,
): FooterHint[] { ): FooterHint[] {
if (mode === "runtime") return width >= 64 if (mode === "runtime") return width >= 64
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")] ? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
@@ -60,9 +112,7 @@ function hintsFor(
if (mode === "mention") return width >= 64 if (mode === "mention") return width >= 64
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")] ? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
: [hint("enter", "insert"), hint("esc", "close")] : [hint("enter", "insert"), hint("esc", "close")]
if (mode === "active") return width >= 96 if (mode === "active") return []
? [hint("enter", "steer"), hint("tab", "queue"), hint(optionArrowUp(platform), "edit"), stopHint()]
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
if (mode === "branch") return width >= 64 if (mode === "branch") return width >= 64
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")] ? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
: [hint("enter", "branch"), hint("esc", "close")] : [hint("enter", "branch"), hint("esc", "close")]
@@ -76,26 +126,13 @@ function hintsFor(
if (mode === "history") return width >= 72 if (mode === "history") return width >= 72
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")] ? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
: width >= 48 ? [hint("ctrl+end", "latest")] : [] : width >= 48 ? [hint("ctrl+end", "latest")] : []
const newline = shiftedEnter ? "shift+enter" : "ctrl+j" return []
if (width >= 112) return [
hint("enter", "send"),
hint(newline, "newline"),
hint("pgup/pgdn", "scroll"),
hint("ctrl+o", "tools"),
stopHint(),
]
if (width >= 72) return [hint("enter", "send"), hint(newline, "newline"), stopHint()]
return width >= 48 ? [hint("enter", "send"), hint(newline, "newline")] : []
} }
function hint(key: string, label: string): FooterHint { function hint(key: string, label: string): FooterHint {
return { key, label } return { key, label }
} }
function stopHint(): FooterHint {
return { key: "ctrl+c", label: "stop", tone: "danger" }
}
function chunk(text: string, color: string, bold = false): TextChunk { function chunk(text: string, color: string, bold = false): TextChunk {
return { return {
__isChunk: true, __isChunk: true,
+8
View File
@@ -181,6 +181,10 @@ export interface TokenUsage {
provider_tokens?: number provider_tokens?: number
estimated_tokens?: number estimated_tokens?: number
cost_usd?: number cost_usd?: number
generation_ms?: number
measured_completion_tokens?: number
ttft_ms?: number
timed_requests?: number
} }
export interface SessionContextSnapshot { export interface SessionContextSnapshot {
@@ -325,6 +329,10 @@ function isTokenUsage(value: unknown): value is TokenUsage {
"provider_tokens", "provider_tokens",
"estimated_tokens", "estimated_tokens",
"cost_usd", "cost_usd",
"generation_ms",
"measured_completion_tokens",
"ttft_ms",
"timed_requests",
].every((key) => optional(value[key], "number")) ].every((key) => optional(value[key], "number"))
} }