mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
a5b3715a46
commit
f46e9d3bb8
9 files changed
+242
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user