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 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, `chars/4` estimated original/summary tokens, 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.
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. 单次提炼超时后按照 `timeoutRetryCount` 重试,其他异常按照 `errorRetryCount` 重试(两者默认都重试 1 次);全部尝试失败、没有可用模型或结果收益过低时,扩展保留原始事实,并通过 details 和审计卡片暴露状态;模型用 Markdown 的 JSON 代码围栏(如 `````json … `````)包裹响应时也会兼容解析。
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
- 统计包括工具结果数量、成功/失败/回退次数、模型尝试次数、原始与摘要字符数、压缩比、按 `chars/4` 估算的原文/摘要 Token、预计节省 Token、提炼实际消耗的 input/output/cache/total Token 和成本。数量达到 1,000 或 1,000,000 时分别使用 `k` 或 `m` 紧凑显示;耗时会根据数值显示为 `ms`、`s` 或 `min`。原文/摘要 Token 是估算值;provider 未返回 usage 时,提炼消耗 Token 或成本字段显示为不可用。
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": "Original restored"
3
+ "zh-CN": "已回退原文",
4
+ "en-US": "Original restored"
9
5
  },
10
6
  "disabled": {
11
- "zh-CN": "已禁用",
12
- "en-US": "Disabled"
7
+ "zh-CN": "已禁用",
8
+ "en-US": "Disabled"
13
9
  },
14
10
  "off": {
15
- "zh-CN": "关闭",
16
- "en-US": "Off"
11
+ "zh-CN": "关闭",
12
+ "en-US": "Off"
17
13
  },
18
14
  "original": {
19
- "zh-CN": "原始输出",
20
- "en-US": "Original"
15
+ "zh-CN": "原始输出",
16
+ "en-US": "Original"
21
17
  },
22
18
  "raw": {
23
- "zh-CN": "RAW",
24
- "en-US": "RAW"
19
+ "zh-CN": "RAW",
20
+ "en-US": "RAW"
25
21
  },
26
22
  "belowThreshold": {
27
- "zh-CN": "低于阈值",
28
- "en-US": "Below threshold"
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": "! Read failed"
31
+ "zh-CN": "读取失败",
32
+ "en-US": "Read failed"
37
33
  },
38
34
  "summaryFailed": {
39
- "zh-CN": "提炼失败",
40
- "en-US": "Summary failed"
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": "Token",
48
- "en-US": "tokens"
43
+ "zh-CN": "tok",
44
+ "en-US": "tok"
49
45
  },
50
46
  "compressionTokens": {
51
- "zh-CN": "压缩消耗",
52
- "en-US": "compression"
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": "Tool"
52
+ "en-US": "tool"
65
53
  },
66
54
  "distill": {
67
55
  "zh-CN": "提炼",
68
- "en-US": "Distill"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-distill",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Pi tool-output distillation with file-first configuration",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,7 +17,10 @@ type AuditTone = "success" | "muted" | "dim" | "warning" | "error";
17
17
 
18
18
  type DistillAuditView = {
19
19
  lines: string[];
20
- statusLabel: string;
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(" Distill"));
69
- const afterTitle = line.slice("◇ Distill".length);
70
- const statusIndex = afterTitle.indexOf(audit.statusLabel);
71
- if (statusIndex < 0) return `${title}${theme.fg("muted", afterTitle)}`;
72
- const beforeStatus = afterTitle.slice(0, statusIndex);
73
- const afterStatus = afterTitle.slice(statusIndex + audit.statusLabel.length);
74
- return `${title}${theme.fg("toolTitle", beforeStatus)}${theme.fg(audit.statusTone, audit.statusLabel)}${theme.fg("muted", afterStatus)}`;
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, { label: string; tone: AuditTone }> = {
183
- summarized: { label: i18n.t("summarized"), tone: "success" },
184
- "summary-fallback": { label: i18n.t("summaryFallback"), tone: "warning" },
185
- disabled: { label: i18n.t("disabled"), tone: "dim" },
186
- "disabled-by-config": { label: i18n.t("off"), tone: "dim" },
187
- "not-requested": { label: i18n.t("original"), tone: "muted" },
188
- "full-output": { label: i18n.t("raw"), tone: "warning" },
189
- "below-threshold": { label: i18n.t("belowThreshold"), tone: "dim" },
190
- "non-text-output": { label: i18n.t("nonTextOutput"), tone: "muted" },
191
- "diagnostic-failed": { label: i18n.t("readFailed"), tone: "warning" },
192
- "summary-failed": { label: i18n.t("summaryFailed"), tone: "error" },
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] ?? { label: status, tone: "muted" as const };
213
- const metrics: string[] = [];
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
- const tokenMetric = `≈${formatCompactCount(estimatedOriginalTokens)} → ${formatCompactCount(estimatedSummaryTokens)} ${i18n.t("tokens")}`;
217
- const savedMetric = estimatedTokensSaved !== undefined && estimatedTokensSaved > 0
218
- ? `(${i18n.t("tokensSaved")} ≈${formatCompactCount(estimatedTokensSaved)}${compressionSavedPercent !== undefined ? `,${compressionSavedPercent.toFixed(1)}%` : ""})`
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
- metrics.push(`${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`);
223
- if (compressionRatio !== undefined) metrics.push(`${compressionRatio.toFixed(2)}×`);
226
+ mainMetric = `${formatCount(originalChars)} → ${formatCount(summaryChars)} ${i18n.t("chars")}`;
224
227
  if (compressionSavedPercent !== undefined) {
225
- metrics.push(`${compressionSavedPercent.toFixed(1)}% ${i18n.t("savedPercent")}`);
228
+ mainMetric += ` ↓${compressionSavedPercent.toFixed(1)}%`;
226
229
  }
227
230
  } else if (originalChars !== undefined) {
228
- metrics.push(`${formatCount(originalChars)} ${i18n.t("chars")}`);
231
+ mainMetric = `${formatCount(originalChars)} ${i18n.t("chars")}`;
229
232
  }
233
+
234
+ const secondaryMetrics: string[] = [];
230
235
  if (summaryTotalTokens !== undefined && summaryTotalTokens > 0) {
231
- metrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)} ${i18n.t("tokens")}`);
236
+ secondaryMetrics.push(`${i18n.t("compressionTokens")} ${formatCompactCount(summaryTotalTokens)}`);
232
237
  }
233
- if (toolExecutionMs !== undefined && toolExecutionMs >= 50) {
234
- metrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
238
+ if (toolExecutionMs !== undefined && toolExecutionMs >= MIN_TOOL_DURATION_DISPLAY_MS) {
239
+ secondaryMetrics.push(`${i18n.t("tool")} ${formatDuration(toolExecutionMs)}`);
235
240
  }
236
- if (summaryDurationMs !== undefined) metrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
241
+ if (summaryDurationMs !== undefined) secondaryMetrics.push(`${i18n.t("distill")} ${formatDuration(summaryDurationMs)}`);
237
242
 
238
243
  const expandHint = expanded ? "" : i18n.t("expand");
239
- const lines = [
240
- i18n.t("header", { status: `${statusView.label}${metrics.length > 0 ? ` ${metrics.join(" · ")}` : ""}${expandHint}` }),
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
- statusLabel: statusView.label,
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
- /** 原始输出与最终摘要的 Token 数是按 Pi chars/4 规则估算的。 */
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 = estimateTextTokens(originalOutput);
289
- const estimatedSummaryTokens = estimateTextTokens(finalOutput);
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
+ }