补齐缓存写量与请求轮廓记账,便于核对提示缓存是否真的命中。
给用量行挂上轮次步骤和分段字数,统计页能看出缓存断在哪。 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
|
||||
|
||||
Reference in New Issue
Block a user