codex-copilot-dx 0.6.18 → 0.6.20

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
@@ -378,6 +378,8 @@ Environment variables:
378
378
  | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
379
379
  | `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
380
380
  | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Per-pool byte budget for raw bodies, decoded bodies, and materialized response history; a larger item runs exclusively in its pool |
381
+ | `CCDX_MAX_UPSTREAM_CHAT_RESPONSE_BYTES` | `67108864` | Maximum buffered successful Chat Completions response used by Responses, Anthropic, and PM Studio conversion paths |
382
+ | `CCDX_MAX_UPSTREAM_RESPONSES_RESPONSE_BYTES` | `268435456` | Maximum buffered successful native Responses or compact response; streaming Responses are forwarded with bounded backpressure instead |
381
383
  | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
382
384
  | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
383
385
  | `CCDX_REQUEST_BODY_TIMEOUT_MS` | `120000` | Maximum time to receive and decode one request body before returning `408` |
package/bin/cli.mjs CHANGED
@@ -17,6 +17,7 @@ import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
17
  import { runInBackground } from "../src/startup.mjs";
18
18
  import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
19
19
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
20
+ import { formatCliError } from "../src/cli-error.mjs";
20
21
  import { closeHttpServer } from "../src/shutdown.mjs";
21
22
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
22
23
  import { autoReviewModelPreference } from "../src/user-settings.mjs";
@@ -152,7 +153,7 @@ if (CLI.command === "pms") {
152
153
  await runPmStudioSetup({ commandName: CLI_NAME });
153
154
  process.exit(0);
154
155
  } catch (e) {
155
- console.error(status("err", e.message));
156
+ console.error(formatCliError(e));
156
157
  process.exit(1);
157
158
  }
158
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -15,6 +15,7 @@ import { createPmStudioRelayHandler, PM_STUDIO_RELAY_PREFIX } from "./pm-studio-
15
15
  import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
16
16
  import { status } from "./status.mjs";
17
17
  import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
18
+ import { createTokenCounterService } from "./token-counter-service.mjs";
18
19
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
19
20
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
20
21
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
@@ -145,6 +146,11 @@ export function createAdapterHandler(options = {}) {
145
146
  const requestMetrics = options.requestMetrics || createRequestMetrics();
146
147
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
147
148
  const imagePressure = options.imagePressure || createResponsesImagePressureController();
149
+ const ownsTokenCounterService = !options.tokenCounterService;
150
+ const tokenCounterService = options.tokenCounterService || createTokenCounterService({
151
+ timeoutMs: options.countTokensTimeoutMs,
152
+ maxQueued: options.countTokensMaxQueued,
153
+ });
148
154
  const claudeDesktopModelOptions = () => {
149
155
  const modelDefs = claudeModelRegistry?.modelDefs || options.claudeDesktopModelDefs;
150
156
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -178,7 +184,11 @@ export function createAdapterHandler(options = {}) {
178
184
  streamIdleTimeoutMs,
179
185
  upstreamTimeoutMs,
180
186
  });
181
- const anthropicCountTokensHandler = createAnthropicCountTokensHandler({ acquireRequest, requestBodyTimeoutMs });
187
+ const anthropicCountTokensHandler = createAnthropicCountTokensHandler({
188
+ acquireRequest,
189
+ requestBodyTimeoutMs,
190
+ countTokensFn: (body, countOptions) => tokenCounterService.count(body, countOptions),
191
+ });
182
192
  const anthropicMessagesHandler = createAnthropicMessagesHandler({
183
193
  acquireRequest,
184
194
  chatCompletionsFn: claudeChatCompletionsFn,
@@ -300,7 +310,7 @@ export function createAdapterHandler(options = {}) {
300
310
  ? options.dispatchRequestForTests
301
311
  : dispatch;
302
312
 
303
- return (req, res) => {
313
+ const handler = (req, res) => {
304
314
  const requestId = createRequestId();
305
315
  if (typeof res.setHeader === "function") {
306
316
  res.setHeader("X-Request-Id", requestId);
@@ -378,6 +388,10 @@ export function createAdapterHandler(options = {}) {
378
388
  containUnexpectedError(error);
379
389
  }
380
390
  };
391
+ handler.cleanup = () => {
392
+ if (ownsTokenCounterService) tokenCounterService.close();
393
+ };
394
+ return handler;
381
395
  }
382
396
 
383
397
  // Public server entry point.
@@ -385,9 +399,11 @@ export function createAdapterHandler(options = {}) {
385
399
  export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
386
400
  const ownsTerminalActivity = !Object.hasOwn(options, "terminalActivity");
387
401
  const terminalActivity = ownsTerminalActivity ? createTerminalActivityIndicator() : options.terminalActivity;
388
- const server = http.createServer(createAdapterHandler({ ...options, terminalActivity }));
402
+ const handler = createAdapterHandler({ ...options, terminalActivity });
403
+ const server = http.createServer(handler);
389
404
  const cleanupTerminalActivity = () => {
390
405
  if (ownsTerminalActivity) terminalActivity?.cleanup?.();
406
+ handler.cleanup?.();
391
407
  };
392
408
  server.once("close", cleanupTerminalActivity);
393
409
 
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  createRequestAbort,
9
9
  logRequestFailure,
10
+ MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
10
11
  MAX_UPSTREAM_ERROR_BODY_BYTES,
11
12
  readJsonBody,
12
13
  readBoundedResponseText,
@@ -60,7 +61,7 @@ function sendAnthropicUpstreamError(res, response, text) {
60
61
  }
61
62
 
62
63
  export function createAnthropicCountTokensHandler(options) {
63
- const { acquireRequest, requestBodyTimeoutMs } = options;
64
+ const { acquireRequest, requestBodyTimeoutMs, countTokensFn = countTokens } = options;
64
65
 
65
66
  return async function handleAnthropicCountTokens(req, res) {
66
67
  const abort = createRequestAbort(req, res);
@@ -70,12 +71,12 @@ export function createAnthropicCountTokensHandler(options) {
70
71
  abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
71
72
  const parsed = await readJsonBody(req, { admission: releaseRequest, signal: abort.signal });
72
73
  abort.clearTimeout();
73
- const result = await countTokens(parsed);
74
+ const result = await countTokensFn(parsed, { signal: abort.signal });
74
75
  releaseRequest();
75
76
  res.writeHead(200, { "Content-Type": "application/json" });
76
77
  res.end(JSON.stringify(result));
77
78
  } catch (error) {
78
- sendJsonError(res, error, abort.reason === "request_body_timeout" ? 408 : 400);
79
+ sendJsonError(res, error, abort.reason === "request_body_timeout" ? 408 : 500);
79
80
  } finally {
80
81
  releaseRequest();
81
82
  abort.cleanup();
@@ -157,7 +158,11 @@ export function createAnthropicMessagesHandler(options) {
157
158
  const upstream = await chatCompletionsFn({ ...chatReq, stream: false }, { signal: abort.signal });
158
159
  releaseRequest();
159
160
  const data = upstream.ok
160
- ? await upstream.text()
161
+ ? await readBoundedResponseText(upstream, {
162
+ maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
163
+ label: "Copilot Chat success body",
164
+ signal: abort.signal,
165
+ })
161
166
  : await readBoundedResponseText(upstream, {
162
167
  maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
163
168
  label: "Copilot Chat error body",
package/src/anthropic.mjs CHANGED
@@ -350,8 +350,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
350
350
  await emit("message_stop", { type: "message_stop" });
351
351
  }
352
352
 
353
- // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
354
- export async function countTokens(body) {
353
+ export function anthropicTokenText(body) {
355
354
  const parts = [];
356
355
  const sys = systemToText(body.system);
357
356
  if (sys) parts.push(sys);
@@ -374,7 +373,15 @@ export async function countTokens(body) {
374
373
  }
375
374
  }
376
375
 
377
- const text = parts.join("\n");
376
+ return parts.join("\n");
377
+ }
378
+
379
+ export async function countTokenText(text) {
378
380
  const count = await tokenCounter();
379
381
  return { input_tokens: count(text) };
380
382
  }
383
+
384
+ // Copilot has no count_tokens endpoint; estimate locally with gpt-tokenizer.
385
+ export async function countTokens(body) {
386
+ return countTokenText(anthropicTokenText(body));
387
+ }
@@ -12,7 +12,7 @@ import {
12
12
  normalizeGithubIdentity,
13
13
  } from "./github-identity.mjs";
14
14
  import { withFileLock } from "./lock.mjs";
15
- import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
15
+ import { parseSafePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
16
16
 
17
17
  export const AUTH_PROFILE_CODEX = "codex";
18
18
  export const AUTH_PROFILE_CLAUDE = "claude";
@@ -117,8 +117,8 @@ export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}
117
117
 
118
118
  function lockOptions(env = process.env) {
119
119
  return {
120
- timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
- staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
120
+ timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
+ staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
122
122
  };
123
123
  }
124
124
 
package/src/auth.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  githubTokenFingerprint,
9
9
  normalizeGithubIdentity,
10
10
  } from "./github-identity.mjs";
11
- import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
11
+ import { parseSafePositiveInteger, parseTimerMs, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
12
12
  import { status } from "./status.mjs";
13
13
  import { withFileLock } from "./lock.mjs";
14
14
 
@@ -20,6 +20,7 @@ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
20
20
  const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
21
21
  const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
22
22
  const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 30 * 1000;
23
+ const DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS = 30 * 1000;
23
24
  const MAX_DEVICE_FLOW_JSON_BYTES = 64 * 1024;
24
25
  const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
25
26
  const MAX_AUTH_JSON_BYTES = 1024 * 1024;
@@ -258,8 +259,8 @@ function isTokenDiscoveryDisabled(env = process.env) {
258
259
 
259
260
  function tokenLockOptions(env = process.env) {
260
261
  return {
261
- timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
262
- staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
262
+ timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
263
+ staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
263
264
  };
264
265
  }
265
266
 
@@ -425,19 +426,47 @@ function expectedGithubIdentity(home, env, token) {
425
426
  return token ? readGithubTokenMetadata(home, token) : null;
426
427
  }
427
428
 
428
- export async function fetchGithubIdentity(token, { fetchImpl = fetch, signal } = {}) {
429
+ function githubIdentityTimeoutError(timeoutMs) {
430
+ const error = new Error(`GitHub identity request timed out after ${timeoutMs}ms`);
431
+ error.code = "CCDX_GITHUB_IDENTITY_TIMEOUT";
432
+ return error;
433
+ }
434
+
435
+ export async function fetchGithubIdentity(token, {
436
+ fetchImpl = fetch,
437
+ signal,
438
+ timeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
439
+ } = {}) {
429
440
  if (typeof token !== "string" || !token.trim()) return { ok: false, reason: "empty_token" };
441
+ const deadlineMs = parseTimerMs(timeoutMs, DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS);
442
+ const controller = new AbortController();
443
+ const onCallerAbort = () => controller.abort(signal?.reason);
444
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
445
+ if (signal?.aborted) onCallerAbort();
446
+ const timer = setTimeout(() => controller.abort(githubIdentityTimeoutError(deadlineMs)), deadlineMs);
447
+ let resp;
430
448
  try {
431
- const resp = await fetchImpl(`${GITHUB_API}/user`, {
449
+ resp = await awaitWithSignal(() => fetchImpl(`${GITHUB_API}/user`, {
432
450
  headers: { Authorization: `token ${token.trim()}`, Accept: "application/json" },
433
- signal,
434
- });
451
+ signal: controller.signal,
452
+ }), controller.signal);
435
453
  if (!resp.ok) return { ok: false, status: resp.status, reason: "github_user_failed" };
436
- const data = await resp.json();
454
+ const data = await awaitWithSignal(() => resp.json(), controller.signal);
437
455
  const identity = normalizeGithubIdentity(data);
438
456
  return identity ? { ok: true, ...identity } : { ok: false, reason: "github_identity_missing" };
439
457
  } catch (error) {
440
- return { ok: false, transient: true, reason: "github_user_request_failed", error };
458
+ cancelResponseBody(resp);
459
+ const timedOut = controller.signal.aborted && !signal?.aborted
460
+ && controller.signal.reason?.code === "CCDX_GITHUB_IDENTITY_TIMEOUT";
461
+ return {
462
+ ok: false,
463
+ transient: true,
464
+ reason: timedOut ? "github_user_timeout" : "github_user_request_failed",
465
+ error,
466
+ };
467
+ } finally {
468
+ clearTimeout(timer);
469
+ signal?.removeEventListener("abort", onCallerAbort);
441
470
  }
442
471
  }
443
472
 
@@ -661,6 +690,7 @@ export async function ensureAuth({
661
690
  log = console.log,
662
691
  openAndCopyFn = openAndCopy,
663
692
  deviceCodeTimeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
693
+ githubIdentityTimeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
664
694
  sleepImpl = sleep,
665
695
  now = Date.now,
666
696
  } = {}) {
@@ -720,7 +750,11 @@ export async function ensureAuth({
720
750
  now,
721
751
  });
722
752
  if (result.state === "done") {
723
- const identity = await fetchGithubIdentity(result.token, { fetchImpl, signal });
753
+ const identity = await fetchGithubIdentity(result.token, {
754
+ fetchImpl,
755
+ signal,
756
+ timeoutMs: githubIdentityTimeoutMs,
757
+ });
724
758
  writeToken(result.token, home, identity.ok ? identity : null);
725
759
  log(status("ok", "Login successful"));
726
760
  return;
@@ -12,12 +12,12 @@ import {
12
12
  readUserSettings,
13
13
  writeAutoReviewModel,
14
14
  } from "./user-settings.mjs";
15
+ import { parseTimerMs } from "./runtime-config.mjs";
15
16
 
16
17
  const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 5000;
17
18
 
18
19
  function modelFetchTimeout(env) {
19
- const value = Number(env.CCDX_MODEL_REFRESH_TIMEOUT_MS);
20
- return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MODEL_FETCH_TIMEOUT_MS;
20
+ return parseTimerMs(env.CCDX_MODEL_REFRESH_TIMEOUT_MS, DEFAULT_MODEL_FETCH_TIMEOUT_MS);
21
21
  }
22
22
 
23
23
  function sortedModelIds(modelIds, currentModel) {
package/src/cli-auth.mjs CHANGED
@@ -15,12 +15,16 @@ import {
15
15
  withAuthProfileLock,
16
16
  writeClaudeAuthProfile,
17
17
  } from "./auth-profile.mjs";
18
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
18
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
19
19
  import {
20
20
  githubIdentitiesEqual,
21
21
  githubTokenFingerprint,
22
22
  normalizeGithubIdentity,
23
23
  } from "./github-identity.mjs";
24
+ import {
25
+ MAX_UPSTREAM_MODEL_CATALOG_BYTES,
26
+ readBoundedResponseText,
27
+ } from "./http-transport.mjs";
24
28
  import { saveModelCache } from "./model-cache.mjs";
25
29
  import { isClaudeCopilotModel } from "./models.mjs";
26
30
  import { profileRouting } from "./profile-routing.mjs";
@@ -32,6 +36,8 @@ import {
32
36
  terminalCell,
33
37
  } from "./cli-table.mjs";
34
38
 
39
+ const DEFAULT_COPILOT_CATALOG_TIMEOUT_MS = 30 * 1000;
40
+
35
41
  function openAndCopy(userCode, verificationUri) {
36
42
  if (process.platform !== "darwin") return;
37
43
  try {
@@ -50,6 +56,44 @@ function responseDetail(value) {
50
56
  return String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
51
57
  }
52
58
 
59
+ function abortError(signal) {
60
+ if (signal?.reason instanceof Error) return signal.reason;
61
+ const error = new Error("The operation was aborted");
62
+ error.name = "AbortError";
63
+ return error;
64
+ }
65
+
66
+ function cancelResponseBody(response) {
67
+ try { Promise.resolve(response?.body?.cancel?.()).catch(() => {}); } catch {}
68
+ }
69
+
70
+ function fetchWithSignal(operation, signal) {
71
+ if (signal?.aborted) return Promise.reject(abortError(signal));
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const finish = (callback, value) => {
75
+ if (settled) return false;
76
+ settled = true;
77
+ signal?.removeEventListener("abort", onAbort);
78
+ callback(value);
79
+ return true;
80
+ };
81
+ const onAbort = () => finish(reject, abortError(signal));
82
+ signal?.addEventListener("abort", onAbort, { once: true });
83
+ Promise.resolve()
84
+ .then(operation)
85
+ .then((response) => {
86
+ if (!finish(resolve, response)) cancelResponseBody(response);
87
+ }, (error) => finish(reject, error));
88
+ });
89
+ }
90
+
91
+ function catalogTimeoutError(label, timeoutMs) {
92
+ const error = new Error(`${label} model catalog timed out after ${timeoutMs}ms`);
93
+ error.code = "CCDX_COPILOT_CATALOG_TIMEOUT";
94
+ return error;
95
+ }
96
+
53
97
  async function jsonResponse({ response, data, jsonError }, label) {
54
98
  if (!response.ok) {
55
99
  throw new Error(`${label} failed with HTTP ${response.status}`);
@@ -121,33 +165,58 @@ async function fetchCopilotCatalog(tokenData, {
121
165
  signal,
122
166
  vscodeVersion = FALLBACK_VSCODE_VERSION,
123
167
  label = "Copilot",
168
+ timeoutMs = DEFAULT_COPILOT_CATALOG_TIMEOUT_MS,
124
169
  } = {}) {
125
- const apiBase = tokenData?.endpoints?.api || DEFAULT_API_BASE;
126
- const headers = buildHeaders({
127
- token: tokenData.token,
128
- version: vscodeVersion,
129
- initiator: "user",
130
- vision: false,
131
- });
132
- const modelResponse = await fetchImpl(`${apiBase}/models`, {
133
- headers,
134
- redirect: "error",
135
- signal,
136
- });
137
- const modelText = await modelResponse.text();
138
- if (!modelResponse.ok) {
139
- const detail = responseDetail(modelText);
140
- throw new Error(`${label} model catalog failed with HTTP ${modelResponse.status}${detail ? `: ${detail}` : ""}`);
141
- }
142
-
143
- let catalog;
170
+ const parsedTimeout = Number(timeoutMs);
171
+ const deadlineMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0
172
+ ? parsedTimeout
173
+ : DEFAULT_COPILOT_CATALOG_TIMEOUT_MS;
174
+ const controller = new AbortController();
175
+ const onCallerAbort = () => controller.abort(signal?.reason);
176
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
177
+ if (signal?.aborted) onCallerAbort();
178
+ const timer = setTimeout(() => controller.abort(catalogTimeoutError(label, deadlineMs)), deadlineMs);
179
+ let modelResponse;
144
180
  try {
145
- catalog = JSON.parse(modelText);
146
- } catch {
147
- throw new Error(`${label} model catalog returned invalid JSON`);
181
+ const apiBase = parseApiBase(tokenData);
182
+ const headers = buildHeaders({
183
+ token: tokenData.token,
184
+ version: vscodeVersion,
185
+ initiator: "user",
186
+ vision: false,
187
+ });
188
+ modelResponse = await fetchWithSignal(() => fetchImpl(`${apiBase}/models`, {
189
+ headers,
190
+ redirect: "error",
191
+ signal: controller.signal,
192
+ }), controller.signal);
193
+ const modelText = await readBoundedResponseText(modelResponse, {
194
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
195
+ label: `${label} model catalog`,
196
+ signal: controller.signal,
197
+ });
198
+ if (!modelResponse.ok) {
199
+ const detail = responseDetail(modelText);
200
+ throw new Error(`${label} model catalog failed with HTTP ${modelResponse.status}${detail ? `: ${detail}` : ""}`);
201
+ }
202
+
203
+ let catalog;
204
+ try {
205
+ catalog = JSON.parse(modelText);
206
+ } catch {
207
+ throw new Error(`${label} model catalog returned invalid JSON`);
208
+ }
209
+ catalogModels(catalog);
210
+ return { catalog, apiBase };
211
+ } catch (error) {
212
+ if (signal?.aborted) throw abortError(signal);
213
+ if (controller.signal.aborted) throw abortError(controller.signal);
214
+ throw error;
215
+ } finally {
216
+ clearTimeout(timer);
217
+ signal?.removeEventListener("abort", onCallerAbort);
218
+ if (controller.signal.aborted) cancelResponseBody(modelResponse);
148
219
  }
149
- catalogModels(catalog);
150
- return { catalog, apiBase };
151
220
  }
152
221
 
153
222
  export async function validateClaudeCandidate(githubToken, {
@@ -156,6 +225,7 @@ export async function validateClaudeCandidate(githubToken, {
156
225
  fetchImpl = fetch,
157
226
  signal,
158
227
  vscodeVersion = FALLBACK_VSCODE_VERSION,
228
+ catalogTimeoutMs,
159
229
  } = {}) {
160
230
  const validation = await validateGithubToken(githubToken, { fetchImpl, signal });
161
231
  if (!validation.ok) {
@@ -180,6 +250,7 @@ export async function validateClaudeCandidate(githubToken, {
180
250
  signal,
181
251
  vscodeVersion,
182
252
  label: "Claude account",
253
+ timeoutMs: catalogTimeoutMs,
183
254
  });
184
255
  const models = claudeModels(catalog);
185
256
  if (!models.length) {
@@ -205,6 +276,7 @@ export async function authorizeClaudeProfile({
205
276
  log = console.log,
206
277
  openAndCopyFn = openAndCopy,
207
278
  deviceCodeTimeoutMs,
279
+ catalogTimeoutMs,
208
280
  sleepImpl,
209
281
  now = Date.now,
210
282
  saveModelCacheFn = saveModelCache,
@@ -268,6 +340,7 @@ export async function authorizeClaudeProfile({
268
340
  expectedLogin,
269
341
  fetchImpl,
270
342
  signal,
343
+ catalogTimeoutMs,
271
344
  });
272
345
  await withAuthProfileLock(AUTH_PROFILE_CODEX, () => {
273
346
  const currentCodex = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
@@ -0,0 +1,50 @@
1
+ import { terminalCell } from "./cli-table.mjs";
2
+ import { status } from "./status.mjs";
3
+
4
+ const MAX_MESSAGE_CHARACTERS = 1024;
5
+ const MAX_DETAIL_CHARACTERS = 512;
6
+ const MAX_DETAILS = 16;
7
+
8
+ function redactSensitiveValues(value) {
9
+ return value
10
+ .replace(/\b(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/gi, "$1[REDACTED]")
11
+ .replace(/\bbearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
12
+ .replace(/\b((?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|password|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]")
13
+ .replace(/\b(?:github_pat_[A-Za-z0-9_]{8,}|gh[pousr]_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{8,})\b/g, "[REDACTED]");
14
+ }
15
+
16
+ function boundedTerminalText(value, fallback, maxCharacters) {
17
+ try {
18
+ return redactSensitiveValues(terminalCell(value, { fallback })).slice(0, maxCharacters);
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ export function formatCliError(error) {
25
+ let rawMessage;
26
+ try {
27
+ rawMessage = error?.message;
28
+ } catch {
29
+ rawMessage = undefined;
30
+ }
31
+ const message = boundedTerminalText(rawMessage, "Command failed", MAX_MESSAGE_CHARACTERS);
32
+ let details;
33
+ try {
34
+ details = error?.details;
35
+ } catch {
36
+ details = undefined;
37
+ }
38
+ const values = details === undefined
39
+ ? []
40
+ : Array.isArray(details) ? details : [details];
41
+ const visible = values.slice(0, MAX_DETAILS);
42
+ const lines = [status("err", message)];
43
+ for (const detail of visible) {
44
+ lines.push(status("info", boundedTerminalText(detail, "Detail unavailable", MAX_DETAIL_CHARACTERS)));
45
+ }
46
+ if (values.length > visible.length) {
47
+ lines.push(status("info", `${values.length - visible.length} additional detail(s) omitted`));
48
+ }
49
+ return lines.join("\n");
50
+ }
@@ -5,7 +5,7 @@ import {
5
5
  AUTH_PROFILE_CODEX,
6
6
  readAuthProfileCredentials,
7
7
  } from "./auth-profile.mjs";
8
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
8
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
9
9
  import {
10
10
  isClaudeCopilotCatalogEntry,
11
11
  isClaudeCopilotModel,
@@ -134,7 +134,7 @@ export async function fetchLiveCopilotModels({
134
134
  if (!validation.ok) throw new Error(validationFailure(validation));
135
135
 
136
136
  const tokenData = validation.copilotTokenData;
137
- const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
137
+ const apiBase = parseApiBase(tokenData);
138
138
  const headers = buildHeaders({
139
139
  token: tokenData.token,
140
140
  version: vscodeVersion,
@@ -1,3 +1,5 @@
1
+ import { MAX_TIMER_DELAY_MS } from "./runtime-config.mjs";
2
+
1
3
  const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
4
  const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
5
  const START_OPTIONS = new Map([
@@ -262,13 +264,13 @@ export function parseRuntimeOptions(env = process.env) {
262
264
  return {
263
265
  adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
264
266
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
265
- modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
266
- existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
267
- modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0 }),
268
- upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
269
- streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
270
- streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
271
- shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1 }),
267
+ modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
268
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
269
+ modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0, max: MAX_TIMER_DELAY_MS }),
270
+ upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
271
+ streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
272
+ streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
273
+ shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
272
274
  };
273
275
  }
274
276
 
@@ -276,7 +278,7 @@ export function parseAdapterProbeOptions(env = process.env) {
276
278
  return {
277
279
  adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
278
280
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
279
- existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
281
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
280
282
  };
281
283
  }
282
284
 
@@ -60,6 +60,20 @@ function refreshError(message, { statusCode, transient = false } = {}) {
60
60
  return error;
61
61
  }
62
62
 
63
+ function transientRefreshError(error) {
64
+ let message = "Copilot token request failed";
65
+ try {
66
+ if (typeof error?.message === "string" && error.message) message = error.message;
67
+ } catch {}
68
+ const wrapped = new Error(message, { cause: error });
69
+ try {
70
+ if (typeof error?.code === "string") wrapped.code = error.code;
71
+ if (Number.isInteger(error?.statusCode)) wrapped.statusCode = error.statusCode;
72
+ } catch {}
73
+ wrapped.transient = true;
74
+ return wrapped;
75
+ }
76
+
63
77
  function copilotProfileLabel(profile) {
64
78
  if (profile === "codex") return "Copilot";
65
79
  const name = String(profile || "profile");
@@ -189,6 +203,7 @@ export function createCopilotTokenSession({
189
203
  allowAccountSwitch = false,
190
204
  }) {
191
205
  if (!data.token) throw new Error("Copilot token response missing token field");
206
+ const nextApiBase = parseApiBase(data);
192
207
  const fingerprint = githubTokenFingerprint(githubToken);
193
208
  const normalizedIdentity = normalizeGithubIdentity(identity);
194
209
  const tokenChanged = Boolean(githubFingerprint && fingerprint !== githubFingerprint);
@@ -204,7 +219,7 @@ export function createCopilotTokenSession({
204
219
  throw error;
205
220
  }
206
221
  copilotToken = data.token;
207
- apiBase = parseApiBase(data);
222
+ apiBase = nextApiBase;
208
223
  copilotTokenExpiry = typeof data.expires_at === "number"
209
224
  ? data.expires_at * 1000
210
225
  : Date.now() + 25 * 60 * 1000;
@@ -232,7 +247,7 @@ export function createCopilotTokenSession({
232
247
  signal,
233
248
  }, { fetchImpl, ...tokenRetryOptions });
234
249
  } catch (error) {
235
- if (!isAbortError(error, signal)) error.transient = true;
250
+ if (!isAbortError(error, signal)) throw transientRefreshError(error);
236
251
  throw error;
237
252
  }
238
253
  if (!response.ok) {