补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。
给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+140
-21
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+103
-19
@@ -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,15 +280,20 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
},
|
||||
async () => {
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeWithToolsStream!(messages, options, {
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
const started = Date.now();
|
||||
const result = await inner.completeWithToolsStream!(
|
||||
messages,
|
||||
this.withSession(options),
|
||||
{
|
||||
onReasoningDelta: (delta) => {
|
||||
reasoningBuf += delta;
|
||||
const ctx = this.getContext();
|
||||
ctx.pendingReasoning = reasoningBuf;
|
||||
callbacks.onReasoningDelta?.(delta);
|
||||
},
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
},
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user