codex-copilot-dx 0.4.32 → 0.4.34

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 CHANGED
@@ -64,7 +64,15 @@ codex-copilot-dx doctor --online
64
64
 
65
65
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
66
66
 
67
- When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery accepts only the same GitHub account. Explicit token variables remain the intentional way to switch accounts.
67
+ To actively verify the protocol path through an already-running adapter, run:
68
+
69
+ ```bash
70
+ codex-copilot-dx doctor --compat
71
+ ```
72
+
73
+ The compatibility doctor sends a few minimal Copilot requests to check native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
74
+
75
+ When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
68
76
 
69
77
  If Copilot token refresh still fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
70
78
 
@@ -167,6 +175,7 @@ MIT
167
175
  ```bash
168
176
  npm ci
169
177
  npm run verify
178
+ npm run bench:payload
170
179
  ```
171
180
 
172
- `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run pack:check` verifies the npm tarball contents without publishing. The CI workflow runs all three checks on supported Node.js release lines.
181
+ `npm test` runs the unit and handler-level suite. `npm run test:smoke` starts a real local HTTP adapter with fully injected offline upstreams. `npm run pack:check` verifies the npm tarball contents without publishing. `npm run bench:payload` is a report-only, isolated-process benchmark for 5-60 MiB image payloads and does not contact Copilot. The CI workflow runs the verification checks on supported Node.js release lines.
package/bin/cli.mjs CHANGED
@@ -187,6 +187,7 @@ if (CLI.command === "doctor") {
187
187
  port: ADAPTER_PORT,
188
188
  host: ADAPTER_HOST,
189
189
  online: CLI.online,
190
+ compat: CLI.compat,
190
191
  });
191
192
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
192
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.32",
3
+ "version": "0.4.34",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "src/",
13
+ "scripts/benchmark.mjs",
13
14
  "README.md"
14
15
  ],
15
16
  "keywords": [
@@ -41,6 +42,8 @@
41
42
  "test": "node --test",
42
43
  "test:smoke": "node scripts/offline-smoke.mjs",
43
44
  "test:e2e": "bash test/e2e.sh",
45
+ "bench": "node scripts/benchmark.mjs",
46
+ "bench:payload": "node scripts/benchmark.mjs --large-payload",
44
47
  "pack:check": "npm pack --dry-run --json",
45
48
  "verify": "npm test && npm run test:smoke && npm run pack:check",
46
49
  "prepublishOnly": "npm run verify"
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ import sharp from "sharp";
10
+ import { optimizeImagesInBody, prepareResponsesPayload } from "../src/image-optimization.mjs";
11
+
12
+ const packageRoot = fileURLToPath(new URL("../", import.meta.url));
13
+
14
+ function runProbe(source) {
15
+ const result = spawnSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", source], {
16
+ cwd: packageRoot,
17
+ encoding: "utf8",
18
+ });
19
+ if (result.status !== 0) {
20
+ throw new Error(result.stderr.trim() || `benchmark probe exited with ${result.status}`);
21
+ }
22
+ return JSON.parse(result.stdout.trim());
23
+ }
24
+
25
+ function deterministicPixels(byteLength, initialSeed) {
26
+ const pixels = Buffer.alloc(byteLength);
27
+ let seed = initialSeed;
28
+ for (let index = 0; index < pixels.length; index += 1) {
29
+ seed = ((seed * 1664525) + 1013904223) >>> 0;
30
+ pixels[index] = seed >>> 24;
31
+ }
32
+ return pixels;
33
+ }
34
+
35
+ function largePayloadProbe(targetMiB, concurrency) {
36
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccdx-payload-bench-"));
37
+ const fixtureStarted = performance.now();
38
+ try {
39
+ const generator = spawnSync(process.execPath, ["--input-type=module", "-e", `
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+ import sharp from "sharp";
43
+ const targetBytes = ${targetMiB} * 1024 * 1024;
44
+ const requestConcurrency = ${concurrency};
45
+ const imageCount = 8;
46
+ const fixtureDir = ${JSON.stringify(fixtureDir)};
47
+ const basePng = await sharp({
48
+ create: { width: 32, height: 32, channels: 3, background: { r: 40, g: 90, b: 140 } },
49
+ }).png().toBuffer();
50
+ for (let requestIndex = 0; requestIndex < requestConcurrency; requestIndex += 1) {
51
+ const approximateDataUrlBytes = Math.floor(targetBytes / imageCount);
52
+ const rawBytes = Math.max(basePng.length, Math.floor((approximateDataUrlBytes - 32) * 3 / 4));
53
+ const content = [];
54
+ for (let imageIndex = 0; imageIndex < imageCount; imageIndex += 1) {
55
+ const raw = Buffer.alloc(rawBytes, (requestIndex * imageCount + imageIndex + 1) % 251);
56
+ basePng.copy(raw, 0);
57
+ content.push({ type: "input_image", image_url: \`data:image/png;base64,\${raw.toString("base64")}\` });
58
+ }
59
+ const source = {
60
+ model: "gpt-benchmark",
61
+ stream: false,
62
+ input: [{ type: "message", role: "user", content }],
63
+ };
64
+ fs.writeFileSync(path.join(fixtureDir, \`request-\${requestIndex}.json\`), JSON.stringify(source));
65
+ }
66
+ `], { cwd: packageRoot, encoding: "utf8" });
67
+ if (generator.status !== 0) {
68
+ throw new Error(generator.stderr.trim() || `benchmark fixture generator exited with ${generator.status}`);
69
+ }
70
+ const fixturePrepareMs = performance.now() - fixtureStarted;
71
+ const fixtureFiles = Array.from({ length: concurrency }, (_, index) => path.join(fixtureDir, `request-${index}.json`));
72
+ const result = runProbe(`
73
+ import fs from "node:fs";
74
+ import { prepareResponsesRequest } from "./src/adapter.mjs";
75
+ import { readJsonBody } from "./src/http-transport.mjs";
76
+ import { prepareResponsesPayload } from "./src/image-optimization.mjs";
77
+
78
+ const fixtureFiles = ${JSON.stringify(fixtureFiles)};
79
+ const memoryFields = ["rss", "heapUsed", "external", "arrayBuffers"];
80
+ const peak = {};
81
+ const sample = () => {
82
+ const memory = process.memoryUsage();
83
+ for (const field of memoryFields) peak[field] = Math.max(peak[field] || 0, memory[field] || 0);
84
+ };
85
+ globalThis.gc?.();
86
+ const idleMemory = process.memoryUsage();
87
+ for (const field of memoryFields) peak[field] = idleMemory[field] || 0;
88
+ const timer = setInterval(sample, 5);
89
+ timer.unref?.();
90
+
91
+ async function processRequest(filePath) {
92
+ const inputBytes = fs.statSync(filePath).size;
93
+ const req = fs.createReadStream(filePath);
94
+ req.headers = { "content-length": String(inputBytes), "content-encoding": "identity" };
95
+ const parsed = await readJsonBody(req);
96
+ req.destroy();
97
+ sample();
98
+ const prepared = prepareResponsesRequest(parsed, { mutate: true });
99
+ const result = await prepareResponsesPayload(prepared.body);
100
+ sample();
101
+ return { inputBytes, outputBytes: result.bodyBytes };
102
+ }
103
+
104
+ const originalLog = console.log;
105
+ const originalWarn = console.warn;
106
+ console.log = () => {};
107
+ console.warn = () => {};
108
+ const started = performance.now();
109
+ let results;
110
+ try {
111
+ results = await Promise.all(fixtureFiles.map(processRequest));
112
+ } finally {
113
+ clearInterval(timer);
114
+ console.log = originalLog;
115
+ console.warn = originalWarn;
116
+ }
117
+ sample();
118
+ const finalMemory = process.memoryUsage();
119
+ const mib = (bytes) => +(bytes / 1048576).toFixed(1);
120
+ const memory = Object.fromEntries(memoryFields.map((field) => [field.replace("Used", "_used").replace("Buffers", "_buffers").toLowerCase(), {
121
+ idle_mib: mib(idleMemory[field] || 0),
122
+ peak_mib: mib(peak[field] || 0),
123
+ peak_delta_mib: mib((peak[field] || 0) - (idleMemory[field] || 0)),
124
+ final_mib: mib(finalMemory[field] || 0),
125
+ }]));
126
+ process.stdout.write(JSON.stringify({
127
+ target_request_mib: ${targetMiB},
128
+ concurrency: fixtureFiles.length,
129
+ elapsed_ms: +(performance.now() - started).toFixed(1),
130
+ input_body_mib: results.map((entry) => mib(entry.inputBytes)),
131
+ output_body_mib: results.map((entry) => mib(entry.outputBytes)),
132
+ output_body_kib: results.map((entry) => +(entry.outputBytes / 1024).toFixed(1)),
133
+ memory,
134
+ }));
135
+ `);
136
+ result.fixture_prepare_ms = +fixturePrepareMs.toFixed(1);
137
+ return result;
138
+ } finally {
139
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ const adapterImport = runProbe(`
144
+ const started = performance.now();
145
+ await import("./src/adapter.mjs");
146
+ globalThis.gc?.();
147
+ const memory = process.memoryUsage();
148
+ console.log(JSON.stringify({
149
+ elapsed_ms: +(performance.now() - started).toFixed(1),
150
+ rss_mib: +(memory.rss / 1048576).toFixed(1),
151
+ heap_used_mib: +(memory.heapUsed / 1048576).toFixed(1),
152
+ }));
153
+ `);
154
+
155
+ const tokenCount = runProbe(`
156
+ const importStarted = performance.now();
157
+ const { countTokens } = await import("./src/anthropic.mjs");
158
+ const importMs = performance.now() - importStarted;
159
+ const body = { messages: [{ role: "user", content: "a ".repeat(50000) }] };
160
+ const firstStarted = performance.now();
161
+ const first = await countTokens(body);
162
+ const firstMs = performance.now() - firstStarted;
163
+ const warmStarted = performance.now();
164
+ const warm = await countTokens(body);
165
+ const warmMs = performance.now() - warmStarted;
166
+ globalThis.gc?.();
167
+ const memory = process.memoryUsage();
168
+ console.log(JSON.stringify({
169
+ module_import_ms: +importMs.toFixed(1),
170
+ first_call_ms: +firstMs.toFixed(1),
171
+ warm_call_ms: +warmMs.toFixed(1),
172
+ input_tokens: first.input_tokens,
173
+ deterministic: first.input_tokens === warm.input_tokens,
174
+ rss_mib: +(memory.rss / 1048576).toFixed(1),
175
+ }));
176
+ `);
177
+
178
+ const repeatedImage = "data:image/png;base64,QUJDRA==";
179
+ const duplicateBody = {
180
+ input: [{
181
+ type: "message",
182
+ role: "user",
183
+ content: Array.from({ length: 8 }, () => ({ type: "input_image", image_url: repeatedImage })),
184
+ }],
185
+ };
186
+ let duplicateCalls = 0;
187
+ const duplicateStarted = performance.now();
188
+ await optimizeImagesInBody(duplicateBody, {
189
+ concurrency: 2,
190
+ optimizeImage: async (value) => {
191
+ duplicateCalls += 1;
192
+ await delay(5);
193
+ return value;
194
+ },
195
+ });
196
+ const duplicateImages = {
197
+ elapsed_ms: +(performance.now() - duplicateStarted).toFixed(1),
198
+ image_occurrences: 8,
199
+ optimizer_calls: duplicateCalls,
200
+ };
201
+
202
+ const pixels = deterministicPixels(1024 * 1024 * 3, 0x87654321);
203
+ const webp = await sharp(pixels, { raw: { width: 1024, height: 1024, channels: 3 } })
204
+ .webp({ quality: 100 })
205
+ .toBuffer();
206
+ const payloadBody = {
207
+ input: [{
208
+ type: "message",
209
+ role: "user",
210
+ content: [{ type: "input_image", image_url: `data:image/webp;base64,${webp.toString("base64")}` }],
211
+ }],
212
+ };
213
+ const originalLog = console.log;
214
+ const originalWarn = console.warn;
215
+ let oversizedPayload;
216
+ let profilePasses = 0;
217
+ try {
218
+ console.log = () => {};
219
+ console.warn = () => { profilePasses += 1; };
220
+ const started = performance.now();
221
+ const result = await prepareResponsesPayload(payloadBody, {
222
+ maxBytes: 300000,
223
+ profiles: [
224
+ { maxDim: 800, quality: 75 },
225
+ { maxDim: 640, quality: 65 },
226
+ ],
227
+ });
228
+ oversizedPayload = {
229
+ elapsed_ms: +(performance.now() - started).toFixed(1),
230
+ original_image_bytes: webp.length,
231
+ output_body_bytes: result.bodyBytes,
232
+ profile_passes: profilePasses,
233
+ adapted: result.adapted,
234
+ };
235
+ } finally {
236
+ console.log = originalLog;
237
+ console.warn = originalWarn;
238
+ }
239
+
240
+ const report = {
241
+ note: "Report-only benchmark; timings vary by machine and are not pass/fail thresholds.",
242
+ adapter_import: adapterImport,
243
+ token_count_100kb: tokenCount,
244
+ duplicate_images: duplicateImages,
245
+ oversized_payload: oversizedPayload,
246
+ };
247
+
248
+ if (process.argv.includes("--large-payload")) {
249
+ report.large_payload_peak = [
250
+ largePayloadProbe(5, 1),
251
+ largePayloadProbe(5, 4),
252
+ largePayloadProbe(30, 1),
253
+ largePayloadProbe(30, 2),
254
+ largePayloadProbe(60, 1),
255
+ ];
256
+ }
257
+
258
+ console.log(JSON.stringify(report, null, 2));
package/src/adapter.mjs CHANGED
@@ -890,7 +890,7 @@ export function createAdapterHandler(options = {}) {
890
890
  try {
891
891
  const parsed = await readJsonBody(req);
892
892
  res.writeHead(200, { "Content-Type": "application/json" });
893
- res.end(JSON.stringify(countTokens(parsed)));
893
+ res.end(JSON.stringify(await countTokens(parsed)));
894
894
  } catch (e) {
895
895
  sendJsonError(res, e);
896
896
  } finally {
package/src/anthropic.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { encode } from "gpt-tokenizer";
2
+
3
+ let tokenizerModulePromise = null;
4
+
5
+ async function tokenEncoder() {
6
+ tokenizerModulePromise ||= import("gpt-tokenizer");
7
+ return (await tokenizerModulePromise).encode;
8
+ }
3
9
 
4
10
  // Anthropic Messages API to OpenAI chat/completions translation.
5
11
  // Pure functions only; network calls stay in adapter.mjs and copilot.mjs.
@@ -252,7 +258,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
252
258
  }
253
259
 
254
260
  // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
255
- export function countTokens(body) {
261
+ export async function countTokens(body) {
256
262
  const parts = [];
257
263
  const sys = systemToText(body.system);
258
264
  if (sys) parts.push(sys);
@@ -276,5 +282,6 @@ export function countTokens(body) {
276
282
  }
277
283
 
278
284
  const text = parts.join("\n");
285
+ const encode = await tokenEncoder();
279
286
  return { input_tokens: encode(text).length };
280
287
  }
@@ -7,28 +7,36 @@ function unexpectedArgs(args) {
7
7
 
8
8
  export function parseCliArgs(args = []) {
9
9
  const [command, ...rest] = args;
10
- if (!command) return { command: "start", configureClaudeDesktop: false, online: false };
10
+ if (!command) return { command: "start", configureClaudeDesktop: false, online: false, compat: false };
11
11
  if (HELP_COMMANDS.has(command)) {
12
12
  if (rest.length) unexpectedArgs(rest);
13
- return { command: "help", configureClaudeDesktop: false, online: false };
13
+ return { command: "help", configureClaudeDesktop: false, online: false, compat: false };
14
14
  }
15
15
  if (VERSION_COMMANDS.has(command)) {
16
16
  if (rest.length) unexpectedArgs(rest);
17
- return { command: "version", configureClaudeDesktop: false, online: false };
17
+ return { command: "version", configureClaudeDesktop: false, online: false, compat: false };
18
18
  }
19
19
  if (command === "usage") {
20
20
  if (rest.length) unexpectedArgs(rest);
21
- return { command: "usage", configureClaudeDesktop: false, online: false };
21
+ return { command: "usage", configureClaudeDesktop: false, online: false, compat: false };
22
22
  }
23
23
  if (command === "doctor" || command === "--doctor") {
24
- const invalid = rest.filter((arg) => arg !== "--online");
24
+ const supported = new Set(["--online", "--compat"]);
25
+ const invalid = rest.filter((arg) => !supported.has(arg));
25
26
  if (invalid.length) unexpectedArgs(invalid);
26
- if (rest.filter((arg) => arg === "--online").length > 1) unexpectedArgs(["--online"]);
27
- return { command: "doctor", configureClaudeDesktop: false, online: rest.includes("--online") };
27
+ for (const option of supported) {
28
+ if (rest.filter((arg) => arg === option).length > 1) unexpectedArgs([option]);
29
+ }
30
+ return {
31
+ command: "doctor",
32
+ configureClaudeDesktop: false,
33
+ online: rest.includes("--online"),
34
+ compat: rest.includes("--compat"),
35
+ };
28
36
  }
29
37
  if (command === "--configure-claude-desktop") {
30
38
  if (rest.length) unexpectedArgs(rest);
31
- return { command: "start", configureClaudeDesktop: true, online: false };
39
+ return { command: "start", configureClaudeDesktop: true, online: false, compat: false };
32
40
  }
33
41
  throw new Error(`Unknown command or option: ${command}`);
34
42
  }
@@ -62,7 +70,7 @@ export function parseRuntimeOptions(env = process.env) {
62
70
  export function cliHelp() {
63
71
  return `Usage:
64
72
  codex-copilot-dx [--configure-claude-desktop]
65
- codex-copilot-dx doctor [--online]
73
+ codex-copilot-dx doctor [--online] [--compat]
66
74
  codex-copilot-dx usage
67
75
  codex-copilot-dx --version
68
76
  codex-copilot-dx --help`;
package/src/copilot.mjs CHANGED
@@ -1,14 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
- import { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
+ import {
7
+ fetchGithubIdentity,
8
+ githubReauthMessage,
9
+ githubTokenPath,
10
+ importDiscoveredGithubToken,
11
+ readGithubTokenMetadata,
12
+ writeGithubTokenMetadata,
13
+ } from "./auth.mjs";
7
14
  import { prepareResponsesPayload } from "./image-optimization.mjs";
8
15
 
9
16
  export {
10
17
  optimizeImageDataUrl,
11
18
  optimizeImagesInBody,
19
+ createTaskLimiter,
12
20
  parseImageConcurrency,
13
21
  prepareResponsesPayload,
14
22
  runWithConcurrency,
@@ -266,6 +274,11 @@ export async function refreshVSCodeVersion() {
266
274
  let copilotToken = null;
267
275
  let copilotTokenExpiry = 0;
268
276
  let copilotTokenRefresh = null;
277
+ let copilotTokenSourceKey = null;
278
+ let copilotTokenGithubFingerprint = "";
279
+ let copilotTokenGithubIdentity = null;
280
+ let copilotTokenRefreshRetryAt = 0;
281
+ const modelListFlights = new Map();
269
282
 
270
283
  function getGithubToken({ home = os.homedir() } = {}) {
271
284
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
@@ -284,17 +297,76 @@ function requestCopilotToken(ghToken, { fetchImpl = fetch, signal } = {}) {
284
297
  });
285
298
  }
286
299
 
287
- function cacheCopilotTokenData(data) {
300
+ function githubTokenFingerprint(token) {
301
+ return createHash("sha256").update(String(token || "")).digest("hex").slice(0, 24);
302
+ }
303
+
304
+ function normalizeGithubIdentity(identity) {
305
+ if (!identity || typeof identity !== "object") return null;
306
+ const login = typeof identity.login === "string" ? identity.login.trim() : "";
307
+ const id = identity.id === undefined || identity.id === null ? "" : String(identity.id).trim();
308
+ return login || id ? { login, id } : null;
309
+ }
310
+
311
+ function githubIdentitiesMatch(first, second) {
312
+ const a = normalizeGithubIdentity(first);
313
+ const b = normalizeGithubIdentity(second);
314
+ if (!a || !b) return false;
315
+ if (a.id && b.id) return a.id === b.id;
316
+ return Boolean(a.login && b.login && a.login.toLowerCase() === b.login.toLowerCase());
317
+ }
318
+
319
+ function githubIdentityLabel(identity) {
320
+ const normalized = normalizeGithubIdentity(identity);
321
+ return normalized?.login || normalized?.id || "unknown";
322
+ }
323
+
324
+ function cacheCopilotTokenData(data, {
325
+ sourceKey,
326
+ githubToken,
327
+ githubIdentity,
328
+ allowAccountSwitch = false,
329
+ } = {}) {
288
330
  if (!data.token) throw new Error("Copilot token response missing token field");
331
+ const fingerprint = githubTokenFingerprint(githubToken);
332
+ const identity = normalizeGithubIdentity(githubIdentity);
333
+ const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
334
+ if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
335
+ && !githubIdentitiesMatch(copilotTokenGithubIdentity, identity)) {
336
+ throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart codex-copilot-dx to switch accounts intentionally.`);
337
+ }
338
+ if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
339
+ const error = new Error("The saved GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.");
340
+ error.transient = true;
341
+ throw error;
342
+ }
289
343
  copilotToken = data.token;
290
344
  apiBase = parseApiBase(data);
291
345
  copilotTokenExpiry = typeof data.expires_at === "number"
292
346
  ? data.expires_at * 1000
293
347
  : Date.now() + 25 * 60 * 1000; // fallback if expires_at absent: refresh in ~25min
348
+ copilotTokenSourceKey = sourceKey || copilotTokenSourceKey;
349
+ copilotTokenGithubFingerprint = fingerprint;
350
+ if (identity) copilotTokenGithubIdentity = identity;
351
+ copilotTokenRefreshRetryAt = 0;
294
352
  console.log(status("ok", "Copilot token refreshed"));
295
353
  return copilotToken;
296
354
  }
297
355
 
356
+ async function githubIdentityForToken(token, { home, fetchImpl, signal }) {
357
+ const cached = readGithubTokenMetadata(home, token);
358
+ if (cached) return cached;
359
+ const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
360
+ return identity.ok ? identity : null;
361
+ }
362
+
363
+ function tokenRefreshError(message, { statusCode, transient = false } = {}) {
364
+ const error = new Error(message);
365
+ if (statusCode) error.statusCode = statusCode;
366
+ if (transient) error.transient = true;
367
+ return error;
368
+ }
369
+
298
370
  async function refreshCopilotToken({
299
371
  signal,
300
372
  home = os.homedir(),
@@ -302,7 +374,14 @@ async function refreshCopilotToken({
302
374
  fetchImpl = fetch,
303
375
  } = {}) {
304
376
  const ghToken = getGithubToken({ home });
305
- const resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
377
+ const sourceKey = githubTokenPath(home);
378
+ let resp;
379
+ try {
380
+ resp = await requestCopilotToken(ghToken, { fetchImpl, signal });
381
+ } catch (e) {
382
+ if (!isAbortError(e, signal)) e.transient = true;
383
+ throw e;
384
+ }
306
385
  if (!resp.ok) {
307
386
  if (resp.status === 401 || resp.status === 403) {
308
387
  const imported = await importDiscoveredGithubToken({
@@ -314,35 +393,89 @@ async function refreshCopilotToken({
314
393
  validateSavedToken: true,
315
394
  });
316
395
  if (imported?.validation?.copilotTokenData?.token) {
317
- return cacheCopilotTokenData(imported.validation.copilotTokenData);
396
+ const explicitSource = imported.source?.type === "env" || imported.source?.type === "token-file";
397
+ return cacheCopilotTokenData(imported.validation.copilotTokenData, {
398
+ sourceKey,
399
+ githubToken: imported.token,
400
+ githubIdentity: imported.validation,
401
+ allowAccountSwitch: explicitSource,
402
+ });
318
403
  }
319
404
  throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
320
405
  }
321
- throw new Error(`Failed to get Copilot token: ${resp.status}`);
406
+ throw tokenRefreshError(`Failed to get Copilot token: ${resp.status}`, {
407
+ statusCode: resp.status,
408
+ transient: resp.status === 408 || resp.status === 425 || resp.status === 429 || resp.status >= 500,
409
+ });
322
410
  }
323
411
  const data = await resp.json();
324
- await ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
325
- return cacheCopilotTokenData(data);
412
+ const identity = await githubIdentityForToken(ghToken, { home, fetchImpl, signal });
413
+ const token = cacheCopilotTokenData(data, {
414
+ sourceKey,
415
+ githubToken: ghToken,
416
+ githubIdentity: identity,
417
+ });
418
+ if (identity) writeGithubTokenMetadata(identity, home, ghToken);
419
+ return token;
420
+ }
421
+
422
+ function canUseCachedToken(sourceKey, now = Date.now(), allowCurrentProcessToken = false) {
423
+ const sameSource = copilotTokenSourceKey === sourceKey || allowCurrentProcessToken;
424
+ return Boolean(copilotToken && sameSource && now < copilotTokenExpiry);
326
425
  }
327
426
 
328
- export async function getCopilotToken(options = {}) {
329
- if (copilotToken && Date.now() < copilotTokenExpiry - 60000) return copilotToken;
427
+ export function getCopilotToken(options = {}) {
330
428
  const home = options.home || os.homedir();
331
- const refreshKey = githubTokenPath(home);
332
- if (copilotTokenRefresh?.key === refreshKey) return copilotTokenRefresh.promise;
429
+ const sourceKey = githubTokenPath(home);
430
+ const now = Date.now();
431
+ const allowCurrentProcessToken = options.home === undefined;
432
+ if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenExpiry - 60000) {
433
+ return Promise.resolve(copilotToken);
434
+ }
435
+ if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenRefreshRetryAt) {
436
+ return Promise.resolve(copilotToken);
437
+ }
438
+ if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
333
439
 
334
- const promise = refreshCopilotToken(options).finally(() => {
335
- if (copilotTokenRefresh?.promise === promise) copilotTokenRefresh = null;
336
- });
337
- copilotTokenRefresh = { key: refreshKey, promise };
338
- return promise;
440
+ let flight = copilotTokenRefresh?.key === sourceKey ? copilotTokenRefresh : null;
441
+ if (flight?.controller.signal.aborted) {
442
+ if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
443
+ flight = null;
444
+ }
445
+
446
+ if (!flight) {
447
+ const controller = new AbortController();
448
+ flight = { key: sourceKey, controller, waiters: 0, settled: false, promise: null };
449
+ flight.promise = refreshCopilotToken({ ...options, home, signal: controller.signal })
450
+ .catch((error) => {
451
+ const fallbackNow = Date.now();
452
+ if (error?.transient && canUseCachedToken(sourceKey, fallbackNow)) {
453
+ copilotTokenRefreshRetryAt = Math.min(copilotTokenExpiry, fallbackNow + 5000);
454
+ console.warn(status("warn", `Copilot token refresh failed temporarily; using the existing token until ${new Date(copilotTokenExpiry).toISOString()} (${error.message})`));
455
+ return copilotToken;
456
+ }
457
+ throw error;
458
+ })
459
+ .finally(() => {
460
+ flight.settled = true;
461
+ if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
462
+ });
463
+ copilotTokenRefresh = flight;
464
+ }
465
+ return waitForSingleflight(flight, options.signal);
339
466
  }
340
467
 
341
468
  export function resetCopilotTokenForTests() {
469
+ copilotTokenRefresh?.controller.abort();
342
470
  copilotToken = null;
343
471
  copilotTokenExpiry = 0;
344
472
  copilotTokenRefresh = null;
473
+ copilotTokenSourceKey = null;
474
+ copilotTokenGithubFingerprint = "";
475
+ copilotTokenGithubIdentity = null;
476
+ copilotTokenRefreshRetryAt = 0;
345
477
  apiBase = DEFAULT_API_BASE;
478
+ resetModelListSingleflightForTests();
346
479
  }
347
480
 
348
481
  // chatReq is already converted by the adapter. The caller parses the raw Response.
@@ -363,7 +496,7 @@ export async function chatCompletions(chatReq, { signal, fetchImpl, retryOptions
363
496
  }, { fetchImpl, ...retryOptions });
364
497
  }
365
498
 
366
- export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
499
+ async function fetchModels({ signal, fetchImpl, retryOptions }) {
367
500
  const token = await getCopilotToken({ signal });
368
501
  const headers = buildHeaders({
369
502
  token, version: getVSCodeVersion(), initiator: "user", vision: false,
@@ -376,10 +509,61 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
376
509
  return { status: resp.status, body };
377
510
  }
378
511
 
512
+ function waitForSingleflight(flight, signal) {
513
+ if (signal?.aborted) return Promise.reject(abortError(signal));
514
+ flight.waiters += 1;
515
+
516
+ return new Promise((resolve, reject) => {
517
+ let finished = false;
518
+ const finish = (handler, value) => {
519
+ if (finished) return;
520
+ finished = true;
521
+ signal?.removeEventListener("abort", onAbort);
522
+ flight.waiters -= 1;
523
+ if (!flight.settled && flight.waiters === 0) flight.controller.abort();
524
+ handler(value);
525
+ };
526
+ const onAbort = () => finish(reject, abortError(signal));
527
+ signal?.addEventListener("abort", onAbort, { once: true });
528
+ if (signal?.aborted) onAbort();
529
+ flight.promise.then(
530
+ (value) => finish(resolve, value),
531
+ (error) => finish(reject, error),
532
+ );
533
+ });
534
+ }
535
+
536
+ export function listModels({ signal, fetchImpl = fetch, retryOptions } = {}) {
537
+ if (signal?.aborted) return Promise.reject(abortError(signal));
538
+ let flight = modelListFlights.get(fetchImpl);
539
+ if (flight?.controller.signal.aborted) {
540
+ modelListFlights.delete(fetchImpl);
541
+ flight = null;
542
+ }
543
+
544
+ if (!flight) {
545
+ const controller = new AbortController();
546
+ flight = { controller, waiters: 0, settled: false, promise: null };
547
+ flight.promise = fetchModels({ signal: controller.signal, fetchImpl, retryOptions })
548
+ .finally(() => {
549
+ flight.settled = true;
550
+ if (modelListFlights.get(fetchImpl) === flight) modelListFlights.delete(fetchImpl);
551
+ });
552
+ modelListFlights.set(fetchImpl, flight);
553
+ }
554
+
555
+ return waitForSingleflight(flight, signal);
556
+ }
557
+
558
+ export function resetModelListSingleflightForTests() {
559
+ for (const flight of modelListFlights.values()) flight.controller.abort();
560
+ modelListFlights.clear();
561
+ }
562
+
379
563
  // Responses-only models go directly to Copilot's /responses endpoint.
380
564
  export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
381
565
  const token = await getCopilotToken({ signal });
382
- const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody);
566
+ const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody, { signal });
383
567
  console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
384
568
  const headers = buildHeaders({
385
569
  token,
package/src/doctor.mjs CHANGED
@@ -48,6 +48,152 @@ function valueLabel(value) {
48
48
  return value ? `"${value}"` : "missing";
49
49
  }
50
50
 
51
+ function copilotModelData(models) {
52
+ const data = Array.isArray(models) ? models : models?.data;
53
+ return Array.isArray(data) ? data : [];
54
+ }
55
+
56
+ function modelEndpoints(model) {
57
+ return Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
58
+ }
59
+
60
+ export function selectCompatibilityModels(models) {
61
+ const data = copilotModelData(models).filter((model) => model?.model_picker_enabled !== false);
62
+ const responsesCandidates = data.filter((model) => {
63
+ const id = String(model?.id || "");
64
+ const endpoints = modelEndpoints(model);
65
+ return id.startsWith("gpt-") && (endpoints.includes("/responses") || endpoints.includes("/v1/responses"));
66
+ });
67
+ const responsesOnly = [...responsesCandidates].reverse()
68
+ .find((model) => !modelEndpoints(model).includes("/chat/completions"));
69
+ const claude = data.find((model) => {
70
+ const id = String(model?.id || "");
71
+ const vendor = String(model?.vendor || "").toLowerCase();
72
+ return (id.startsWith("claude-") || vendor === "anthropic")
73
+ && modelEndpoints(model).includes("/chat/completions");
74
+ });
75
+ return {
76
+ responsesModel: String((responsesOnly || responsesCandidates[0])?.id || ""),
77
+ claudeModel: String(claude?.id || ""),
78
+ };
79
+ }
80
+
81
+ async function fetchTextWithTimeout(fetchImpl, url, init, timeoutMs) {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
84
+ try {
85
+ const resp = await fetchImpl(url, { ...init, signal: controller.signal });
86
+ return { resp, text: await resp.text() };
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ function responseFailure(resp, text) {
93
+ const detail = String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
94
+ return new Error(`HTTP ${resp.status}${detail ? `: ${detail}` : ""}`);
95
+ }
96
+
97
+ async function compatibilityRequest(fetchImpl, url, body, timeoutMs) {
98
+ const { resp, text } = await fetchTextWithTimeout(fetchImpl, url, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json", Accept: body.stream ? "text/event-stream" : "application/json" },
101
+ body: JSON.stringify(body),
102
+ }, timeoutMs);
103
+ if (!resp.ok) throw responseFailure(resp, text);
104
+ return text;
105
+ }
106
+
107
+ async function runCompatibilityCheck(checks, label, task) {
108
+ const started = Date.now();
109
+ try {
110
+ const value = await task();
111
+ checks.push({ kind: "ok", message: `${label} passed (${Date.now() - started}ms)` });
112
+ return value;
113
+ } catch (e) {
114
+ const reason = e?.name === "AbortError" ? "timed out" : e?.message || "unknown error";
115
+ checks.push({ kind: "err", message: `${label} failed: ${reason}` });
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function parseResponseObject(text) {
121
+ const response = JSON.parse(text);
122
+ if (!response?.id || !Array.isArray(response.output)) throw new Error("response body is missing id or output");
123
+ return response;
124
+ }
125
+
126
+ export async function inspectAdapterCompatibility({
127
+ host = "127.0.0.1",
128
+ port = 2026,
129
+ fetchImpl = fetch,
130
+ timeoutMs = 120000,
131
+ } = {}) {
132
+ const baseUrl = localGatewayBaseUrl(connectHost(host), port);
133
+ const checks = [];
134
+ const models = await runCompatibilityCheck(checks, "Compatibility model discovery", async () => {
135
+ const { resp, text } = await fetchTextWithTimeout(fetchImpl, `${baseUrl}/v1/models`, { headers: { Accept: "application/json" } }, timeoutMs);
136
+ if (!resp.ok) throw responseFailure(resp, text);
137
+ const parsed = JSON.parse(text);
138
+ if (!copilotModelData(parsed).length) throw new Error("model list is empty");
139
+ return parsed;
140
+ });
141
+ if (!models) return checks;
142
+
143
+ const { responsesModel, claudeModel } = selectCompatibilityModels(models);
144
+ if (!responsesModel) {
145
+ checks.push({ kind: "err", message: "Compatibility Responses check failed: no GPT model advertises /responses" });
146
+ return checks;
147
+ }
148
+
149
+ const firstResponse = await runCompatibilityCheck(checks, `Native Responses (${responsesModel})`, async () => {
150
+ const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
151
+ model: responsesModel,
152
+ stream: false,
153
+ input: "Reply with OK only.",
154
+ }, timeoutMs);
155
+ return parseResponseObject(text);
156
+ });
157
+
158
+ if (firstResponse) {
159
+ await runCompatibilityCheck(checks, "Responses stream, history, and image tool compatibility", async () => {
160
+ const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses`, {
161
+ model: responsesModel,
162
+ stream: true,
163
+ previous_response_id: firstResponse.id,
164
+ input: "Reply with OK again.",
165
+ tools: [{ type: "image_generation" }],
166
+ }, timeoutMs);
167
+ if (!/^event:\s*response\.completed\s*$/m.test(text)) throw new Error("stream did not contain response.completed");
168
+ });
169
+ }
170
+
171
+ await runCompatibilityCheck(checks, "Responses compact", async () => {
172
+ const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/responses/compact`, {
173
+ model: responsesModel,
174
+ stream: false,
175
+ input: "Compact this short context.",
176
+ }, timeoutMs);
177
+ parseResponseObject(text);
178
+ });
179
+
180
+ if (!claudeModel) {
181
+ checks.push({ kind: "warn", message: "Anthropic stream compatibility skipped because no Claude chat model was advertised" });
182
+ return checks;
183
+ }
184
+
185
+ await runCompatibilityCheck(checks, `Anthropic Messages stream (${claudeModel})`, async () => {
186
+ const text = await compatibilityRequest(fetchImpl, `${baseUrl}/v1/messages`, {
187
+ model: claudeModel,
188
+ max_tokens: 16,
189
+ stream: true,
190
+ messages: [{ role: "user", content: "Reply with OK only." }],
191
+ }, timeoutMs);
192
+ if (!/^event:\s*message_stop\s*$/m.test(text)) throw new Error("stream did not contain message_stop");
193
+ });
194
+ return checks;
195
+ }
196
+
51
197
  export function inspectGitHubToken({ home = os.homedir() } = {}) {
52
198
  const filePath = githubTokenPath(home);
53
199
  const token = readText(filePath);
@@ -237,8 +383,11 @@ export async function collectDoctorChecks({
237
383
  checkAdapterListeningFn = checkAdapterListening,
238
384
  checkRunningAdapterFn = checkRunningAdapter,
239
385
  online = false,
386
+ compat = false,
240
387
  fetchImpl = fetch,
241
388
  onlineTimeoutMs = 10000,
389
+ compatTimeoutMs = 120000,
390
+ inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
242
391
  } = {}) {
243
392
  const checks = [
244
393
  ...inspectGitHubToken({ home }),
@@ -251,13 +400,16 @@ export async function collectDoctorChecks({
251
400
  checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
252
401
  }
253
402
 
254
- if (checkAdapter) {
255
- let running;
403
+ let running = null;
404
+ if (checkAdapter || compat) {
256
405
  try {
257
406
  running = await checkRunningAdapterFn({ host, port, fetchImpl });
258
407
  } catch {
259
408
  running = null;
260
409
  }
410
+ }
411
+
412
+ if (checkAdapter) {
261
413
  if (running?.ok) {
262
414
  checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
263
415
  } else if (running?.incompatible) {
@@ -270,12 +422,21 @@ export async function collectDoctorChecks({
270
422
  }
271
423
  }
272
424
 
425
+ if (compat) {
426
+ if (!running?.ok) {
427
+ checks.push({ kind: "err", message: "Compatibility checks require a running, version-compatible codex-copilot-dx adapter" });
428
+ } else {
429
+ checks.push(...await inspectAdapterCompatibilityFn({ host, port, fetchImpl, timeoutMs: compatTimeoutMs }));
430
+ }
431
+ }
432
+
273
433
  return checks;
274
434
  }
275
435
 
276
436
  export async function runDoctor(options = {}) {
277
437
  const log = options.log || console.log;
278
- log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
438
+ const flags = [options.online ? "--online" : "", options.compat ? "--compat" : ""].filter(Boolean);
439
+ log(`codex-copilot-dx doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
279
440
  const checks = await collectDoctorChecks(options);
280
441
  for (const check of checks) log(status(check.kind, check.message));
281
442
  return checks;
@@ -35,6 +35,66 @@ export function parseImageConcurrency(value) {
35
35
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
36
36
  }
37
37
 
38
+ function abortError(signal) {
39
+ if (signal?.reason instanceof Error) return signal.reason;
40
+ const error = new Error("The operation was aborted");
41
+ error.name = "AbortError";
42
+ return error;
43
+ }
44
+
45
+ export function createTaskLimiter(concurrency) {
46
+ const limit = parseImageConcurrency(concurrency);
47
+ const queue = [];
48
+ let active = 0;
49
+
50
+ const drain = () => {
51
+ while (active < limit && queue.length) {
52
+ const entry = queue.shift();
53
+ if (entry.cancelled) continue;
54
+ entry.cleanup();
55
+ if (entry.signal?.aborted) {
56
+ entry.reject(abortError(entry.signal));
57
+ continue;
58
+ }
59
+ active += 1;
60
+ entry.resolve();
61
+ }
62
+ };
63
+
64
+ return async function runLimited(task, { signal } = {}) {
65
+ if (signal?.aborted) throw abortError(signal);
66
+ await new Promise((resolve, reject) => {
67
+ const entry = {
68
+ cancelled: false,
69
+ cleanup: () => signal?.removeEventListener("abort", onAbort),
70
+ reject,
71
+ resolve,
72
+ signal,
73
+ };
74
+ const onAbort = () => {
75
+ entry.cancelled = true;
76
+ entry.cleanup();
77
+ reject(abortError(signal));
78
+ drain();
79
+ };
80
+ signal?.addEventListener("abort", onAbort, { once: true });
81
+ queue.push(entry);
82
+ if (signal?.aborted) onAbort();
83
+ else drain();
84
+ });
85
+
86
+ try {
87
+ if (signal?.aborted) throw abortError(signal);
88
+ return await task();
89
+ } finally {
90
+ active -= 1;
91
+ drain();
92
+ }
93
+ };
94
+ }
95
+
96
+ const runGlobalImageTask = createTaskLimiter(IMG_CONCURRENCY);
97
+
38
98
  async function sharp() {
39
99
  sharpImport ||= import("sharp");
40
100
  const mod = await sharpImport;
@@ -63,6 +123,7 @@ export async function optimizeImageDataUrl(dataUrl, {
63
123
  maxDim = IMG_MAX_DIM,
64
124
  quality = IMG_QUALITY,
65
125
  force = false,
126
+ signal,
66
127
  } = {}) {
67
128
  if (IMG_OPT_DISABLED) return dataUrl;
68
129
  if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
@@ -75,22 +136,26 @@ export async function optimizeImageDataUrl(dataUrl, {
75
136
  if (!force && touchOptimizedImage(digest)) return dataUrl;
76
137
 
77
138
  try {
78
- const resize = await sharp();
79
- const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
80
- const out = await image
81
- .rotate()
82
- .resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
83
- .webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
84
- .toBuffer();
85
- if (out.length >= raw.length) {
86
- rememberOptimizedImage(digest);
87
- return dataUrl;
88
- }
89
- rememberOptimizedImage(imageDigest(out));
90
- const ratio = ((out.length / raw.length) * 100).toFixed(1);
91
- console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
92
- return `data:image/webp;base64,${out.toString("base64")}`;
139
+ return await runGlobalImageTask(async () => {
140
+ const resize = await sharp();
141
+ const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
142
+ const out = await image
143
+ .rotate()
144
+ .resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
145
+ .webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
146
+ .toBuffer();
147
+ if (signal?.aborted) throw abortError(signal);
148
+ if (out.length >= raw.length) {
149
+ rememberOptimizedImage(digest);
150
+ return dataUrl;
151
+ }
152
+ rememberOptimizedImage(imageDigest(out));
153
+ const ratio = ((out.length / raw.length) * 100).toFixed(1);
154
+ console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
155
+ return `data:image/webp;base64,${out.toString("base64")}`;
156
+ }, { signal });
93
157
  } catch (e) {
158
+ if (signal?.aborted || e?.name === "AbortError") throw e;
94
159
  console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
95
160
  return dataUrl;
96
161
  }
@@ -131,19 +196,27 @@ function visitImageParts(parts, tasks, optimizeImage) {
131
196
  export async function optimizeImagesInBody(reqBody, {
132
197
  concurrency = IMG_CONCURRENCY,
133
198
  optimizeImage = optimizeImageDataUrl,
199
+ signal,
134
200
  } = {}) {
135
201
  if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
136
202
  const tasks = [];
137
203
  const finalize = [];
204
+ const optimizedByDataUrl = new Map();
205
+ const optimizeOnce = (dataUrl) => {
206
+ if (!optimizedByDataUrl.has(dataUrl)) {
207
+ optimizedByDataUrl.set(dataUrl, Promise.resolve().then(() => optimizeImage(dataUrl, { signal })));
208
+ }
209
+ return optimizedByDataUrl.get(dataUrl);
210
+ };
138
211
 
139
212
  for (const item of reqBody.input) {
140
213
  if (!item) continue;
141
214
  if (item.type === "message" && Array.isArray(item.content)) {
142
- visitImageParts(item.content, tasks, optimizeImage);
215
+ visitImageParts(item.content, tasks, optimizeOnce);
143
216
  }
144
217
  if (item.type === "function_call_output") {
145
218
  if (Array.isArray(item.output)) {
146
- visitImageParts(item.output, tasks, optimizeImage);
219
+ visitImageParts(item.output, tasks, optimizeOnce);
147
220
  } else if (typeof item.output === "string") {
148
221
  const trimmed = item.output.trim();
149
222
  if (trimmed.startsWith("[")) {
@@ -151,7 +224,7 @@ export async function optimizeImagesInBody(reqBody, {
151
224
  const parsed = JSON.parse(trimmed);
152
225
  if (Array.isArray(parsed)) {
153
226
  const taskCount = tasks.length;
154
- visitImageParts(parsed, tasks, optimizeImage);
227
+ visitImageParts(parsed, tasks, optimizeOnce);
155
228
  if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
156
229
  }
157
230
  } catch {
@@ -170,8 +243,9 @@ export async function optimizeImagesInBody(reqBody, {
170
243
  export async function prepareResponsesPayload(reqBody, {
171
244
  maxBytes = MAX_UPSTREAM_BODY_BYTES,
172
245
  profiles = OVERSIZE_IMAGE_PROFILES,
246
+ signal,
173
247
  } = {}) {
174
- await optimizeImagesInBody(reqBody);
248
+ await optimizeImagesInBody(reqBody, { signal });
175
249
  let bodyText = JSON.stringify(reqBody);
176
250
  let bodyBytes = Buffer.byteLength(bodyText);
177
251
  const summary = summarizeReqBody(reqBody);
@@ -183,7 +257,8 @@ export async function prepareResponsesPayload(reqBody, {
183
257
  const beforeBytes = bodyBytes;
184
258
  bodyText = "";
185
259
  await optimizeImagesInBody(reqBody, {
186
- optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true }),
260
+ optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true, signal }),
261
+ signal,
187
262
  });
188
263
  bodyText = JSON.stringify(reqBody);
189
264
  bodyBytes = Buffer.byteLength(bodyText);