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

+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: {