mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
+24
-5
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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