@andares/pi-toolkits 0.3.1 → 0.4.1

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.
@@ -0,0 +1,371 @@
1
+ /**
2
+ * compact-model feature — dedicated model for context compaction.
3
+ *
4
+ * pi's /compact, auto-compact (threshold), and overflow-recovery compaction
5
+ * all summarize with the CURRENT session model by default. This feature lets
6
+ * a cheaper/faster model take over every summarization while the session
7
+ * model stays focused on the conversation:
8
+ *
9
+ * - takeover point: the `session_before_compact` extension event, which pi
10
+ * emits for ALL three trigger reasons (manual / threshold / overflow)
11
+ * before running its own compaction
12
+ * - prompts, message assembly, split-turn handling, output budgets, and the
13
+ * summary file-list tail are verbatim pi 0.87.1 built-in behavior — only
14
+ * the executing model differs
15
+ * - lossless fallback: unset config, unavailable model, empty/failed
16
+ * summary, or user abort → return undefined and pi's default compaction
17
+ * (session model) runs untouched. The extension can never make
18
+ * compaction worse than stock.
19
+ *
20
+ * Config lives in `<agent-dir>/pi-toolkits-compact.json` ("provider/id",
21
+ * see store.ts). /compact-model views/changes/clears it; auto-compact paths
22
+ * never open dialogs (handler only uses notify).
23
+ */
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ ModelRegistry,
28
+ SessionBeforeCompactEvent,
29
+ SessionBeforeCompactResult,
30
+ SessionStartEvent,
31
+ Theme,
32
+ } from "@earendil-works/pi-coding-agent";
33
+ import {
34
+ convertToLlm,
35
+ serializeConversation,
36
+ } from "@earendil-works/pi-coding-agent";
37
+ import { uuidv7 } from "@earendil-works/pi-ai";
38
+ import {
39
+ modelKey,
40
+ resolveConfiguredModel,
41
+ type AvailableModel,
42
+ } from "../../lib/model-config.js";
43
+ import { COMPACT_STATUS_KEY } from "./constants.js";
44
+ import {
45
+ buildHistoryPromptText,
46
+ buildTurnPrefixPromptText,
47
+ combineUsage,
48
+ computeFileLists,
49
+ formatFileOperations,
50
+ SUMMARIZATION_SYSTEM_PROMPT,
51
+ type UsageLike,
52
+ } from "./prompt.js";
53
+ import { getCompactStore, type CompactStore } from "./store.js";
54
+
55
+ /** Usage record type returned by ModelRegistry.complete(). */
56
+ type CompleteUsage = NonNullable<
57
+ Awaited<ReturnType<ModelRegistry["complete"]>>["usage"]
58
+ >;
59
+
60
+ /** Thrown when a summarization response cannot safely become a checkpoint. */
61
+ class SummarizationFailureError extends Error {}
62
+
63
+ /**
64
+ * One-shot summarization call through the configured compact model,
65
+ * mirroring pi's completeSummarization options: signal honored, no prompt
66
+ * cache writes, fresh routing session id.
67
+ */
68
+ async function completeSummary(
69
+ ctx: ExtensionContext,
70
+ model: AvailableModel,
71
+ promptText: string,
72
+ maxTokens: number,
73
+ signal: AbortSignal,
74
+ ): Promise<{ text: string; usage: CompleteUsage }> {
75
+ const response = await ctx.modelRegistry.complete(
76
+ model,
77
+ {
78
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
79
+ messages: [
80
+ {
81
+ role: "user",
82
+ content: [{ type: "text", text: promptText }],
83
+ timestamp: Date.now(),
84
+ },
85
+ ],
86
+ },
87
+ {
88
+ maxTokens,
89
+ signal,
90
+ cacheRetention: "none",
91
+ sessionId: uuidv7(),
92
+ },
93
+ );
94
+ // Same safety gates as the built-in compaction: an errored or
95
+ // length-capped response is not a usable checkpoint, and a summarizer
96
+ // that tries to call tools has gone off the rails.
97
+ if (response.stopReason === "error" || response.stopReason === "length") {
98
+ throw new SummarizationFailureError(
99
+ response.stopReason === "error"
100
+ ? `Summarization failed: ${response.errorMessage ?? "unknown error"}`
101
+ : "Summarization failed: generation hit the token cap and the summary is incomplete",
102
+ );
103
+ }
104
+ if (response.content.some((block) => block.type === "toolCall")) {
105
+ throw new SummarizationFailureError(
106
+ "Summarization attempted to call a tool",
107
+ );
108
+ }
109
+ const text = response.content
110
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
111
+ .join("\n");
112
+ return { text, usage: response.usage };
113
+ }
114
+
115
+ /**
116
+ * pi's output-budget formula (0.87.1 compaction.js): a fraction of
117
+ * reserveTokens clamped by the model's own output cap.
118
+ */
119
+ function summarizationBudget(
120
+ fraction: number,
121
+ reserveTokens: number,
122
+ modelMaxTokens: number,
123
+ ): number {
124
+ return Math.min(
125
+ Math.floor(fraction * reserveTokens),
126
+ modelMaxTokens > 0 ? modelMaxTokens : Number.POSITIVE_INFINITY,
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Run the full compaction summarization with the configured model,
132
+ * replicating pi 0.87.1's compact(): history summary (first-connection or
133
+ * iterative-update prompt, custom focus appended), plus a separate
134
+ * turn-prefix checkpoint when the cut point splits a turn, merged into one
135
+ * summary with the file-list tail. Returns undefined on any failure after
136
+ * notifying (pi default compaction then takes over).
137
+ */
138
+ async function runCompactModelCompaction(
139
+ ctx: ExtensionContext,
140
+ event: SessionBeforeCompactEvent,
141
+ model: AvailableModel,
142
+ ): Promise<SessionBeforeCompactResult | undefined> {
143
+ const { preparation, signal, customInstructions } = event;
144
+ const {
145
+ messagesToSummarize,
146
+ turnPrefixMessages,
147
+ isSplitTurn,
148
+ tokensBefore,
149
+ firstKeptEntryId,
150
+ previousSummary,
151
+ fileOps,
152
+ settings,
153
+ } = preparation;
154
+
155
+ try {
156
+ let summaryText: string;
157
+ let usage: CompleteUsage | undefined;
158
+
159
+ if (isSplitTurn && turnPrefixMessages.length > 0) {
160
+ // Split turn: summarize history (if any) and the turn prefix
161
+ // separately, then merge — exactly like the built-in compact().
162
+ let historyText = previousSummary ?? "No prior history.";
163
+ if (messagesToSummarize.length > 0) {
164
+ const history = await completeSummary(
165
+ ctx,
166
+ model,
167
+ buildHistoryPromptText(
168
+ serializeConversation(convertToLlm(messagesToSummarize)),
169
+ previousSummary,
170
+ customInstructions,
171
+ ),
172
+ summarizationBudget(
173
+ 0.8,
174
+ settings.reserveTokens,
175
+ model.maxTokens,
176
+ ),
177
+ signal,
178
+ );
179
+ historyText = history.text;
180
+ usage = history.usage;
181
+ }
182
+ const turnPrefix = await completeSummary(
183
+ ctx,
184
+ model,
185
+ buildTurnPrefixPromptText(
186
+ serializeConversation(convertToLlm(turnPrefixMessages)),
187
+ ),
188
+ summarizationBudget(0.5, settings.reserveTokens, model.maxTokens),
189
+ signal,
190
+ );
191
+ summaryText = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefix.text}`;
192
+ usage = usage
193
+ ? combineUsage(
194
+ usage as UsageLike,
195
+ turnPrefix.usage as UsageLike,
196
+ ) as CompleteUsage
197
+ : turnPrefix.usage;
198
+ } else {
199
+ const history = await completeSummary(
200
+ ctx,
201
+ model,
202
+ buildHistoryPromptText(
203
+ serializeConversation(convertToLlm(messagesToSummarize)),
204
+ previousSummary,
205
+ customInstructions,
206
+ ),
207
+ summarizationBudget(0.8, settings.reserveTokens, model.maxTokens),
208
+ signal,
209
+ );
210
+ summaryText = history.text;
211
+ usage = history.usage;
212
+ }
213
+
214
+ if (!summaryText.trim()) {
215
+ if (!signal.aborted) {
216
+ ctx.ui.notify(
217
+ "compact-model: 压缩摘要为空,已回落 pi 默认压缩。",
218
+ "warning",
219
+ );
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ // File-list tail + details carry (pi does not auto-carry for
225
+ // fromHook compactions — see plan), matching CompactionDetails shape.
226
+ const { readFiles, modifiedFiles } = computeFileLists(fileOps);
227
+ summaryText += formatFileOperations(readFiles, modifiedFiles);
228
+
229
+ return {
230
+ compaction: {
231
+ summary: summaryText,
232
+ firstKeptEntryId,
233
+ tokensBefore,
234
+ usage,
235
+ details: { readFiles, modifiedFiles },
236
+ },
237
+ };
238
+ } catch (error) {
239
+ // User abort is a clean cancel — pi handles the abort messaging.
240
+ if (signal.aborted) return undefined;
241
+ const message =
242
+ error instanceof Error ? error.message : String(error);
243
+ ctx.ui.notify(
244
+ `compact-model: 压缩摘要失败(${message}),已回落 pi 默认压缩。`,
245
+ "error",
246
+ );
247
+ return undefined;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Let the user pick a compact model (or clear the config) and persist the
253
+ * choice. Mirrors review's pickReviewModel, plus a leading clear option.
254
+ */
255
+ export async function pickCompactModel(
256
+ ctx: ExtensionContext,
257
+ store: CompactStore,
258
+ ): Promise<AvailableModel | undefined> {
259
+ if (!ctx.hasUI) {
260
+ ctx.ui.notify(
261
+ "compact-model: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-compact.json 手动配置。",
262
+ "error",
263
+ );
264
+ return undefined;
265
+ }
266
+ const available = ctx.modelRegistry.getAvailable();
267
+ if (available.length === 0) {
268
+ ctx.ui.notify(
269
+ "compact-model: 当前没有可用模型,请先在 pi 中配置模型。",
270
+ "error",
271
+ );
272
+ return undefined;
273
+ }
274
+ const configured = store.getModel();
275
+ const clearLabel = "✕ 清除配置(用 pi 默认压缩)";
276
+ const options = [
277
+ clearLabel,
278
+ ...available.map(
279
+ (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
280
+ ),
281
+ ];
282
+ const choice = await ctx.ui.select(
283
+ configured
284
+ ? `选择压缩模型 (当前: ${configured})`
285
+ : "选择压缩模型 (compact-model)",
286
+ options,
287
+ );
288
+ if (choice === undefined) {
289
+ ctx.ui.notify("compact-model: 已取消,压缩模型配置未变更。", "info");
290
+ return undefined;
291
+ }
292
+ if (choice === clearLabel) {
293
+ store.setModel(undefined);
294
+ ctx.ui.notify(
295
+ "compact-model: 已清除配置,压缩恢复 pi 默认(当前会话模型)。",
296
+ "info",
297
+ );
298
+ return undefined;
299
+ }
300
+ const model = available[options.indexOf(choice) - 1];
301
+ if (!model) return undefined;
302
+ store.setModel(modelKey(model));
303
+ ctx.ui.notify(
304
+ `compact-model: 已设置压缩模型「${model.name}」,后续压缩总结将由它执行。`,
305
+ "info",
306
+ );
307
+ return model;
308
+ }
309
+
310
+ export function registerCompact(pi: ExtensionAPI): void {
311
+ // Take over every compaction trigger (manual / threshold / overflow)
312
+ // when a compact model is configured; otherwise stay invisible.
313
+ pi.on(
314
+ "session_before_compact",
315
+ async (
316
+ event: SessionBeforeCompactEvent,
317
+ ctx: ExtensionContext,
318
+ ): Promise<SessionBeforeCompactResult | undefined> => {
319
+ const configured = getCompactStore().getModel();
320
+ if (!configured) return undefined;
321
+ const model = resolveConfiguredModel(ctx, configured);
322
+ if (!model) {
323
+ ctx.ui.notify(
324
+ `compact-model: 配置的压缩模型 ${configured} 不存在或不可用,已回落 pi 默认压缩。`,
325
+ "warning",
326
+ );
327
+ return undefined;
328
+ }
329
+ return runCompactModelCompaction(ctx, event, model);
330
+ },
331
+ );
332
+
333
+ // Config entry point: view, change, or clear the compact model.
334
+ pi.registerCommand("compact-model", {
335
+ description:
336
+ "Set, change, or clear the dedicated model used for /compact summaries",
337
+ handler: async (_args: string, ctx: ExtensionContext) => {
338
+ await pickCompactModel(ctx, getCompactStore());
339
+ },
340
+ });
341
+
342
+ // Footer indicator: muted "compact" while a compact model is configured.
343
+ let theme: Theme | undefined;
344
+ let latestCtx: ExtensionContext | undefined;
345
+
346
+ const updateFooterStatus = (ctx: ExtensionContext): void => {
347
+ ctx.ui.setStatus(
348
+ COMPACT_STATUS_KEY,
349
+ getCompactStore().getModel() && theme
350
+ ? theme.fg("muted", "compact")
351
+ : undefined,
352
+ );
353
+ };
354
+
355
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
356
+ latestCtx = ctx;
357
+ try {
358
+ const t = ctx.ui.theme;
359
+ if (t) {
360
+ theme = t;
361
+ }
362
+ } catch {
363
+ // No theme in this mode — footer indicator stays off.
364
+ }
365
+ updateFooterStatus(ctx);
366
+ });
367
+
368
+ getCompactStore().subscribe(() => {
369
+ if (latestCtx) updateFooterStatus(latestCtx);
370
+ });
371
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * compact-model feature — summarization prompts.
3
+ *
4
+ * Every prompt below is copied VERBATIM from pi 0.87.1's built-in
5
+ * compaction (dist/core/compaction/compaction.js and utils.js) so that
6
+ * summaries produced by the configured compact model are formatted exactly
7
+ * like pi's own compaction output. Only the executing model differs.
8
+ *
9
+ * Update these strings whenever pi changes its built-in prompts (diff
10
+ * dist/core/compaction/compaction.js between versions).
11
+ */
12
+
13
+ /** System prompt for every summarization call (pi 0.87.1 utils.js). */
14
+ export const SUMMARIZATION_SYSTEM_PROMPT =
15
+ "You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
16
+
17
+ /** First-connection history summary prompt (pi 0.87.1 compaction.js). */
18
+ export const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
19
+
20
+ Use this EXACT format:
21
+
22
+ ## Goal
23
+ [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
24
+
25
+ ## Constraints & Preferences
26
+ - [Any constraints, preferences, or requirements mentioned by user]
27
+ - [Or "(none)" if none were mentioned]
28
+
29
+ ## Progress
30
+ ### Done
31
+ - [x] [Completed tasks/changes]
32
+
33
+ ### In Progress
34
+ - [ ] [Current work]
35
+
36
+ ### Blocked
37
+ - [Issues preventing progress, if any]
38
+
39
+ ## Key Decisions
40
+ - **[Decision]**: [Brief rationale]
41
+
42
+ ## Next Steps
43
+ 1. [Ordered list of what should happen next]
44
+
45
+ ## Critical Context
46
+ - [Any data, examples, or references needed to continue]
47
+ - [Or "(none)" if not applicable]
48
+
49
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`;
50
+
51
+ /**
52
+ * Iterative-update rules, used when a previous summary exists
53
+ * (pi 0.87.1 compaction.js).
54
+ */
55
+ export const UPDATE_SUMMARIZATION_INSTRUCTIONS = `Update the existing structured summary with new information. RULES:
56
+ - PRESERVE all existing information from the previous summary
57
+ - ADD new progress, decisions, and context from the new messages
58
+ - UPDATE the Progress section: move items from "In Progress" to "Done" when completed
59
+ - UPDATE "Next Steps" based on what was accomplished
60
+ - PRESERVE exact file paths, function names, and error messages
61
+ - If something is no longer relevant, you may remove it
62
+
63
+ Use this EXACT format:
64
+
65
+ ## Goal
66
+ [Preserve existing goals, add new ones if the task expanded]
67
+
68
+ ## Constraints & Preferences
69
+ - [Preserve existing, add new ones discovered]
70
+
71
+ ## Progress
72
+ ### Done
73
+ - [x] [Include previously done items AND newly completed items]
74
+
75
+ ### In Progress
76
+ - [ ] [Current work - update based on progress]
77
+
78
+ ### Blocked
79
+ - [Current blockers - remove if resolved]
80
+
81
+ ## Key Decisions
82
+ - **[Decision]**: [Brief rationale] (preserve all previous, add new)
83
+
84
+ ## Next Steps
85
+ 1. [Update based on current state]
86
+
87
+ ## Critical Context
88
+ - [Preserve important context, add new if needed]
89
+
90
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`;
91
+
92
+ /** Full iterative-update prompt (leading sentence + rules, pi 0.87.1). */
93
+ export const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
94
+
95
+ ${UPDATE_SUMMARIZATION_INSTRUCTIONS}`;
96
+
97
+ /** Split-turn prefix checkpoint prompt (pi 0.87.1 compaction.js). */
98
+ export const TURN_PREFIX_SUMMARIZATION_PROMPT = `The messages above are earlier context from an ongoing conversation. Later messages are stored separately and do not need to be reconstructed.
99
+
100
+ Create a concise checkpoint of the user's request and the progress shown above. This checkpoint will be placed before the later messages so the conversation can continue with the necessary context.
101
+
102
+ ## Original Request
103
+ [What did the user ask for?]
104
+
105
+ ## Progress So Far
106
+ - [Key decisions and work completed in these messages]
107
+
108
+ ## Context Needed to Continue
109
+ - [Information from these messages needed to understand the later work]
110
+
111
+ Only summarize information explicitly present above. Do not infer or recreate later messages.`;
112
+
113
+ /**
114
+ * Build the history-summary prompt text (pi 0.87.1 generateSummaryWithUsage):
115
+ * conversation wrapped in tags, optional previous summary, then the base
116
+ * prompt with the custom focus appended (`Additional focus: ...` — the exact
117
+ * phrase pi's built-in compaction uses for /compact <instructions>).
118
+ */
119
+ export function buildHistoryPromptText(
120
+ conversationText: string,
121
+ previousSummary: string | undefined,
122
+ customInstructions: string | undefined,
123
+ ): string {
124
+ let basePrompt = previousSummary
125
+ ? UPDATE_SUMMARIZATION_PROMPT
126
+ : SUMMARIZATION_PROMPT;
127
+ if (customInstructions) {
128
+ basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
129
+ }
130
+ let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
131
+ if (previousSummary) {
132
+ promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
133
+ }
134
+ promptText += basePrompt;
135
+ return promptText;
136
+ }
137
+
138
+ /**
139
+ * Build the split-turn prefix prompt text (pi 0.87.1
140
+ * generateTurnPrefixSummary): `# Conversation` block followed by
141
+ * `# Instructions`.
142
+ */
143
+ export function buildTurnPrefixPromptText(conversationText: string): string {
144
+ return `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
145
+ }
146
+
147
+ /** File-operation sets extracted by pi's prepareCompaction. */
148
+ export interface FileOpsLike {
149
+ read: Set<string>;
150
+ written: Set<string>;
151
+ edited: Set<string>;
152
+ }
153
+
154
+ /**
155
+ * Compute read/modified file lists from compaction file operations
156
+ * (pi 0.87.1 utils.js computeFileLists): modified = written ∪ edited,
157
+ * read-only = read minus modified, both sorted.
158
+ */
159
+ export function computeFileLists(fileOps: FileOpsLike): {
160
+ readFiles: string[];
161
+ modifiedFiles: string[];
162
+ } {
163
+ const modified = new Set([...fileOps.edited, ...fileOps.written]);
164
+ // pi-lens-ignore: no-sort-without-comparator, — 逐字对齐 pi 0.87.1 的裸 .sort():文件路径按码元序排序(加 locale 比较器反而会改变顺序)
165
+ const readOnly = [...fileOps.read]
166
+ .filter((f) => !modified.has(f))
167
+ .sort();
168
+ // pi-lens-ignore: no-sort-without-comparator, — 同上(pi 0.87.1 computeFileLists 原文)
169
+ const modifiedFiles = [...modified].sort();
170
+ return { readFiles: readOnly, modifiedFiles };
171
+ }
172
+
173
+ /**
174
+ * Format file lists as XML tags appended to the summary (pi 0.87.1 utils.js
175
+ * formatFileOperations): sections joined by a blank line, prefixed by one.
176
+ */
177
+ export function formatFileOperations(
178
+ readFiles: string[],
179
+ modifiedFiles: string[],
180
+ ): string {
181
+ const sections: string[] = [];
182
+ if (readFiles.length > 0) {
183
+ sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
184
+ }
185
+ if (modifiedFiles.length > 0) {
186
+ sections.push(
187
+ `<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`,
188
+ );
189
+ }
190
+ if (sections.length === 0) return "";
191
+ return `\n\n${sections.join("\n\n")}`;
192
+ }
193
+
194
+ /**
195
+ * Combine two provider usage records field-wise (pi 0.87.1 compaction.js
196
+ * combineUsage): used when a split turn produces two summarization calls.
197
+ */
198
+ export function combineUsage<T extends UsageLike>(first: T, second: T): T {
199
+ return {
200
+ ...first,
201
+ input: first.input + second.input,
202
+ output: first.output + second.output,
203
+ cacheRead: first.cacheRead + second.cacheRead,
204
+ cacheWrite: first.cacheWrite + second.cacheWrite,
205
+ ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined
206
+ ? {
207
+ cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0),
208
+ }
209
+ : {}),
210
+ ...(first.reasoning !== undefined || second.reasoning !== undefined
211
+ ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) }
212
+ : {}),
213
+ totalTokens: first.totalTokens + second.totalTokens,
214
+ cost: {
215
+ input: first.cost.input + second.cost.input,
216
+ output: first.cost.output + second.cost.output,
217
+ cacheRead: first.cost.cacheRead + second.cost.cacheRead,
218
+ cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
219
+ total: first.cost.total + second.cost.total,
220
+ },
221
+ } as T;
222
+ }
223
+
224
+ /** Structural view of pi's Usage record (the fields combineUsage touches). */
225
+ export interface UsageLike {
226
+ input: number;
227
+ output: number;
228
+ cacheRead: number;
229
+ cacheWrite: number;
230
+ cacheWrite1h?: number;
231
+ reasoning?: number;
232
+ totalTokens: number;
233
+ cost: {
234
+ input: number;
235
+ output: number;
236
+ cacheRead: number;
237
+ cacheWrite: number;
238
+ total: number;
239
+ };
240
+ }