codex-copilot-dx 0.9.16 → 0.9.18
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 +2 -0
- package/bin/cli.mjs +10 -5
- package/docs/REFERENCE.md +7 -1
- package/package.json +1 -1
- package/src/adapter.mjs +5 -6
- package/src/auth-status.mjs +175 -0
- package/src/cli-auth.mjs +2 -180
- package/src/cli-models.mjs +1 -131
- package/src/cli-table.mjs +3 -11
- package/src/copilot-responses-compat.mjs +1 -1
- package/src/dashboard-api.mjs +4 -4
- package/src/dashboard.mjs +1 -1
- package/src/http-errors.mjs +5 -0
- package/src/http-transport.mjs +6 -350
- package/src/image-mcp.mjs +33 -1
- package/src/image-provider.mjs +34 -17
- package/src/live-models.mjs +137 -0
- package/src/observability.mjs +2 -22
- package/src/request-admission.mjs +348 -0
- package/src/response-failures.mjs +1 -1
- package/src/response-history.mjs +1 -1
- package/src/responses-bridge.mjs +14 -1
- package/src/responses-compaction.mjs +1 -1
- package/src/responses-handler.mjs +1 -1
- package/src/responses-proxy.mjs +2 -2
- package/src/security.mjs +22 -0
- package/src/terminal-text.mjs +11 -0
- package/src/usage-analytics.mjs +1 -1
- package/src/usage-store.mjs +369 -0
- package/src/usage.mjs +17 -368
package/src/dashboard.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { isLoopbackAddress, isLoopbackHostHeader } from "./
|
|
2
|
+
import { isLoopbackAddress, isLoopbackHostHeader } from "./security.mjs";
|
|
3
3
|
|
|
4
4
|
const ASSETS = new Map([
|
|
5
5
|
["/", ["./dashboard/index.html", "text/html; charset=utf-8"]],
|
package/src/http-transport.mjs
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { promisify } from "node:util";
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
|
-
import { loadRuntimeConfig
|
|
3
|
+
import { loadRuntimeConfig } from "./runtime-config.mjs";
|
|
4
4
|
import { decodedBodyLimitPreference } from "./user-settings.mjs";
|
|
5
5
|
import { status } from "./status.mjs";
|
|
6
6
|
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
7
7
|
import { redactDiagnosticText } from "./diagnostic-text.mjs";
|
|
8
|
+
import { httpError } from "./http-errors.mjs";
|
|
9
|
+
import { admissionAbortError, contentEncodings, requestContentLength } from "./request-admission.mjs";
|
|
10
|
+
|
|
11
|
+
export { httpError } from "./http-errors.mjs";
|
|
12
|
+
export { createRequestAdmission } from "./request-admission.mjs";
|
|
8
13
|
|
|
9
14
|
const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
|
|
10
15
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
@@ -15,9 +20,6 @@ const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress)
|
|
|
15
20
|
const HTTP_RUNTIME_CONFIG = loadRuntimeConfig();
|
|
16
21
|
const MAX_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxBodyBytes;
|
|
17
22
|
const MAX_DECODED_BODY_BYTES = decodedBodyLimitPreference().bytes;
|
|
18
|
-
const MAX_INFLIGHT_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxInflightBodyBytes;
|
|
19
|
-
const MAX_QUEUED_REQUESTS = HTTP_RUNTIME_CONFIG.maxQueuedRequests;
|
|
20
|
-
const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
|
|
21
23
|
|
|
22
24
|
export const MAX_UPSTREAM_ERROR_BODY_BYTES = 1024 * 1024;
|
|
23
25
|
export const MAX_UPSTREAM_MODEL_CATALOG_BYTES = 8 * 1024 * 1024;
|
|
@@ -25,12 +27,6 @@ export const MAX_UPSTREAM_RETRY_DRAIN_BYTES = 64 * 1024;
|
|
|
25
27
|
export const MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamChatResponseBytes;
|
|
26
28
|
export const MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamResponsesResponseBytes;
|
|
27
29
|
|
|
28
|
-
export function httpError(message, statusCode) {
|
|
29
|
-
const err = new Error(message);
|
|
30
|
-
err.statusCode = statusCode;
|
|
31
|
-
return err;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
function upstreamBodyTooLarge(label, maxBytes) {
|
|
35
31
|
const error = httpError(`${label} exceeds ${maxBytes} bytes`, 502);
|
|
36
32
|
error.code = "ccdx_upstream_response_too_large";
|
|
@@ -167,339 +163,6 @@ function payloadTooLarge(kind, maxBytes) {
|
|
|
167
163
|
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
168
164
|
}
|
|
169
165
|
|
|
170
|
-
function requestContentLength(req) {
|
|
171
|
-
return Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function requestAdmissionWeight(req, maxBytes) {
|
|
175
|
-
const encodings = contentEncodings(req.headers?.["content-encoding"]);
|
|
176
|
-
const contentLength = requestContentLength(req);
|
|
177
|
-
if (!Number.isFinite(contentLength) || contentLength < 0) return maxBytes;
|
|
178
|
-
const compressed = encodings.some((encoding) => encoding !== "identity");
|
|
179
|
-
const weightedLength = compressed ? contentLength * COMPRESSED_BODY_WEIGHT_MULTIPLIER : contentLength;
|
|
180
|
-
return Math.max(1, Math.min(weightedLength, maxBytes));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function admissionAbortError(signal) {
|
|
184
|
-
if (signal?.reason instanceof Error) return signal.reason;
|
|
185
|
-
const error = new Error("The operation was aborted");
|
|
186
|
-
error.name = "AbortError";
|
|
187
|
-
return error;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function createSupplementalAdmission({ maxWeight, maxQueued, waitTimeoutMs, label, blockBehindExclusive = false }) {
|
|
191
|
-
const queue = [];
|
|
192
|
-
let activeWeight = 0;
|
|
193
|
-
let activeRequestedWeight = 0;
|
|
194
|
-
let activeRequests = 0;
|
|
195
|
-
|
|
196
|
-
const activate = (entry) => {
|
|
197
|
-
entry.cleanup();
|
|
198
|
-
activeWeight += entry.weight;
|
|
199
|
-
activeRequestedWeight += entry.requestedWeight;
|
|
200
|
-
activeRequests += 1;
|
|
201
|
-
let released = false;
|
|
202
|
-
const release = () => {
|
|
203
|
-
if (released) return;
|
|
204
|
-
released = true;
|
|
205
|
-
activeWeight = Math.max(0, activeWeight - entry.weight);
|
|
206
|
-
activeRequestedWeight = Math.max(0, activeRequestedWeight - entry.requestedWeight);
|
|
207
|
-
activeRequests = Math.max(0, activeRequests - 1);
|
|
208
|
-
drain();
|
|
209
|
-
};
|
|
210
|
-
release.resize = (requestedWeight) => {
|
|
211
|
-
if (released) return 0;
|
|
212
|
-
const parsedWeight = Number.isFinite(requestedWeight) && requestedWeight > 0
|
|
213
|
-
? Math.ceil(requestedWeight)
|
|
214
|
-
: maxWeight;
|
|
215
|
-
const requested = Math.max(1, parsedWeight);
|
|
216
|
-
const weight = Math.min(requested, maxWeight);
|
|
217
|
-
if (weight > entry.weight) {
|
|
218
|
-
throw new RangeError(`${label} admission weight cannot grow`);
|
|
219
|
-
}
|
|
220
|
-
activeWeight = Math.max(0, activeWeight - (entry.weight - weight));
|
|
221
|
-
activeRequestedWeight = Math.max(0, activeRequestedWeight + requested - entry.requestedWeight);
|
|
222
|
-
entry.weight = weight;
|
|
223
|
-
entry.requestedWeight = requested;
|
|
224
|
-
drain();
|
|
225
|
-
return weight;
|
|
226
|
-
};
|
|
227
|
-
Object.defineProperties(release, {
|
|
228
|
-
weight: { get: () => released ? 0 : entry.weight },
|
|
229
|
-
requestedWeight: { get: () => released ? 0 : entry.requestedWeight },
|
|
230
|
-
maxWeight: { value: maxWeight },
|
|
231
|
-
});
|
|
232
|
-
entry.resolve(release);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const drain = () => {
|
|
236
|
-
for (let index = 0; index < queue.length;) {
|
|
237
|
-
const entry = queue[index];
|
|
238
|
-
if (activeWeight + entry.weight > maxWeight) {
|
|
239
|
-
if (blockBehindExclusive && entry.weight === maxWeight) break;
|
|
240
|
-
index += 1;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
queue.splice(index, 1);
|
|
244
|
-
activate(entry);
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
const acquire = (requestedWeight, { signal } = {}) => {
|
|
249
|
-
if (signal?.aborted) return Promise.reject(admissionAbortError(signal));
|
|
250
|
-
const parsedWeight = Number.isFinite(requestedWeight) && requestedWeight > 0
|
|
251
|
-
? Math.ceil(requestedWeight)
|
|
252
|
-
: maxWeight;
|
|
253
|
-
const requested = Math.max(1, parsedWeight);
|
|
254
|
-
const weight = Math.min(requested, maxWeight);
|
|
255
|
-
const blockedByExclusive = blockBehindExclusive
|
|
256
|
-
&& queue.some((entry) => entry.weight === maxWeight);
|
|
257
|
-
const mustWait = blockedByExclusive || activeWeight + weight > maxWeight;
|
|
258
|
-
if (queue.length >= maxQueued && mustWait) {
|
|
259
|
-
return Promise.reject(httpError(`${label} queue is full (${maxQueued} waiting)`, 503));
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return new Promise((resolve, reject) => {
|
|
263
|
-
let timer;
|
|
264
|
-
const entry = {
|
|
265
|
-
weight,
|
|
266
|
-
requestedWeight: requested,
|
|
267
|
-
resolve,
|
|
268
|
-
cleanup: () => {
|
|
269
|
-
if (timer) clearTimeout(timer);
|
|
270
|
-
signal?.removeEventListener("abort", onAbort);
|
|
271
|
-
},
|
|
272
|
-
};
|
|
273
|
-
const remove = () => {
|
|
274
|
-
const index = queue.indexOf(entry);
|
|
275
|
-
if (index >= 0) queue.splice(index, 1);
|
|
276
|
-
};
|
|
277
|
-
const fail = (error) => {
|
|
278
|
-
entry.cleanup();
|
|
279
|
-
remove();
|
|
280
|
-
reject(error);
|
|
281
|
-
drain();
|
|
282
|
-
};
|
|
283
|
-
const onAbort = () => fail(admissionAbortError(signal));
|
|
284
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
285
|
-
timer = setTimeout(() => {
|
|
286
|
-
fail(httpError(`${label} admission timed out after ${waitTimeoutMs}ms`, 503));
|
|
287
|
-
}, waitTimeoutMs);
|
|
288
|
-
timer.unref?.();
|
|
289
|
-
queue.push(entry);
|
|
290
|
-
if (signal?.aborted) onAbort();
|
|
291
|
-
else drain();
|
|
292
|
-
});
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
acquire.stats = () => ({
|
|
296
|
-
activeWeight,
|
|
297
|
-
activeRequestedWeight,
|
|
298
|
-
activeRequests,
|
|
299
|
-
queued: queue.length,
|
|
300
|
-
maxWeight,
|
|
301
|
-
});
|
|
302
|
-
return acquire;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
export function createRequestAdmission({
|
|
306
|
-
maxBytes = MAX_INFLIGHT_BODY_BYTES,
|
|
307
|
-
maxQueued = MAX_QUEUED_REQUESTS,
|
|
308
|
-
waitTimeoutMs = REQUEST_QUEUE_TIMEOUT_MS,
|
|
309
|
-
} = {}) {
|
|
310
|
-
const byteLimit = parsePositiveInteger(maxBytes, MAX_INFLIGHT_BODY_BYTES);
|
|
311
|
-
const queueLimit = parsePositiveInteger(maxQueued, MAX_QUEUED_REQUESTS);
|
|
312
|
-
const timeoutMs = parsePositiveInteger(waitTimeoutMs, REQUEST_QUEUE_TIMEOUT_MS);
|
|
313
|
-
const acquireDecompression = createSupplementalAdmission({
|
|
314
|
-
maxWeight: 1,
|
|
315
|
-
maxQueued: queueLimit,
|
|
316
|
-
waitTimeoutMs: timeoutMs,
|
|
317
|
-
label: "Request decompression",
|
|
318
|
-
});
|
|
319
|
-
const acquireDecodedBody = createSupplementalAdmission({
|
|
320
|
-
maxWeight: byteLimit,
|
|
321
|
-
maxQueued: queueLimit,
|
|
322
|
-
waitTimeoutMs: timeoutMs,
|
|
323
|
-
label: "Decoded request body",
|
|
324
|
-
blockBehindExclusive: true,
|
|
325
|
-
});
|
|
326
|
-
const acquireResponseHistory = createSupplementalAdmission({
|
|
327
|
-
maxWeight: byteLimit,
|
|
328
|
-
maxQueued: queueLimit,
|
|
329
|
-
waitTimeoutMs: timeoutMs,
|
|
330
|
-
label: "Response history",
|
|
331
|
-
});
|
|
332
|
-
const queue = [];
|
|
333
|
-
let activeBytes = 0;
|
|
334
|
-
let activeRequests = 0;
|
|
335
|
-
const counters = {
|
|
336
|
-
total: 0,
|
|
337
|
-
activated: 0,
|
|
338
|
-
queued: 0,
|
|
339
|
-
rejected: 0,
|
|
340
|
-
timedOut: 0,
|
|
341
|
-
aborted: 0,
|
|
342
|
-
waitMsTotal: 0,
|
|
343
|
-
waitMsMax: 0,
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
const remove = (entry) => {
|
|
347
|
-
const index = queue.indexOf(entry);
|
|
348
|
-
if (index >= 0) queue.splice(index, 1);
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
const activate = (entry) => {
|
|
352
|
-
entry.cleanup();
|
|
353
|
-
activeBytes += entry.weight;
|
|
354
|
-
activeRequests += 1;
|
|
355
|
-
counters.activated += 1;
|
|
356
|
-
const waitMs = Math.max(0, Date.now() - entry.startedAt);
|
|
357
|
-
counters.waitMsTotal += waitMs;
|
|
358
|
-
counters.waitMsMax = Math.max(counters.waitMsMax, waitMs);
|
|
359
|
-
let released = false;
|
|
360
|
-
const supplementalReleases = new Set();
|
|
361
|
-
const release = () => {
|
|
362
|
-
if (released) return;
|
|
363
|
-
released = true;
|
|
364
|
-
for (const releaseSupplemental of supplementalReleases) releaseSupplemental();
|
|
365
|
-
supplementalReleases.clear();
|
|
366
|
-
activeBytes = Math.max(0, activeBytes - entry.weight);
|
|
367
|
-
activeRequests = Math.max(0, activeRequests - 1);
|
|
368
|
-
drain();
|
|
369
|
-
};
|
|
370
|
-
const reserve = async (gate, weight, options) => {
|
|
371
|
-
const releaseSupplemental = await gate(weight, options);
|
|
372
|
-
if (released) {
|
|
373
|
-
releaseSupplemental();
|
|
374
|
-
throw admissionAbortError(options?.signal);
|
|
375
|
-
}
|
|
376
|
-
let supplementalReleased = false;
|
|
377
|
-
const releaseReservation = () => {
|
|
378
|
-
if (supplementalReleased) return;
|
|
379
|
-
supplementalReleased = true;
|
|
380
|
-
supplementalReleases.delete(releaseReservation);
|
|
381
|
-
releaseSupplemental();
|
|
382
|
-
};
|
|
383
|
-
releaseReservation.resize = (nextWeight) => {
|
|
384
|
-
if (supplementalReleased) return 0;
|
|
385
|
-
return releaseSupplemental.resize(nextWeight);
|
|
386
|
-
};
|
|
387
|
-
Object.defineProperties(releaseReservation, {
|
|
388
|
-
weight: { get: () => supplementalReleased ? 0 : releaseSupplemental.weight },
|
|
389
|
-
requestedWeight: {
|
|
390
|
-
get: () => supplementalReleased ? 0 : releaseSupplemental.requestedWeight,
|
|
391
|
-
},
|
|
392
|
-
maxWeight: { value: releaseSupplemental.maxWeight },
|
|
393
|
-
});
|
|
394
|
-
supplementalReleases.add(releaseReservation);
|
|
395
|
-
return releaseReservation;
|
|
396
|
-
};
|
|
397
|
-
release.acquireDecompression = (options) => acquireDecompression(1, options);
|
|
398
|
-
release.reserveDecodedBody = (bytes, options) => reserve(acquireDecodedBody, bytes, options);
|
|
399
|
-
release.reserveDecodedBody.supportsResize = true;
|
|
400
|
-
release.reserveResponseHistory = (bytes, options) => reserve(acquireResponseHistory, bytes, options);
|
|
401
|
-
entry.resolve(release);
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const drain = () => {
|
|
405
|
-
for (let index = 0; index < queue.length;) {
|
|
406
|
-
const entry = queue[index];
|
|
407
|
-
if (entry.cancelled) {
|
|
408
|
-
queue.splice(index, 1);
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (activeBytes + entry.weight > byteLimit) {
|
|
412
|
-
// Unknown-length requests reserve the whole budget. Once one is waiting,
|
|
413
|
-
// do not let later arrivals keep extending its wait indefinitely.
|
|
414
|
-
if (entry.weight === byteLimit) break;
|
|
415
|
-
index += 1;
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
queue.splice(index, 1);
|
|
419
|
-
activate(entry);
|
|
420
|
-
}
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const acquire = (req, { signal } = {}) => {
|
|
424
|
-
counters.total += 1;
|
|
425
|
-
if (signal?.aborted) {
|
|
426
|
-
counters.aborted += 1;
|
|
427
|
-
return Promise.reject(admissionAbortError(signal));
|
|
428
|
-
}
|
|
429
|
-
const weight = requestAdmissionWeight(req, byteLimit);
|
|
430
|
-
const blockedByExclusive = queue.some((entry) => !entry.cancelled && entry.weight === byteLimit);
|
|
431
|
-
const mustWait = blockedByExclusive || activeBytes + weight > byteLimit;
|
|
432
|
-
if (queue.length >= queueLimit && mustWait) {
|
|
433
|
-
counters.rejected += 1;
|
|
434
|
-
return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
|
|
435
|
-
}
|
|
436
|
-
if (mustWait) counters.queued += 1;
|
|
437
|
-
|
|
438
|
-
return new Promise((resolve, reject) => {
|
|
439
|
-
let timer;
|
|
440
|
-
const entry = {
|
|
441
|
-
cancelled: false,
|
|
442
|
-
startedAt: Date.now(),
|
|
443
|
-
weight,
|
|
444
|
-
resolve,
|
|
445
|
-
cleanup: () => {
|
|
446
|
-
if (timer) clearTimeout(timer);
|
|
447
|
-
signal?.removeEventListener("abort", onAbort);
|
|
448
|
-
},
|
|
449
|
-
};
|
|
450
|
-
const cancel = (error, reason) => {
|
|
451
|
-
if (entry.cancelled) return;
|
|
452
|
-
entry.cancelled = true;
|
|
453
|
-
if (reason === "aborted") counters.aborted += 1;
|
|
454
|
-
if (reason === "timed_out") counters.timedOut += 1;
|
|
455
|
-
entry.cleanup();
|
|
456
|
-
remove(entry);
|
|
457
|
-
reject(error);
|
|
458
|
-
drain();
|
|
459
|
-
};
|
|
460
|
-
const onAbort = () => cancel(admissionAbortError(signal), "aborted");
|
|
461
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
462
|
-
timer = setTimeout(() => {
|
|
463
|
-
cancel(httpError(`Request admission timed out after ${timeoutMs}ms`, 503), "timed_out");
|
|
464
|
-
}, timeoutMs);
|
|
465
|
-
timer.unref?.();
|
|
466
|
-
queue.push(entry);
|
|
467
|
-
if (signal?.aborted) onAbort();
|
|
468
|
-
else drain();
|
|
469
|
-
});
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
acquire.stats = () => ({ activeBytes, queued: queue.length, maxBytes: byteLimit });
|
|
473
|
-
acquire.diagnostics = () => ({
|
|
474
|
-
activeBytes,
|
|
475
|
-
activeRequests,
|
|
476
|
-
queued: queue.length,
|
|
477
|
-
maxBytes: byteLimit,
|
|
478
|
-
maxQueued: queueLimit,
|
|
479
|
-
waitTimeoutMs: timeoutMs,
|
|
480
|
-
total: counters.total,
|
|
481
|
-
activated: counters.activated,
|
|
482
|
-
queuedTotal: counters.queued,
|
|
483
|
-
rejected: counters.rejected,
|
|
484
|
-
timedOut: counters.timedOut,
|
|
485
|
-
aborted: counters.aborted,
|
|
486
|
-
waitMsAvg: counters.activated > 0
|
|
487
|
-
? Number((counters.waitMsTotal / counters.activated).toFixed(1))
|
|
488
|
-
: 0,
|
|
489
|
-
waitMsMax: counters.waitMsMax,
|
|
490
|
-
decompressionsActive: acquireDecompression.stats().activeRequests,
|
|
491
|
-
decompressionsQueued: acquireDecompression.stats().queued,
|
|
492
|
-
decodedBodyBytes: acquireDecodedBody.stats().activeRequestedWeight,
|
|
493
|
-
decodedBodyAdmissionBytes: acquireDecodedBody.stats().activeWeight,
|
|
494
|
-
decodedBodiesActive: acquireDecodedBody.stats().activeRequests,
|
|
495
|
-
decodedBodiesQueued: acquireDecodedBody.stats().queued,
|
|
496
|
-
responseHistoryBytes: acquireResponseHistory.stats().activeWeight,
|
|
497
|
-
responseHistoriesActive: acquireResponseHistory.stats().activeRequests,
|
|
498
|
-
responseHistoriesQueued: acquireResponseHistory.stats().queued,
|
|
499
|
-
});
|
|
500
|
-
return acquire;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
166
|
function consumeRequestChunks(req, { signal, onChunk }) {
|
|
504
167
|
return new Promise((resolve, reject) => {
|
|
505
168
|
let settled = false;
|
|
@@ -610,13 +273,6 @@ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes, signal)
|
|
|
610
273
|
return text + decoder.decode();
|
|
611
274
|
}
|
|
612
275
|
|
|
613
|
-
function contentEncodings(contentEncoding) {
|
|
614
|
-
return String(contentEncoding || "identity")
|
|
615
|
-
.split(",")
|
|
616
|
-
.map((value) => value.trim().toLowerCase())
|
|
617
|
-
.filter(Boolean);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
276
|
async function decompressBody(decompress, buffer, maxBytes) {
|
|
621
277
|
try {
|
|
622
278
|
return await decompress(buffer, { maxOutputLength: maxBytes });
|
package/src/image-mcp.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { isLoopbackAddress, isLoopbackHostHeader } from "./security.mjs";
|
|
6
7
|
import { generateImage, supportsImageEditing, IMAGE_MAX_BYTES } from "./image-provider.mjs";
|
|
7
8
|
import { readImageProviderConfig } from "./image-provider-config.mjs";
|
|
8
9
|
import { createImageReferenceStore } from "./image-references.mjs";
|
|
@@ -49,6 +50,28 @@ function sameHostSocket(socket) {
|
|
|
49
50
|
return remote && local && remote === local;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function canonicalIpHost(hostname) {
|
|
54
|
+
const address = hostname.replace(/^\[|\]$/g, "");
|
|
55
|
+
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(address);
|
|
56
|
+
if (!mapped) return address;
|
|
57
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
58
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
59
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ownHostHeader(host, socket, bindHostname) {
|
|
63
|
+
if (isLoopbackHostHeader(host)) return true;
|
|
64
|
+
if (typeof host !== "string" || !host || host.includes(",")) return false;
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(`http://${host}`);
|
|
67
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) return false;
|
|
68
|
+
if (bindHostname && url.hostname === bindHostname) return true;
|
|
69
|
+
const local = String(socket?.localAddress || "");
|
|
70
|
+
if (!isIP(local)) return false;
|
|
71
|
+
return canonicalIpHost(url.hostname) === canonicalIpHost(new URL(`http://${isIP(local) === 6 ? `[${local}]` : local}`).hostname);
|
|
72
|
+
} catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
function writeJson(res, statusCode, body) {
|
|
53
76
|
if (res.destroyed || res.writableEnded) return;
|
|
54
77
|
res.writeHead(statusCode, {
|
|
@@ -134,9 +157,14 @@ export function createImageMcpHandler({
|
|
|
134
157
|
configLoader = () => readImageProviderConfig(),
|
|
135
158
|
generateImageFn = generateImage,
|
|
136
159
|
maxConcurrent = 2,
|
|
160
|
+
localHostname = "",
|
|
137
161
|
imageReferences = createImageReferenceStore(),
|
|
138
162
|
imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
|
|
139
163
|
} = {}) {
|
|
164
|
+
let bindHostname = "";
|
|
165
|
+
if (localHostname && !isIP(localHostname.replace(/^\[|\]$/g, ""))) {
|
|
166
|
+
try { bindHostname = new URL(`http://${localHostname}`).hostname; } catch {}
|
|
167
|
+
}
|
|
140
168
|
let active = 0;
|
|
141
169
|
const metrics = { total: 0, succeeded: 0, failed: 0, busy: 0, cancelled: 0, delivery_failures: 0 };
|
|
142
170
|
const handler = async function imageMcpHandler(req, res) {
|
|
@@ -144,6 +172,10 @@ export function createImageMcpHandler({
|
|
|
144
172
|
writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
|
|
145
173
|
return;
|
|
146
174
|
}
|
|
175
|
+
if (!ownHostHeader(req.headers?.host, req.socket, bindHostname)) {
|
|
176
|
+
writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
147
179
|
if (req.method !== "POST") {
|
|
148
180
|
res.setHeader?.("Allow", "POST");
|
|
149
181
|
writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
|
package/src/image-provider.mjs
CHANGED
|
@@ -207,10 +207,11 @@ function publicAddress(address) {
|
|
|
207
207
|
catch { return false; }
|
|
208
208
|
if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
|
|
209
209
|
|| /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
|
|
210
|
-
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower)
|
|
210
|
+
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower)
|
|
211
|
+
|| /^64:ff9b::(?:([a-f0-9]{1,4}):)?([a-f0-9]{1,4})?$/.exec(lower);
|
|
211
212
|
if (!mapped) return true;
|
|
212
|
-
const high = Number.parseInt(mapped[1], 16);
|
|
213
|
-
const low = Number.parseInt(mapped[2], 16);
|
|
213
|
+
const high = Number.parseInt(mapped[1] || "0", 16);
|
|
214
|
+
const low = Number.parseInt(mapped[2] || "0", 16);
|
|
214
215
|
return ipv4Public([high >> 8, high & 255, low >> 8, low & 255].join("."));
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -305,20 +306,36 @@ export async function generateImage(config, {
|
|
|
305
306
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
306
307
|
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
307
308
|
let response;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
309
|
+
let requestUrl = config.endpoint;
|
|
310
|
+
let method = "POST";
|
|
311
|
+
let requestBody = JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize, normalizedImage));
|
|
312
|
+
let headers = { Authorization: `Bearer ${config.api_key}`, "Content-Type": "application/json", Accept: "application/json" };
|
|
313
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
314
|
+
try {
|
|
315
|
+
if (requestSignal.aborted) throw requestSignal.reason;
|
|
316
|
+
response = await fetchImpl(requestUrl, { method, headers, body: requestBody, signal: requestSignal, redirect: "manual" });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (requestSignal.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
|
|
319
|
+
throw providerError("Image API could not be reached");
|
|
320
|
+
}
|
|
321
|
+
const location = [301, 302, 303, 307, 308].includes(response.status) ? response.headers?.get?.("location") : null;
|
|
322
|
+
if (location == null) break;
|
|
323
|
+
await response.body?.cancel?.().catch?.(() => {});
|
|
324
|
+
let target;
|
|
325
|
+
try { target = new URL(location, requestUrl); }
|
|
326
|
+
catch { throw providerError("Image API returned an invalid redirect", "ccdx_image_redirect_unsafe"); }
|
|
327
|
+
if (target.protocol !== "https:" || target.username || target.password || target.origin !== new URL(requestUrl).origin) {
|
|
328
|
+
throw providerError("Image API redirect was refused; use a same-origin HTTPS endpoint", "ccdx_image_redirect_unsafe");
|
|
329
|
+
}
|
|
330
|
+
if (redirects >= 20) throw providerError("Image API returned too many redirects", "ccdx_image_redirect_limit");
|
|
331
|
+
// Preserve Fetch's method/body rules for legitimate same-origin redirects.
|
|
332
|
+
if (((response.status === 301 || response.status === 302) && method === "POST") || (response.status === 303 && method !== "GET")) {
|
|
333
|
+
method = "GET";
|
|
334
|
+
requestBody = undefined;
|
|
335
|
+
headers = { ...headers };
|
|
336
|
+
delete headers["Content-Type"];
|
|
337
|
+
}
|
|
338
|
+
requestUrl = target.href;
|
|
322
339
|
}
|
|
323
340
|
const body = await boundedJson(response);
|
|
324
341
|
if (!response.ok) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { validateGithubToken } from "./auth.mjs";
|
|
3
|
+
import { AUTH_PROFILE_CODEX, readAuthProfileCredentials } from "./auth-profile.mjs";
|
|
4
|
+
import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MODELS_TIMEOUT_MS = 10000;
|
|
7
|
+
const SUPPORTED_ENDPOINTS = [
|
|
8
|
+
["responses", new Set(["/responses", "/v1/responses"])],
|
|
9
|
+
["chat", new Set(["/chat/completions"])],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function safeText(value, fallback = "") {
|
|
13
|
+
const text = String(value ?? "")
|
|
14
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
return text || fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function endpointLabels(model) {
|
|
21
|
+
const advertised = new Set(Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : []);
|
|
22
|
+
return SUPPORTED_ENDPOINTS
|
|
23
|
+
.filter(([, values]) => [...values].some((value) => advertised.has(value)))
|
|
24
|
+
.map(([label]) => label);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function selectableCopilotModels(payload) {
|
|
28
|
+
const data = Array.isArray(payload) ? payload : payload?.data;
|
|
29
|
+
if (!Array.isArray(data)) throw new Error("Copilot models response contained no model list");
|
|
30
|
+
|
|
31
|
+
const models = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const model of data) {
|
|
34
|
+
if (model?.model_picker_enabled !== true) continue;
|
|
35
|
+
const policyState = safeText(model?.policy?.state).toLowerCase();
|
|
36
|
+
if (policyState && policyState !== "enabled") continue;
|
|
37
|
+
const capabilityType = safeText(model?.capabilities?.type).toLowerCase();
|
|
38
|
+
if (capabilityType && capabilityType !== "chat") continue;
|
|
39
|
+
|
|
40
|
+
const id = safeText(model?.id);
|
|
41
|
+
const endpoints = endpointLabels(model);
|
|
42
|
+
if (!id.startsWith("gpt-") || id === "codex-auto-review" || !endpoints.length || seen.has(id)) continue;
|
|
43
|
+
seen.add(id);
|
|
44
|
+
models.push({
|
|
45
|
+
id,
|
|
46
|
+
vendor: safeText(model?.vendor || model?.owned_by, "Unknown"),
|
|
47
|
+
endpoints,
|
|
48
|
+
preview: model?.preview === true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
models.sort((left, right) => left.vendor.localeCompare(right.vendor, "en")
|
|
53
|
+
|| left.id.localeCompare(right.id, "en"));
|
|
54
|
+
return { advertised: data.length, models };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validationFailure(validation) {
|
|
58
|
+
const httpStatus = validation?.status ? ` (HTTP ${validation.status})` : "";
|
|
59
|
+
return `GitHub Copilot authentication failed: ${validation?.reason || "unknown error"}${httpStatus}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function responseText(response) {
|
|
63
|
+
try {
|
|
64
|
+
return await response.text();
|
|
65
|
+
} catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readModelCredentials(home) {
|
|
71
|
+
let credentials;
|
|
72
|
+
try {
|
|
73
|
+
credentials = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`Could not read the GitHub token: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
if (credentials.valid) return credentials;
|
|
78
|
+
if (!credentials.configured) throw new Error("GitHub token not found. Start ccdx once to log in.");
|
|
79
|
+
if (credentials.reason === "empty_token") throw new Error("GitHub token file is empty. Start ccdx again to log in.");
|
|
80
|
+
throw new Error(`GitHub token is unavailable (${credentials.reason || "unknown"}). Start ccdx again to log in.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function fetchLiveCopilotModels({
|
|
84
|
+
home = os.homedir(),
|
|
85
|
+
fetchImpl = fetch,
|
|
86
|
+
timeoutMs = DEFAULT_MODELS_TIMEOUT_MS,
|
|
87
|
+
vscodeVersion = FALLBACK_VSCODE_VERSION,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const credentials = readModelCredentials(home);
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
92
|
+
try {
|
|
93
|
+
const validation = await validateGithubToken(credentials.token, {
|
|
94
|
+
fetchImpl,
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
if (controller.signal.aborted) throw new Error(`Live model lookup timed out after ${timeoutMs}ms`);
|
|
98
|
+
if (!validation.ok) throw new Error(validationFailure(validation));
|
|
99
|
+
|
|
100
|
+
const tokenData = validation.copilotTokenData;
|
|
101
|
+
const apiBase = parseApiBase(tokenData);
|
|
102
|
+
const headers = buildHeaders({
|
|
103
|
+
token: tokenData.token,
|
|
104
|
+
version: vscodeVersion,
|
|
105
|
+
initiator: "user",
|
|
106
|
+
vision: false,
|
|
107
|
+
});
|
|
108
|
+
const response = await fetchImpl(`${apiBase}/models`, {
|
|
109
|
+
headers,
|
|
110
|
+
redirect: "error",
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
const body = await responseText(response);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const detail = safeText(body).slice(0, 240);
|
|
116
|
+
throw new Error(`Copilot models endpoint returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let payload;
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(body);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error("Copilot models endpoint returned invalid JSON");
|
|
124
|
+
}
|
|
125
|
+
const catalog = selectableCopilotModels(payload);
|
|
126
|
+
let upstreamHost = "GitHub Copilot";
|
|
127
|
+
try { upstreamHost = new URL(apiBase).hostname; } catch {}
|
|
128
|
+
return { ...catalog, profile: AUTH_PROFILE_CODEX, upstreamHost: safeText(upstreamHost, "GitHub Copilot") };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (controller.signal.aborted && !/timed out/.test(error?.message || "")) {
|
|
131
|
+
throw new Error(`Live model lookup timed out after ${timeoutMs}ms`);
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|