Compare commits

...

1 Commits

Author SHA1 Message Date
Ryan Vogel 5e27dd7b3e feat(tui): show token throughput 2026-08-12 19:30:50 +00:00
14 changed files with 162 additions and 14 deletions
+2
View File
@@ -595,6 +595,7 @@ export type Endpoint5_31Output =
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
@@ -619,6 +620,7 @@ export type Endpoint5_31Output =
readonly cache: { readonly read: number; readonly write: number }
}
| undefined
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
+21 -4
View File
@@ -624,6 +624,7 @@ export type SessionStepEnded = {
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
cost: MoneyUSD
tokens: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -1137,6 +1138,7 @@ export type SessionStepFailed = {
error: SessionStructuredError
cost?: MoneyUSD
tokens?: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -1921,7 +1923,7 @@ export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSy
export type SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; completed?: number }
time: { created: number; started?: number; generated?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -2642,7 +2644,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -2909,7 +2916,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3176,7 +3188,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.time.started = undefined
draft.time.generated = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -230,6 +232,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -248,6 +251,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.cost = event.data.cost
draft.tokens = castDraft(event.data.tokens)
}
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -258,6 +262,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
if (draft.time.completed === undefined) draft.time.started ??= event.created
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
+1
View File
@@ -275,6 +275,7 @@ const layer = Layer.effect(
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
generated: finish.generated,
})
const captureStepEnd = Effect.fnUntraced(function* () {
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { DateTime, Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -36,6 +36,7 @@ export interface StepRecord {
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly generated: DateTime.Utc
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -310,6 +311,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const publishStepFailure = Effect.fnUntraced(function* (details?: {
readonly cost?: Money.USD
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
readonly generated?: DateTime.Utc
readonly snapshot?: Snapshot.ID
readonly files?: readonly RelativePath[]
}) {
@@ -493,9 +495,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
}
case "step-finish":
const generated = yield* DateTime.now
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
stepSettlement = {
finish: event.reason.normalized,
tokens: SessionUsage.tokens(event.usage),
generated,
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+14 -2
View File
@@ -47,7 +47,9 @@ const build = Agent.defaultID
const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
time: { created: DateTime.Utc; started?: DateTime.Utc; generated?: DateTime.Utc; completed?: DateTime.Utc } = {
created,
},
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
@@ -667,12 +669,18 @@ describe("SessionProjector", () => {
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
ordinal: 0,
})
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
generated: DateTime.makeUnsafe(0),
})
const rows = yield* db
@@ -691,7 +699,11 @@ describe("SessionProjector", () => {
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
time: {
started: DateTime.makeUnsafe(0),
generated: DateTime.makeUnsafe(0),
completed: DateTime.makeUnsafe(0),
},
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
@@ -266,6 +266,7 @@ test("step finish records settlement without publishing step ended", async () =>
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toHaveProperty("generated")
})
test("content-filter finish retains failure evidence until step closeout", async () => {
+3 -1
View File
@@ -6,7 +6,7 @@ import { Event } from "./event.js"
import { FinishReason } from "./llm.js"
import { Content } from "./tool.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { DateTimeUtcFromMillis, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment } from "./prompt.js"
import { SessionID } from "./session-id.js"
import { Location } from "./location.js"
@@ -309,6 +309,7 @@ export namespace Step {
finish: FinishReason,
cost: Money.USD,
tokens: TokenUsage.Info,
generated: DateTimeUtcFromMillis.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
},
@@ -324,6 +325,7 @@ export namespace Step {
error: SessionError.Error,
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
generated: DateTimeUtcFromMillis.pipe(optional),
snapshot: Snapshot.ID.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional),
},
+2
View File
@@ -221,6 +221,8 @@ export const Assistant = Schema.Struct({
retry: AssistantRetry.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
started: DateTimeUtcFromMillis.pipe(optional),
generated: DateTimeUtcFromMillis.pipe(optional),
completed: DateTimeUtcFromMillis.pipe(optional),
}),
}).annotate({ identifier: "Session.Message.Assistant" })
+14 -3
View File
@@ -584,6 +584,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
existing.retry = undefined
existing.error = undefined
existing.finish = undefined
existing.time.started = undefined
existing.time.generated = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
return
@@ -613,6 +615,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
currentAssistant.time.generated = event.data.generated
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
@@ -630,11 +633,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
}
currentAssistant.time.generated = event.data.generated
})
break
case "session.text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
if (assistant.time.completed === undefined) assistant.time.started ??= event.created
assistant.content.push({
type: "text",
text: "",
})
@@ -654,7 +661,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -743,7 +752,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!assistant) return
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
+21
View File
@@ -93,6 +93,7 @@ import {
createSessionRows,
messageBoundaryIDs,
resolvePart,
tokenThroughput,
turnDuration,
type CacheUsage,
type PartRef,
@@ -1243,6 +1244,7 @@ function TurnTokenUsage(props: {
const newTokens = total - message.tokens.cache.read
const currentCache = { read: message.tokens.cache.read, model: message.model }
const reuseDrop = cacheReuseDrop(previousCache, currentCache)
const throughput = tokenThroughput(message)
previousCache = currentCache
return [
{
@@ -1252,6 +1254,7 @@ function TurnTokenUsage(props: {
cached: message.tokens.cache.read,
total,
reuseDrop,
throughput,
},
]
})
@@ -1261,16 +1264,25 @@ function TurnTokenUsage(props: {
newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)),
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
throughput: Math.max("Tok/s".length, ...steps().map((item) => (item.throughput?.rate.toFixed(1) ?? "-").length)),
}))
const summary = createMemo(() => {
const items = steps()
const last = items[items.length - 1]
const throughput = items.reduce(
(result, item) => ({
tokens: result.tokens + (item.throughput?.tokens ?? 0),
duration: result.duration + (item.throughput?.duration ?? 0),
}),
{ tokens: 0, duration: 0 },
)
return {
count: items.length,
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
cached: last?.cached ?? 0,
total: last?.total ?? 0,
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
throughput: throughput.duration > 0 ? (throughput.tokens * 1_000) / throughput.duration : undefined,
}
})
return (
@@ -1292,6 +1304,7 @@ function TurnTokenUsage(props: {
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
</span>
<Show when={summary().throughput}>{(value) => <span> · {value().toFixed(1)} tok/s</span>}</Show>
<Show when={summary().reuseDrops > 0}>
<span style={{ fg: theme.text.feedback.warning.default }}>
{" "}
@@ -1309,6 +1322,8 @@ function TurnTokenUsage(props: {
{"Cached".padStart(columns().cached)}
{" "}
{"Total".padStart(columns().total)}
{" "}
{"Tok/s".padStart(columns().throughput)}
</text>
</box>
<For each={steps()}>
@@ -1323,6 +1338,8 @@ function TurnTokenUsage(props: {
{item.cached.toLocaleString().padStart(columns().cached)}
{" "}
{item.total.toLocaleString().padStart(columns().total)}
{" "}
{(item.throughput?.rate.toFixed(1) ?? "-").padStart(columns().throughput)}
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
@@ -1669,6 +1686,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
const throughput = createMemo(() => tokenThroughput(props.message)?.rate)
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
@@ -1689,6 +1707,9 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={dimensions().width >= 48 ? throughput() : undefined}>
{(value) => <span style={{ fg: theme.text.subdued }}> · {value().toFixed(1)} tok/s</span>}
</Show>
<Show when={interrupted()}>
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
</Show>
+10
View File
@@ -357,6 +357,16 @@ export function turnDuration(message: SessionMessageAssistant, messages: Session
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
}
export function tokenThroughput(message: SessionMessageAssistant) {
if (message.finish !== "stop" && message.finish !== "length") return undefined
if (message.tokens === undefined || message.time.started === undefined || message.time.generated === undefined)
return undefined
const duration = message.time.generated - message.time.started
const tokens = message.tokens.output - 1
if (duration < 250 || tokens <= 0) return undefined
return { tokens, duration, rate: (tokens * 1_000) / duration }
}
function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
+19 -1
View File
@@ -513,19 +513,31 @@ test("truncates committed revert messages without changing lifetime usage", asyn
model: { providerID: "provider", id: "model" },
},
})
emitEvent(events, {
id: "evt_revert_boundary_text_started",
created: 1.25,
type: "session.text.started",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
ordinal: 0,
},
})
cost = 0.5
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_boundary_ended",
created: 2,
type: "session.step.ended",
durable: durable(sessionID, 2),
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
finish: "stop",
cost: 0.5,
tokens,
generated: 1.75,
},
})
emitEvent(events, {
@@ -535,6 +547,12 @@ test("truncates committed revert messages without changing lifetime usage", asyn
data: { sessionID, cost, tokens },
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.message.get(sessionID, "msg_revert_boundary")?.time).toEqual({
created: 1,
started: 1.25,
generated: 1.75,
completed: 2,
})
emitEvent(events, {
id: "evt_revert_later_started",
+40 -1
View File
@@ -1,6 +1,12 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
import {
cacheReuseDrop,
messageBoundaryIDs,
reduceSessionRows,
tokenThroughput,
turnDuration,
} from "../../../src/routes/session/rows"
test("measures turn duration from the user prompt across assistant steps", () => {
const first = assistant("assistant-1", [])
@@ -16,6 +22,39 @@ test("measures turn duration from the user prompt across assistant steps", () =>
expect(turnDuration(final, messages)).toBe(29_000)
})
test("measures visible output throughput after the first token", () => {
const message = assistant("assistant-1", [])
message.finish = "stop"
message.time = { created: 1_000, started: 2_000, generated: 4_000, completed: 8_000 }
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 50, write: 0 } }
expect(tokenThroughput(message)).toEqual({ tokens: 79, duration: 2_000, rate: 39.5 })
})
test("does not report throughput without a stable generation window", () => {
const message = assistant("assistant-1", [])
message.finish = "stop"
message.time = { created: 1_000, completed: 4_000 }
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 0, write: 0 } }
expect(tokenThroughput(message)).toBeUndefined()
message.time.started = 4_000
message.time.generated = 4_000
expect(tokenThroughput(message)).toBeUndefined()
message.time.started = 3_751
expect(tokenThroughput(message)).toBeUndefined()
})
test("does not report throughput for tool-call steps", () => {
const message = assistant("assistant-1", [])
message.finish = "tool-calls"
message.time = { created: 1_000, started: 2_000, generated: 4_000, completed: 8_000 }
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 0, write: 0 } }
expect(tokenThroughput(message)).toBeUndefined()
})
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()