diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ecb970d7..f0db8ff6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: \\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: diff --git a/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql new file mode 100644 index 000000000..14d9a4c88 --- /dev/null +++ b/prisma/migrations/0182_v1189_coach_message_token_usage/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index deeb77edf..480c5eaa2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 88d04749b..ea8907ad7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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, + }, }), ); }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index cae800634..1e650e5db 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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 { + 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("", () => { const html = render( , ); // 1 persisted user + 1 persisted assistant + 1 streaming assistant. @@ -168,14 +194,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Drafting…"); @@ -186,14 +212,14 @@ describe("", () => { const html = render( , ); expect(html).toContain("Thinking"); @@ -203,14 +229,14 @@ describe("", () => { const html = render( , ); // v1.4.33 IW7 — copy rewritten from "AI provider" to "Insights @@ -237,6 +263,8 @@ describe("", () => { metricSource: null, providerType: "admin-openai", promptVersion: "4.20.0", + tokensUsed: null, + model: null, }, ], }; @@ -244,14 +272,14 @@ describe("", () => { const html = render( , ); // 1 persisted user + 2 persisted assistant + 0 streaming → 2. @@ -271,14 +299,14 @@ describe("", () => { const html = render( , ); const assistantBubbles = ( @@ -470,14 +498,14 @@ describe("", () => { 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("", () => { const html = render( , ); expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); @@ -598,14 +626,14 @@ describe("", () => { const html = render( , ); expect(html).toContain( @@ -627,14 +655,14 @@ describe("", () => { 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) ?? []) diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 11c462516..7c59eb0e0 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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, }; diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 827dcc63c..dfe7bef9a 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -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 diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 7d50c42ae..13c19b255 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -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 { diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index efa0a6ed5..8a091018d 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -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 = { 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: \\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: \\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: {