补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。
给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+139
-20
@@ -57,8 +57,33 @@ export type TokenUsage = {
|
|||||||
cachedTokens?: number;
|
cachedTokens?: number;
|
||||||
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
|
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
|
||||||
cacheMissTokens?: number;
|
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 = {
|
export type CompleteResult = {
|
||||||
content: string;
|
content: string;
|
||||||
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
|
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
|
||||||
@@ -84,6 +109,14 @@ export type CompleteOptions = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** 取消进行中的 LLM 请求(停止并重试) */
|
/** 取消进行中的 LLM 请求(停止并重试) */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
|
||||||
|
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
|
/** 统计补充。不进上游请求体。 */
|
||||||
|
usageDetail?: UsageDetail;
|
||||||
|
usageSink?: UsageSink;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolChoice =
|
export type ToolChoice =
|
||||||
@@ -132,6 +165,33 @@ export type LlmProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
|
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 {
|
function messagesMentionJson(messages: ChatMessage[]): boolean {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
@@ -210,6 +270,9 @@ export function buildRequestBody(
|
|||||||
body.stream_options = { include_usage: true };
|
body.stream_options = { include_usage: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
|
||||||
|
if (sessionId) body.session_id = sessionId;
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +295,20 @@ function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
|
|||||||
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
|
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. */
|
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
|
||||||
export function parseUsage(raw: unknown): TokenUsage | undefined {
|
export function parseUsage(raw: unknown): TokenUsage | undefined {
|
||||||
if (!raw || typeof raw !== "object") return undefined;
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
@@ -243,6 +320,7 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
|
|||||||
|
|
||||||
const cachedTokens = readCachedTokens(u);
|
const cachedTokens = readCachedTokens(u);
|
||||||
const cacheMissTokens = readCacheMissTokens(u);
|
const cacheMissTokens = readCacheMissTokens(u);
|
||||||
|
const cacheWriteTokens = readCacheWriteTokens(u);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promptTokens: Number.isFinite(prompt) ? prompt : 0,
|
promptTokens: Number.isFinite(prompt) ? prompt : 0,
|
||||||
@@ -253,22 +331,66 @@ export function parseUsage(raw: unknown): TokenUsage | undefined {
|
|||||||
(Number.isFinite(completion) ? completion : 0),
|
(Number.isFinite(completion) ? completion : 0),
|
||||||
...(cachedTokens != null ? { cachedTokens } : {}),
|
...(cachedTokens != null ? { cachedTokens } : {}),
|
||||||
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
|
...(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;
|
content: string | null;
|
||||||
reasoning?: string;
|
reasoning?: string;
|
||||||
toolCalls: ParsedToolCall[];
|
toolCalls: ParsedToolCall[];
|
||||||
} {
|
} {
|
||||||
if (!message) return { content: "", toolCalls: [] };
|
if (!message) return { content: "", toolCalls: [] };
|
||||||
const rawContent = message.content;
|
const embedded = embeddedContent(message.content);
|
||||||
const content =
|
|
||||||
typeof rawContent === "string"
|
|
||||||
? rawContent.trim() || null
|
|
||||||
: rawContent == null
|
|
||||||
? null
|
|
||||||
: "";
|
|
||||||
const reasoningRaw =
|
const reasoningRaw =
|
||||||
(typeof message.reasoning_content === "string" &&
|
(typeof message.reasoning_content === "string" &&
|
||||||
message.reasoning_content.trim()) ||
|
message.reasoning_content.trim()) ||
|
||||||
@@ -280,21 +402,18 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
|||||||
const toolCalls: ParsedToolCall[] = [];
|
const toolCalls: ParsedToolCall[] = [];
|
||||||
const rawCalls = message.tool_calls;
|
const rawCalls = message.tool_calls;
|
||||||
if (Array.isArray(rawCalls)) {
|
if (Array.isArray(rawCalls)) {
|
||||||
for (const call of rawCalls) {
|
rawCalls.forEach((call, index) => {
|
||||||
if (!call || typeof call !== "object") continue;
|
if (!call || typeof call !== "object") return;
|
||||||
const c = call as Record<string, unknown>;
|
const parsed = toolCallFromRow(call as Record<string, unknown>, index);
|
||||||
const fn = c.function;
|
if (parsed) toolCalls.push(parsed);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
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) {
|
if (toolCalls.length > 0) {
|
||||||
return { content, reasoning: reasoning || undefined, toolCalls };
|
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>;
|
const f = fn as Record<string, unknown>;
|
||||||
if (typeof f.name === "string") entry.name = f.name;
|
if (typeof f.name === "string") entry.name = f.name;
|
||||||
if (typeof f.arguments === "string") entry.arguments += f.arguments;
|
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);
|
acc.set(index, entry);
|
||||||
}
|
}
|
||||||
@@ -36,10 +39,10 @@ function applyToolCallDelta(
|
|||||||
|
|
||||||
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
|
function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
|
||||||
const out: 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 name = entry.name?.trim();
|
||||||
const id = entry.id?.trim();
|
if (!name) continue;
|
||||||
if (!name || !id) continue;
|
const id = entry.id?.trim() || `call-${index}`;
|
||||||
out.push({ id, name, arguments: entry.arguments || "{}" });
|
out.push({ id, name, arguments: entry.arguments || "{}" });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
+97
-13
@@ -6,7 +6,15 @@ import type {
|
|||||||
LlmProvider,
|
LlmProvider,
|
||||||
StreamCallbacks,
|
StreamCallbacks,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
|
ToolDefinition,
|
||||||
|
UsageDetail,
|
||||||
|
UsageSink,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
outlineRequest,
|
||||||
|
type ContextSegment,
|
||||||
|
type RequestOutline,
|
||||||
|
} from "./context-outline.js";
|
||||||
import {
|
import {
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
toMessageTokenUsage,
|
toMessageTokenUsage,
|
||||||
@@ -28,6 +36,9 @@ export type LlmTrackingContext = {
|
|||||||
pendingReasoning?: string;
|
pendingReasoning?: string;
|
||||||
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
|
/** 全量请求上下文;挂到下一条「结果向」系统消息 */
|
||||||
pendingContextTrace?: LlmContextTrace;
|
pendingContextTrace?: LlmContextTrace;
|
||||||
|
/** 本条回复之前的各次请求轮廓;查看时选题和填写都在 */
|
||||||
|
pendingOutlines?: RequestOutline[];
|
||||||
|
lastSegments?: ContextSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function capturePendingTrace(
|
function capturePendingTrace(
|
||||||
@@ -41,12 +52,16 @@ function capturePendingTrace(
|
|||||||
caller: string | undefined,
|
caller: string | undefined,
|
||||||
model?: string,
|
model?: string,
|
||||||
generation?: CompleteOptions["generation"],
|
generation?: CompleteOptions["generation"],
|
||||||
|
tools?: ToolDefinition[],
|
||||||
): void {
|
): void {
|
||||||
|
const segments = outlineRequest(messages, tools);
|
||||||
|
ctx.lastSegments = segments;
|
||||||
ctx.pendingContextTrace = buildContextTrace({
|
ctx.pendingContextTrace = buildContextTrace({
|
||||||
caller: caller ?? "unknown",
|
caller: caller ?? "unknown",
|
||||||
messages,
|
messages,
|
||||||
model,
|
model,
|
||||||
generation,
|
generation,
|
||||||
|
segments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +70,13 @@ function recordUsage(
|
|||||||
caller: string | undefined,
|
caller: string | undefined,
|
||||||
model: string | undefined,
|
model: string | undefined,
|
||||||
usage: TokenUsage | undefined,
|
usage: TokenUsage | undefined,
|
||||||
|
meta?: { detail?: UsageDetail; latencyMs?: number; sink?: UsageSink },
|
||||||
): void {
|
): void {
|
||||||
if (!usage) return;
|
const segments = ctx.lastSegments;
|
||||||
const record = recordTokenUsage({
|
ctx.lastSegments = undefined;
|
||||||
|
const detail = meta?.detail;
|
||||||
|
const record = usage
|
||||||
|
? recordTokenUsage({
|
||||||
sessionId: ctx.sessionId,
|
sessionId: ctx.sessionId,
|
||||||
bookId: ctx.bookId,
|
bookId: ctx.bookId,
|
||||||
bookTitle: ctx.bookTitle,
|
bookTitle: ctx.bookTitle,
|
||||||
@@ -69,8 +88,36 @@ function recordUsage(
|
|||||||
totalTokens: usage.totalTokens,
|
totalTokens: usage.totalTokens,
|
||||||
cachedTokens: usage.cachedTokens,
|
cachedTokens: usage.cachedTokens,
|
||||||
cacheMissTokens: usage.cacheMissTokens,
|
cacheMissTokens: usage.cacheMissTokens,
|
||||||
});
|
cacheWriteTokens: usage.cacheWriteTokens,
|
||||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
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 {
|
export class TokenTrackingProvider implements LlmProvider {
|
||||||
@@ -79,6 +126,14 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
private readonly getContext: () => LlmTrackingContext,
|
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(
|
async complete(
|
||||||
messages: Parameters<LlmProvider["complete"]>[0],
|
messages: Parameters<LlmProvider["complete"]>[0],
|
||||||
options?: CompleteOptions,
|
options?: CompleteOptions,
|
||||||
@@ -91,7 +146,8 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
messages,
|
messages,
|
||||||
},
|
},
|
||||||
async () => {
|
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();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -100,7 +156,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
result.model,
|
result.model,
|
||||||
options?.generation,
|
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()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -130,7 +190,8 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
let reasoningBuf = "";
|
let reasoningBuf = "";
|
||||||
const result = await inner.completeStream!(messages, options, {
|
const started = Date.now();
|
||||||
|
const result = await inner.completeStream!(messages, this.withSession(options), {
|
||||||
onReasoningDelta: (delta) => {
|
onReasoningDelta: (delta) => {
|
||||||
reasoningBuf += delta;
|
reasoningBuf += delta;
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
@@ -147,7 +208,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
result.model,
|
result.model,
|
||||||
options?.generation,
|
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()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -169,7 +234,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
tools: options.tools,
|
tools: options.tools,
|
||||||
},
|
},
|
||||||
async () => {
|
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();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -177,8 +246,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
options.caller,
|
options.caller,
|
||||||
result.model,
|
result.model,
|
||||||
options.generation,
|
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()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
@@ -206,7 +280,11 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
let reasoningBuf = "";
|
let reasoningBuf = "";
|
||||||
const result = await inner.completeWithToolsStream!(messages, options, {
|
const started = Date.now();
|
||||||
|
const result = await inner.completeWithToolsStream!(
|
||||||
|
messages,
|
||||||
|
this.withSession(options),
|
||||||
|
{
|
||||||
onReasoningDelta: (delta) => {
|
onReasoningDelta: (delta) => {
|
||||||
reasoningBuf += delta;
|
reasoningBuf += delta;
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
@@ -214,7 +292,8 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
callbacks.onReasoningDelta?.(delta);
|
callbacks.onReasoningDelta?.(delta);
|
||||||
},
|
},
|
||||||
onContentDelta: callbacks.onContentDelta,
|
onContentDelta: callbacks.onContentDelta,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const ctx = this.getContext();
|
const ctx = this.getContext();
|
||||||
capturePendingTrace(
|
capturePendingTrace(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -222,8 +301,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
|||||||
options.caller,
|
options.caller,
|
||||||
result.model,
|
result.model,
|
||||||
options.generation,
|
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()) {
|
if (result.reasoning?.trim()) {
|
||||||
ctx.pendingReasoning = result.reasoning.trim();
|
ctx.pendingReasoning = result.reasoning.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ const TOOL_LABEL: Record<string, string> = {
|
|||||||
review_blackboard: "审黑板",
|
review_blackboard: "审黑板",
|
||||||
finish: "结束",
|
finish: "结束",
|
||||||
insert: "写入产物",
|
insert: "写入产物",
|
||||||
|
reply_user: "回复用户",
|
||||||
|
select_nodes: "选定节点",
|
||||||
write_product: "写入产物",
|
write_product: "写入产物",
|
||||||
delete: "删除产物",
|
delete: "删除产物",
|
||||||
declare_variable: "声明变量",
|
declare_variable: "声明变量",
|
||||||
@@ -214,6 +216,7 @@ export function labelCaller(caller: string | undefined): string {
|
|||||||
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
||||||
if (who === "intake_extract") return "需求抽取";
|
if (who === "intake_extract") return "需求抽取";
|
||||||
if (who === "dictate_agent") return "对话落盘";
|
if (who === "dictate_agent") return "对话落盘";
|
||||||
|
if (who.startsWith("listen_serial_")) return "听书连载";
|
||||||
if (who === "preset-probe") return "预设探测";
|
if (who === "preset-probe") return "预设探测";
|
||||||
if (who.startsWith("model-compare:")) {
|
if (who.startsWith("model-compare:")) {
|
||||||
return `模型对比 ${who.slice("model-compare:".length)}`;
|
return `模型对比 ${who.slice("model-compare:".length)}`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SUGGESTED_PRICING_DOCS,
|
SUGGESTED_PRICING_DOCS,
|
||||||
} from "../stats/pricing.js";
|
} from "../stats/pricing.js";
|
||||||
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
|
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
|
||||||
|
import { annotateCacheBreaks } from "../llm/context-outline.js";
|
||||||
|
|
||||||
async function readBody(req: IncomingMessage): Promise<string> {
|
async function readBody(req: IncomingMessage): Promise<string> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -77,6 +78,7 @@ export async function handleStatsApi(
|
|||||||
const records = readRecords({ ...query, limit: query.limit ?? 100 });
|
const records = readRecords({ ...query, limit: query.limit ?? 100 });
|
||||||
const catalog = resolveRateCatalog();
|
const catalog = resolveRateCatalog();
|
||||||
const cost = buildCostReport(summaryRecords, catalog);
|
const cost = buildCostReport(summaryRecords, catalog);
|
||||||
|
const breaks = annotateCacheBreaks(summaryRecords);
|
||||||
json(res, 200, {
|
json(res, 200, {
|
||||||
summary: {
|
summary: {
|
||||||
...summarizeRecords(summaryRecords),
|
...summarizeRecords(summaryRecords),
|
||||||
@@ -92,6 +94,7 @@ export async function handleStatsApi(
|
|||||||
...r,
|
...r,
|
||||||
estimatedCost: priced.cost,
|
estimatedCost: priced.cost,
|
||||||
unmatchedRate: priced.unmatched,
|
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 path from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
|
||||||
|
import type { ContextSegment } from "../llm/context-outline.js";
|
||||||
|
|
||||||
export type TokenUsageRecord = {
|
export type TokenUsageRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,7 +18,25 @@ export type TokenUsageRecord = {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
cachedTokens?: number;
|
cachedTokens?: number;
|
||||||
cacheMissTokens?: number;
|
cacheMissTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
messageId?: string;
|
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 */
|
/** Token usage attached to a chat message in SessionView */
|
||||||
@@ -97,7 +116,18 @@ export function recordTokenUsage(
|
|||||||
totalTokens: partial.totalTokens,
|
totalTokens: partial.totalTokens,
|
||||||
cachedTokens: partial.cachedTokens,
|
cachedTokens: partial.cachedTokens,
|
||||||
cacheMissTokens: partial.cacheMissTokens,
|
cacheMissTokens: partial.cacheMissTokens,
|
||||||
|
cacheWriteTokens: partial.cacheWriteTokens,
|
||||||
messageId: partial.messageId,
|
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");
|
appendFileSync(dayFile(new Date(record.at)), `${JSON.stringify(record)}\n`, "utf8");
|
||||||
return record;
|
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 {
|
export function toMessageTokenUsage(record: TokenUsageRecord): MessageTokenUsage {
|
||||||
return {
|
return {
|
||||||
totalTokens: record.totalTokens,
|
totalTokens: record.totalTokens,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ContextSegment } from "../llm/context-outline.js";
|
||||||
import type { GenerationParameters } from "./preset.js";
|
import type { GenerationParameters } from "./preset.js";
|
||||||
|
|
||||||
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
|
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
|
||||||
@@ -11,6 +12,8 @@ export type LlmContextTrace = {
|
|||||||
caller: string;
|
caller: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
messages: LlmContextMessage[];
|
messages: LlmContextMessage[];
|
||||||
|
/** 有序标签,供查看排列和缓存断点;不含正文 */
|
||||||
|
segments?: ContextSegment[];
|
||||||
/** 便于列表展示 */
|
/** 便于列表展示 */
|
||||||
charCount: number;
|
charCount: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -57,6 +60,7 @@ export function buildContextTrace(params: {
|
|||||||
}>;
|
}>;
|
||||||
model?: string;
|
model?: string;
|
||||||
generation?: GenerationParameters;
|
generation?: GenerationParameters;
|
||||||
|
segments?: ContextSegment[];
|
||||||
}): LlmContextTrace {
|
}): LlmContextTrace {
|
||||||
const messages = snapshotRequestMessages(params.messages);
|
const messages = snapshotRequestMessages(params.messages);
|
||||||
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
|
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
|
||||||
@@ -64,6 +68,7 @@ export function buildContextTrace(params: {
|
|||||||
caller: params.caller,
|
caller: params.caller,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
messages,
|
messages,
|
||||||
|
...(params.segments?.length ? { segments: params.segments } : {}),
|
||||||
charCount,
|
charCount,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
...(params.generation && Object.keys(params.generation).length
|
...(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);
|
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", () => {
|
it("returns undefined for empty usage", () => {
|
||||||
expect(parseUsage(null)).toBeUndefined();
|
expect(parseUsage(null)).toBeUndefined();
|
||||||
expect(parseUsage({})).toBeUndefined();
|
expect(parseUsage({})).toBeUndefined();
|
||||||
@@ -52,8 +62,10 @@ describe("parseUsage", () => {
|
|||||||
|
|
||||||
describe("TokenTrackingProvider", () => {
|
describe("TokenTrackingProvider", () => {
|
||||||
it("stores pending usage with cache fields on context", async () => {
|
it("stores pending usage with cache fields on context", async () => {
|
||||||
|
let seenSession: string | undefined;
|
||||||
const inner: LlmProvider = {
|
const inner: LlmProvider = {
|
||||||
async complete(): Promise<CompleteResult> {
|
async complete(_messages, options): Promise<CompleteResult> {
|
||||||
|
seenSession = options?.sessionId;
|
||||||
return {
|
return {
|
||||||
content: "ok",
|
content: "ok",
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
@@ -71,6 +83,7 @@ describe("TokenTrackingProvider", () => {
|
|||||||
const ctx: LlmTrackingContext = { sessionId: "sess-1" };
|
const ctx: LlmTrackingContext = { sessionId: "sess-1" };
|
||||||
const provider = new TokenTrackingProvider(inner, () => ctx);
|
const provider = new TokenTrackingProvider(inner, () => ctx);
|
||||||
await provider.complete([], { caller: "worker:write-rules" });
|
await provider.complete([], { caller: "worker:write-rules" });
|
||||||
|
expect(seenSession).toBe("sess-1");
|
||||||
|
|
||||||
expect(ctx.pendingUsage).toMatchObject({
|
expect(ctx.pendingUsage).toMatchObject({
|
||||||
totalTokens: 15,
|
totalTokens: 15,
|
||||||
|
|||||||
@@ -396,6 +396,26 @@
|
|||||||
border-bottom: none;
|
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 {
|
.stats-table tbody tr:hover td {
|
||||||
background: color-mix(in srgb, var(--st-surface) 58%, var(--st-bg));
|
background: color-mix(in srgb, var(--st-surface) 58%, var(--st-bg));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -117,12 +117,13 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">时间</th>
|
<th scope="col">时间</th>
|
||||||
|
<th scope="col">步骤</th>
|
||||||
<th scope="col">caller</th>
|
<th scope="col">caller</th>
|
||||||
<th scope="col">model</th>
|
<th scope="col">model</th>
|
||||||
<th scope="col" class="num">prompt</th>
|
<th scope="col" class="num">prompt</th>
|
||||||
<th scope="col" class="num">completion</th>
|
<th scope="col" class="num">未缓存</th>
|
||||||
<th scope="col" class="num">total</th>
|
|
||||||
<th scope="col" class="num">cached</th>
|
<th scope="col" class="num">cached</th>
|
||||||
|
<th scope="col">断开</th>
|
||||||
<th scope="col" class="num">花销</th>
|
<th scope="col" class="num">花销</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
+82
-3
@@ -187,6 +187,57 @@ function renderModels(rows) {
|
|||||||
.join("");
|
.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) {
|
function renderRows(records) {
|
||||||
const tbody = document.getElementById("rows");
|
const tbody = document.getElementById("rows");
|
||||||
const empty = document.getElementById("empty");
|
const empty = document.getElementById("empty");
|
||||||
@@ -206,15 +257,35 @@ function renderRows(records) {
|
|||||||
const cost = r.unmatchedRate || !r.estimatedCost
|
const cost = r.unmatchedRate || !r.estimatedCost
|
||||||
? `<span class="stats-unpriced">—</span>`
|
? `<span class="stats-unpriced">—</span>`
|
||||||
: formatMoney(r.estimatedCost);
|
: 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 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-caller" title="${escapeHtml(r.caller || "")}">${escapeHtml(r.caller || "—")}</td>
|
||||||
<td class="col-model" title="${escapeHtml(r.model || "")}">${escapeHtml(r.model || "—")}</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.promptTokens ?? 0)}</td>
|
||||||
<td class="num">${formatNum(r.completionTokens ?? 0)}</td>
|
<td class="num">${uncachedTokens(r) == null ? "—" : formatNum(uncachedTokens(r))}</td>
|
||||||
<td class="num col-total">${formatNum(r.totalTokens ?? 0)}</td>
|
|
||||||
<td class="num">${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)}</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>
|
<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>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.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 now = new Date();
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user