codex-copilot-dx 0.8.6 → 0.8.8
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 +9 -3
- package/package.json +1 -1
- package/src/adapter.mjs +17 -4
- package/src/cli-status.mjs +9 -1
- package/src/dashboard/index.html +78 -0
- package/src/dashboard/ui.css +83 -0
- package/src/dashboard/ui.js +347 -0
- package/src/dashboard-api.mjs +204 -0
- package/src/dashboard.mjs +30 -0
- package/src/observability.mjs +13 -0
- package/src/response-history.mjs +24 -1
- package/src/responses-bridge.mjs +54 -16
- package/src/responses-handler.mjs +21 -4
- package/src/responses-proxy.mjs +33 -1
- package/src/stream-performance.mjs +69 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
const element = (id) => document.getElementById(id);
|
|
2
|
+
const text = (id, value) => { element(id).textContent = value; };
|
|
3
|
+
const finite = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));
|
|
4
|
+
const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
|
|
5
|
+
const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
|
|
6
|
+
|
|
7
|
+
function duration(value) {
|
|
8
|
+
if (!finite(value)) return "—";
|
|
9
|
+
const seconds = Math.max(0, Math.round(Number(value) / 1000));
|
|
10
|
+
const days = Math.floor(seconds / 86400);
|
|
11
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
12
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
13
|
+
return days ? `${days}d ${hours}h` : hours ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderFailures(recent) {
|
|
17
|
+
const list = element("failures");
|
|
18
|
+
list.replaceChildren();
|
|
19
|
+
const failures = Array.isArray(recent) ? recent.slice(-5).reverse() : [];
|
|
20
|
+
if (!failures.length) {
|
|
21
|
+
const item = document.createElement("li");
|
|
22
|
+
item.className = "empty";
|
|
23
|
+
item.textContent = "No response.failed events recorded.";
|
|
24
|
+
list.append(item);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const failure of failures) {
|
|
28
|
+
const item = document.createElement("li");
|
|
29
|
+
const title = document.createElement("strong");
|
|
30
|
+
const detail = document.createElement("span");
|
|
31
|
+
title.textContent = `${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)}`;
|
|
32
|
+
detail.textContent = `${String(failure.message || "No message").slice(0, 240)}${failure.retried ? " / retried" : ""}`;
|
|
33
|
+
item.append(title, detail);
|
|
34
|
+
list.append(item);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function render(data) {
|
|
39
|
+
text("version", `v${data.version || "?"}`);
|
|
40
|
+
text("uptime", `pid ${number(data.pid)} / uptime ${duration(data.uptime_ms)}`);
|
|
41
|
+
|
|
42
|
+
const copilot = data.copilot || {};
|
|
43
|
+
text("copilot", copilot.account_bound ? "BOUND" : "UNCONFIRMED");
|
|
44
|
+
text("token", copilot.token_cached ? `service token / TTL ${duration(copilot.token_expires_in_ms)}` : "service token not cached");
|
|
45
|
+
|
|
46
|
+
const models = data.models || {};
|
|
47
|
+
text("models", number(models.models));
|
|
48
|
+
text("model-source", `source ${String(models.source || "unknown").slice(0, 80)} / live: ccdx models`);
|
|
49
|
+
|
|
50
|
+
const requests = data.requests || {};
|
|
51
|
+
text("requests", number(requests.total));
|
|
52
|
+
text("request-errors", `4xx ${number(requests.status_4xx)} / 5xx ${number(requests.status_5xx)} / active ${number(requests.active)}`);
|
|
53
|
+
|
|
54
|
+
const outcomes = data.stream_performance?.by_route?.responses?.terminal_outcomes?.totals || {};
|
|
55
|
+
for (const key of ["completed", "incomplete", "failed", "cancelled"]) text(key, number(outcomes[key]));
|
|
56
|
+
|
|
57
|
+
const history = data.response_history || {};
|
|
58
|
+
const limit = Number(history.maxBytes || data.limits?.response_history_max_bytes || 0);
|
|
59
|
+
const used = Number(history.bytes || 0);
|
|
60
|
+
text("history", `${mib(used)} / ${mib(limit)}`);
|
|
61
|
+
text("history-detail", `entries ${number(history.entries)} / trees ${number(history.tree_count)} / misses ${number(history.lookup_misses)} (evicted ${number(history.evicted_lookup_misses)})`);
|
|
62
|
+
element("history-meter").value = limit > 0 ? Math.min(100, Math.max(0, used / limit * 100)) : 0;
|
|
63
|
+
|
|
64
|
+
const image = data.image_generation;
|
|
65
|
+
text("image", image ? `${number(image.succeeded)} succeeded` : "NOT INITIALIZED");
|
|
66
|
+
text("image-detail", image
|
|
67
|
+
? `active ${number(image.active)} / failed ${number(image.failed)} / delivery failures ${number(image.delivery_failures)}`
|
|
68
|
+
: "Setup state unknown / ccdx image-status");
|
|
69
|
+
|
|
70
|
+
renderFailures(data.response_failures?.recent);
|
|
71
|
+
text("updated", `Snapshot ${new Date().toLocaleTimeString()}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function refresh() {
|
|
75
|
+
const button = element("refresh");
|
|
76
|
+
button.disabled = true;
|
|
77
|
+
text("connection", "READING");
|
|
78
|
+
element("connection").className = "badge neutral";
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch("/_ccdx/status", {
|
|
81
|
+
cache: "no-store",
|
|
82
|
+
headers: { Accept: "application/json" },
|
|
83
|
+
signal: AbortSignal.timeout(5000),
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
86
|
+
const data = await response.json();
|
|
87
|
+
if (data?.ok !== true || data?.name !== "codex-copilot-dx") throw new Error("Unexpected status response");
|
|
88
|
+
render(data);
|
|
89
|
+
text("connection", "LOCAL OK");
|
|
90
|
+
element("connection").className = "badge good";
|
|
91
|
+
} catch {
|
|
92
|
+
text("connection", "UNAVAILABLE");
|
|
93
|
+
element("connection").className = "badge bad";
|
|
94
|
+
text("updated", "Status unavailable / check ccdx, then refresh");
|
|
95
|
+
} finally {
|
|
96
|
+
button.disabled = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
element("refresh").addEventListener("click", refresh);
|
|
101
|
+
refresh();
|
|
102
|
+
|
|
103
|
+
const animationRows = new Map();
|
|
104
|
+
let animationTimelines = [];
|
|
105
|
+
let savedAnimation = "";
|
|
106
|
+
let selectedAnimation = "";
|
|
107
|
+
let previewDisabledByEnvironment = false;
|
|
108
|
+
let previewVisible = false;
|
|
109
|
+
let previewTimer = null;
|
|
110
|
+
let previewStarted = 0;
|
|
111
|
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
112
|
+
|
|
113
|
+
function renderAnsiFrame(target, frame) {
|
|
114
|
+
const fragments = String(frame).split(/\u001b\[([0-9;]+)m/);
|
|
115
|
+
const content = document.createDocumentFragment();
|
|
116
|
+
let color = "";
|
|
117
|
+
for (let index = 0; index < fragments.length; index += 2) {
|
|
118
|
+
if (fragments[index]) {
|
|
119
|
+
const span = document.createElement("span");
|
|
120
|
+
span.className = color;
|
|
121
|
+
span.textContent = fragments[index];
|
|
122
|
+
content.append(span);
|
|
123
|
+
}
|
|
124
|
+
const code = fragments[index + 1];
|
|
125
|
+
color = code === "97" ? "ansi-white"
|
|
126
|
+
: /^38;5;(159|123|87|51|45|39|33)$/.test(code || "") ? `ansi-${code.slice(5)}` : "";
|
|
127
|
+
}
|
|
128
|
+
target.replaceChildren(content);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderAnimationFrames(elapsed) {
|
|
132
|
+
for (const timeline of animationTimelines) {
|
|
133
|
+
const position = timeline.id === selectedAnimation ? elapsed % timeline.duration : 0;
|
|
134
|
+
const frameIndex = timeline.frames.findIndex(({ until }) => position < until);
|
|
135
|
+
if (frameIndex === timeline.lastFrame) continue;
|
|
136
|
+
timeline.lastFrame = frameIndex;
|
|
137
|
+
renderAnsiFrame(timeline.target, frameIndex < 0 ? `[${" ".repeat(20)}]` : timeline.frames[frameIndex].ansi);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stopAnimationPreview() {
|
|
142
|
+
if (previewTimer !== null) clearTimeout(previewTimer);
|
|
143
|
+
previewTimer = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function tickAnimationPreview() {
|
|
147
|
+
previewTimer = null;
|
|
148
|
+
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
|
|
149
|
+
renderAnimationFrames(performance.now() - previewStarted);
|
|
150
|
+
previewTimer = setTimeout(tickAnimationPreview, 32);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function syncAnimationPreview() {
|
|
154
|
+
stopAnimationPreview();
|
|
155
|
+
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
|
|
156
|
+
for (const timeline of animationTimelines) timeline.lastFrame = -2;
|
|
157
|
+
renderAnimationFrames(0);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
previewStarted = performance.now();
|
|
161
|
+
tickAnimationPreview();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
new IntersectionObserver(([entry]) => {
|
|
165
|
+
previewVisible = entry.isIntersecting;
|
|
166
|
+
syncAnimationPreview();
|
|
167
|
+
}).observe(element("animation-options"));
|
|
168
|
+
document.addEventListener("visibilitychange", syncAnimationPreview);
|
|
169
|
+
reducedMotion.addEventListener("change", syncAnimationPreview);
|
|
170
|
+
|
|
171
|
+
function buildAnimationOptions(data) {
|
|
172
|
+
const container = element("animation-options");
|
|
173
|
+
container.replaceChildren();
|
|
174
|
+
animationRows.clear();
|
|
175
|
+
animationTimelines = [];
|
|
176
|
+
savedAnimation = data.theme;
|
|
177
|
+
selectedAnimation = data.theme;
|
|
178
|
+
previewDisabledByEnvironment = data.disabled_by_environment === true;
|
|
179
|
+
for (const [index, theme] of data.themes.entries()) {
|
|
180
|
+
const option = document.createElement("button");
|
|
181
|
+
option.type = "button";
|
|
182
|
+
option.className = "theme-option";
|
|
183
|
+
option.setAttribute("aria-pressed", String(theme.id === data.theme));
|
|
184
|
+
option.addEventListener("click", () => {
|
|
185
|
+
selectedAnimation = theme.id;
|
|
186
|
+
for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
|
|
187
|
+
element("save-animation").disabled = selectedAnimation === savedAnimation;
|
|
188
|
+
syncAnimationPreview();
|
|
189
|
+
});
|
|
190
|
+
const name = document.createElement("span");
|
|
191
|
+
name.className = "theme-name";
|
|
192
|
+
name.textContent = `${String(index + 1).padStart(2, "0")} ${theme.label}`;
|
|
193
|
+
if (theme.default) {
|
|
194
|
+
const marker = document.createElement("small");
|
|
195
|
+
marker.textContent = "DEFAULT";
|
|
196
|
+
name.append(marker);
|
|
197
|
+
}
|
|
198
|
+
const preview = document.createElement("span");
|
|
199
|
+
preview.className = "theme-preview";
|
|
200
|
+
preview.setAttribute("aria-hidden", "true");
|
|
201
|
+
option.append(name, preview);
|
|
202
|
+
container.append(option);
|
|
203
|
+
animationRows.set(theme.id, option);
|
|
204
|
+
let until = 0;
|
|
205
|
+
const frames = theme.frames.map((frame) => {
|
|
206
|
+
until += frame.delay_ms;
|
|
207
|
+
return { ansi: frame.ansi, until };
|
|
208
|
+
});
|
|
209
|
+
animationTimelines.push({ id: theme.id, target: preview, frames, duration: until + theme.loop_pause_ms, lastFrame: -2 });
|
|
210
|
+
}
|
|
211
|
+
element("save-animation").disabled = true;
|
|
212
|
+
syncAnimationPreview();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function animationNote(data) {
|
|
216
|
+
return data.disabled_by_environment
|
|
217
|
+
? "Disabled by CCDX_TERMINAL_ANIMATION; saved choice applies after override removal and next start."
|
|
218
|
+
: "Saved choice applies on next ccdx start.";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function loadAnimation() {
|
|
222
|
+
text("animation-state", "READING");
|
|
223
|
+
try {
|
|
224
|
+
const response = await fetch("/_ccdx/ui/animation", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
|
|
225
|
+
const data = await response.json();
|
|
226
|
+
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
227
|
+
buildAnimationOptions(data);
|
|
228
|
+
text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
|
|
229
|
+
text("animation-note", animationNote(data));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
text("animation-state", "UNAVAILABLE");
|
|
232
|
+
text("animation-note", String(error.message || error).slice(0, 180));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function saveAnimation() {
|
|
237
|
+
const button = element("save-animation");
|
|
238
|
+
if (!selectedAnimation || selectedAnimation === savedAnimation) return;
|
|
239
|
+
button.disabled = true;
|
|
240
|
+
text("animation-note", "Saving…");
|
|
241
|
+
try {
|
|
242
|
+
const response = await fetch("/_ccdx/ui/animation", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json", "X-CCDX-Dashboard": "1" },
|
|
245
|
+
body: JSON.stringify({ theme: selectedAnimation }),
|
|
246
|
+
signal: AbortSignal.timeout(8000),
|
|
247
|
+
});
|
|
248
|
+
const data = await response.json();
|
|
249
|
+
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
250
|
+
savedAnimation = data.theme;
|
|
251
|
+
text("animation-state", `CURRENT ${data.theme.toUpperCase()}`);
|
|
252
|
+
text("animation-note", animationNote(data));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
|
|
255
|
+
} finally {
|
|
256
|
+
button.disabled = selectedAnimation === savedAnimation;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function emptyTable(bodyId, columns, message) {
|
|
261
|
+
const body = element(bodyId);
|
|
262
|
+
body.replaceChildren();
|
|
263
|
+
const row = document.createElement("tr");
|
|
264
|
+
const cell = document.createElement("td");
|
|
265
|
+
cell.colSpan = columns;
|
|
266
|
+
cell.textContent = message;
|
|
267
|
+
row.append(cell);
|
|
268
|
+
body.append(row);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function tableRow(values, { total = false } = {}) {
|
|
272
|
+
const row = document.createElement("tr");
|
|
273
|
+
if (total) row.className = "total-row";
|
|
274
|
+
for (const value of values) {
|
|
275
|
+
const cell = document.createElement("td");
|
|
276
|
+
cell.textContent = value;
|
|
277
|
+
row.append(cell);
|
|
278
|
+
}
|
|
279
|
+
return row;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadModels() {
|
|
283
|
+
const button = element("refresh-models");
|
|
284
|
+
button.disabled = true;
|
|
285
|
+
text("models-state", "CHECKING LIVE");
|
|
286
|
+
text("models-note", "Querying GitHub Copilot…");
|
|
287
|
+
emptyTable("live-models-body", 4, "Loading…");
|
|
288
|
+
try {
|
|
289
|
+
const response = await fetch("/_ccdx/ui/models/live", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
|
|
290
|
+
const data = await response.json();
|
|
291
|
+
if (!response.ok || data.source !== "live") throw new Error(data.error || `HTTP ${response.status}`);
|
|
292
|
+
const body = element("live-models-body");
|
|
293
|
+
body.replaceChildren();
|
|
294
|
+
for (const model of data.models) {
|
|
295
|
+
body.append(tableRow([model.id, model.vendor, model.endpoints.join(" / "), model.preview ? "preview" : "—"]));
|
|
296
|
+
}
|
|
297
|
+
if (!data.models.length) emptyTable("live-models-body", 4, "No selectable GPT models advertised.");
|
|
298
|
+
text("models-state", "LIVE SNAPSHOT");
|
|
299
|
+
text("models-note", `${number(data.selectable)} selectable / ${number(data.advertised)} advertised / ${data.upstream_host} / ${new Date(data.checked_at).toLocaleTimeString()}`);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
text("models-state", "LIVE LOOKUP FAILED");
|
|
302
|
+
text("models-note", String(error.message || error).slice(0, 240));
|
|
303
|
+
emptyTable("live-models-body", 4, "No live catalog available.");
|
|
304
|
+
} finally {
|
|
305
|
+
button.disabled = false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function usageValues(row) {
|
|
310
|
+
return [row.model, number(row.requests), number(row.input_tokens), number(row.cache_read_tokens), number(row.output_tokens), number(row.total_tokens)];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function loadUsage() {
|
|
314
|
+
const button = element("refresh-usage");
|
|
315
|
+
button.disabled = true;
|
|
316
|
+
text("usage-state", "READING LOG");
|
|
317
|
+
text("usage-note", "Aggregating local usage metadata…");
|
|
318
|
+
emptyTable("usage-body", 6, "Loading…");
|
|
319
|
+
try {
|
|
320
|
+
const response = await fetch("/_ccdx/ui/usage", { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
|
|
321
|
+
const data = await response.json();
|
|
322
|
+
if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
|
|
323
|
+
const body = element("usage-body");
|
|
324
|
+
body.replaceChildren();
|
|
325
|
+
if (data.total.requests > 0) {
|
|
326
|
+
body.append(tableRow(usageValues(data.total), { total: true }));
|
|
327
|
+
for (const row of data.rows) body.append(tableRow(usageValues(row)));
|
|
328
|
+
} else {
|
|
329
|
+
emptyTable("usage-body", 6, "No usage records.");
|
|
330
|
+
}
|
|
331
|
+
text("usage-state", "LOCAL LOG");
|
|
332
|
+
text("usage-note", `${number(data.total.requests)} records / ${number(data.rows.length)} of ${number(data.model_count)} models shown`);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
text("usage-state", "LOG UNAVAILABLE");
|
|
335
|
+
text("usage-note", String(error.message || error).slice(0, 180));
|
|
336
|
+
emptyTable("usage-body", 6, "No usage summary available.");
|
|
337
|
+
} finally {
|
|
338
|
+
button.disabled = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
element("save-animation").addEventListener("click", saveAnimation);
|
|
343
|
+
element("refresh-models").addEventListener("click", loadModels);
|
|
344
|
+
element("refresh-usage").addEventListener("click", loadUsage);
|
|
345
|
+
loadAnimation();
|
|
346
|
+
loadModels();
|
|
347
|
+
loadUsage();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { fetchLiveCopilotModels } from "./cli-models.mjs";
|
|
3
|
+
import { createRequestAbort, readJsonBody } from "./http-transport.mjs";
|
|
4
|
+
import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
7
|
+
getTerminalAnimationFrameDelay,
|
|
8
|
+
isTerminalAnimationTheme,
|
|
9
|
+
renderTerminalAnimationFrame,
|
|
10
|
+
TERMINAL_ANIMATION_THEMES,
|
|
11
|
+
} from "./terminal-animation.mjs";
|
|
12
|
+
import { summarizeUsageLogs } from "./usage.mjs";
|
|
13
|
+
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
14
|
+
|
|
15
|
+
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
16
|
+
const MODELS_PATH = "/_ccdx/ui/models/live";
|
|
17
|
+
const USAGE_PATH = "/_ccdx/ui/usage";
|
|
18
|
+
const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
|
|
19
|
+
|
|
20
|
+
function sendJson(res, statusCode, value) {
|
|
21
|
+
res.writeHead(statusCode, {
|
|
22
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
23
|
+
"Cache-Control": "no-store",
|
|
24
|
+
"X-Content-Type-Options": "nosniff",
|
|
25
|
+
});
|
|
26
|
+
res.end(JSON.stringify(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeText(value, length = 160) {
|
|
30
|
+
return String(value ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function liveModelError(error) {
|
|
34
|
+
const message = String(error?.message || error || "");
|
|
35
|
+
const status = /\bHTTP (\d{3})\b/.exec(message)?.[1];
|
|
36
|
+
if (status) return `Live model lookup failed (HTTP ${status}). Run ccdx models for details.`;
|
|
37
|
+
if (/timed out/i.test(message)) return "Live model lookup timed out. Retry or run ccdx models.";
|
|
38
|
+
if (/GitHub token not found/i.test(message)) return "GitHub token not found. Start ccdx to sign in.";
|
|
39
|
+
return "Live model lookup failed. Run ccdx models for details.";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function animationState(env, home, { includeFrames = false } = {}) {
|
|
43
|
+
readUserSettings({ env, home, strict: true });
|
|
44
|
+
const preference = terminalAnimationPreference({ env, home });
|
|
45
|
+
return {
|
|
46
|
+
theme: preference.theme,
|
|
47
|
+
source: preference.source,
|
|
48
|
+
disabled_by_environment: DISABLED_ANIMATION.test(String(env.CCDX_TERMINAL_ANIMATION || "").trim()),
|
|
49
|
+
themes: TERMINAL_ANIMATION_THEMES.map((theme) => ({
|
|
50
|
+
id: theme.id,
|
|
51
|
+
label: theme.label,
|
|
52
|
+
default: theme.id === DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
53
|
+
...(includeFrames ? {
|
|
54
|
+
loop_pause_ms: theme.loopPauseMs,
|
|
55
|
+
frames: Array.from({ length: theme.frameCount }, (_, offset) => {
|
|
56
|
+
const index = theme.startFrame + offset;
|
|
57
|
+
return {
|
|
58
|
+
ansi: renderTerminalAnimationFrame(theme.id, index),
|
|
59
|
+
delay_ms: getTerminalAnimationFrameDelay(theme.id, index),
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
} : {}),
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sameOrigin(req) {
|
|
68
|
+
const origin = req.headers?.origin;
|
|
69
|
+
const host = req.headers?.host;
|
|
70
|
+
if (typeof origin !== "string" || typeof host !== "string") return false;
|
|
71
|
+
try {
|
|
72
|
+
return new URL(origin).origin === new URL(`http://${host}`).origin;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function finite(value) {
|
|
79
|
+
return Number.isFinite(value) ? Math.max(0, value) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cacheReadTokens(usage = {}) {
|
|
83
|
+
const values = [usage.cache_read_input_tokens, usage.cached_input_tokens].filter(Number.isFinite);
|
|
84
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function usageRow(model, values) {
|
|
88
|
+
return {
|
|
89
|
+
model: safeText(model, 100),
|
|
90
|
+
requests: finite(values.requests),
|
|
91
|
+
input_tokens: finite(values.input_tokens),
|
|
92
|
+
cache_read_tokens: cacheReadTokens(values),
|
|
93
|
+
output_tokens: finite(values.output_tokens),
|
|
94
|
+
total_tokens: finite(values.total_tokens),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function usageTable(summary) {
|
|
99
|
+
const models = Object.entries(summary.byModel || {})
|
|
100
|
+
.sort(([leftName, left], [rightName, right]) => (right.total_tokens || 0) - (left.total_tokens || 0)
|
|
101
|
+
|| leftName.localeCompare(rightName, "en"));
|
|
102
|
+
return {
|
|
103
|
+
total: usageRow("TOTAL", { requests: summary.requests, ...summary.totals }),
|
|
104
|
+
model_count: models.length,
|
|
105
|
+
rows: models.slice(0, 100).map(([name, values]) => usageRow(name, values)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function handleDashboardApi(req, res, pathname, {
|
|
110
|
+
env = process.env,
|
|
111
|
+
home = os.homedir(),
|
|
112
|
+
liveModelsFn = () => fetchLiveCopilotModels({ home }),
|
|
113
|
+
usageSummaryFn = () => summarizeUsageLogs(),
|
|
114
|
+
} = {}) {
|
|
115
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
116
|
+
|| req.headers?.host === undefined
|
|
117
|
+
|| !isLoopbackHostHeader(req.headers.host)) {
|
|
118
|
+
sendJson(res, 403, { error: "Dashboard API is available only at a loopback address" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (req.headers?.["x-ccdx-dashboard"] !== "1") {
|
|
122
|
+
sendJson(res, 403, { error: "Dashboard API requires a same-origin page request" });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (pathname === ANIMATION_PATH && req.method === "GET") {
|
|
127
|
+
try {
|
|
128
|
+
sendJson(res, 200, animationState(env, home, { includeFrames: true }));
|
|
129
|
+
} catch {
|
|
130
|
+
sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (pathname === ANIMATION_PATH && req.method === "POST") {
|
|
136
|
+
if (!sameOrigin(req)) {
|
|
137
|
+
sendJson(res, 403, { error: "Animation changes require the same-origin dashboard" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase() !== "application/json") {
|
|
141
|
+
sendJson(res, 415, { error: "Animation changes require application/json" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const abort = createRequestAbort(req, res);
|
|
145
|
+
abort.setTimeout(5000, "request_body_timeout");
|
|
146
|
+
let body;
|
|
147
|
+
try {
|
|
148
|
+
body = await readJsonBody(req, { maxBodyBytes: 2048, maxDecodedBodyBytes: 2048, signal: abort.signal });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
sendJson(res, error?.statusCode === 413 ? 413 : 400, { error: "Invalid animation request body" });
|
|
151
|
+
return;
|
|
152
|
+
} finally {
|
|
153
|
+
abort.cleanup();
|
|
154
|
+
}
|
|
155
|
+
if (!isTerminalAnimationTheme(body?.theme)) {
|
|
156
|
+
sendJson(res, 400, { error: "Select a listed animation theme" });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const result = writeTerminalAnimationTheme(body.theme, { env, home });
|
|
161
|
+
sendJson(res, 200, { changed: result.changed, ...animationState(env, home) });
|
|
162
|
+
} catch {
|
|
163
|
+
sendJson(res, 409, { error: "Animation setting was not saved; check ccdx animation" });
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (pathname === MODELS_PATH && req.method === "GET") {
|
|
169
|
+
try {
|
|
170
|
+
const catalog = await liveModelsFn();
|
|
171
|
+
if (!Array.isArray(catalog?.models) || !Number.isFinite(catalog?.advertised)) {
|
|
172
|
+
throw new Error("Invalid live model catalog");
|
|
173
|
+
}
|
|
174
|
+
const models = catalog.models;
|
|
175
|
+
sendJson(res, 200, {
|
|
176
|
+
source: "live",
|
|
177
|
+
checked_at: new Date().toISOString(),
|
|
178
|
+
upstream_host: safeText(catalog?.upstreamHost || "GitHub Copilot", 120),
|
|
179
|
+
advertised: finite(catalog?.advertised),
|
|
180
|
+
selectable: models.length,
|
|
181
|
+
models: models.slice(0, 500).map((model) => ({
|
|
182
|
+
id: safeText(model.id, 100),
|
|
183
|
+
vendor: safeText(model.vendor, 80),
|
|
184
|
+
endpoints: Array.isArray(model.endpoints) ? model.endpoints.filter((value) => value === "responses" || value === "chat") : [],
|
|
185
|
+
preview: model.preview === true,
|
|
186
|
+
})),
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
sendJson(res, 502, { error: liveModelError(error) });
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (pathname === USAGE_PATH && req.method === "GET") {
|
|
195
|
+
try {
|
|
196
|
+
sendJson(res, 200, { source: "local_usage_log", ...(usageTable(await usageSummaryFn())) });
|
|
197
|
+
} catch {
|
|
198
|
+
sendJson(res, 500, { error: "Could not read local usage summary" });
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
204
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isLoopbackAddress, isLoopbackHostHeader } from "./observability.mjs";
|
|
3
|
+
|
|
4
|
+
const ASSETS = new Map([
|
|
5
|
+
["/", ["./dashboard/index.html", "text/html; charset=utf-8"]],
|
|
6
|
+
["/ui.css", ["./dashboard/ui.css", "text/css; charset=utf-8"]],
|
|
7
|
+
["/ui.js", ["./dashboard/ui.js", "text/javascript; charset=utf-8"]],
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export async function serveDashboard(req, res, pathname) {
|
|
11
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
12
|
+
|| req.headers?.host === undefined
|
|
13
|
+
|| !isLoopbackHostHeader(req.headers.host)) {
|
|
14
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
15
|
+
res.end("The CCDX dashboard is available only at a loopback address.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const [file, contentType] = ASSETS.get(pathname);
|
|
20
|
+
const body = await readFile(new URL(file, import.meta.url));
|
|
21
|
+
res.writeHead(200, {
|
|
22
|
+
"Content-Type": contentType,
|
|
23
|
+
"Cache-Control": "no-store",
|
|
24
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
25
|
+
"X-Content-Type-Options": "nosniff",
|
|
26
|
+
"X-Frame-Options": "DENY",
|
|
27
|
+
"Referrer-Policy": "no-referrer",
|
|
28
|
+
});
|
|
29
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
30
|
+
}
|
package/src/observability.mjs
CHANGED
|
@@ -108,6 +108,19 @@ export function isLoopbackAddress(address) {
|
|
|
108
108
|
return isIP(ipv4) === 4 && ipv4.startsWith("127.");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
export function isLoopbackHostHeader(host) {
|
|
112
|
+
if (host === undefined) return true; // HTTP/1.0 and in-process callers may omit Host.
|
|
113
|
+
if (typeof host !== "string" || !host || host.includes(",")) return false;
|
|
114
|
+
try {
|
|
115
|
+
const url = new URL(`http://${host}`);
|
|
116
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
117
|
+
return !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash
|
|
118
|
+
&& (hostname === "localhost" || isLoopbackAddress(hostname));
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
function modelRegistryStatus(modelRegistry) {
|
|
112
125
|
const modelData = Array.isArray(modelRegistry?.models)
|
|
113
126
|
? modelRegistry.models
|
package/src/response-history.mjs
CHANGED
|
@@ -81,6 +81,9 @@ const treeLru = new Map();
|
|
|
81
81
|
const pinnedTrees = new Map();
|
|
82
82
|
const evictedIds = new Set();
|
|
83
83
|
let totalBytes = 0;
|
|
84
|
+
let evictedEntriesTotal = 0;
|
|
85
|
+
let lookupMisses = 0;
|
|
86
|
+
let evictedLookupMisses = 0;
|
|
84
87
|
const HISTORY_RUNTIME_CONFIG = loadRuntimeConfig();
|
|
85
88
|
let maxBytes = HISTORY_RUNTIME_CONFIG.responseHistoryMaxBytes;
|
|
86
89
|
let maxEntries = HISTORY_RUNTIME_CONFIG.responseHistoryMaxEntries;
|
|
@@ -133,6 +136,7 @@ function removeSubtree(rootId) {
|
|
|
133
136
|
const pending = [rootId];
|
|
134
137
|
const removed = new Set();
|
|
135
138
|
const affectedRoots = new Set();
|
|
139
|
+
let removedEntries = 0;
|
|
136
140
|
while (pending.length) {
|
|
137
141
|
const id = pending.pop();
|
|
138
142
|
if (removed.has(id)) continue;
|
|
@@ -149,7 +153,9 @@ function removeSubtree(rootId) {
|
|
|
149
153
|
totalBytes -= entry.bytes;
|
|
150
154
|
histories.delete(id);
|
|
151
155
|
rememberEvictedId(id);
|
|
156
|
+
removedEntries += 1;
|
|
152
157
|
}
|
|
158
|
+
evictedEntriesTotal += removedEntries;
|
|
153
159
|
for (const affectedRoot of affectedRoots) {
|
|
154
160
|
const entry = histories.get(affectedRoot);
|
|
155
161
|
if (!entry || entry.rootId !== affectedRoot) treeLru.delete(affectedRoot);
|
|
@@ -223,6 +229,9 @@ export function clearResponseHistory() {
|
|
|
223
229
|
pinnedTrees.clear();
|
|
224
230
|
evictedIds.clear();
|
|
225
231
|
totalBytes = 0;
|
|
232
|
+
evictedEntriesTotal = 0;
|
|
233
|
+
lookupMisses = 0;
|
|
234
|
+
evictedLookupMisses = 0;
|
|
226
235
|
return removed;
|
|
227
236
|
}
|
|
228
237
|
|
|
@@ -253,7 +262,18 @@ export function configureResponseHistoryForTests({ maxBytes: nextMaxBytes, maxEn
|
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
export function responseHistoryStats() {
|
|
265
|
+
const trees = new Map();
|
|
266
|
+
for (const entry of histories.values()) {
|
|
267
|
+
trees.set(entry.rootId, (trees.get(entry.rootId) || 0) + entry.bytes);
|
|
268
|
+
}
|
|
269
|
+
let largestTreeBytes = 0;
|
|
270
|
+
for (const bytes of trees.values()) largestTreeBytes = Math.max(largestTreeBytes, bytes);
|
|
256
271
|
return { entries: histories.size, bytes: totalBytes, evicted: evictedIds.size,
|
|
272
|
+
evicted_entries_total: evictedEntriesTotal,
|
|
273
|
+
lookup_misses: lookupMisses,
|
|
274
|
+
evicted_lookup_misses: evictedLookupMisses,
|
|
275
|
+
tree_count: trees.size,
|
|
276
|
+
largest_tree_bytes: largestTreeBytes,
|
|
257
277
|
pinnedTrees: pinnedTrees.size, maxBytes, maxEntries };
|
|
258
278
|
}
|
|
259
279
|
|
|
@@ -270,7 +290,10 @@ function responseHistoryChain(responseId) {
|
|
|
270
290
|
seen.add(currentId);
|
|
271
291
|
const entry = histories.get(currentId);
|
|
272
292
|
if (!entry) {
|
|
273
|
-
|
|
293
|
+
lookupMisses += 1;
|
|
294
|
+
const wasEvicted = evictedIds.has(currentId);
|
|
295
|
+
if (wasEvicted) evictedLookupMisses += 1;
|
|
296
|
+
const reason = wasEvicted ? " was evicted after reaching the local history limit" : " is not available";
|
|
274
297
|
throw httpError(`previous_response_id${reason}: ${currentId}`, 400);
|
|
275
298
|
}
|
|
276
299
|
chain.push(entry);
|