codex-copilot-dx 0.4.25 → 0.4.27

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,151 @@
1
+ import { status } from "./status.mjs";
2
+
3
+ const IMG_MAX_DIM = Number.parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
4
+ const IMG_QUALITY = Number.parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
5
+ const IMG_MIN_BYTES = Number.parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
6
+ const DEFAULT_IMG_CONCURRENCY = 4;
7
+ const IMG_MAX_CONCURRENCY = 12;
8
+ const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
9
+ const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
10
+ let sharpImport = null;
11
+
12
+ export function parseImageConcurrency(value) {
13
+ const n = Number.parseInt(value, 10);
14
+ return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
15
+ }
16
+
17
+ async function sharp() {
18
+ sharpImport ||= import("sharp");
19
+ const mod = await sharpImport;
20
+ return mod.default || mod;
21
+ }
22
+
23
+ export async function optimizeImageDataUrl(dataUrl) {
24
+ if (IMG_OPT_DISABLED) return dataUrl;
25
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
26
+ const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
27
+ if (!match) return dataUrl;
28
+ const mime = match[1].toLowerCase();
29
+ const raw = Buffer.from(match[2], "base64");
30
+ if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
31
+
32
+ try {
33
+ const resize = await sharp();
34
+ const out = await resize(raw, { failOn: "none" })
35
+ .rotate()
36
+ .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
37
+ .webp({ quality: IMG_QUALITY, effort: 4 })
38
+ .toBuffer();
39
+ const ratio = ((out.length / raw.length) * 100).toFixed(1);
40
+ console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
41
+ return `data:image/webp;base64,${out.toString("base64")}`;
42
+ } catch (e) {
43
+ console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
44
+ return dataUrl;
45
+ }
46
+ }
47
+
48
+ export async function runWithConcurrency(taskFns, concurrency) {
49
+ if (!Array.isArray(taskFns) || taskFns.length === 0) return;
50
+ const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
51
+ let next = 0;
52
+ await Promise.all(Array.from({ length: limit }, async () => {
53
+ while (next < taskFns.length) {
54
+ const task = taskFns[next++];
55
+ await task();
56
+ }
57
+ }));
58
+ }
59
+
60
+ function visitImageParts(parts, tasks) {
61
+ if (!Array.isArray(parts)) return;
62
+ for (const part of parts) {
63
+ if (!part) continue;
64
+ if (part.type === "input_image" && typeof part.image_url === "string") {
65
+ tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
66
+ } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
67
+ tasks.push(async () => {
68
+ const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
69
+ const optimized = await optimizeImageDataUrl(dataUrl);
70
+ const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
71
+ if (match) {
72
+ part.source.media_type = match[1];
73
+ part.source.data = match[2];
74
+ }
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ export async function optimizeImagesInBody(reqBody) {
81
+ if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
82
+ const tasks = [];
83
+
84
+ for (const item of reqBody.input) {
85
+ if (!item) continue;
86
+ if (item.type === "message" && Array.isArray(item.content)) {
87
+ visitImageParts(item.content, tasks);
88
+ }
89
+ if (item.type === "function_call_output") {
90
+ if (Array.isArray(item.output)) {
91
+ visitImageParts(item.output, tasks);
92
+ } else if (typeof item.output === "string") {
93
+ const trimmed = item.output.trim();
94
+ if (trimmed.startsWith("[")) {
95
+ try {
96
+ const parsed = JSON.parse(trimmed);
97
+ if (Array.isArray(parsed)) {
98
+ const localTasks = [];
99
+ visitImageParts(parsed, localTasks);
100
+ if (localTasks.length) {
101
+ tasks.push(async () => {
102
+ await runWithConcurrency(localTasks, IMG_CONCURRENCY);
103
+ item.output = JSON.stringify(parsed);
104
+ });
105
+ }
106
+ }
107
+ } catch {
108
+ // Leave non-JSON tool output untouched.
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
116
+ return reqBody;
117
+ }
118
+
119
+ export function summarizeReqBody(reqBody) {
120
+ try {
121
+ const input = reqBody.input;
122
+ if (!Array.isArray(input)) return { items: 0, images: 0 };
123
+ let images = 0;
124
+ const countImages = (parts) => {
125
+ if (!Array.isArray(parts)) return;
126
+ for (const part of parts) {
127
+ if (part?.type === "input_image" || part?.type === "image") images += 1;
128
+ }
129
+ };
130
+
131
+ for (const item of input) {
132
+ if (item?.type === "message") countImages(item.content);
133
+ if (item?.type === "function_call_output") {
134
+ if (Array.isArray(item.output)) {
135
+ countImages(item.output);
136
+ } else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
137
+ try {
138
+ const parsed = JSON.parse(item.output);
139
+ if (Array.isArray(parsed)) countImages(parsed);
140
+ } catch {
141
+ // Ignore non-JSON tool output.
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ return { items: input.length, images };
148
+ } catch {
149
+ return { items: -1, images: -1 };
150
+ }
151
+ }
package/src/log.mjs CHANGED
@@ -3,8 +3,10 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { format } from "node:util";
5
5
  import { status } from "./status.mjs";
6
+ import { parseByteLimit, rotateFileIfNeededSync } from "./file-rotation.mjs";
6
7
 
7
8
  let globalInstall = null;
9
+ const DEFAULT_LOG_MAX_BYTES = 16 * 1024 * 1024;
8
10
 
9
11
  export function normalizeLogLevel(value) {
10
12
  return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
@@ -27,6 +29,10 @@ export function isDebugLoggingEnabled(env = process.env) {
27
29
  return normalizeLogLevel(env.CCDX_LOG_LEVEL) === "debug";
28
30
  }
29
31
 
32
+ export function debugLogMaxBytes(env = process.env) {
33
+ return parseByteLimit(env.CCDX_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES);
34
+ }
35
+
30
36
  function renderLogPayload(args) {
31
37
  const stamp = new Date().toISOString();
32
38
  const text = format(...args);
@@ -56,7 +62,9 @@ export function configureLogging({
56
62
 
57
63
  function append(args) {
58
64
  try {
59
- fs.appendFileSync(filePath, renderLogPayload(args), { encoding: "utf8", mode: 0o600 });
65
+ const payload = renderLogPayload(args);
66
+ rotateFileIfNeededSync(filePath, Buffer.byteLength(payload), debugLogMaxBytes(env));
67
+ fs.appendFileSync(filePath, payload, { encoding: "utf8", mode: 0o600 });
60
68
  } catch (e) {
61
69
  if (writeFailed) return;
62
70
  writeFailed = true;
package/src/models.mjs CHANGED
@@ -60,6 +60,9 @@ const DEFAULT_CLAUDE_MODEL_ALIAS_DEFS = [
60
60
  },
61
61
  ];
62
62
 
63
+ export const CODEX_AUTO_REVIEW_MODEL = "codex-auto-review";
64
+ export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.4-mini";
65
+
63
66
  function cleanList(value) {
64
67
  return String(value || "")
65
68
  .split(",")
@@ -209,6 +212,19 @@ export function resolveAnthropicModel(model, env = process.env, options = {}) {
209
212
  };
210
213
  }
211
214
 
215
+ export function resolveOpenAIModel(model, env = process.env) {
216
+ const requestedModel = String(model || "");
217
+ if (requestedModel !== CODEX_AUTO_REVIEW_MODEL) {
218
+ return { requestedModel, upstreamModel: requestedModel };
219
+ }
220
+
221
+ const configuredModel = String(env.CCDX_AUTO_REVIEW_MODEL || "").trim();
222
+ return {
223
+ requestedModel,
224
+ upstreamModel: configuredModel || DEFAULT_CODEX_AUTO_REVIEW_MODEL,
225
+ };
226
+ }
227
+
212
228
  export function anthropicModelInfo(model) {
213
229
  return {
214
230
  id: model.id,
@@ -0,0 +1,195 @@
1
+ import { httpError } from "./http-transport.mjs";
2
+
3
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
4
+ const DEFAULT_MAX_ENTRIES = 4096;
5
+
6
+ function positiveInt(value, fallback) {
7
+ const n = Number.parseInt(value, 10);
8
+ return Number.isFinite(n) && n > 0 ? n : fallback;
9
+ }
10
+
11
+ function cloneJson(value) {
12
+ return value === undefined ? undefined : structuredClone(value);
13
+ }
14
+
15
+ function jsonStringByteLength(value) {
16
+ let bytes = 2;
17
+ for (let index = 0; index < value.length; index += 1) {
18
+ const code = value.charCodeAt(index);
19
+ if (code === 0x22 || code === 0x5c) {
20
+ bytes += 2;
21
+ } else if (code <= 0x1f) {
22
+ bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(code) ? 2 : 6;
23
+ } else if (code <= 0x7f) {
24
+ bytes += 1;
25
+ } else if (code <= 0x7ff) {
26
+ bytes += 2;
27
+ } else if (code >= 0xd800 && code <= 0xdbff) {
28
+ const next = value.charCodeAt(index + 1);
29
+ if (next >= 0xdc00 && next <= 0xdfff) {
30
+ bytes += 4;
31
+ index += 1;
32
+ } else {
33
+ bytes += 6;
34
+ }
35
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
36
+ bytes += 6;
37
+ } else {
38
+ bytes += 3;
39
+ }
40
+ }
41
+ return bytes;
42
+ }
43
+
44
+ function jsonByteLength(value, seen = new Set()) {
45
+ if (value === null) return 4;
46
+ if (typeof value === "string") return jsonStringByteLength(value);
47
+ if (typeof value === "number") return Buffer.byteLength(JSON.stringify(value));
48
+ if (typeof value === "boolean") return value ? 4 : 5;
49
+ if (Array.isArray(value)) {
50
+ if (seen.has(value)) throw new TypeError("Circular response history value");
51
+ seen.add(value);
52
+ let bytes = 2 + Math.max(0, value.length - 1);
53
+ for (const item of value) {
54
+ bytes += item === undefined || typeof item === "function" || typeof item === "symbol"
55
+ ? 4
56
+ : jsonByteLength(item, seen);
57
+ }
58
+ seen.delete(value);
59
+ return bytes;
60
+ }
61
+ if (value && typeof value === "object") {
62
+ if (seen.has(value)) throw new TypeError("Circular response history value");
63
+ seen.add(value);
64
+ const entries = Object.entries(value)
65
+ .filter(([, item]) => item !== undefined && typeof item !== "function" && typeof item !== "symbol");
66
+ let bytes = 2 + Math.max(0, entries.length - 1);
67
+ for (const [key, item] of entries) bytes += jsonStringByteLength(key) + 1 + jsonByteLength(item, seen);
68
+ seen.delete(value);
69
+ return bytes;
70
+ }
71
+ return 4;
72
+ }
73
+
74
+ const histories = new Map();
75
+ const childrenById = new Map();
76
+ const evictedIds = new Set();
77
+ let totalBytes = 0;
78
+ let maxBytes = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_BYTES, DEFAULT_MAX_BYTES);
79
+ let maxEntries = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, DEFAULT_MAX_ENTRIES);
80
+
81
+ function rememberEvictedId(id) {
82
+ evictedIds.add(id);
83
+ while (evictedIds.size > 256) evictedIds.delete(evictedIds.values().next().value);
84
+ }
85
+
86
+ function linkChild(parentId, id) {
87
+ if (!parentId) return;
88
+ let children = childrenById.get(parentId);
89
+ if (!children) {
90
+ children = new Set();
91
+ childrenById.set(parentId, children);
92
+ }
93
+ children.add(id);
94
+ }
95
+
96
+ function unlinkChild(parentId, id) {
97
+ if (!parentId) return;
98
+ const children = childrenById.get(parentId);
99
+ if (!children) return;
100
+ children.delete(id);
101
+ if (!children.size) childrenById.delete(parentId);
102
+ }
103
+
104
+ function removeSubtree(rootId) {
105
+ const pending = [rootId];
106
+ const removed = new Set();
107
+ while (pending.length) {
108
+ const id = pending.pop();
109
+ if (removed.has(id)) continue;
110
+ removed.add(id);
111
+ const children = childrenById.get(id);
112
+ if (children) pending.push(...children);
113
+ }
114
+ for (const id of removed) {
115
+ const entry = histories.get(id);
116
+ if (!entry) continue;
117
+ unlinkChild(entry.parentId, id);
118
+ childrenById.delete(id);
119
+ totalBytes -= entry.bytes;
120
+ histories.delete(id);
121
+ rememberEvictedId(id);
122
+ }
123
+ }
124
+
125
+ function enforceLimits() {
126
+ while (histories.size > maxEntries || totalBytes > maxBytes) {
127
+ const oldestId = histories.keys().next().value;
128
+ if (!oldestId) break;
129
+ removeSubtree(oldestId);
130
+ }
131
+ }
132
+
133
+ export function clearResponseHistoryForTests() {
134
+ histories.clear();
135
+ childrenById.clear();
136
+ evictedIds.clear();
137
+ totalBytes = 0;
138
+ maxBytes = DEFAULT_MAX_BYTES;
139
+ maxEntries = DEFAULT_MAX_ENTRIES;
140
+ }
141
+
142
+ export function configureResponseHistoryForTests({ maxBytes: nextMaxBytes, maxEntries: nextMaxEntries } = {}) {
143
+ if (nextMaxBytes !== undefined) maxBytes = positiveInt(nextMaxBytes, DEFAULT_MAX_BYTES);
144
+ if (nextMaxEntries !== undefined) maxEntries = positiveInt(nextMaxEntries, DEFAULT_MAX_ENTRIES);
145
+ }
146
+
147
+ export function responseHistoryStats() {
148
+ return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size };
149
+ }
150
+
151
+ export function materializeResponseHistory(responseId) {
152
+ const chain = [];
153
+ const seen = new Set();
154
+ let currentId = responseId;
155
+ while (currentId) {
156
+ if (seen.has(currentId)) throw httpError(`Cycle detected in local response history: ${currentId}`, 500);
157
+ seen.add(currentId);
158
+ const entry = histories.get(currentId);
159
+ if (!entry) {
160
+ const reason = evictedIds.has(currentId) ? " was evicted after reaching the local history limit" : " is not available";
161
+ throw httpError(`previous_response_id${reason}: ${currentId}`, 400);
162
+ }
163
+ chain.push(entry);
164
+ currentId = entry.parentId;
165
+ }
166
+ const items = [];
167
+ for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
168
+ return cloneJson(items);
169
+ }
170
+
171
+ export function rememberResponseHistoryNode({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
172
+ if (!id || !Array.isArray(inputItems)) return;
173
+ const bytes = jsonByteLength([inputItems, outputItems]);
174
+ if (bytes > maxBytes) {
175
+ if (histories.has(id)) removeSubtree(id);
176
+ rememberEvictedId(id);
177
+ return;
178
+ }
179
+ const entry = {
180
+ parentId: parentId || null,
181
+ inputItems: takeOwnership ? inputItems : cloneJson(inputItems),
182
+ outputItems: takeOwnership ? outputItems : cloneJson(outputItems),
183
+ bytes,
184
+ };
185
+ const existing = histories.get(id);
186
+ if (existing) {
187
+ totalBytes -= existing.bytes;
188
+ unlinkChild(existing.parentId, id);
189
+ }
190
+ histories.set(id, entry);
191
+ linkChild(entry.parentId, id);
192
+ totalBytes += entry.bytes;
193
+ evictedIds.delete(id);
194
+ enforceLimits();
195
+ }
@@ -0,0 +1,29 @@
1
+ export async function closeHttpServer(server, { timeoutMs = 5000 } = {}) {
2
+ if (!server?.listening) return { forced: false };
3
+
4
+ return new Promise((resolve, reject) => {
5
+ let settled = false;
6
+ let timer;
7
+ const finish = (error, forced = false) => {
8
+ if (settled) return;
9
+ settled = true;
10
+ if (timer) clearTimeout(timer);
11
+ if (error) reject(error);
12
+ else resolve({ forced });
13
+ };
14
+
15
+ timer = setTimeout(() => {
16
+ server.closeAllConnections?.();
17
+ finish(null, true);
18
+ }, timeoutMs);
19
+ timer.unref?.();
20
+
21
+ try {
22
+ server.close((error) => finish(error));
23
+ server.closeIdleConnections?.();
24
+ } catch (e) {
25
+ if (e?.code === "ERR_SERVER_NOT_RUNNING") finish(null);
26
+ else finish(e);
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,19 @@
1
+ export async function initializeModelRegistry({
2
+ loadCached,
3
+ currentModelDefs,
4
+ refresh,
5
+ onBackgroundError = () => {},
6
+ }) {
7
+ if (!loadCached()) {
8
+ return { modelDefs: await refresh(), source: "live", backgroundRefresh: null };
9
+ }
10
+
11
+ const backgroundRefresh = Promise.resolve()
12
+ .then(refresh)
13
+ .catch(onBackgroundError);
14
+ return { modelDefs: currentModelDefs(), source: "cache", backgroundRefresh };
15
+ }
16
+
17
+ export function runInBackground(task, onError = () => {}) {
18
+ return Promise.resolve().then(task).catch(onError);
19
+ }
package/src/stream.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Yield fetch Response body lines while preserving SSE semantics for callers.
2
2
  // Strip CRLF tails and release the reader if iteration stops early.
3
- export async function* webStreamLines(response) {
3
+ export async function* webStreamLines(response, { onChunk } = {}) {
4
4
  const reader = response.body.getReader();
5
5
  const decoder = new TextDecoder();
6
6
  let buf = "";
@@ -8,6 +8,7 @@ export async function* webStreamLines(response) {
8
8
  while (true) {
9
9
  const { done, value } = await reader.read();
10
10
  if (done) break;
11
+ onChunk?.(value);
11
12
  buf += decoder.decode(value, { stream: true });
12
13
  const lines = buf.split("\n");
13
14
  buf = lines.pop();
package/src/usage.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import readline from "node:readline";
5
+ import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
4
6
 
5
7
  const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
8
+ const DEFAULT_USAGE_MAX_BYTES = 32 * 1024 * 1024;
6
9
 
7
10
  let writeQueue = Promise.resolve();
8
11
 
@@ -10,6 +13,10 @@ export function usageLogPath() {
10
13
  return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
11
14
  }
12
15
 
16
+ export function usageLogMaxBytes(env = process.env) {
17
+ return parseByteLimit(env.CCDX_USAGE_MAX_BYTES, DEFAULT_USAGE_MAX_BYTES);
18
+ }
19
+
13
20
  function numberOrUndefined(value) {
14
21
  return Number.isFinite(value) ? value : undefined;
15
22
  }
@@ -119,6 +126,7 @@ export function recordUsage(record) {
119
126
  .catch(() => {})
120
127
  .then(async () => {
121
128
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
129
+ rotateFileIfNeededSync(filePath, Buffer.byteLength(line), usageLogMaxBytes());
122
130
  await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
123
131
  })
124
132
  .catch((e) => console.error(`codex-copilot-dx usage log write failed: ${e.message}`));
@@ -137,14 +145,34 @@ export async function flushUsageWritesForTests() {
137
145
  await writeQueue;
138
146
  }
139
147
 
140
- export async function readUsageRecords(filePath = usageLogPath()) {
148
+ export async function flushUsageWrites() {
149
+ await writeQueue;
150
+ }
151
+
152
+ async function* iterateUsageRecords(filePath) {
153
+ let input;
141
154
  try {
142
- const text = await fs.promises.readFile(filePath, "utf8");
143
- return text.split(/\n/).filter(Boolean).map((line) => JSON.parse(line));
155
+ input = fs.createReadStream(filePath, { encoding: "utf8" });
156
+ await new Promise((resolve, reject) => {
157
+ input.once("open", resolve);
158
+ input.once("error", reject);
159
+ });
144
160
  } catch (e) {
145
- if (e.code === "ENOENT") return [];
161
+ input?.destroy();
162
+ if (e?.code === "ENOENT") return;
146
163
  throw e;
147
164
  }
165
+
166
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
167
+ for await (const line of lines) {
168
+ if (line) yield JSON.parse(line);
169
+ }
170
+ }
171
+
172
+ export async function readUsageRecords(filePath = usageLogPath()) {
173
+ const records = [];
174
+ for await (const record of iterateUsageRecords(filePath)) records.push(record);
175
+ return records;
148
176
  }
149
177
 
150
178
  function addUsageTotals(target, usage = {}) {
@@ -154,21 +182,37 @@ function addUsageTotals(target, usage = {}) {
154
182
  }
155
183
 
156
184
  export function summarizeUsage(records) {
157
- const totals = {};
158
- const byModel = new Map();
185
+ const summary = { requests: 0, totals: {}, byModel: {} };
159
186
  for (const record of records) {
160
- addUsageTotals(totals, record.usage);
187
+ summary.requests += 1;
188
+ addUsageTotals(summary.totals, record.usage);
161
189
  if (record.copilot_usage) {
162
- totals.copilot_total_tokens = (totals.copilot_total_tokens || 0) + (record.copilot_usage.total_tokens || 0);
163
- totals.total_nano_aiu = (totals.total_nano_aiu || 0) + (record.copilot_usage.total_nano_aiu || 0);
190
+ summary.totals.copilot_total_tokens = (summary.totals.copilot_total_tokens || 0) + (record.copilot_usage.total_tokens || 0);
191
+ summary.totals.total_nano_aiu = (summary.totals.total_nano_aiu || 0) + (record.copilot_usage.total_nano_aiu || 0);
164
192
  }
165
193
  const model = record.model || "unknown";
166
- if (!byModel.has(model)) byModel.set(model, { requests: 0 });
167
- const modelTotals = byModel.get(model);
194
+ if (!summary.byModel[model]) summary.byModel[model] = { requests: 0 };
195
+ const modelTotals = summary.byModel[model];
168
196
  modelTotals.requests += 1;
169
197
  addUsageTotals(modelTotals, record.usage);
170
198
  }
171
- return { requests: records.length, totals, byModel: Object.fromEntries(byModel) };
199
+ return summary;
200
+ }
201
+
202
+ export async function summarizeUsageLogs(filePath = usageLogPath()) {
203
+ const summary = { requests: 0, totals: {}, byModel: {} };
204
+ for (const candidate of [rotatedFilePath(filePath), filePath]) {
205
+ for await (const record of iterateUsageRecords(candidate)) {
206
+ const one = summarizeUsage([record]);
207
+ summary.requests += one.requests;
208
+ addUsageTotals(summary.totals, one.totals);
209
+ for (const [model, values] of Object.entries(one.byModel)) {
210
+ if (!summary.byModel[model]) summary.byModel[model] = { requests: 0 };
211
+ addUsageTotals(summary.byModel[model], values);
212
+ }
213
+ }
214
+ }
215
+ return summary;
172
216
  }
173
217
 
174
218
  function fmt(n) {
@@ -176,8 +220,7 @@ function fmt(n) {
176
220
  }
177
221
 
178
222
  export async function printUsageSummary() {
179
- const records = await readUsageRecords();
180
- const summary = summarizeUsage(records);
223
+ const summary = await summarizeUsageLogs();
181
224
  console.log(`Usage log: ${usageLogPath()}`);
182
225
  if (summary.requests === 0) {
183
226
  console.log("No usage records yet.");