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:
iris-aiandClaude Opus 5 committed 2026-09-19 15:57:56 -04:00
1 parent b660905098
commit 369b8f7e52
14 files changed
+135 -42

No files matched your search

+5 -3
View File
@@ -67,9 +67,11 @@ Module-by-module intent is in PLAN.md's "Backend layout".
the span the *driver* measured, and the phone draws a card that spins while the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` off the same stream becomes `UsageDelta`'s `timings.predicted_per_second` and `timings.prompt_ms` off the same stream
`tokensPerSecond`, which is the "149 tok/s" under a finished reply — nothing become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
else here measures one, so every other driver sends `None`. "read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A turn's wait has two halves and says which** (2026-09-19): **A turn's wait has two halves and says which** (2026-09-19):
`SessionStatus::Loading` is the model coming off disk and `SessionStatus::Loading` is the model coming off disk and
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted `SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
+22 -15
View File
@@ -169,13 +169,17 @@ seq N", so there is no separate history path to drift from the live one.
waiting for itself and will speak again with nobody having typed anything. waiting for itself and will speak again with nobody having typed anything.
Reporting it as idle sent a "finished" notification at the one moment that Reporting it as idle sent a "finished" notification at the one moment that
was untrue. was untrue.
- `UsageDelta { tokens, context, tokensPerSecond }` — what a turn cost, how - `UsageDelta { tokens, context, tokensPerSecond, prefillMs }` — what a turn
much the model was holding when it ended, and how fast it was generated. cost, how much the model was holding when it ended, how fast it was
`tokensPerSecond` (2026-09-19) is the provider's own measurement or nothing: generated, and how long the provider spent reading the prompt first. The last
llama.cpp reports `timings.predicted_per_second`, and the coding CLIs report two (2026-09-19) are the provider's own measurements or nothing: llama.cpp
no such figure, so dividing what this server watched a reply arrive over reports `timings.predicted_per_second` and `timings.prompt_ms`, and the
would count the network, the tool calls and the reader's own permission coding CLIs report neither, so dividing what this server watched a reply
answers as generation. The phone draws it under the reply it measured. `context` is prompt plus both cache figures, arrive over would count the network, the tool calls and the reader's own
permission answers as generation. The phone draws both under the reply they
measured. `prefillMs` is small on a turn whose prompt the server still had
cached -- 22ms against 64s for the first turn after a model loads, measured
on the 0.6B -- which is a fact about the turn rather than a missing figure. `context` is prompt plus both cache figures,
taken from the **last assistant message** rather than the turn's `result`: taken from the **last assistant message** rather than the turn's `result`:
measured 2026-08-30 against CLI 2.1.237, the result adds a turn's messages measured 2026-08-30 against CLI 2.1.237, the result adds a turn's messages
up, so its cache read of 40,211 was the same conversation counted twice. up, so its cache read of 40,211 was the same conversation counted twice.
@@ -1275,14 +1279,17 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
the model stopped to think in the middle of it. A block cut by a page the model stopped to think in the middle of it. A block cut by a page
boundary is welded like a reply is (`healSplitThinking`), since the half boundary is welded like a reply is (`healSplitThinking`), since the half
with no ending would otherwise spin for the rest of the conversation. with no ending would otherwise spin for the rest of the conversation.
- **A finished reply carries a line under it saying when it was sent and, - **A finished reply carries a line under it saying what it cost to produce
where the provider measured one, how fast it was generated** (2026-09-19) and when it was sent** (2026-09-19) — "read 9.5s · 50.3 tok/s · 3:00 PM",
— "3:00 PM · 149 tok/s", small and set back, right-aligned because it small and set back, right-aligned because it closes the message rather
closes the message rather than opening one. The time is the transcript's than opening one. **The clock is last**, so it sits against the right edge
own timestamp, so every device draws the same one; the rate is the whatever else is on the line: the measurements in front of it belong to
provider's own figure or nothing at all. It is a list unit of its own the provider, and a reader who has learned where the time is should not
(`ReplyFoot`), because a settled reply *is* its blocks and there is no have to find it again because the session is on a different one. The time
row left to hang it on. is the transcript's own timestamp, so every device draws the same one; the
costs are the provider's own figures or nothing at all. It is a list unit
of its own (`ReplyFoot`), because a settled reply *is* its blocks and
there is no row left to hang it on.
- **Anything that is a note *about* the conversation rather than a turn in - **Anything that is a note *about* the conversation rather than a turn in
it is closed by default** — a tool call, a peer message, a memory note, it is closed by default** — a tool call, a peer message, a memory note,
a thinking block. a thinking block.
@@ -185,6 +185,12 @@ sealed class SessionEvent {
* over includes the network and whatever the server was doing between tokens. * over includes the network and whatever the server was doing between tokens.
*/ */
val tokensPerSecond: Double? = null, 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() ) : SessionEvent()
/** How much context this session's model has, which is what [UsageDelta.context] is out of. */ /** 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"), body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null, if (body.has("context")) body.getLong("context") else null,
if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null, if (body.has("tokensPerSecond")) body.getDouble("tokensPerSecond") else null,
if (body.has("prefillMs")) body.getLong("prefillMs") else null,
) )
"compacted" -> "compacted" ->
SessionEvent.Compacted( SessionEvent.Compacted(
@@ -13,7 +13,7 @@ import java.time.format.FormatStyle
import java.util.Locale 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 * 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. * 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. * left edge is reading what was said, and this is where that ends.
*/ */
@Composable @Composable
fun ReplyFooter(ts: Double, tokensPerSecond: Double?, modifier: Modifier = Modifier) { fun ReplyFooter(
val text = replyFooterText(ts, tokensPerSecond, ZoneId.systemDefault()) ?: return ts: Double,
tokensPerSecond: Double?,
prefillMs: Long?,
modifier: Modifier = Modifier,
) {
val text = replyFooterText(ts, tokensPerSecond, prefillMs, ZoneId.systemDefault()) ?: return
Text( Text(
text, text,
style = MaterialTheme.typography.labelSmall, 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 * 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. * 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 * **The time is last, and so sits against the right edge whatever else is on the line.** The
* turn cost and never how long the model spent -- and the time this app watched a reply arrive over * measurements in front of it are the provider's, so a session on another provider has fewer of
* is not the same quantity: it counts the network, the pauses between tokens and whatever the * them or none -- and a reader who has learned where the clock is should not have to find it again
* server was doing in them. So the line is the time alone rather than a plausible figure beside it. * 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 = val at =
if (ts <= 0.0) null if (ts <= 0.0) null
else 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) if (it >= 100) String.format(Locale.getDefault(), "%.0f tok/s", it)
else String.format(Locale.getDefault(), "%.1f 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) { when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.ReplyFoot -> is TranscriptUnit.ReplyFoot ->
ReplyFooter(unit.ts, unit.tokensPerSecond) ReplyFooter(unit.ts, unit.tokensPerSecond, unit.prefillMs)
is TranscriptUnit.PeerHead -> is TranscriptUnit.PeerHead ->
PeerHeadRow( PeerHeadRow(
unit.item, unit.item,
@@ -1823,6 +1823,7 @@ fun SessionScreen(
ReplyFooter( ReplyFooter(
item.ts, item.ts,
item.tokensPerSecond, item.tokensPerSecond,
item.prefillMs,
) )
} }
} }
@@ -80,6 +80,8 @@ sealed class TranscriptItem {
* known until the reply is over. * known until the reply is over.
*/ */
val tokensPerSecond: Double? = null, val tokensPerSecond: Double? = null,
/** How long the provider spent reading the prompt, where it measured that. */
val prefillMs: Long? = null,
) : TranscriptItem() ) : TranscriptItem()
/** /**
@@ -633,7 +635,11 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
is SessionEvent.UsageDelta -> is SessionEvent.UsageDelta ->
when (val last = items.lastOrNull()) { when (val last = items.lastOrNull()) {
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg ->
items.dropLast(1) + last.copy(tokensPerSecond = event.tokensPerSecond) items.dropLast(1) +
last.copy(
tokensPerSecond = event.tokensPerSecond,
prefillMs = event.prefillMs,
)
else -> items else -> items
} }
is SessionEvent.ContextWindow -> items is SessionEvent.ContextWindow -> items
@@ -142,6 +142,7 @@ sealed class TranscriptUnit {
override val ordinal: Int, override val ordinal: Int,
val ts: Double, val ts: Double,
val tokensPerSecond: Double?, val tokensPerSecond: Double?,
val prefillMs: Long?,
override val gap: Dp, override val gap: Dp,
) : TranscriptUnit() { ) : TranscriptUnit() {
override val key: Any override val key: Any
@@ -265,6 +266,7 @@ fun transcriptUnits(
ordinal, ordinal,
item.ts, item.ts,
item.tokensPerSecond, item.tokensPerSecond,
item.prefillMs,
gap(FOOT_SPACING), gap(FOOT_SPACING),
) )
} else { } else {
@@ -14,6 +14,7 @@ import kotlin.test.assertTrue
* generation speed has no figure -- neither may borrow one. * generation speed has no figure -- neither may borrow one.
*/ */
class ThinkingTest { class ThinkingTest {
private val utc = ZoneId.of("UTC")
private var seq = 0L private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) = private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
@@ -77,29 +78,41 @@ class ThinkingTest {
} }
@Test @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 = val items =
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let { 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() val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
assertEquals(1_788_609_600.0, reply.ts) assertEquals(1_788_609_600.0, reply.ts)
assertEquals(18.37, reply.tokensPerSecond) 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 // 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. // differs by JDK and locale, which is the point of asking it for one.
assertTrue(footer!!.contains("12:00"), footer) assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
assertTrue(footer.endsWith("18.4 tok/s"), footer) assertTrue(footer.contains("12:00"), footer)
} }
@Test @Test
fun `a provider that measures no speed gets a footer of the time alone`() { fun `the clock stays at the end however much the provider measured`() {
val footer = replyFooterText(1_788_609_600.0, null, ZoneId.of("UTC")) // What a provider that measures nothing leaves: the time, and nothing in front of it.
assertTrue(footer!!.contains("12:00"), footer) val bare = replyFooterText(1_788_609_600.0, null, null, utc)
assertTrue(!footer.contains("tok/s"), footer) assertTrue(bare!!.contains("12:00"), bare)
// And a reply with neither has no line at all rather than an empty one. assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
assertNull(replyFooterText(0.0, null, ZoneId.of("UTC"))) // 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 @Test
@@ -130,7 +143,7 @@ class ThinkingTest {
SessionEvent.AssistantText("Reading it."), SessionEvent.AssistantText("Reading it."),
SessionEvent.ToolStart("t1", "Read", "{}"), SessionEvent.ToolStart("t1", "Read", "{}"),
SessionEvent.ToolEnd("t1", "done"), SessionEvent.ToolEnd("t1", "done"),
SessionEvent.UsageDelta(42, 100, 18.0), SessionEvent.UsageDelta(42, 100, 18.0, 500),
) )
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond) assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
} }
+5
View File
@@ -429,6 +429,7 @@ impl Translator {
tokens, tokens,
context, context,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}); });
} }
// A level snapshot is authoritative at a turn boundary. In // A level snapshot is authoritative at a turn boundary. In
@@ -2554,6 +2555,7 @@ mod tests {
tokens: 182, tokens: 182,
context: None, context: None,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}, },
Event::Status { Event::Status {
state: SessionStatus::Idle state: SessionStatus::Idle
@@ -2597,6 +2599,7 @@ mod tests {
tokens: 7, tokens: 7,
context: None, context: None,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}, },
Event::Status { Event::Status {
state: SessionStatus::Idle state: SessionStatus::Idle
@@ -2651,6 +2654,7 @@ mod tests {
tokens: 173, tokens: 173,
context: Some(26_131), context: Some(26_131),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}) })
); );
@@ -2668,6 +2672,7 @@ mod tests {
tokens: 13, tokens: 13,
context: None, context: None,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}) })
); );
} }
+5
View File
@@ -230,6 +230,7 @@ impl Translator {
// Cached input is a subset of this figure, not an additional count. // Cached input is a subset of this figure, not an additional count.
context: last.get("inputTokens").and_then(Value::as_u64), context: last.get("inputTokens").and_then(Value::as_u64),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}) })
.into_iter() .into_iter()
.collect() .collect()
@@ -247,6 +248,7 @@ impl Translator {
tokens: input.unwrap_or(0) + output.unwrap_or(0), tokens: input.unwrap_or(0) + output.unwrap_or(0),
context: input, context: input,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}); });
} }
} }
@@ -939,6 +941,7 @@ mod tests {
tokens: 18, tokens: 18,
context: Some(13), context: Some(13),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
} }
); );
assert!(translator.completed()); assert!(translator.completed());
@@ -1162,6 +1165,7 @@ mod tests {
tokens: 42, tokens: 42,
context: Some(39), context: Some(39),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}] }]
); );
assert_eq!( assert_eq!(
@@ -1172,6 +1176,7 @@ mod tests {
tokens: 65, tokens: 65,
context: Some(61), context: Some(61),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}] }]
); );
assert_eq!( assert_eq!(
+13
View File
@@ -438,6 +438,17 @@ pub enum Event {
/// times those were. /// times those were.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
tokens_per_second: Option<f64>, tokens_per_second: Option<f64>,
/// How long the provider spent reading the prompt before it began
/// answering -- see [`SessionStatus::Reading`], which is this while it
/// is happening.
///
/// The provider's own measurement or nothing, for the same reason
/// `tokens_per_second` above is: the wait this server watched also
/// contains the request and whatever else the machine was doing. It is worth reporting because it is the larger half of a
/// turn on a long conversation -- 22 seconds against 3 of generation,
/// measured on a 14,000-token prompt.
#[serde(default, skip_serializing_if = "Option::is_none")]
prefill_ms: Option<u64>,
}, },
/// A compaction that finished, and how much context it recovered. /// A compaction that finished, and how much context it recovered.
/// ///
@@ -904,6 +915,7 @@ mod tests {
tokens: 12, tokens: 12,
context: Some(30_100), context: Some(30_100),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
} }
), ),
Some(30_100) Some(30_100)
@@ -944,6 +956,7 @@ mod tests {
tokens: 12, tokens: 12,
context: None, context: None,
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
} }
), ),
Some(30_100) Some(30_100)
+2
View File
@@ -894,6 +894,8 @@ impl EchoDriver {
tokens_per_second: Some(streaming.elapsed().as_secs_f64()) tokens_per_second: Some(streaming.elapsed().as_secs_f64())
.filter(|elapsed| *elapsed > 0.0) .filter(|elapsed| *elapsed > 0.0)
.map(|elapsed| words as f64 / elapsed), .map(|elapsed| words as f64 / elapsed),
// Nothing to read: this driver has no prompt to process.
prefill_ms: None,
}); });
finish(); finish();
}); });
+9
View File
@@ -1640,6 +1640,10 @@ fn generate(
// divided out of the wall time here, which would count the request, the // divided out of the wall time here, which would count the request, the
// prompt processing and this loop's own scheduling as generation. // prompt processing and this loop's own scheduling as generation.
let mut per_second = None; let mut per_second = None;
// What it spent reading the prompt, the same way: `prompt_ms` is the
// server's own account of prompt processing, and a prompt it had cached is
// a small number rather than a missing one.
let mut prefill = None;
// Closes the open block, which is anything the model says that is not more // Closes the open block, which is anything the model says that is not more
// working: the first word of the reply, or a tool call. // working: the first word of the reply, or a tool call.
let done_thinking = |thinking: &mut Option<std::time::Instant>| { let done_thinking = |thinking: &mut Option<std::time::Instant>| {
@@ -1678,6 +1682,9 @@ fn generate(
{ {
per_second = Some(rate); per_second = Some(rate);
} }
if let Some(ms) = chunk.pointer("/timings/prompt_ms").and_then(Value::as_f64) {
prefill = Some(ms.round() as u64);
}
let Some(delta) = chunk.pointer("/choices/0/delta") else { let Some(delta) = chunk.pointer("/choices/0/delta") else {
continue; continue;
}; };
@@ -1723,6 +1730,7 @@ fn generate(
tokens, tokens,
context, context,
tokens_per_second: per_second, tokens_per_second: per_second,
prefill_ms: prefill,
}); });
} }
// A call whose name never arrived is not a call. It happens when a stream // A call whose name never arrived is not a call. It happens when a stream
@@ -1967,6 +1975,7 @@ mod tests {
tokens: 12, tokens: 12,
context: Some(12), context: Some(12),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}, },
]); ]);
let messages = conversation(&path); let messages = conversation(&path);
+1
View File
@@ -997,6 +997,7 @@ mod tests {
tokens: 42, tokens: 42,
context: Some(42), context: Some(42),
tokens_per_second: None, tokens_per_second: None,
prefill_ms: None,
}, },
Event::AuthenticationRequired { Event::AuthenticationRequired {
message: "sign in again".into(), message: "sign in again".into(),