补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。

给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:47:51 +08:00
co-authored by Cursor
parent d0274afb80
commit bb4f934b3d
13 changed files with 738 additions and 50 deletions
+139 -20
View File
@@ -57,8 +57,33 @@ export type TokenUsage = {
cachedTokens?: number;
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
cacheMissTokens?: number;
/** 上游若单独返回缓存写入 token(如 cache_creation_input_tokens) */
cacheWriteTokens?: number;
};
/** 用量行上的调度信息。不包含提示词或产物正文。 */
export type DictateUsageStep = "select" | "fill" | "fill-retry" | "reply" | "play";
export type UsageDetail = {
turnId?: string;
step?: DictateUsageStep;
retryIndex?: number;
segmentChars?: {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
plannedTags?: string[];
succeededTags?: string[];
failedTags?: string[];
finish?: string;
};
/** complete 返回后由统计层写入 recordId,供补记成功或失败的 tag。 */
export type UsageSink = { recordId?: string };
export type CompleteResult = {
content: string;
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
@@ -84,6 +109,14 @@ export type CompleteOptions = {
label?: string;
/** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal;
/**
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
*/
sessionId?: string;
/** 统计补充。不进上游请求体。 */
usageDetail?: UsageDetail;
usageSink?: UsageSink;
};
export type ToolChoice =
@@ -132,6 +165,33 @@ export type LlmProvider = {
};
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
const OPENROUTER_SESSION_ID_MAX = 256;
/** OpenRouter 用 `session_id` 做粘滞路由;其它 OpenAI 兼容渠道不认识这个字段。 */
export function isOpenRouterBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) return false;
try {
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
const host = new URL(withProtocol).hostname.toLowerCase();
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
} catch {
return false;
}
}
/** 合法则返回要写入请求体的 session_id;非 OpenRouter 或空 id 则不写。 */
export function openRouterSessionId(
baseUrl: string,
sessionId: string | undefined,
): string | undefined {
if (!isOpenRouterBaseUrl(baseUrl)) return undefined;
const id = sessionId?.trim();
if (!id) return undefined;
return id.slice(0, OPENROUTER_SESSION_ID_MAX);
}
function messagesMentionJson(messages: ChatMessage[]): boolean {
for (const message of messages) {
@@ -210,6 +270,9 @@ export function buildRequestBody(
body.stream_options = { include_usage: true };
}
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
if (sessionId) body.session_id = sessionId;
return body;
}
@@ -232,6 +295,20 @@ function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
}
function readCacheWriteTokens(u: Record<string, unknown>): number | undefined {
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
if (details && typeof details === "object") {
const row = details as Record<string, unknown>;
const nested = readFiniteNumber(row.cache_write_tokens ?? row.cacheWriteTokens);
if (nested != null) return nested;
}
return readFiniteNumber(
u.cache_creation_input_tokens ??
u.cache_write_input_tokens ??
u.cacheWriteTokens,
);
}
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
export function parseUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined;
@@ -243,6 +320,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
const cachedTokens = readCachedTokens(u);
const cacheMissTokens = readCacheMissTokens(u);
const cacheWriteTokens = readCacheWriteTokens(u);
return {
promptTokens: Number.isFinite(prompt) ? prompt : 0,
@@ -253,22 +331,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
(Number.isFinite(completion) ? completion : 0),
...(cachedTokens != null ? { cachedTokens } : {}),
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
...(cacheWriteTokens != null ? { cacheWriteTokens } : {}),
};
}
function extractMessageParts(message: Record<string, unknown> | undefined): {
function stringifyToolArguments(value: unknown): string {
if (typeof value === "string" && value.trim()) return value;
if (value && typeof value === "object") return JSON.stringify(value);
return "{}";
}
function toolCallFromRow(row: Record<string, unknown>, index: number): ParsedToolCall | null {
const fn = row.function;
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : null;
const source = nested ?? row;
const name = typeof source.name === "string" ? source.name.trim() : "";
if (!name) return null;
const rawId = typeof row.id === "string" ? row.id.trim() : "";
return {
id: rawId || `call-${index}`,
name,
arguments: stringifyToolArguments(
source.arguments ?? source.args ?? row.arguments ?? row.args ?? row.input,
),
};
}
function embeddedContent(raw: unknown): { text: string | null; calls: ParsedToolCall[] } {
if (typeof raw === "string") return { text: raw.trim() || null, calls: [] };
if (!Array.isArray(raw)) return { text: raw == null ? null : "", calls: [] };
const texts: string[] = [];
const calls: ParsedToolCall[] = [];
raw.forEach((part, index) => {
if (!part || typeof part !== "object") return;
const row = part as Record<string, unknown>;
if (typeof row.text === "string" && row.text.trim()) texts.push(row.text.trim());
const fn = row.functionCall ?? row.function_call;
if (row.type === "tool_use" || (fn && typeof fn === "object")) {
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : row;
const parsed = toolCallFromRow(
{
id: row.id,
name: nested.name ?? row.name,
arguments: nested.args ?? nested.arguments ?? row.input ?? row.arguments,
},
index,
);
if (parsed) calls.push(parsed);
}
});
return { text: texts.join("\n").trim() || null, calls };
}
/** 从一条 assistant message 取出正文和工具调用。对象参数、缺 id、content 片段里的调用都收。 */
export function extractMessageParts(message: Record<string, unknown> | undefined): {
content: string | null;
reasoning?: string;
toolCalls: ParsedToolCall[];
} {
if (!message) return { content: "", toolCalls: [] };
const rawContent = message.content;
const content =
typeof rawContent === "string"
? rawContent.trim() || null
: rawContent == null
? null
: "";
const embedded = embeddedContent(message.content);
const reasoningRaw =
(typeof message.reasoning_content === "string" &&
message.reasoning_content.trim()) ||
@@ -280,21 +402,18 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
const toolCalls: ParsedToolCall[] = [];
const rawCalls = message.tool_calls;
if (Array.isArray(rawCalls)) {
for (const call of rawCalls) {
if (!call || typeof call !== "object") continue;
const c = call as Record<string, unknown>;
const fn = c.function;
if (!fn || typeof fn !== "object") continue;
const f = fn as Record<string, unknown>;
const name = typeof f.name === "string" ? f.name : "";
const id = typeof c.id === "string" ? c.id : "";
const args =
typeof f.arguments === "string" ? f.arguments : "{}";
if (name && id) {
toolCalls.push({ id, name, arguments: args });
rawCalls.forEach((call, index) => {
if (!call || typeof call !== "object") return;
const parsed = toolCallFromRow(call as Record<string, unknown>, index);
if (parsed) toolCalls.push(parsed);
});
}
for (const call of embedded.calls) {
if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) {
toolCalls.push(call);
}
}
const content = embedded.text;
if (toolCalls.length > 0) {
return { content, reasoning: reasoning || undefined, toolCalls };
+181
View File
@@ -0,0 +1,181 @@
import { createHash } from "node:crypto";
import type { ToolDefinition } from "./client.js";
/** 一段请求的位置标签。只存标签、字数和哈希,不存正文。 */
export type ContextSegment = {
tag: string;
chars: number;
hash: string;
};
/** 挂在一条回复上的各次请求轮廓,选题和填写都保留。 */
export type RequestOutline = {
at: string;
caller: string;
step?: string;
turnId?: string;
model?: string;
promptTokens?: number;
completionTokens?: number;
cachedTokens?: number;
cacheMissTokens?: number;
cacheWriteTokens?: number;
segments: ContextSegment[];
};
/** 和同会话上一条请求比,前缀从哪里开始对不上。 */
export type CacheBreakInfo = {
/** 本窗口里没有更早的同会话请求 */
cold: boolean;
/** -1:本条在上一请求的前缀里整段对上 */
breakAt: number;
tag?: string;
/** 断点之前、和上一请求逐段相同的段数 */
matched: number;
};
const SPLIT =
/(?:^|\n)(#{2,4} [^\n]+|最后一轮用户输入|【用户】|【助手】)/g;
function hashText(text: string): string {
return createHash("sha256").update(text).digest("hex").slice(0, 12);
}
function segment(tag: string, text: string): ContextSegment {
return { tag, chars: text.length, hash: hashText(text) };
}
function headingTag(title: string): string {
const beforeOrder = title.split("〔")[0]?.trim() ?? title;
const beforeParen = beforeOrder.split(/(|\(/)[0]?.trim() ?? beforeOrder;
const beforeAt = beforeParen.split(" @ ")[0]?.trim() ?? beforeParen;
const tag = (beforeAt || title).trim();
return tag.length > 48 ? `${tag.slice(0, 48)}…` : tag;
}
function collectMarks(content: string): Array<{ index: number; tag: string }> {
const marks: Array<{ index: number; tag: string }> = [];
let section = "";
for (const match of content.matchAll(SPLIT)) {
const raw = match[1] ?? "";
const start = (match.index ?? 0) + (match[0].startsWith("\n") ? 1 : 0);
if (raw.startsWith("#")) {
const level = /^#+/.exec(raw)?.[0].length ?? 2;
const title = raw.replace(/^#+\s*/, "");
const tag = headingTag(title);
if (level === 2) section = tag;
marks.push({ index: start, tag });
continue;
}
if (raw === "最后一轮用户输入" && section.startsWith("本轮")) {
marks.push({ index: start, tag: "最后一轮用户输入" });
continue;
}
if (
(raw === "【用户】" || raw === "【助手】") &&
section.startsWith("更早的对话")
) {
marks.push({
index: start,
tag: raw === "【用户】" ? "更早·用户" : "更早·助手",
});
}
}
return marks;
}
/** 把一块正文切成有序标签。前缀里的标题与更早对话轮次分开,方便对上断点。 */
export function outlineBlock(fallbackTag: string, content: string): ContextSegment[] {
if (!content) return [];
const marks = collectMarks(content);
if (marks.length === 0) return [segment(fallbackTag, content)];
const out: ContextSegment[] = [];
if (marks[0]!.index > 0) {
const head = content.slice(0, marks[0]!.index);
if (head.trim()) out.push(segment(fallbackTag, head));
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1]!.index : content.length;
out.push(segment(marks[i]!.tag, content.slice(marks[i]!.index, end)));
}
return out;
}
function roleTag(role: string): string {
if (role === "system") return "规则";
if (role === "user") return "用户";
if (role === "assistant") return "助手";
if (role === "tool") return "工具结果";
return role || "消息";
}
/** 按实际送出的消息切段。工具定义若有,放在正文之前。 */
export function outlineRequest(
messages: Array<{ role: string; content?: string | null }>,
tools?: ToolDefinition[],
): ContextSegment[] {
const out: ContextSegment[] = [];
if (tools?.length) {
out.push(segment("工具", JSON.stringify(tools)));
}
for (const message of messages) {
const content = typeof message.content === "string" ? message.content : "";
out.push(...outlineBlock(roleTag(message.role), content));
}
return out;
}
/**
* 返回下一请求里第一段对不上的下标。
* 下一请求比上一请求更短、但已有段全部相同,视为整段接上(-1)。
*/
export function firstCacheBreak(
prev: Array<Pick<ContextSegment, "tag" | "hash">>,
next: Array<Pick<ContextSegment, "tag" | "hash">>,
): number {
const shared = Math.min(prev.length, next.length);
for (let i = 0; i < shared; i++) {
if (prev[i]!.tag !== next[i]!.tag || prev[i]!.hash !== next[i]!.hash) return i;
}
if (next.length > prev.length) return prev.length;
return -1;
}
export function annotateCacheBreaks<
T extends { id: string; at: string; sessionId?: string; segments?: ContextSegment[] },
>(records: T[]): Map<string, CacheBreakInfo> {
const bySession = new Map<string, T[]>();
for (const record of records) {
const key = record.sessionId?.trim() || `__none__:${record.id}`;
const list = bySession.get(key) ?? [];
list.push(record);
bySession.set(key, list);
}
const marks = new Map<string, CacheBreakInfo>();
for (const list of bySession.values()) {
list.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
let prev: ContextSegment[] | undefined;
for (const record of list) {
const segments = record.segments ?? [];
if (!prev) {
marks.set(record.id, {
cold: true,
breakAt: segments.length ? 0 : -1,
tag: segments[0]?.tag,
matched: 0,
});
prev = segments;
continue;
}
const breakAt = firstCacheBreak(prev, segments);
marks.set(record.id, {
cold: false,
breakAt,
tag: breakAt >= 0 ? segments[breakAt]?.tag : undefined,
matched: breakAt < 0 ? segments.length : breakAt,
});
prev = segments;
}
}
return marks;
}
+6 -3
View File
@@ -29,6 +29,9 @@ function applyToolCallDelta(
const f = fn as Record<string, unknown>;
if (typeof f.name === "string") entry.name = f.name;
if (typeof f.arguments === "string") entry.arguments += f.arguments;
else if (f.arguments && typeof f.arguments === "object" && !entry.arguments) {
entry.arguments = JSON.stringify(f.arguments);
}
}
acc.set(index, entry);
}
@@ -36,10 +39,10 @@ function applyToolCallDelta(
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
const out: ParsedToolCall[] = [];
for (const [, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) {
for (const [index, entry] of [...acc.entries()].sort((a, b) => a[0] - b[0])) {
const name = entry.name?.trim();
const id = entry.id?.trim();
if (!name || !id) continue;
if (!name) continue;
const id = entry.id?.trim() || `call-${index}`;
out.push({ id, name, arguments: entry.arguments || "{}" });
}
return out;
+97 -13
View File
@@ -6,7 +6,15 @@ import type {
LlmProvider,
StreamCallbacks,
TokenUsage,
ToolDefinition,
UsageDetail,
UsageSink,
} from "./client.js";
import {
outlineRequest,
type ContextSegment,
type RequestOutline,
} from "./context-outline.js";
import {
recordTokenUsage,
toMessageTokenUsage,
@@ -28,6 +36,9 @@ export type LlmTrackingContext = {
pendingReasoning?: string;
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
pendingContextTrace?: LlmContextTrace;
/** 本条回复之前的各次请求轮廓;查看时选题和填写都在 */
pendingOutlines?: RequestOutline[];
lastSegments?: ContextSegment[];
};
function capturePendingTrace(
@@ -41,12 +52,16 @@ function capturePendingTrace(
caller: string | undefined,
model?: string,
generation?: CompleteOptions["generation"],
tools?: ToolDefinition[],
): void {
const segments = outlineRequest(messages, tools);
ctx.lastSegments = segments;
ctx.pendingContextTrace = buildContextTrace({
caller: caller ?? "unknown",
messages,
model,
generation,
segments,
});
}
@@ -55,9 +70,13 @@ function recordUsage(
caller: string | undefined,
model: string | undefined,
usage: TokenUsage | undefined,
meta?: { detail?: UsageDetail; latencyMs?: number; sink?: UsageSink },
): void {
if (!usage) return;
const record = recordTokenUsage({
const segments = ctx.lastSegments;
ctx.lastSegments = undefined;
const detail = meta?.detail;
const record = usage
? recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
@@ -69,8 +88,36 @@ function recordUsage(
totalTokens: usage.totalTokens,
cachedTokens: usage.cachedTokens,
cacheMissTokens: usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
cacheWriteTokens: usage.cacheWriteTokens,
turnId: detail?.turnId,
step: detail?.step,
retryIndex: detail?.retryIndex,
segmentChars: detail?.segmentChars,
plannedTags: detail?.plannedTags,
succeededTags: detail?.succeededTags,
failedTags: detail?.failedTags,
finish: detail?.finish,
latencyMs: meta?.latencyMs,
segments,
})
: undefined;
if (record && meta?.sink) meta.sink.recordId = record.id;
if (record) ctx.pendingUsage = toMessageTokenUsage(record);
if (!segments?.length && !record) return;
const outline: RequestOutline = {
at: record?.at ?? new Date().toISOString(),
caller: record?.caller ?? caller ?? "unknown",
step: detail?.step,
turnId: detail?.turnId,
model: record?.model ?? model,
promptTokens: record?.promptTokens,
completionTokens: record?.completionTokens,
cachedTokens: record?.cachedTokens,
cacheMissTokens: record?.cacheMissTokens,
cacheWriteTokens: record?.cacheWriteTokens,
segments: segments ?? [],
};
ctx.pendingOutlines = [...(ctx.pendingOutlines ?? []), outline];
}
export class TokenTrackingProvider implements LlmProvider {
@@ -79,6 +126,14 @@ export class TokenTrackingProvider implements LlmProvider {
private readonly getContext: () => LlmTrackingContext,
) {}
/** 调用方未指定时,用本局会话 id,供 OpenRouter 粘滞路由。 */
private withSession<T extends CompleteOptions>(options?: T): T {
const sessionId = this.getContext().sessionId?.trim();
if (!sessionId) return (options ?? {}) as T;
if (options?.sessionId?.trim()) return options;
return { ...options, sessionId } as T;
}
async complete(
messages: Parameters<LlmProvider["complete"]>[0],
options?: CompleteOptions,
@@ -91,7 +146,8 @@ export class TokenTrackingProvider implements LlmProvider {
messages,
},
async () => {
const result = await this.inner.complete(messages, options);
const started = Date.now();
const result = await this.inner.complete(messages, this.withSession(options));
const ctx = this.getContext();
capturePendingTrace(
ctx,
@@ -100,7 +156,11 @@ export class TokenTrackingProvider implements LlmProvider {
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
recordUsage(ctx, options?.caller, result.model, result.usage, {
detail: options?.usageDetail,
latencyMs: Date.now() - started,
sink: options?.usageSink,
});
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
@@ -130,7 +190,8 @@ export class TokenTrackingProvider implements LlmProvider {
},
async () => {
let reasoningBuf = "";
const result = await inner.completeStream!(messages, options, {
const started = Date.now();
const result = await inner.completeStream!(messages, this.withSession(options), {
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
@@ -147,7 +208,11 @@ export class TokenTrackingProvider implements LlmProvider {
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
recordUsage(ctx, options?.caller, result.model, result.usage, {
detail: options?.usageDetail,
latencyMs: Date.now() - started,
sink: options?.usageSink,
});
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
@@ -169,7 +234,11 @@ export class TokenTrackingProvider implements LlmProvider {
tools: options.tools,
},
async () => {
const result = await this.inner.completeWithTools(messages, options);
const started = Date.now();
const result = await this.inner.completeWithTools(
messages,
this.withSession(options),
);
const ctx = this.getContext();
capturePendingTrace(
ctx,
@@ -177,8 +246,13 @@ export class TokenTrackingProvider implements LlmProvider {
options.caller,
result.model,
options.generation,
options.tools,
);
recordUsage(ctx, options.caller, result.model, result.usage);
recordUsage(ctx, options.caller, result.model, result.usage, {
detail: options.usageDetail,
latencyMs: Date.now() - started,
sink: options.usageSink,
});
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
@@ -206,7 +280,11 @@ export class TokenTrackingProvider implements LlmProvider {
},
async () => {
let reasoningBuf = "";
const result = await inner.completeWithToolsStream!(messages, options, {
const started = Date.now();
const result = await inner.completeWithToolsStream!(
messages,
this.withSession(options),
{
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
@@ -214,7 +292,8 @@ export class TokenTrackingProvider implements LlmProvider {
callbacks.onReasoningDelta?.(delta);
},
onContentDelta: callbacks.onContentDelta,
});
},
);
const ctx = this.getContext();
capturePendingTrace(
ctx,
@@ -222,8 +301,13 @@ export class TokenTrackingProvider implements LlmProvider {
options.caller,
result.model,
options.generation,
options.tools,
);
recordUsage(ctx, options.caller, result.model, result.usage);
recordUsage(ctx, options.caller, result.model, result.usage, {
detail: options.usageDetail,
latencyMs: Date.now() - started,
sink: options.usageSink,
});
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
+3
View File
@@ -98,6 +98,8 @@ const TOOL_LABEL: Record<string, string> = {
review_blackboard: "审黑板",
finish: "结束",
insert: "写入产物",
reply_user: "回复用户",
select_nodes: "选定节点",
write_product: "写入产物",
delete: "删除产物",
declare_variable: "声明变量",
@@ -214,6 +216,7 @@ export function labelCaller(caller: string | undefined): string {
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
if (who === "intake_extract") return "需求抽取";
if (who === "dictate_agent") return "对话落盘";
if (who.startsWith("listen_serial_")) return "听书连载";
if (who === "preset-probe") return "预设探测";
if (who.startsWith("model-compare:")) {
return `模型对比 ${who.slice("model-compare:".length)}`;
+3
View File
@@ -14,6 +14,7 @@ import {
SUGGESTED_PRICING_DOCS,
} from "../stats/pricing.js";
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
import { annotateCacheBreaks } from "../llm/context-outline.js";
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
@@ -77,6 +78,7 @@ export async function handleStatsApi(
const records = readRecords({ ...query, limit: query.limit ?? 100 });
const catalog = resolveRateCatalog();
const cost = buildCostReport(summaryRecords, catalog);
const breaks = annotateCacheBreaks(summaryRecords);
json(res, 200, {
summary: {
...summarizeRecords(summaryRecords),
@@ -92,6 +94,7 @@ export async function handleStatsApi(
...r,
estimatedCost: priced.cost,
unmatchedRate: priced.unmatched,
cacheBreak: breaks.get(r.id) ?? null,
};
}),
});
+64 -1
View File
@@ -1,7 +1,8 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
import type { ContextSegment } from "../llm/context-outline.js";
export type TokenUsageRecord = {
id: string;
@@ -17,7 +18,25 @@ export type TokenUsageRecord = {
totalTokens: number;
cachedTokens?: number;
cacheMissTokens?: number;
cacheWriteTokens?: number;
messageId?: string;
turnId?: string;
step?: string;
retryIndex?: number;
segmentChars?: {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
/** 有序标签与哈希,不含正文 */
segments?: ContextSegment[];
plannedTags?: string[];
succeededTags?: string[];
failedTags?: string[];
finish?: string;
latencyMs?: number;
};
/** Token usage attached to a chat message in SessionView */
@@ -97,7 +116,18 @@ export function recordTokenUsage(
totalTokens: partial.totalTokens,
cachedTokens: partial.cachedTokens,
cacheMissTokens: partial.cacheMissTokens,
cacheWriteTokens: partial.cacheWriteTokens,
messageId: partial.messageId,
turnId: partial.turnId,
step: partial.step,
retryIndex: partial.retryIndex,
segmentChars: partial.segmentChars,
segments: partial.segments,
plannedTags: partial.plannedTags,
succeededTags: partial.succeededTags,
failedTags: partial.failedTags,
finish: partial.finish,
latencyMs: partial.latencyMs,
};
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
return record;
@@ -238,6 +268,39 @@ export function getSessionTokenTotals(sessionId: string): {
};
}
export function patchTokenUsage(
id: string,
patch: Partial<
Pick<
TokenUsageRecord,
"plannedTags" | "succeededTags" | "failedTags" | "finish"
>
>,
): void {
if (!id) return;
const files = listStatFiles();
for (let i = files.length - 1; i >= 0; i--) {
const file = files[i]!;
const lines = readFileSync(file, "utf8").split(/\r?\n/);
let changed = false;
const next = lines.map((line) => {
if (!line.trim()) return line;
try {
const record = JSON.parse(line) as TokenUsageRecord;
if (record.id !== id) return line;
changed = true;
return JSON.stringify({ ...record, ...patch });
} catch {
return line;
}
});
if (changed) {
writeFileSync(file, next.join("\n"), "utf8");
return;
}
}
}
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
return {
totalTokens: record.totalTokens,
+5
View File
@@ -1,3 +1,4 @@
import type { ContextSegment } from "../llm/context-outline.js";
import type { GenerationParameters } from "./preset.js";
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
@@ -11,6 +12,8 @@ export type LlmContextTrace = {
caller: string;
createdAt: string;
messages: LlmContextMessage[];
/** 有序标签,供查看排列和缓存断点;不含正文 */
segments?: ContextSegment[];
/** 便于列表展示 */
charCount: number;
model?: string;
@@ -57,6 +60,7 @@ export function buildContextTrace(params: {
}>;
model?: string;
generation?: GenerationParameters;
segments?: ContextSegment[];
}): LlmContextTrace {
const messages = snapshotRequestMessages(params.messages);
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
@@ -64,6 +68,7 @@ export function buildContextTrace(params: {
caller: params.caller,
createdAt: new Date().toISOString(),
messages,
...(params.segments?.length ? { segments: params.segments } : {}),
charCount,
model: params.model,
...(params.generation && Object.keys(params.generation).length
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
annotateCacheBreaks,
firstCacheBreak,
outlineRequest,
} from "../src/llm/context-outline.js";
const sample = [
"规则开头",
"",
"## 写入规则",
"固定规则",
"",
"## 更早的对话",
"",
"【用户】",
"第一句",
"",
"【助手】",
"回过",
"",
"## 当前产物(已写入黑板)",
"",
"### 设计.氛围 〔order=0〕",
"雨",
"",
"### 设计.角色 〔order=1〕",
"甲",
"",
"## 本轮注入",
"",
"### topology @ 3",
"#### task",
"做一件事",
"",
"## 本轮",
"",
"方案进度",
"",
"最后一轮用户输入",
"再改一下",
].join("\n");
describe("outlineRequest", () => {
it("按标题、对话轮次和最后一轮输入切开,不留下正文", () => {
const segments = outlineRequest([
{ role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) },
{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) },
]);
expect(segments.map((item) => item.tag)).toEqual([
"规则",
"写入规则",
"更早的对话",
"更早·用户",
"更早·助手",
"当前产物",
"设计.氛围",
"设计.角色",
"本轮注入",
"topology",
"task",
"本轮",
"最后一轮用户输入",
]);
expect(JSON.stringify(segments)).not.toContain("再改一下");
expect(JSON.stringify(segments)).not.toContain("雨");
const again = outlineRequest([
{ role: "system", content: sample.slice(0, sample.indexOf("## 更早的对话")) },
{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) },
]);
expect(again.map((item) => item.hash)).toEqual(segments.map((item) => item.hash));
});
it("工具定义放在正文之前", () => {
const segments = outlineRequest(
[{ role: "user", content: "你好" }],
[
{
type: "function",
function: { name: "insert", description: "写", parameters: {} },
},
],
);
expect(segments.map((item) => item.tag)).toEqual(["工具", "用户"]);
});
});
describe("firstCacheBreak", () => {
it("标出第一处标签或哈希不同的位置", () => {
const prev = outlineRequest([{ role: "user", content: sample.slice(sample.indexOf("## 更早的对话")) }]);
const changed = sample.replace("雨", "雪");
const next = outlineRequest([{ role: "user", content: changed.slice(changed.indexOf("## 更早的对话")) }]);
const index = firstCacheBreak(prev, next);
expect(next[index]?.tag).toBe("设计.氛围");
expect(firstCacheBreak(prev, prev)).toBe(-1);
expect(firstCacheBreak(prev, prev.slice(0, 3))).toBe(-1);
expect(firstCacheBreak(prev.slice(0, 3), prev)).toBe(3);
});
});
describe("annotateCacheBreaks", () => {
it("按同会话时间比较,窗口里第一条算冷启动", () => {
const a = outlineRequest([{ role: "user", content: "第一" }]);
const b = outlineRequest([{ role: "user", content: "第二" }]);
const marks = annotateCacheBreaks([
{ id: "2", at: "2026-10-02T02:00:00.000Z", sessionId: "s", segments: b },
{ id: "1", at: "2026-10-02T01:00:00.000Z", sessionId: "s", segments: a },
{ id: "9", at: "2026-10-02T03:00:00.000Z", sessionId: "other", segments: a },
]);
expect(marks.get("1")).toMatchObject({ cold: true, matched: 0 });
expect(marks.get("2")).toMatchObject({ cold: false, breakAt: 0, tag: "用户", matched: 0 });
expect(marks.get("9")?.cold).toBe(true);
});
});
+14 -1
View File
@@ -44,6 +44,16 @@ describe("parseUsage", () => {
expect(usage?.cacheMissTokens).toBe(100);
});
it("parses cache write tokens when the provider returns them", () => {
const usage = parseUsage({
prompt_tokens: 100,
completion_tokens: 10,
total_tokens: 110,
cache_creation_input_tokens: 40,
});
expect(usage?.cacheWriteTokens).toBe(40);
});
it("returns undefined for empty usage", () => {
expect(parseUsage(null)).toBeUndefined();
expect(parseUsage({})).toBeUndefined();
@@ -52,8 +62,10 @@ describe("parseUsage", () => {
describe("TokenTrackingProvider", () => {
it("stores pending usage with cache fields on context", async () => {
let seenSession: string | undefined;
const inner: LlmProvider = {
async complete(): Promise<CompleteResult> {
async complete(_messages, options): Promise<CompleteResult> {
seenSession = options?.sessionId;
return {
content: "ok",
model: "test-model",
@@ -71,6 +83,7 @@ describe("TokenTrackingProvider", () => {
const ctx: LlmTrackingContext = { sessionId: "sess-1" };
const provider = new TokenTrackingProvider(inner, () => ctx);
await provider.complete([], { caller: "worker:write-rules" });
expect(seenSession).toBe("sess-1");
expect(ctx.pendingUsage).toMatchObject({
totalTokens: 15,
+20
View File
@@ -396,6 +396,26 @@
border-bottom: none;
}
.stats-table tbody tr.stats-call {
cursor: pointer;
}
.stats-table tbody tr.stats-detail td {
padding-top: 8px;
padding-bottom: 12px;
white-space: normal;
}
.stats-table .stats-system-note {
margin: 0 0 6px;
color: var(--st-muted, var(--muted, #6b7280));
font-size: 12px;
}
.stats-table .stats-segments {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.stats-table tbody tr:hover td {
background: color-mix(in srgb, var(--st-surface) 58%, var(--st-bg));
}
+3 -2
View File
@@ -117,12 +117,13 @@
<thead>
<tr>
<th scope="col">时间</th>
<th scope="col">步骤</th>
<th scope="col">caller</th>
<th scope="col">model</th>
<th scope="col" class="num">prompt</th>
<th scope="col" class="num">completion</th>
<th scope="col" class="num">total</th>
<th scope="col" class="num">未缓存</th>
<th scope="col" class="num">cached</th>
<th scope="col">断开</th>
<th scope="col" class="num">花销</th>
</tr>
</thead>
+82 -3
View File
@@ -187,6 +187,57 @@ function renderModels(rows) {
.join("");
}
function uncachedTokens(record) {
if (record.cacheMissTokens != null) return record.cacheMissTokens;
if (record.promptTokens != null && record.cachedTokens != null) {
return Math.max(0, record.promptTokens - record.cachedTokens);
}
return null;
}
function breakLabel(info) {
if (!info) return "—";
if (info.cold) return "冷启动";
if (info.breakAt < 0) return "整段接上";
return info.tag || "断开";
}
function breakNote(record) {
const info = record.cacheBreak;
const segments = record.segments || [];
if (!segments.length) return "这次没有分段标签。";
if (!info || info.cold) {
return "本页没有更早的同会话请求可比。";
}
if (info.breakAt < 0) {
let note = "与上一条同会话请求的前缀整段相同。";
if (!record.cachedTokens) note += " 账单缓存是 0。";
return note;
}
const tag = segments[info.breakAt]?.tag || info.tag || "(上一条更长)";
let note = `前 ${info.matched ?? info.breakAt} 段与上一条相同,从「${tag}」起前缀不再一致。`;
if (!record.cachedTokens && info.breakAt > 0) {
note += " 账单缓存是 0。";
}
return note;
}
function segmentLines(record) {
const info = record.cacheBreak;
const segments = record.segments || [];
return segments
.map((seg, i) => {
let mark = " ";
if (info && !info.cold) {
if (info.breakAt < 0 || i < info.breakAt) mark = "已接上";
else if (i === info.breakAt) mark = "断开 ";
else mark = "之后 ";
}
return `${String(i + 1).padStart(2, "0")} ${mark} ${escapeHtml(seg.tag)} ${formatNum(seg.chars)}字 ${escapeHtml(seg.hash)}`;
})
.join("\n");
}
function renderRows(records) {
const tbody = document.getElementById("rows");
const empty = document.getElementById("empty");
@@ -206,15 +257,35 @@ function renderRows(records) {
const cost = r.unmatchedRate || !r.estimatedCost
? `<span class="stats-unpriced">—</span>`
: formatMoney(r.estimatedCost);
return `<tr>
const chars = r.segmentChars;
const charLine = chars
? `规则 ${formatNum(chars.rules)} · 更早对话 ${formatNum(chars.history)} · 产物 ${formatNum(chars.products)} · 注入 ${formatNum(chars.injected)} · 末尾 ${formatNum(chars.tail)}`
: "";
const tags = [
r.turnId ? `turn ${escapeHtml(r.turnId)}` : "",
r.plannedTags?.length ? `计划 ${escapeHtml(r.plannedTags.join("、"))}` : "",
r.succeededTags?.length ? `已写入 ${escapeHtml(r.succeededTags.join("、"))}` : "",
r.failedTags?.length ? `未完成 ${escapeHtml(r.failedTags.join("、"))}` : "",
].filter(Boolean).join(" · ");
return `<tr class="stats-call" data-call="${escapeHtml(r.id)}">
<td class="col-time">${formatAt(r.at)}</td>
<td>${escapeHtml(r.step || "—")}</td>
<td class="col-caller" title="${escapeHtml(r.caller || "")}">${escapeHtml(r.caller || "—")}</td>
<td class="col-model" title="${escapeHtml(r.model || "")}">${escapeHtml(r.model || "—")}</td>
<td class="num">${formatNum(r.promptTokens ?? 0)}</td>
<td class="num">${formatNum(r.completionTokens ?? 0)}</td>
<td class="num col-total">${formatNum(r.totalTokens ?? 0)}</td>
<td class="num">${uncachedTokens(r) == null ? "—" : formatNum(uncachedTokens(r))}</td>
<td class="num">${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)}</td>
<td class="col-break" title="相对本页更早的同会话请求">${escapeHtml(breakLabel(r.cacheBreak))}</td>
<td class="num">${cost}</td>
</tr>
<tr class="stats-detail" hidden>
<td colspan="9">
<p class="stats-system-note">系统信息:断点、分段标签和哈希只给你对照,不会发给模型。「工具」是 tools 字段。</p>
<p>${escapeHtml(breakNote(r))} 写出 ${formatNum(r.completionTokens ?? 0)}。</p>
${charLine ? `<p>${charLine}</p>` : ""}
${tags ? `<p>${tags}</p>` : ""}
<pre class="stats-segments">${segmentLines(r) || "(无分段)"}</pre>
</td>
</tr>`;
})
.join("");
@@ -394,6 +465,14 @@ document.getElementById("pricing-sources").addEventListener("click", async (ev)
}
});
document.getElementById("rows")?.addEventListener("click", (event) => {
const row = event.target.closest("tr.stats-call");
if (!row) return;
const detail = row.nextElementSibling;
if (!detail?.classList.contains("stats-detail")) return;
detail.hidden = !detail.hidden;
});
(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);