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.
- package/README.md +21 -2
- package/bin/cli.mjs +89 -36
- package/package.json +9 -2
- package/src/adapter.mjs +122 -383
- package/src/atomic-file.mjs +63 -0
- package/src/claude-config.mjs +7 -7
- package/src/claude-desktop-config.mjs +3 -4
- package/src/cli-options.mjs +69 -0
- package/src/config.mjs +10 -6
- package/src/copilot.mjs +20 -161
- package/src/file-rotation.mjs +30 -0
- package/src/http-transport.mjs +182 -0
- package/src/image-optimization.mjs +151 -0
- package/src/log.mjs +9 -1
- package/src/models.mjs +16 -0
- package/src/response-history.mjs +195 -0
- package/src/shutdown.mjs +29 -0
- package/src/startup.mjs +19 -0
- package/src/stream.mjs +2 -1
- package/src/usage.mjs +57 -14
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/shutdown.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/startup.mjs
ADDED
|
@@ -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
|
|
148
|
+
export async function flushUsageWrites() {
|
|
149
|
+
await writeQueue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function* iterateUsageRecords(filePath) {
|
|
153
|
+
let input;
|
|
141
154
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
const byModel = new Map();
|
|
185
|
+
const summary = { requests: 0, totals: {}, byModel: {} };
|
|
159
186
|
for (const record of records) {
|
|
160
|
-
|
|
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
|
|
167
|
-
const modelTotals = byModel
|
|
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
|
|
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
|
|
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.");
|