Report what a reply spent reading its prompt, and pin the clock right
`UsageDelta` gains `prefillMs`, llama-server's own `timings.prompt_ms`, so the footer under a finished reply is "read 9.5s · 50.3 tok/s · 3:00 PM". Prefill is the half of a turn that was invisible and is often the larger: measured on the 0.6B here, 1m 4s for the first turn after a model loads against 22ms for the next, whose prompt the server still had cached. The clock moves to the end of the line. Everything in front of it is a provider's own measurement, so a session on another provider has fewer of them or none, and a reader who has learned where the time is should not have to find it again because the model changed. The costs grow leftwards into the space instead, and a test asserts every shape of the line ends with the same thing. Verified on the emulator against a real llama session: three replies reading "read 1m 4s · 193 tok/s · 3:54 PM", "read 25ms · 308 tok/s · 3:54 PM" and "read 22ms · 194 tok/s · 3:54 PM", with the clock in one column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b660905098
commit
369b8f7e52
14 files changed
+135
-42
No files matched your search
@@ -185,6 +185,12 @@ sealed class SessionEvent {
|
||||
* over includes the network and whatever the server was doing between tokens.
|
||||
*/
|
||||
val tokensPerSecond: Double? = null,
|
||||
/**
|
||||
* How long the provider spent reading the prompt before it began answering; null where
|
||||
* nothing measured it. The same rule as [tokensPerSecond]: the provider's own figure, or
|
||||
* nothing at all.
|
||||
*/
|
||||
val prefillMs: Long? = null,
|
||||
) : SessionEvent()
|
||||
|
||||
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */
|
||||
@@ -328,6 +334,7 @@ fun parseSeqEvent(json: String): SeqEvent {
|
||||
body.getLong("tokens"),
|
||||
if (body.has("context")) body.getLong("context") else null,
|
||||
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
|
||||
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
|
||||
)
|
||||
"compacted" ->
|
||||
SessionEvent.Compacted(
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The line under a finished reply: when it was sent, and how fast it was generated.
|
||||
* The line under a finished reply: what it cost to produce, and when it was sent.
|
||||
*
|
||||
* Small and set back, in the tone the session's own subtitle takes: it is about the message rather
|
||||
* than part of it, and at the reply's own size it would read as the last thing the model said.
|
||||
@@ -22,8 +22,13 @@ import java.util.Locale
|
||||
* left edge is reading what was said, and this is where that ends.
|
||||
*/
|
||||
@Composable
|
||||
fun ReplyFooter(ts: Double, tokensPerSecond: Double?, modifier: Modifier = Modifier) {
|
||||
val text = replyFooterText(ts, tokensPerSecond, ZoneId.systemDefault()) ?: return
|
||||
fun ReplyFooter(
|
||||
ts: Double,
|
||||
tokensPerSecond: Double?,
|
||||
prefillMs: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -34,17 +39,28 @@ fun ReplyFooter(ts: Double, tokensPerSecond: Double?, modifier: Modifier = Modif
|
||||
}
|
||||
|
||||
/**
|
||||
* What the footer says, or null when there is nothing to say.
|
||||
* What the footer says, or null when there is nothing to say: "read 9.5s · 50.3 tok/s · 3:00 PM".
|
||||
*
|
||||
* Split out so the wording is testable without a screen, and [zone] is a parameter for the same
|
||||
* reason [limitSummary] takes one: a test has to say the same thing wherever it runs.
|
||||
*
|
||||
* A rate is drawn only where the provider measured one. Most do not -- a coding CLI reports what a
|
||||
* turn cost and never how long the model spent -- and the time this app watched a reply arrive over
|
||||
* is not the same quantity: it counts the network, the pauses between tokens and whatever the
|
||||
* server was doing in them. So the line is the time alone rather than a plausible figure beside it.
|
||||
* **The time is last, and so sits against the right edge whatever else is on the line.** The
|
||||
* measurements in front of it are the provider's, so a session on another provider has fewer of
|
||||
* them or none -- and a reader who has learned where the clock is should not have to find it again
|
||||
* because the model changed. The costs grow leftwards into the space instead.
|
||||
*
|
||||
* Those measurements are drawn only where the provider made them. Most do not -- a coding CLI
|
||||
* reports what a turn cost and never how long the model spent on it -- and the time this app
|
||||
* watched a reply arrive over is a different quantity: it counts the network, the pauses between
|
||||
* tokens and whatever else the machine was doing. So the line is the clock alone rather than a
|
||||
* plausible figure beside it.
|
||||
*/
|
||||
fun replyFooterText(ts: Double, tokensPerSecond: Double?, zone: ZoneId): String? {
|
||||
fun replyFooterText(
|
||||
ts: Double,
|
||||
tokensPerSecond: Double?,
|
||||
prefillMs: Long?,
|
||||
zone: ZoneId,
|
||||
): String? {
|
||||
val at =
|
||||
if (ts <= 0.0) null
|
||||
else
|
||||
@@ -65,5 +81,9 @@ fun replyFooterText(ts: Double, tokensPerSecond: Double?, zone: ZoneId): String?
|
||||
if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
|
||||
else String.format(Locale.getDefault(), "%.1f tok/s", it)
|
||||
}
|
||||
return listOfNotNull(at, rate).joinToString(" · ").ifEmpty { null }
|
||||
// Named "read" rather than given a unit alone, because a second figure in seconds beside a
|
||||
// rate is unreadable otherwise -- and it is the same word the status row uses while it is
|
||||
// happening, so the wait and the figure for it are one vocabulary.
|
||||
val read = prefillMs?.takeIf { it > 0 }?.let { "read ${formatMillis(it)}" }
|
||||
return listOfNotNull(read, rate, at).joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
@@ -1710,7 +1710,7 @@ fun SessionScreen(
|
||||
when (unit) {
|
||||
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
||||
is TranscriptUnit.ReplyFoot ->
|
||||
ReplyFooter(unit.ts, unit.tokensPerSecond)
|
||||
ReplyFooter(unit.ts, unit.tokensPerSecond, unit.prefillMs)
|
||||
is TranscriptUnit.PeerHead ->
|
||||
PeerHeadRow(
|
||||
unit.item,
|
||||
@@ -1823,6 +1823,7 @@ fun SessionScreen(
|
||||
ReplyFooter(
|
||||
item.ts,
|
||||
item.tokensPerSecond,
|
||||
item.prefillMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ sealed class TranscriptItem {
|
||||
* known until the reply is over.
|
||||
*/
|
||||
val tokensPerSecond: Double? = null,
|
||||
/** How long the provider spent reading the prompt, where it measured that. */
|
||||
val prefillMs: Long? = null,
|
||||
) : TranscriptItem()
|
||||
|
||||
/**
|
||||
@@ -633,7 +635,11 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
||||
is SessionEvent.UsageDelta ->
|
||||
when (val last = items.lastOrNull()) {
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
items.dropLast(1) + last.copy(tokensPerSecond = event.tokensPerSecond)
|
||||
items.dropLast(1) +
|
||||
last.copy(
|
||||
tokensPerSecond = event.tokensPerSecond,
|
||||
prefillMs = event.prefillMs,
|
||||
)
|
||||
else -> items
|
||||
}
|
||||
is SessionEvent.ContextWindow -> items
|
||||
|
||||
@@ -142,6 +142,7 @@ sealed class TranscriptUnit {
|
||||
override val ordinal: Int,
|
||||
val ts: Double,
|
||||
val tokensPerSecond: Double?,
|
||||
val prefillMs: Long?,
|
||||
override val gap: Dp,
|
||||
) : TranscriptUnit() {
|
||||
override val key: Any
|
||||
@@ -265,6 +266,7 @@ fun transcriptUnits(
|
||||
ordinal,
|
||||
item.ts,
|
||||
item.tokensPerSecond,
|
||||
item.prefillMs,
|
||||
gap(FOOT_SPACING),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlin.test.assertTrue
|
||||
* generation speed has no figure -- neither may borrow one.
|
||||
*/
|
||||
class ThinkingTest {
|
||||
private val utc = ZoneId.of("UTC")
|
||||
private var seq = 0L
|
||||
|
||||
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
|
||||
@@ -77,29 +78,41 @@ class ThinkingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reply carries when it was sent and what it was generated at`() {
|
||||
fun `a reply carries when it was sent and what it cost to produce`() {
|
||||
val items =
|
||||
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
|
||||
fold(it, SessionEvent.UsageDelta(42, 100, 18.37))
|
||||
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
|
||||
}
|
||||
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
|
||||
assertEquals(1_788_609_600.0, reply.ts)
|
||||
assertEquals(18.37, reply.tokensPerSecond)
|
||||
assertEquals(9_489, reply.prefillMs)
|
||||
|
||||
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, ZoneId.of("UTC"))
|
||||
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
|
||||
// The clock reading rather than the whole string: the platform's own short-time format
|
||||
// differs by JDK and locale, which is the point of asking it for one.
|
||||
assertTrue(footer!!.contains("12:00"), footer)
|
||||
assertTrue(footer.endsWith("18.4 tok/s"), footer)
|
||||
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
|
||||
assertTrue(footer.contains("12:00"), footer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that measures no speed gets a footer of the time alone`() {
|
||||
val footer = replyFooterText(1_788_609_600.0, null, ZoneId.of("UTC"))
|
||||
assertTrue(footer!!.contains("12:00"), footer)
|
||||
assertTrue(!footer.contains("tok/s"), footer)
|
||||
// And a reply with neither has no line at all rather than an empty one.
|
||||
assertNull(replyFooterText(0.0, null, ZoneId.of("UTC")))
|
||||
fun `the clock stays at the end however much the provider measured`() {
|
||||
// What a provider that measures nothing leaves: the time, and nothing in front of it.
|
||||
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
|
||||
assertTrue(bare!!.contains("12:00"), bare)
|
||||
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
|
||||
// Every shape ends with the same thing, which is the whole point of the order: the clock
|
||||
// does not move because the session is on a provider that measures more or less.
|
||||
val shapes =
|
||||
listOf(
|
||||
bare,
|
||||
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
|
||||
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
|
||||
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
|
||||
)
|
||||
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
|
||||
// A reply with nothing to say has no line at all rather than an empty one.
|
||||
assertNull(replyFooterText(0.0, null, null, utc))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,7 +143,7 @@ class ThinkingTest {
|
||||
SessionEvent.AssistantText("Reading it."),
|
||||
SessionEvent.ToolStart("t1", "Read", "{}"),
|
||||
SessionEvent.ToolEnd("t1", "done"),
|
||||
SessionEvent.UsageDelta(42, 100, 18.0),
|
||||
SessionEvent.UsageDelta(42, 100, 18.0, 500),
|
||||
)
|
||||
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user