pi-distill 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/locales/fallback-renderer.json +22 -38
- package/package.json +1 -1
- package/src/fallback-renderer.ts +47 -39
- package/src/index.ts +49 -16
- package/src/token-estimator.ts +40 -0
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Agent consumes a result suited to the current decision, with auditable diagnosti
|
|
|
110
110
|
2. The `tool_call` handler captures the parameter and removes it before forwarding the call, so the underlying tool never receives the extension-only field.
|
|
111
111
|
3. The `tool_result` handler sees the actual output and decides what to do; it does not rely on the agent predicting the output size.
|
|
112
112
|
4. Every tool call must include a non-empty `outputRequest`. A prompt containing only `RAW` explicitly requests the original. Any other non-empty prompt permits distillation once the configured threshold is reached.
|
|
113
|
-
5. A timed-out attempt is retried according to `timeoutRetryCount`, while other failures use `errorRetryCount` (both default to one retry). If all attempts fail, no model is available, or compression is ineffective, the original facts are retained and the status is exposed through details and the audit card. JSON responses wrapped in Markdown fences such as `````json … ````` are also accepted.
|
|
113
|
+
5. OpenAI-compatible completion requests enable native JSON mode with `response_format: { "type": "json_object" }`; OpenAI Responses-compatible requests use the equivalent `text.format`. A timed-out attempt is retried according to `timeoutRetryCount`, while other failures use `errorRetryCount` (both default to one retry). If all attempts fail, no model is available, or compression is ineffective, the original facts are retained and the status is exposed through details and the audit card. JSON responses wrapped in Markdown fences such as `````json … ````` are also accepted.
|
|
114
114
|
|
|
115
115
|
## Output contract
|
|
116
116
|
|
|
@@ -193,7 +193,7 @@ Configuration-file fields take precedence over environment variables. Unspecifie
|
|
|
193
193
|
|
|
194
194
|
Use `/distill:stats` to view distillation statistics for the current Pi session. Statistics are kept in memory, reset when the session starts, and never store raw tool output.
|
|
195
195
|
|
|
196
|
-
The report includes tool-result counts, success/failure/fallback counts, model attempts, original and summary character totals, compression ratio,
|
|
196
|
+
The report includes tool-result counts, success/failure/fallback counts, model attempts, original and summary character totals, compression ratio, estimated original/summary tokens (heuristic: CJK characters count ~1 token each, other text ~4 chars/token), estimated tokens saved, and model-reported input/output/cache/total tokens and cost. Counts use compact `k` and `m` units at 1,000 and 1,000,000; durations use `ms`, `s`, or `min` based on their value. Estimated context tokens are labeled as estimates; usage and cost fields are shown as unavailable when the provider does not return usage data.
|
|
197
197
|
|
|
198
198
|
The main environment variables are `PI_DISTILL_MODEL`, `PI_DISTILL_MIN_CHARS`, `PI_DISTILL_MAX_CHARS`, `PI_DISTILL_MAX_OUTPUT_CHARS`, `PI_DISTILL_TIMEOUT_SECONDS`, `PI_DISTILL_TIMEOUT_RETRY_COUNT`, `PI_DISTILL_ERROR_RETRY_COUNT`, `PI_DISTILL_MISSED_COMPRESSION_RATIO`, and `PI_DISTILL_SUMMARIZE_ERRORS`.
|
|
199
199
|
|
package/README.zh-CN.md
CHANGED
|
@@ -112,7 +112,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
112
112
|
2. `tool_call` 事件捕获这个参数,并在交给底层工具前移除它,因此原工具不会收到扩展专用字段。
|
|
113
113
|
3. `tool_result` 事件拿到真实输出后再做判断,不依赖 Agent 对输出长度的预测。
|
|
114
114
|
4. 每次工具调用都必须包含非空的 `outputRequest`;严格的 `RAW` 表示明确要求原文;其他非空 prompt 才允许进入提炼流程。
|
|
115
|
-
5.
|
|
115
|
+
5. OpenAI-compatible Completions 提炼请求会通过 `response_format: { "type": "json_object" }` 启用原生 JSON 模式;OpenAI Responses-compatible 请求使用等价的 `text.format`。单次提炼超时后按照 `timeoutRetryCount` 重试,其他异常按照 `errorRetryCount` 重试(两者默认都重试 1 次);全部尝试失败、没有可用模型或结果收益过低时,扩展保留原始事实,并通过 details 和审计卡片暴露状态;模型用 Markdown 的 JSON 代码围栏(如 `````json … `````)包裹响应时也会兼容解析。
|
|
116
116
|
|
|
117
117
|
## 输出处理契约
|
|
118
118
|
|
|
@@ -196,7 +196,7 @@ Agent 消费更适合当前决策的结果,并获得可审计的处理诊断
|
|
|
196
196
|
|
|
197
197
|
使用 `/distill:stats` 查看当前 Pi 会话的提炼统计。统计只保存在内存中,在会话开始时重置,不保存原始工具输出。
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
统计包括工具结果数量、成功/失败/回退次数、模型尝试次数、原始与摘要字符数、压缩比、估算的原文/摘要 Token(启发式:CJK 字符约每字 1 token,其余文本约 4 字符 1 token)、预计节省 Token、提炼实际消耗的 input/output/cache/total Token 和成本。数量达到 1,000 或 1,000,000 时分别使用 `k` 或 `m` 紧凑显示;耗时会根据数值显示为 `ms`、`s` 或 `min`。原文/摘要 Token 是估算值;provider 未返回 usage 时,提炼消耗 Token 或成本字段显示为不可用。
|
|
200
200
|
|
|
201
201
|
主要环境变量包括 `PI_DISTILL_MODEL`、`PI_DISTILL_MIN_CHARS`、`PI_DISTILL_MAX_CHARS`、`PI_DISTILL_MAX_OUTPUT_CHARS`、`PI_DISTILL_TIMEOUT_SECONDS`、`PI_DISTILL_TIMEOUT_RETRY_COUNT`、`PI_DISTILL_ERROR_RETRY_COUNT`、`PI_DISTILL_MISSED_COMPRESSION_RATIO` 和 `PI_DISTILL_SUMMARIZE_ERRORS`。
|
|
202
202
|
|
|
@@ -1,80 +1,64 @@
|
|
|
1
1
|
{
|
|
2
|
-
"summarized": {
|
|
3
|
-
"zh-CN": "✓ 已提炼",
|
|
4
|
-
"en-US": "✓ Summarized"
|
|
5
|
-
},
|
|
6
2
|
"summaryFallback": {
|
|
7
|
-
"zh-CN": "
|
|
8
|
-
"en-US": "
|
|
3
|
+
"zh-CN": "已回退原文",
|
|
4
|
+
"en-US": "Original restored"
|
|
9
5
|
},
|
|
10
6
|
"disabled": {
|
|
11
|
-
"zh-CN": "
|
|
12
|
-
"en-US": "
|
|
7
|
+
"zh-CN": "已禁用",
|
|
8
|
+
"en-US": "Disabled"
|
|
13
9
|
},
|
|
14
10
|
"off": {
|
|
15
|
-
"zh-CN": "
|
|
16
|
-
"en-US": "
|
|
11
|
+
"zh-CN": "关闭",
|
|
12
|
+
"en-US": "Off"
|
|
17
13
|
},
|
|
18
14
|
"original": {
|
|
19
|
-
"zh-CN": "
|
|
20
|
-
"en-US": "
|
|
15
|
+
"zh-CN": "原始输出",
|
|
16
|
+
"en-US": "Original"
|
|
21
17
|
},
|
|
22
18
|
"raw": {
|
|
23
|
-
"zh-CN": "
|
|
24
|
-
"en-US": "
|
|
19
|
+
"zh-CN": "RAW",
|
|
20
|
+
"en-US": "RAW"
|
|
25
21
|
},
|
|
26
22
|
"belowThreshold": {
|
|
27
|
-
"zh-CN": "
|
|
28
|
-
"en-US": "
|
|
23
|
+
"zh-CN": "低于阈值",
|
|
24
|
+
"en-US": "Below threshold"
|
|
29
25
|
},
|
|
30
26
|
"nonTextOutput": {
|
|
31
27
|
"zh-CN": "非文本结果",
|
|
32
28
|
"en-US": "Non-text output"
|
|
33
29
|
},
|
|
34
30
|
"readFailed": {
|
|
35
|
-
"zh-CN": "
|
|
36
|
-
"en-US": "
|
|
31
|
+
"zh-CN": "读取失败",
|
|
32
|
+
"en-US": "Read failed"
|
|
37
33
|
},
|
|
38
34
|
"summaryFailed": {
|
|
39
|
-
"zh-CN": "
|
|
40
|
-
"en-US": "
|
|
35
|
+
"zh-CN": "提炼失败",
|
|
36
|
+
"en-US": "Summary failed"
|
|
41
37
|
},
|
|
42
38
|
"chars": {
|
|
43
39
|
"zh-CN": "字符",
|
|
44
40
|
"en-US": "chars"
|
|
45
41
|
},
|
|
46
42
|
"tokens": {
|
|
47
|
-
"zh-CN": "
|
|
48
|
-
"en-US": "
|
|
43
|
+
"zh-CN": "tok",
|
|
44
|
+
"en-US": "tok"
|
|
49
45
|
},
|
|
50
46
|
"compressionTokens": {
|
|
51
|
-
"zh-CN": "
|
|
52
|
-
"en-US": "
|
|
53
|
-
},
|
|
54
|
-
"tokensSaved": {
|
|
55
|
-
"zh-CN": "节省",
|
|
56
|
-
"en-US": "saved"
|
|
57
|
-
},
|
|
58
|
-
"savedPercent": {
|
|
59
|
-
"zh-CN": "节省",
|
|
60
|
-
"en-US": "saved"
|
|
47
|
+
"zh-CN": "用量",
|
|
48
|
+
"en-US": "usage"
|
|
61
49
|
},
|
|
62
50
|
"tool": {
|
|
63
51
|
"zh-CN": "工具",
|
|
64
|
-
"en-US": "
|
|
52
|
+
"en-US": "tool"
|
|
65
53
|
},
|
|
66
54
|
"distill": {
|
|
67
55
|
"zh-CN": "提炼",
|
|
68
|
-
"en-US": "
|
|
56
|
+
"en-US": "distill"
|
|
69
57
|
},
|
|
70
58
|
"expand": {
|
|
71
59
|
"zh-CN": " • Ctrl+O 展开",
|
|
72
60
|
"en-US": " • Ctrl+O to expand"
|
|
73
61
|
},
|
|
74
|
-
"header": {
|
|
75
|
-
"zh-CN": "◇ Distill {status}",
|
|
76
|
-
"en-US": "◇ Distill {status}"
|
|
77
|
-
},
|
|
78
62
|
"summary": {
|
|
79
63
|
"zh-CN": "摘要",
|
|
80
64
|
"en-US": "Summary"
|
package/package.json
CHANGED
package/src/fallback-renderer.ts
CHANGED
|
@@ -17,7 +17,10 @@ type AuditTone = "success" | "muted" | "dim" | "warning" | "error";
|
|
|
17
17
|
|
|
18
18
|
type DistillAuditView = {
|
|
19
19
|
lines: string[];
|
|
20
|
-
|
|
20
|
+
/** 状态图标同时承担品牌位(替换原 ◇),是语言无关符号,不走 locale。 */
|
|
21
|
+
statusIcon: string;
|
|
22
|
+
/** 状态文字;summarized 等自解释状态为空。 */
|
|
23
|
+
statusText: string;
|
|
21
24
|
statusTone: AuditTone;
|
|
22
25
|
};
|
|
23
26
|
|
|
@@ -34,6 +37,8 @@ function getString(value: unknown): string | undefined {
|
|
|
34
37
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
const MIN_TOOL_DURATION_DISPLAY_MS = 50;
|
|
41
|
+
|
|
37
42
|
function formatDuration(milliseconds: number): string {
|
|
38
43
|
return `${(milliseconds / 1000).toFixed(1)}s`;
|
|
39
44
|
}
|
|
@@ -65,13 +70,13 @@ function formatCompactCount(value: number): string {
|
|
|
65
70
|
|
|
66
71
|
function renderDistillAuditLine(audit: DistillAuditView, line: string, index: number, theme: RenderTheme): string {
|
|
67
72
|
if (index === 0) {
|
|
68
|
-
const title = theme.fg("accent", theme.bold("
|
|
69
|
-
const afterTitle = line.slice(
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return `${title}${theme.fg("
|
|
73
|
+
const title = `${theme.fg(audit.statusTone, theme.bold(audit.statusIcon))}${theme.fg("accent", theme.bold(" Distill"))}`;
|
|
74
|
+
const afterTitle = line.slice(`${audit.statusIcon} Distill`.length);
|
|
75
|
+
const statusPrefix = audit.statusText ? ` ${audit.statusText}` : "";
|
|
76
|
+
if (statusPrefix && afterTitle.startsWith(statusPrefix)) {
|
|
77
|
+
return `${title}${theme.fg(audit.statusTone, statusPrefix)}${theme.fg("muted", afterTitle.slice(statusPrefix.length))}`;
|
|
78
|
+
}
|
|
79
|
+
return `${title}${theme.fg("muted", afterTitle)}`;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
const section = line.match(/^([├└]─ )([^ ]+)( )(.*)$/);
|
|
@@ -179,17 +184,17 @@ export function buildDistillAuditLines(
|
|
|
179
184
|
const status = getString(details.outputSummaryStatus);
|
|
180
185
|
if (!status) return undefined;
|
|
181
186
|
|
|
182
|
-
const statusViews: Record<string, {
|
|
183
|
-
summarized: {
|
|
184
|
-
"summary-fallback": {
|
|
185
|
-
disabled: {
|
|
186
|
-
"disabled-by-config": {
|
|
187
|
-
"not-requested": {
|
|
188
|
-
"full-output": {
|
|
189
|
-
"below-threshold": {
|
|
190
|
-
"non-text-output": {
|
|
191
|
-
"diagnostic-failed": {
|
|
192
|
-
"summary-failed": {
|
|
187
|
+
const statusViews: Record<string, { icon: string; textKey?: string; tone: AuditTone }> = {
|
|
188
|
+
summarized: { icon: "✓", tone: "success" },
|
|
189
|
+
"summary-fallback": { icon: "↺", textKey: "summaryFallback", tone: "warning" },
|
|
190
|
+
disabled: { icon: "○", textKey: "disabled", tone: "dim" },
|
|
191
|
+
"disabled-by-config": { icon: "○", textKey: "off", tone: "dim" },
|
|
192
|
+
"not-requested": { icon: "○", textKey: "original", tone: "muted" },
|
|
193
|
+
"full-output": { icon: "↺", textKey: "raw", tone: "warning" },
|
|
194
|
+
"below-threshold": { icon: "–", textKey: "belowThreshold", tone: "dim" },
|
|
195
|
+
"non-text-output": { icon: "○", textKey: "nonTextOutput", tone: "muted" },
|
|
196
|
+
"diagnostic-failed": { icon: "!", textKey: "readFailed", tone: "warning" },
|
|
197
|
+
"summary-failed": { icon: "✕", textKey: "summaryFailed", tone: "error" },
|
|
193
198
|
};
|
|
194
199
|
const anomalies = Array.isArray(details.outputSummaryAnomalies)
|
|
195
200
|
? details.outputSummaryAnomalies.filter((value): value is string => typeof value === "string")
|
|
@@ -202,43 +207,45 @@ export function buildDistillAuditLines(
|
|
|
202
207
|
const estimatedSummaryTokens = getFiniteNumber(details.estimatedSummaryTokens);
|
|
203
208
|
const estimatedTokensSaved = getFiniteNumber(details.estimatedTokensSaved);
|
|
204
209
|
const summaryTotalTokens = getFiniteNumber(details.summaryTotalTokens);
|
|
205
|
-
const compressionRatio = getFiniteNumber(details.compressionRatio);
|
|
206
210
|
const compressionSavedPercent = getFiniteNumber(details.compressionSavedPercent);
|
|
207
211
|
const toolExecutionMs = getFiniteNumber(details.toolExecutionMs);
|
|
208
212
|
const summaryDurationMs = getFiniteNumber(details.summaryDurationMs);
|
|
209
213
|
const fullOutputPath = getString(details.fullOutputPath);
|
|
210
214
|
const outputRequest = getString(details.outputSummaryPrompt);
|
|
211
215
|
const summaryText = getString(details.summaryText);
|
|
212
|
-
const statusView = statusViews[status] ?? {
|
|
213
|
-
const
|
|
216
|
+
const statusView = statusViews[status] ?? { icon: "○", tone: "muted" as const };
|
|
217
|
+
const statusText = statusView.textKey ? i18n.t(statusView.textKey) : status in statusViews ? "" : status;
|
|
214
218
|
|
|
219
|
+
let mainMetric = "";
|
|
215
220
|
if (estimatedOriginalTokens !== undefined && estimatedSummaryTokens !== undefined) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
metrics.push(`${tokenMetric}${savedMetric}`);
|
|
221
|
+
mainMetric = `${formatCompactCount(estimatedOriginalTokens)} → ${formatCompactCount(estimatedSummaryTokens)} ${i18n.t("tokens")}`;
|
|
222
|
+
if (estimatedTokensSaved !== undefined && estimatedTokensSaved > 0 && compressionSavedPercent !== undefined) {
|
|
223
|
+
mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
|
|
224
|
+
}
|
|
221
225
|
} else if (originalChars !== undefined && summaryChars !== undefined) {
|
|
222
|
-
|
|
223
|
-
if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
|
|
226
|
+
mainMetric = `${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`;
|
|
224
227
|
if (compressionSavedPercent !== undefined) {
|
|
225
|
-
|
|
228
|
+
mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
|
|
226
229
|
}
|
|
227
230
|
} else if (originalChars !== undefined) {
|
|
228
|
-
|
|
231
|
+
mainMetric = `${formatCount(originalChars)} ${i18n.t("chars")}`;
|
|
229
232
|
}
|
|
233
|
+
|
|
234
|
+
const secondaryMetrics: string[] = [];
|
|
230
235
|
if (summaryTotalTokens !== undefined && summaryTotalTokens > 0) {
|
|
231
|
-
|
|
236
|
+
secondaryMetrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)}`);
|
|
232
237
|
}
|
|
233
|
-
if (toolExecutionMs !== undefined && toolExecutionMs >=
|
|
234
|
-
|
|
238
|
+
if (toolExecutionMs !== undefined && toolExecutionMs >= MIN_TOOL_DURATION_DISPLAY_MS) {
|
|
239
|
+
secondaryMetrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
|
|
235
240
|
}
|
|
236
|
-
if (summaryDurationMs !== undefined)
|
|
241
|
+
if (summaryDurationMs !== undefined) secondaryMetrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
|
|
237
242
|
|
|
238
243
|
const expandHint = expanded ? "" : i18n.t("expand");
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
const metricParts = [mainMetric, ...secondaryMetrics].filter((part) => part.length > 0);
|
|
245
|
+
const headerSegments = [`${statusView.icon} Distill`];
|
|
246
|
+
if (statusText) headerSegments.push(statusText);
|
|
247
|
+
if (metricParts.length > 0) headerSegments.push(metricParts.join(" · "));
|
|
248
|
+
const lines = [`${headerSegments.join(" ")}${expandHint}`];
|
|
242
249
|
if (expanded) {
|
|
243
250
|
const sections: Array<{ label: string; text: string }> = [];
|
|
244
251
|
if (render.showPrompt && outputRequest) sections.push({ label: i18n.t("outputRequest"), text: outputRequest });
|
|
@@ -263,7 +270,8 @@ export function buildDistillAuditLines(
|
|
|
263
270
|
|
|
264
271
|
return {
|
|
265
272
|
lines,
|
|
266
|
-
|
|
273
|
+
statusIcon: statusView.icon,
|
|
274
|
+
statusText,
|
|
267
275
|
statusTone: statusView.tone,
|
|
268
276
|
};
|
|
269
277
|
}
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type DistillToolConfig,
|
|
62
62
|
type OutputSummaryDecision,
|
|
63
63
|
} from "./summary-utils.ts";
|
|
64
|
+
import { estimateHeuristicTokens } from "./token-estimator.ts";
|
|
64
65
|
|
|
65
66
|
const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
|
|
66
67
|
|
|
@@ -151,8 +152,44 @@ type SummaryResult = {
|
|
|
151
152
|
};
|
|
152
153
|
|
|
153
154
|
type SummaryCompletion = (...args: Parameters<typeof complete>) => ReturnType<typeof complete>;
|
|
155
|
+
type SummaryCompletionModel = Parameters<SummaryCompletion>[0];
|
|
156
|
+
type SummaryCompletionOptions = Parameters<SummaryCompletion>[2];
|
|
154
157
|
type DistillWarningReporter = (message: string) => void;
|
|
155
158
|
|
|
159
|
+
const OPENAI_RESPONSES_APIS = new Set([
|
|
160
|
+
"openai-responses",
|
|
161
|
+
"openai-codex-responses",
|
|
162
|
+
"azure-openai-responses",
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
166
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Enforce JSON mode on OpenAI-compatible summary requests without breaking other APIs. */
|
|
170
|
+
function addSummaryJsonResponseFormat(payload: unknown, model: SummaryCompletionModel): unknown {
|
|
171
|
+
if (!isObjectRecord(payload)) return undefined;
|
|
172
|
+
|
|
173
|
+
if (model.api === "openai-completions") {
|
|
174
|
+
return {
|
|
175
|
+
...payload,
|
|
176
|
+
response_format: { type: "json_object" },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (OPENAI_RESPONSES_APIS.has(model.api)) {
|
|
181
|
+
return {
|
|
182
|
+
...payload,
|
|
183
|
+
text: {
|
|
184
|
+
...(isObjectRecord(payload.text) ? payload.text : {}),
|
|
185
|
+
format: { type: "json_object" },
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
156
193
|
class SummaryAttemptError extends Error {
|
|
157
194
|
constructor(message: string, readonly usage?: SummaryUsage) {
|
|
158
195
|
super(message);
|
|
@@ -182,7 +219,7 @@ type SummaryDiagnostics = {
|
|
|
182
219
|
summaryCacheWriteTokens?: number;
|
|
183
220
|
summaryTotalTokens?: number;
|
|
184
221
|
summaryCost?: number;
|
|
185
|
-
/**
|
|
222
|
+
/** 原始输出与最终摘要的展示用 Token 数,由分段启发式估算(CJK 约每字 1 token,其余约 4 字符 1 token)。 */
|
|
186
223
|
estimatedOriginalOutputTokens?: number;
|
|
187
224
|
estimatedSummaryTokens?: number;
|
|
188
225
|
estimatedTokensSaved?: number;
|
|
@@ -275,18 +312,12 @@ function getSummaryUsageDiagnostics(usage: SummaryUsage | undefined): Pick<
|
|
|
275
312
|
};
|
|
276
313
|
}
|
|
277
314
|
|
|
278
|
-
const ESTIMATED_CHARS_PER_TOKEN = 4;
|
|
279
|
-
|
|
280
|
-
function estimateTextTokens(text: string): number {
|
|
281
|
-
return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
315
|
function getTokenCompressionDiagnostics(
|
|
285
316
|
originalOutput: string,
|
|
286
317
|
finalOutput: string,
|
|
287
318
|
): Pick<SummaryDiagnostics, "estimatedOriginalOutputTokens" | "estimatedSummaryTokens" | "estimatedTokensSaved"> {
|
|
288
|
-
const estimatedOriginalOutputTokens =
|
|
289
|
-
const estimatedSummaryTokens =
|
|
319
|
+
const estimatedOriginalOutputTokens = estimateHeuristicTokens(originalOutput);
|
|
320
|
+
const estimatedSummaryTokens = estimateHeuristicTokens(finalOutput);
|
|
290
321
|
return {
|
|
291
322
|
estimatedOriginalOutputTokens,
|
|
292
323
|
estimatedSummaryTokens,
|
|
@@ -689,6 +720,14 @@ async function summarizeOutput(
|
|
|
689
720
|
const auth = await context.ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
690
721
|
if (auth.ok === false) throw new Error(`Summarizer authentication failed: ${auth.error}`);
|
|
691
722
|
|
|
723
|
+
const completionOptions = {
|
|
724
|
+
apiKey: auth.apiKey,
|
|
725
|
+
headers: auth.headers,
|
|
726
|
+
env: auth.env,
|
|
727
|
+
maxTokens: Math.max(256, Math.ceil(config.maxChars / 2)),
|
|
728
|
+
onPayload: addSummaryJsonResponseFormat,
|
|
729
|
+
signal,
|
|
730
|
+
} satisfies SummaryCompletionOptions;
|
|
692
731
|
const response = await completion(
|
|
693
732
|
model,
|
|
694
733
|
{
|
|
@@ -707,13 +746,7 @@ async function summarizeOutput(
|
|
|
707
746
|
},
|
|
708
747
|
],
|
|
709
748
|
},
|
|
710
|
-
|
|
711
|
-
apiKey: auth.apiKey,
|
|
712
|
-
headers: auth.headers,
|
|
713
|
-
env: auth.env,
|
|
714
|
-
maxTokens: Math.max(256, Math.ceil(config.maxChars / 2)),
|
|
715
|
-
signal,
|
|
716
|
-
},
|
|
749
|
+
completionOptions,
|
|
717
750
|
);
|
|
718
751
|
|
|
719
752
|
const usage = normalizeSummaryUsage(response.usage);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 展示用 token 估算。跨模型没有通用的精确 tokenizer(实测误差 24%~65%),
|
|
3
|
+
* Anthropic 官方 JS 包的词表也停留在 Claude 2 时代,所以不引入第三方
|
|
4
|
+
* tokenizer 依赖,统一使用分段启发式:
|
|
5
|
+
*
|
|
6
|
+
* - CJK、假名、谚文和全角字符:现代 BPE 分词器通常每字 1 token;
|
|
7
|
+
* - 其余字符:约 4 字符 1 token(与 Pi 的 chars/4 规则一致)。
|
|
8
|
+
*
|
|
9
|
+
* 相比纯 chars/4,中文内容不再被低估约 4 倍。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 现代 BPE 分词器通常按单字编码的 Unicode 区间(CJK/假名/谚文/全角)。 */
|
|
13
|
+
const DENSE_SCRIPT_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
14
|
+
[0x2e80, 0x9fff], // CJK 部首补充、假名、CJK 统一表意文字等
|
|
15
|
+
[0xac00, 0xd7af], // 谚文音节
|
|
16
|
+
[0xf900, 0xfaff], // CJK 兼容表意文字
|
|
17
|
+
[0xff00, 0xffef], // 全角字符
|
|
18
|
+
[0x20000, 0x2fa1f], // CJK 扩展 B–F 及兼容补充(代理对区间)
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** 判断码点是否属于 dense script 区间(这些文字通常每字 1 token)。 */
|
|
22
|
+
function isDenseScript(codePoint: number): boolean {
|
|
23
|
+
return DENSE_SCRIPT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ASCII_CHARS_PER_TOKEN = 4;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 分段启发式估算 token 数:dense script 字符按 1 token/字,其余按
|
|
30
|
+
* 4 字符 1 token。结果是展示用估算值,不保证与任何具体模型 tokenizer 一致。
|
|
31
|
+
*/
|
|
32
|
+
export function estimateHeuristicTokens(text: string): number {
|
|
33
|
+
let dense = 0;
|
|
34
|
+
let rest = 0;
|
|
35
|
+
for (const char of text) {
|
|
36
|
+
if (isDenseScript(char.codePointAt(0) ?? 0)) dense += 1;
|
|
37
|
+
else rest += 1;
|
|
38
|
+
}
|
|
39
|
+
return dense + Math.ceil(rest / ASCII_CHARS_PER_TOKEN);
|
|
40
|
+
}
|