Merge Coach tool-based retrieval into release/v1.20.0

Replace the Coach snapshot-stuffing with a small base context plus a data
inventory and six on-demand retrieval tools (metric series, glucose,
sleep, medication compliance, labs, illness recovery) the model calls as
needed, then answers. Cuts first-turn input ~35-65% while preserving
grounding and every gate; tools return a structured present:false rather
than throwing, so the model cites only what it fetched. Falls back to the
snapshot path when a provider lacks tool support.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:38:18 +02:00
commit 5c2d3f12cb
18 files changed
+2094 -43

No files matched your search

@@ -66,6 +66,17 @@ vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
}));
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 2,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
@@ -58,8 +58,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback,
}));
vi.mock("@/lib/ai/provider", () => ({
// v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy
// snapshot-stuffing fallback path (the behaviour it was written to pin). The
// tool-retrieval path is covered by route-tool-mode.test.ts.
resolveProviderChain: vi.fn(async () => [
{ providerType: "admin-openai", instance: {} },
{ providerType: "admin-openai", instance: { supportsTools: false } },
]),
resolveProvider: vi.fn(),
}));
@@ -0,0 +1,311 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.20.0 (F1) — Coach tool-retrieval routing contract.
*
* - When every provider in the chain supports tools, the route runs the tool
* loop with a base context (DATA INVENTORY, NOT the snapshot figures), and
* persists the tool trace onto provenance.
* - When any provider lacks tool support, the route falls back to the legacy
* snapshot-stuffing single completion verbatim.
* - The inbound gates (rate limit, refusal, consent, budget reservation) all
* fire BEFORE the loop in both modes.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn<
() => Promise<{
allowed: boolean;
reserved: number;
totalAfter?: number;
}>
>(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget,
reconcileSpend,
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
screenCoachReply: vi.fn(() => ({ block: false })),
coachOutboundFallback: vi.fn(() => "fallback"),
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
// Tool-module surface — stub the inventory + loop so we assert routing only.
const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
})),
renderDataInventory: vi.fn(
() => "DATA INVENTORY\n- blood pressure: present",
),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 2,
buildCoachDataInventory,
renderDataInventory,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(() => ({
prose: "Your BP is steady.",
keyValues: [],
malformed: false,
malformedEntries: [],
})),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
parseSuggestReminder: vi.fn(() => ({ prose: "Your BP is steady." })),
}));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
void Promise.resolve(
producer({ signal: { aborted: true }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("coach chat — tool-mode routing (F1)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
});
it("runs the tool loop (not the snapshot path) when the chain supports tools", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on
]);
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).toHaveBeenCalledTimes(1);
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
// The base context carries the inventory, NOT the snapshot figures.
const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as {
messages: Array<{ content: string }>;
};
const userContent = loopArgs.messages[0].content;
expect(userContent).toContain("DATA INVENTORY");
expect(userContent).not.toContain(SNAPSHOT_JSON);
});
it("reserves the multi-round budget in tool mode", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
// maxTokens (1500) × MAX_ROUNDS (2).
expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21");
});
it("persists the tool trace onto provenance", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistantCall = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
expect(
(assistantCall?.[0] as { metricSource: { toolCalls?: unknown } })
.metricSource.toolCalls,
).toEqual([{ name: "get_metric_series", present: true }]);
});
it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
{ providerType: "local", instance: { supportsTools: false } },
]);
runRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "anthropic" },
});
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
// Single-round budget reservation in the fallback path.
expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21");
// The legacy path ships the snapshot figures in the user turn.
const params = (
(runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
params: { messages: Array<{ content: string }> };
}
).params;
expect(params.messages[0].content).toContain(SNAPSHOT_JSON);
});
it("fires the inbound gates BEFORE the loop (consent throws ⇒ no loop)", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
assertConsentForChain.mockRejectedValue(new Error("consent.ai.required"));
await expect(post(chatReq({ message: "How is my BP?" }))).rejects.toThrow(
"consent.ai.required",
);
expect(runCoachToolLoop).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
});
it("refuses over-budget before entering the loop", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
reserveBudget.mockResolvedValue({
allowed: false,
reserved: 3000,
totalAfter: 99999,
});
await post(chatReq({ message: "How is my BP?" }));
expect(runCoachToolLoop).not.toHaveBeenCalled();
});
});
+131 -42
View File
@@ -73,6 +73,16 @@ import {
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import {
COACH_TOOL_DEFS,
buildCoachDataInventory,
renderDataInventory,
buildToolModeAddendum,
runCoachToolLoop,
MAX_ROUNDS,
type CoachToolTrace,
} from "@/lib/ai/coach/tools";
import type { AiMessage } from "@/lib/ai/types";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
@@ -419,6 +429,36 @@ The user's message answers this clarifying question from their self-context ques
React briefly and personally to the answer; do not repeat the question and do not ask it again.
`
: "";
// ── Provider chain ──────────────────────────────────────────
// v1.20.0 (F1) — resolved BEFORE the prompt is built so we know whether to
// run the tool-based retrieval path or the legacy snapshot-stuffing path.
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({
action: { name: "insights.coach.noProvider" },
});
return streamProviderError({ code: "coach.provider.none" });
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// v1.12.1 — consent gate before any server-managed external egress. When
// the chain could egress via the operator's global key, require an active
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
// chains are the user's own egress and stay ungated. Throws
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
// v1.20.0 (F1) — tool mode is on only when EVERY provider in the chain
// supports tool-calling, so whichever hop the fallback runner lands on can
// still serve tools. A chain that includes a no-tools provider (local /
// Ollama) falls back to the legacy snapshot-stuffing path verbatim, exactly
// as before — the snapshot builder stays alive as the no-tools floor.
const toolMode = chain.every((c) => c.instance.supportsTools !== false);
// v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
// present in this snapshot. Deterministic + brand-free; framed as general
// guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
@@ -447,26 +487,6 @@ ${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// ── Provider chain ──────────────────────────────────────────
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({
action: { name: "insights.coach.noProvider" },
});
return streamProviderError({ code: "coach.provider.none" });
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// v1.12.1 — consent gate before any server-managed external egress. When
// the chain could egress via the operator's global key, require an active
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
// chains are the user's own egress and stay ungated. Throws
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
await assertConsentForChain({ userId, chain, surface: "coach" });
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
// the provider call. The reservation increments the day's total by the
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
@@ -474,10 +494,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// Over-cap → 429 refusal frame, reservation already refunded. The actual
// token count is reconciled against this reservation after the call,
// including on empty / sentinel replies (their tokens were still burned).
//
// v1.20.0 (F1) — the tool loop makes up to MAX_ROUNDS provider round-trips,
// so reserve the per-call ceiling × the round count up front and reconcile
// the SUMMED actual tokens afterwards. The atomic reserve/reconcile
// primitives are unchanged; only the reserved amount scales.
const reqDateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.coach.maxTokens,
toolMode
? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS
: AI_BUDGETS.coach.maxTokens,
reqDateKey,
);
if (!reservation.allowed) {
@@ -490,23 +517,62 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
let result: CompletionResult;
let workingProviderType: string;
let toolTrace: CoachToolTrace[] = [];
let totalTokensSpent: number;
try {
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
// v1.20.0 — the Coach still builds one assembled user turn (the
// transcript-flattening preserves the once-per-conversation snapshot
// trick + grounding exactly), so it ships as a single user message. The
// stable persona/grounding rides `system` and is now cache-eligible.
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
if (toolMode) {
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
// grounding addendum, with the tiny DATA INVENTORY manifest and the
// transcript on the user turn. The figures are NOT in the prompt — the
// model pulls only what it needs via the retrieval tools. The inventory
// build reuses the snapshot we already computed (60s LRU), so the tools
// that fire this turn share its reads.
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`;
const messages: AiMessage[] = [
{
role: "user",
content: `${renderDataInventory(inventory)}${guidedBlock}
CONVERSATION
${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`,
},
];
const loop = await runCoachToolLoop({
userId,
providers: chain,
system: toolSystem,
messages,
tools: COACH_TOOL_DEFS,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
fallbackWindow: effectiveScope?.window,
});
result = loop.result;
workingProviderType = loop.workingProviderType;
toolTrace = loop.toolTrace;
totalTokensSpent = loop.totalTokens;
} else {
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
// v1.20.0 — the no-tools path still builds one assembled user turn (the
// transcript-flattening preserves the once-per-conversation snapshot
// trick + grounding exactly), so it ships as a single user message. The
// stable persona/grounding rides `system` and is now cache-eligible.
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
totalTokensSpent = result.tokensUsed ?? 0;
}
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
// the full reservation before surfacing the error frame.
@@ -555,10 +621,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// against the actual count NOW, before any empty/sentinel short-circuit, so
// an empty or sentinel-only reply still records its burned cost (the old
// post-hoc `recordSpend` ran only on the happy path, undercounting these).
// v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round
// (the loop accumulates them); the no-tools path sums to the single call.
await reconcileSpend(
userId,
reservation.reserved,
result.tokensUsed ?? 0,
totalTokensSpent,
reqDateKey,
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the conservative
@@ -681,6 +749,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
? { keyValues: sentinel.keyValues }
: {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
// v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran +
// whether each found data) so a reload can show "what I looked at" and the
// audit can replay grounding. Metadata only.
...(toolTrace.length > 0
? {
toolCalls: toolTrace.map((t) => ({
name: t.name,
present: t.present,
})),
}
: {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
@@ -720,7 +799,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// 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,
// v1.20.0 (F1) — the summed cost across every tool round, so the footer
// reflects the true turn cost on the tool path too.
tokensUsed: totalTokensSpent || null,
model: result.model ?? null,
});
@@ -733,14 +814,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
action: { name: "insights.coach.replied" },
meta: {
provider: workingProviderType,
tokens: result.tokensUsed ?? null,
// v1.20.0 (F1) — summed tokens across every tool round (the loop) or the
// single call (no-tools path), so the dashboards see the true turn cost.
tokens: totalTokensSpent,
promptVersion: PROMPT_VERSION,
conversationId: workingConversationId,
historyTurns: window.length,
// v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how
// many tools it fetched, so the dashboards can correlate the token delta
// with the new path vs the legacy snapshot path.
toolMode,
toolsCalled: toolTrace.length,
// v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the
// expensive prefix) vs the cheap pointer. Lets the dashboards confirm
// the once-per-conversation grounding cut the tokens-per-message.
snapshotSent: includeFullSnapshot,
// expensive prefix) vs the cheap pointer. On the tool path the snapshot
// never rides the prompt, so this is the legacy-path signal only.
snapshotSent: !toolMode && includeFullSnapshot,
promptChars: userPrompt.length,
// v1.7.0 — count of provenance metrics the snapshot surfaced
// this turn (a proxy for cluster breadth) so the dashboards can
@@ -793,7 +881,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
conversationId: workingConversationId,
messageId: assistantMessage.id,
usage: {
totalTokens: result.tokensUsed ?? null,
// v1.20.0 (F1) — summed across every tool round.
totalTokens: totalTokensSpent || null,
model: result.model ?? null,
},
}),