mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user