feat(coach): surface per-turn token usage on the chat stream

Carry an additive usage envelope on the SSE done frame
({ totalTokens, model }) and persist tokensUsed + model onto
coach_messages (migration 0182, nullable, no backfill) so the
per-message token footer survives a conversation reload. Add an
optional reasoning frame the client can render inside the thinking
disclosure when a reasoning-capable provider emits a summary; providers
without reasoning emit none. Extend CoachMessageDTO and regenerate the
OpenAPI contract. Server-authoritative: clients display the count, never
recompute it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:58:58 +02:00
1 parent a5b3715a46
commit f46e9d3bb8
9 files changed
+242 -29

No files matched your search

+24 -5
View File
@@ -4148,11 +4148,14 @@ paths:
summary: Send a Coach turn (streaming reply)
description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is
`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{
type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a
provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame
types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and
rate-limited. Auth via cookie or Bearer."
of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion`
(a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{
type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId,
messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?,
completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type,
code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error`
frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts
a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer."
requestBody:
required: true
content:
@@ -15702,6 +15705,20 @@ components:
- type: string
- type: "null"
description: Coach prompt version that produced the reply; null for user turns.
tokensUsed:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a
reload. Null on user turns, refusals, and pre-feature rows.
model:
anyOf:
- type: string
- type: "null"
description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns,
refusals, older rows).
required:
- id
- role
@@ -15710,6 +15727,8 @@ components:
- metricSource
- providerType
- promptVersion
- tokensUsed
- model
additionalProperties: false
description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.
CoachProvenance:
@@ -0,0 +1,14 @@
-- v1.18.9 — per-message Coach token usage + model.
--
-- The streaming chat endpoint already computes the per-turn token count
-- (`result.tokensUsed`) and the model that produced the reply, but until
-- now neither reached the client. Two additive nullable columns let the
-- quiet per-message token footer survive a conversation reload: the live
-- turn paints from the `done.usage` SSE frame, persisted history paints
-- from these columns.
--
-- Both NULL by default. No backfill: existing rows (and every user turn
-- or refusal, which never carries a token count) simply render no footer.
ALTER TABLE "coach_messages"
ADD COLUMN "tokens_used" INTEGER,
ADD COLUMN "model" TEXT;
+5
View File
@@ -3643,6 +3643,11 @@ model CoachMessage {
metricSourceJson String? @map("metric_source_json")
providerType String? @map("provider_type")
promptVersion String? @map("prompt_version")
// v1.18.9 — per-turn token usage + model, persisted so the quiet
// per-message token footer survives a conversation reload. Both
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
+13
View File
@@ -652,6 +652,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
metricSource: enrichedProvenance,
providerType: workingProviderType,
promptVersion: PROMPT_VERSION,
// v1.18.9 — persist the per-turn token count + model so the quiet
// token footer survives a conversation reload. The live turn paints
// from the `done.usage` SSE frame below; reloads read these columns.
tokensUsed: result.tokensUsed ?? null,
model: result.model ?? null,
});
// v1.18.7 — the day's spend was already reconciled against the
@@ -703,11 +708,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
);
}
// v1.18.9 — additive `usage` envelope on the `done` frame so the client
// can paint the quiet per-message token footer the instant the stream
// closes. Server-authoritative: the client renders these numbers, never
// recomputes them. Older clients drop the unknown key.
controller.enqueue(
encodeFrame({
type: "done",
conversationId: workingConversationId,
messageId: assistantMessage.id,
usage: {
totalTokens: result.tokensUsed ?? null,
model: result.model ?? null,
},
}),
);
});
@@ -60,6 +60,28 @@ import {
type InterleavedThreadItem,
} from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
import type { CoachStreamingMessage } from "../use-coach";
// v1.18.9 — `CoachStreamingMessage` gained `usage` / `startedAt` /
// `reasoning` (per-message token footer + thinking disclosure). The
// fixtures below predate those fields; this builder fills the defaults so
// each test only spells out the fields it cares about.
function streaming(
partial: Partial<CoachStreamingMessage>,
): CoachStreamingMessage {
return {
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
...partial,
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -82,6 +104,8 @@ const baseConversation: CoachConversationDetailDTO = {
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: "m2",
@@ -96,6 +120,8 @@ const baseConversation: CoachConversationDetailDTO = {
},
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: 312,
model: "gpt-4o",
},
],
};
@@ -143,14 +169,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
@@ -168,14 +194,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "Drafting…",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Drafting…");
@@ -186,14 +212,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Thinking");
@@ -203,14 +229,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
}}
})}
/>,
);
// v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights
@@ -237,6 +263,8 @@ describe("<MessageThread>", () => {
metricSource: null,
providerType: "admin-openai",
promptVersion: "4.20.0",
tokensUsed: null,
model: null,
},
],
};
@@ -244,14 +272,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={conv}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
}}
})}
/>,
);
// 1 persisted user + 2 persisted assistant + 0 streaming → 2.
@@ -271,14 +299,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={baseConversation}
streaming={{
streaming={streaming({
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const assistantBubbles = (
@@ -470,14 +498,14 @@ describe("<MessageThread>", () => {
content: "Wie ist mein Blutdruck letzte Woche?",
conversationId: null,
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
}}
})}
/>,
);
expect(html).toContain("Wie ist mein Blutdruck letzte Woche?");
@@ -577,14 +605,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
}}
})}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
@@ -598,14 +626,14 @@ describe("<MessageThread>", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
}}
})}
/>,
);
expect(html).toContain(
@@ -627,14 +655,14 @@ describe("<MessageThread>", () => {
content: "Why was BP higher on Monday?",
conversationId: "conv-1",
}}
streaming={{
streaming={streaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
}}
})}
/>,
);
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
@@ -11,6 +11,7 @@ import type {
CoachScope,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -191,6 +192,26 @@ export interface CoachStreamingMessage {
messageId: string | null;
/** Server-side error code from an `error` frame, if any. */
errorCode: string | null;
/**
* v1.18.9 — per-turn token usage from the additive `done.usage` frame.
* Null until `done` lands (or when the provider returned no count). The
* thread's token footer reads this for the just-finished streaming
* bubble; persisted bubbles read `CoachMessageDTO.tokensUsed` instead.
*/
usage: CoachUsage | null;
/**
* v1.18.9 — epoch-ms timestamp the send fired, so the thinking
* disclosure can show a live "thinking for N s" counter that freezes
* to a past-tense summary once the first token lands. Null before a
* turn starts.
*/
startedAt: number | null;
/**
* v1.18.9 — optional reasoning-summary text from the additive
* `reasoning` frame, rendered inside the thinking disclosure when a
* reasoning-capable provider emits it. Empty string when none.
*/
reasoning: string;
}
/**
@@ -231,6 +252,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
startedAt: null,
reasoning: "",
};
export interface SendCoachMessageParams {
@@ -324,6 +348,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: true,
messageId: null,
errorCode: null,
usage: null,
// v1.18.9 — anchor the thinking-disclosure elapsed timer to the
// moment the send fired.
startedAt: Date.now(),
reasoning: "",
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -337,6 +366,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -370,6 +402,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: "coach.network",
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -382,6 +417,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
return null;
}
@@ -419,6 +457,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
usage: null,
startedAt: null,
reasoning: "",
});
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
@@ -437,6 +478,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedSuggestion: CoachSuggestion | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
let collectedReasoning = "";
try {
while (true) {
@@ -468,9 +511,21 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestion: evt.suggestion,
}));
break;
case "reasoning":
// v1.18.9 — additive reasoning-summary frame. Append so
// multiple chunks accumulate; the disclosure renders it
// when present, else falls back to the elapsed-time label.
collectedReasoning += evt.text;
setStreaming((prev) => ({
...prev,
reasoning: prev.reasoning + evt.text,
}));
break;
case "done":
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
// v1.18.9 — additive per-turn usage envelope.
collectedUsage = evt.usage ?? null;
break;
case "error":
lastError = evt.code;
@@ -484,8 +539,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (evt.type === "done") {
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
collectedUsage = evt.usage ?? null;
} else if (evt.type === "error") {
lastError = evt.code;
} else if (evt.type === "reasoning") {
collectedReasoning += evt.text;
}
}
} catch (err) {
@@ -494,14 +552,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}
}
setStreaming({
setStreaming((prev) => ({
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
inProgress: false,
messageId,
errorCode: lastError,
});
usage: collectedUsage,
// v1.18.9 — keep the send-anchored timestamp so the disclosure can
// freeze the elapsed time to a past-tense "thought for N s" once
// the turn settles.
startedAt: prev.startedAt,
reasoning: collectedReasoning,
}));
if (resolvedConversationId) {
// Invalidate the freshly-persisted conversation + the rail so
@@ -550,4 +614,5 @@ export type {
CoachConversationDetailDTO,
CoachProvenance,
CoachSuggestion,
CoachUsage,
};
+13
View File
@@ -128,6 +128,13 @@ export interface AppendMessageParams {
metricSource?: CoachProvenance | null;
providerType?: string | null;
promptVersion?: string | null;
/**
* v1.18.9 — per-turn token count + model the reply was produced with,
* persisted so the quiet token footer survives a reload. Omitted on
* user turns and refusals (no token count to record).
*/
tokensUsed?: number | null;
model?: string | null;
}
/**
@@ -168,6 +175,8 @@ export async function appendMessage(
metricSourceJson: provenanceToJson(params.metricSource ?? null),
providerType: params.providerType ?? null,
promptVersion: params.promptVersion ?? null,
tokensUsed: params.tokensUsed ?? null,
model: params.model ?? null,
},
});
await tx.coachConversation.update({
@@ -185,6 +194,8 @@ export async function appendMessage(
metricSource: provenanceFromJson(result.metricSourceJson),
providerType: result.providerType,
promptVersion: result.promptVersion,
tokensUsed: result.tokensUsed,
model: result.model,
};
}
@@ -270,6 +281,8 @@ export async function fetchConversationWithMessages(
metricSource: provenanceFromJson(m.metricSourceJson),
providerType: m.providerType,
promptVersion: m.promptVersion,
tokensUsed: m.tokensUsed,
model: m.model,
}));
// v1.11.1 — decrypt the rolling conversation summary (fail-closed: an
+44 -1
View File
@@ -165,6 +165,21 @@ export interface CoachSuggestion {
label: string;
}
/**
* v1.18.9 — per-turn token-usage envelope carried on the `done` frame
* (and persisted onto the assistant message). Server-authoritative: the
* client renders these numbers, never recomputes them. `totalTokens` is
* the headline count surfaced in the quiet per-message footer; the
* prompt / completion split is optional (not every provider returns it)
* and `model` names the provider model that produced the reply.
*/
export interface CoachUsage {
totalTokens: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
model?: string | null;
}
/**
* SSE event shapes emitted by the streaming endpoint.
*
@@ -173,6 +188,15 @@ export interface CoachSuggestion {
* The `suggestion` frame (v1.18.1) is additive: older web + iOS clients
* that don't know it drop it on the floor (the parser keeps only frames
* whose `type` it handles), so the chat stays backwards-compatible.
*
* v1.18.9 — two additive shapes:
* - `done.usage` carries the per-turn `CoachUsage` (tokens + model) so
* the client can paint the quiet per-message footer the instant the
* stream closes; older clients ignore the extra key.
* - `reasoning` is an optional frame whose `text` the client renders
* inside the thinking disclosure when a reasoning-capable provider
* emits a cheap reasoning summary. Providers without reasoning emit
* none, and the disclosure falls back to its elapsed-time label.
*/
export type CoachStreamEvent =
| { type: "token"; token: string }
@@ -181,7 +205,13 @@ export type CoachStreamEvent =
metricSource: CoachProvenance;
}
| { type: "suggestion"; suggestion: CoachSuggestion }
| { type: "done"; conversationId: string; messageId: string }
| { type: "reasoning"; text: string }
| {
type: "done";
conversationId: string;
messageId: string;
usage?: CoachUsage;
}
| { type: "error"; code: string; message: string };
/**
@@ -335,6 +365,19 @@ export interface CoachMessageDTO {
metricSource: CoachProvenance | null;
providerType: string | null;
promptVersion: string | null;
/**
* v1.18.9 — total tokens the assistant turn cost, persisted so the
* quiet per-message footer survives a conversation reload. Null on
* older messages (pre-feature) and on user turns / refusals where no
* token count was recorded.
*/
tokensUsed: number | null;
/**
* v1.18.9 — the provider model that produced the reply (e.g.
* `gpt-4o`), persisted alongside `tokensUsed` for the footer. Null
* when unknown (user turns, refusals, older rows).
*/
model: string | null;
}
export interface CoachConversationDetailDTO extends CoachConversationDTO {
+14 -1
View File
@@ -307,6 +307,19 @@ const coachMessageSchema = z
.describe(
"Coach prompt version that produced the reply; null for user turns.",
),
tokensUsed: z
.number()
.int()
.nullable()
.describe(
"v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows.",
),
model: z
.string()
.nullable()
.describe(
"v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows).",
),
})
.meta({
id: "CoachMessage",
@@ -425,7 +438,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Send a Coach turn (streaming reply)",
description:
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {