diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index df81aa0b..73fe1697 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -29,6 +29,7 @@ import { shouldLoadGeneralDailyPanchanga, } from "@/lib/consultation-entrypoint"; import { CreditRpcError } from "@/lib/consultation-billing"; +import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings"; import { resolveFeaturePricing } from "@/lib/feature-pricing"; import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; @@ -203,13 +204,22 @@ function chinaCalendarDate(now: Date) { return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); } -type Usage = { inputTokens?: number; outputTokens?: number }; +type Usage = { + inputTokens?: number; + outputTokens?: number; + cache?: ReturnType; +}; function mergeUsage(usages: Promise[]): Promise { - return Promise.all(usages).then((items) => items.reduce((total, item) => ({ - inputTokens: (total.inputTokens ?? 0) + (item.inputTokens ?? 0), - outputTokens: (total.outputTokens ?? 0) + (item.outputTokens ?? 0), - }), {} as Usage)); + return Promise.all(usages).then((items) => items.reduce((total, item) => { + const usage = item && typeof item === "object" ? item as Record : {}; + const cache = promptCacheUsage(usage); + return { + inputTokens: (total.inputTokens ?? 0) + (typeof usage.inputTokens === "number" ? usage.inputTokens : 0), + outputTokens: (total.outputTokens ?? 0) + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0), + cache: mergePromptCacheUsage([total.cache, cache]), + }; + }, {} as Usage)); } function shouldUseAgenticRuntime(user: { id: string; app_metadata?: Record }) { @@ -484,8 +494,10 @@ export async function POST(request: Request) { const usageStartedAt = Date.now(); async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) { const resolved = await usage; - const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0)); - const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0)); + const usageRecord = resolved as Record; + const cache = promptCacheUsage(usageRecord); + const inputTokens = Math.max(0, Math.trunc(typeof usageRecord.inputTokens === "number" ? usageRecord.inputTokens : 0)); + const outputTokens = Math.max(0, Math.trunc(typeof usageRecord.outputTokens === "number" ? usageRecord.outputTokens : 0)); return { eventKey: requestId, actualModelId: selectedModel.id, @@ -497,6 +509,7 @@ export async function POST(request: Request) { + outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0) ) / 1_000_000), durationMs: Date.now() - usageStartedAt, + ...(cache ? { metadata: { cache: { ...cache, hit: cache.readTokens > 0 } } } : {}), }; } @@ -674,7 +687,9 @@ export async function POST(request: Request) { // failure response must not depend on it succeeding. } }; + const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const baseMessages = [ + ...(cacheBoundary ? [cacheBoundary] : []), ...history.map((message) => message.role === "user" ? { role: "user" as const, content: message.text } : { role: "assistant" as const, content: message.text }), @@ -1033,7 +1048,9 @@ export async function POST(request: Request) { return await runAgenticConsultation(consultationMode, history, name, generalDailyContext); } if (!shouldRunBirthChartWorkflow(consultationMode)) { + const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const result = await getGeneralJyotishAgent(selectedModel).stream([ + ...(cacheBoundary ? [cacheBoundary] : []), { role: "user", content: [ @@ -1107,7 +1124,9 @@ export async function POST(request: Request) { ); const workflowReceipt = consultationWorkflowReceipt(workflowContext); + const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const result = await getLegacyJyotishAgent(selectedModel, workflowContext).stream([ + ...(cacheBoundary ? [cacheBoundary] : []), ...history.map((message) => message.role === "user" ? { role: "user" as const, content: message.text } : { role: "assistant" as const, content: message.text }), diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 1b17f1a6..b53351bc 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -594,6 +594,7 @@ export async function POST(request: Request) { modelConfigVersion: selectedModel.configVersion, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, + ...(usage.cache ? { metadata: { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } } : {}), costMicrousd: Math.round(( usage.inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0) + usage.outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0) diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index d21b0c1d..2241c11e 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -215,6 +215,7 @@ export async function POST(request: Request) { eventKey: "report.full", actualModelId: usage.actualModelId, modelConfigVersion: usage.modelConfigVersion, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, costMicrousd, durationMs: usage.durationMs, + ...(usage.cache ? { metadata: { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } } : {}), }); return settled.success; }, diff --git a/frontend/src/lib/agent-generation-settings.ts b/frontend/src/lib/agent-generation-settings.ts index f18e3602..9c696b09 100644 --- a/frontend/src/lib/agent-generation-settings.ts +++ b/frontend/src/lib/agent-generation-settings.ts @@ -17,6 +17,62 @@ export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS; export type ThinkingMode = "enabled" | "disabled"; export type ReasoningEffort = "low" | "medium" | "high"; +export type PromptCacheUsage = Readonly<{ + readTokens: number; + writeTokens: number; + noCacheTokens: number; +}>; + +function finiteTokenCount(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; +} + +/** Normalize AI SDK v6, legacy Mastra, and OpenAI raw cache usage shapes. */ +export function promptCacheUsage(value: unknown): PromptCacheUsage | null { + const record = value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + const details = record.inputTokenDetails && typeof record.inputTokenDetails === "object" + ? record.inputTokenDetails as Record + : {}; + const promptDetails = record.prompt_tokens_details && typeof record.prompt_tokens_details === "object" + ? record.prompt_tokens_details as Record + : {}; + const usage = { + readTokens: finiteTokenCount(details.cacheReadTokens) || finiteTokenCount(record.cachedInputTokens) || finiteTokenCount(promptDetails.cached_tokens), + writeTokens: finiteTokenCount(details.cacheWriteTokens) || finiteTokenCount(record.cacheCreationInputTokens), + noCacheTokens: finiteTokenCount(details.noCacheTokens), + }; + return usage.readTokens || usage.writeTokens || usage.noCacheTokens ? usage : null; +} + +export function mergePromptCacheUsage(usages: readonly (PromptCacheUsage | null | undefined)[]): PromptCacheUsage | null { + const total = usages.reduce<{ readTokens: number; writeTokens: number; noCacheTokens: number }>((sum, usage) => ({ + readTokens: sum.readTokens + (usage?.readTokens ?? 0), + writeTokens: sum.writeTokens + (usage?.writeTokens ?? 0), + noCacheTokens: sum.noCacheTokens + (usage?.noCacheTokens ?? 0), + }), { readTokens: 0, writeTokens: 0, noCacheTokens: 0 }); + return total.readTokens || total.writeTokens || total.noCacheTokens ? total : null; +} + +function modelProviderId(model: unknown): string | undefined { + return typeof model === "string" + ? model.split("/")[0] + : model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string" + ? model.providerId + : undefined; +} + +/** Adds only Anthropic's message-level cache marker; other providers keep current behavior. */ +export function cachedSystemMessage(content: string, model?: unknown) { + if (modelProviderId(model) !== "anthropic") return null; + return { + role: "system" as const, + content, + providerOptions: { anthropic: { cacheControl: { type: "ephemeral" as const } } }, + }; +} + export function agentOutputTokenBudget( thinking: ThinkingMode, options: { answerTokens?: number; thinkingTokens?: number } = {}, diff --git a/frontend/src/lib/consultation-billing.ts b/frontend/src/lib/consultation-billing.ts index 4b850e0f..e50fc779 100644 --- a/frontend/src/lib/consultation-billing.ts +++ b/frontend/src/lib/consultation-billing.ts @@ -43,6 +43,14 @@ export type ActualUsage = { outputTokens: number; costMicrousd: number; durationMs: number; + metadata?: { + cache?: { + readTokens: number; + writeTokens: number; + noCacheTokens: number; + hit: boolean; + }; + }; }; export class CreditRpcError extends Error { @@ -128,6 +136,7 @@ export async function completeUsage( outputTokens: Math.max(0, Math.trunc(usage.outputTokens)), costMicrousd: Math.max(0, Math.trunc(usage.costMicrousd)), durationMs: Math.max(0, Math.trunc(usage.durationMs)), + ...(usage.metadata ? { metadata: usage.metadata } : {}), }, }, settlementSchema); } diff --git a/frontend/src/lib/personal-report-billing.ts b/frontend/src/lib/personal-report-billing.ts index 8541ab19..ec96355f 100644 --- a/frontend/src/lib/personal-report-billing.ts +++ b/frontend/src/lib/personal-report-billing.ts @@ -1,4 +1,5 @@ import type { UsageAuthorization } from "./consultation-billing"; +import type { PromptCacheUsage } from "./agent-generation-settings"; export type ReportBillingUsage = Readonly<{ actualModelId: string; @@ -6,6 +7,7 @@ export type ReportBillingUsage = Readonly<{ inputTokens: number; outputTokens: number; durationMs: number; + cache?: PromptCacheUsage | null; }>; export type ReportBillingPort = Readonly<{ diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 78fe2c4d..2b94bbba 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -2132,7 +2132,7 @@ export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readon export type ReportSchemaInnerReason = string; export type GeneratePersonalReportResult = Readonly< - | { status: "ready"; document: ReportDocumentV2; evidenceHash: string; usage?: Readonly<{ inputTokens: number; outputTokens: number; actualModelId?: string; modelConfigVersion?: number }> } + | { status: "ready"; document: ReportDocumentV2; evidenceHash: string; usage?: Readonly<{ inputTokens: number; outputTokens: number; cache?: import("./agent-generation-settings").PromptCacheUsage | null; actualModelId?: string; modelConfigVersion?: number }> } | { status: "failed"; failureCode: "report_schema_invalid"; innerReason: ReportSchemaInnerReason } | { status: "failed"; failureCode: "report_guard_rejected" } >; diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 877fa6c9..63b7d7c1 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -503,6 +503,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise 0 } } } : {}), }); return settled.success; }, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 56e5c80b..aeb0f4e6 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -25,7 +25,7 @@ import { } from "./tool-service"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status"; import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; -import { agentGenerationSettings } from "../../agent-generation-settings.ts"; +import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts"; import { toAgentModelFinishReason } from "../../agent-observability.ts"; import { decideFromDossier } from "./decision-from-dossier"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; @@ -68,7 +68,12 @@ import { export type V9RunBilling = Readonly<{ reserve(): Promise<{ success: boolean; reason?: string; status: number }>; - complete(input: { inputTokens: number; outputTokens: number; durationMs: number }): Promise; + complete(input: { + inputTokens: number; + outputTokens: number; + durationMs: number; + cache?: ReturnType; + }): Promise; release(): Promise; }>; @@ -115,7 +120,7 @@ export type V9AgentRunResult = Readonly<{ }>; type AttemptStatus = "completed" | "failed" | "retryable"; -type Usage = Readonly<{ inputTokens: number; outputTokens: number }>; +type Usage = Readonly<{ inputTokens: number; outputTokens: number; cache?: ReturnType }>; type AttemptOutcome = Readonly<{ ok: boolean; status: AttemptStatus; @@ -618,7 +623,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise; + providerOptions?: Record; prepareStep: (input: { stepNumber: number }) => { activeTools: string[]; toolChoice: "auto"; @@ -637,7 +642,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise; - totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>; + totalUsage?: Promise>; }>; }).stream(messages, { maxSteps, @@ -889,10 +894,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => { let inputTokens = 0; let outputTokens = 0; + let cache: ReturnType = null; try { const raw = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 })); - inputTokens = Math.max(0, Math.trunc(raw.inputTokens ?? 0)); - outputTokens = Math.max(0, Math.trunc(raw.outputTokens ?? 0)); + inputTokens = Math.max(0, Math.trunc(typeof raw.inputTokens === "number" ? raw.inputTokens : 0)); + outputTokens = Math.max(0, Math.trunc(typeof raw.outputTokens === "number" ? raw.outputTokens : 0)); + cache = promptCacheUsage(raw); } catch { // Timeout/abort can leave provider usage unread. } @@ -902,7 +909,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 1 - ? ["【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"] - : []), - ].join("\n\n"), - }; + const bootstrapContent = [ + "【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。第一步必须调用 rectification-read-case。", + skillInstructions, + ...(attempt > 1 + ? ["【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"] + : []), + ].join("\n\n"); + const bootstrap = cachedSystemMessage(bootstrapContent, options.generationModel) + ?? { role: "system" as const, content: bootstrapContent }; if (options.action === "opening") { return [bootstrap, { role: "user", diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index 65cb60a0..8a63ca85 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -2,7 +2,7 @@ import { Agent } from "@mastra/core/agent"; import { z } from "zod"; import type { ResolvedLanguageModel } from "./model"; import type { PersonalReportSectionPlan, ReportSectionPlanEntry } from "@/lib/personal-report-plan"; -import { agentGenerationSettings } from "@/lib/agent-generation-settings"; +import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage, agentGenerationSettings } from "@/lib/agent-generation-settings"; import type { ReportChartHouse, ReportDashaPeriod, @@ -175,13 +175,15 @@ async function readUsage(value: unknown) { const numberOrNull = (key: string) => ( typeof record[key] === "number" && Number.isFinite(record[key]) ? record[key] as number : null ); + const cache = promptCacheUsage(record); return { inputTokens: numberOrNull("inputTokens"), outputTokens: numberOrNull("outputTokens"), totalTokens: numberOrNull("totalTokens"), + cache, }; } catch { - return { inputTokens: null, outputTokens: null, totalTokens: null }; + return { inputTokens: null, outputTokens: null, totalTokens: null, cache: null }; } } @@ -234,7 +236,7 @@ export type ReportAgentSummaryOptions = Readonly<{ maxOutputTokens?: number; }>; -export type ReportAgentUsage = Readonly<{ inputTokens: number; outputTokens: number }>; +export type ReportAgentUsage = Readonly<{ inputTokens: number; outputTokens: number; cache?: ReturnType }>; export type ReportAgentPort = Readonly<{ modelId: string; @@ -290,9 +292,11 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA let usageTotals: ReportAgentUsage = { inputTokens: 0, outputTokens: 0 }; const recordUsage = async (usage: unknown) => { const tokens = await readUsage(usage); + const cache = mergePromptCacheUsage([usageTotals.cache, tokens.cache]); usageTotals = { inputTokens: usageTotals.inputTokens + Math.max(0, tokens.inputTokens ?? 0), outputTokens: usageTotals.outputTokens + Math.max(0, tokens.outputTokens ?? 0), + ...(cache ? { cache } : {}), }; }; @@ -308,8 +312,12 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA const prompt = input.prompt; let repairAttempted = false; let attemptReturned = false; + const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本次报告输入。", model.model); const runOnce = (content: string) => agent.generate( - [{ role: "user", content }], + [ + ...(cacheBoundary ? [cacheBoundary] : []), + { role: "user", content }, + ], { abortSignal: signal, structuredOutput: { schema: input.schema, jsonPromptInjection: "inline" as const }, diff --git a/frontend/tests/agent-generation-settings.test.ts b/frontend/tests/agent-generation-settings.test.ts new file mode 100644 index 00000000..e0f4b38a --- /dev/null +++ b/frontend/tests/agent-generation-settings.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + cachedSystemMessage, + mergePromptCacheUsage, + promptCacheUsage, +} from "../src/lib/agent-generation-settings.ts"; + +test("normalizes AI SDK cache token details", () => { + assert.deepEqual(promptCacheUsage({ + inputTokenDetails: { cacheReadTokens: 120, cacheWriteTokens: 30, noCacheTokens: 10 }, + }), { readTokens: 120, writeTokens: 30, noCacheTokens: 10 }); +}); + +test("normalizes OpenAI and legacy cache usage", () => { + assert.deepEqual(promptCacheUsage({ prompt_tokens_details: { cached_tokens: 45 } }), { + readTokens: 45, + writeTokens: 0, + noCacheTokens: 0, + }); + assert.deepEqual(promptCacheUsage({ cachedInputTokens: 12, cacheCreationInputTokens: 8 }), { + readTokens: 12, + writeTokens: 8, + noCacheTokens: 0, + }); +}); + +test("merges cache usage and only reports a hit when reads exist", () => { + assert.deepEqual(mergePromptCacheUsage([ + { readTokens: 4, writeTokens: 5, noCacheTokens: 6 }, + { readTokens: 7, writeTokens: 0, noCacheTokens: 1 }, + ]), { readTokens: 11, writeTokens: 5, noCacheTokens: 7 }); + assert.equal(promptCacheUsage({ inputTokenDetails: { cacheWriteTokens: 5 } })?.readTokens, 0); + assert.equal(promptCacheUsage({ inputTokenDetails: { cacheReadTokens: 5 } })?.readTokens, 5); +}); + +test("adds an Anthropic cache boundary and safely degrades for other providers", () => { + assert.deepEqual(cachedSystemMessage("stable", "anthropic/claude-sonnet"), { + role: "system", + content: "stable", + providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, + }); + assert.equal(cachedSystemMessage("stable", "openai/gpt-5"), null); + assert.equal(cachedSystemMessage("stable", { providerId: "deepseek" }), null); +});