codex-copilot-dx 0.6.18 → 0.6.19

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.19",
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
+ }
package/src/cli-auth.mjs CHANGED
@@ -21,6 +21,10 @@ import {
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 = tokenData?.endpoints?.api || DEFAULT_API_BASE;
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,44 @@
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
+ const message = boundedTerminalText(error?.message, "Command failed", MAX_MESSAGE_CHARACTERS);
26
+ let details;
27
+ try {
28
+ details = error?.details;
29
+ } catch {
30
+ details = undefined;
31
+ }
32
+ const values = details === undefined
33
+ ? []
34
+ : Array.isArray(details) ? details : [details];
35
+ const visible = values.slice(0, MAX_DETAILS);
36
+ const lines = [status("err", message)];
37
+ for (const detail of visible) {
38
+ lines.push(status("info", boundedTerminalText(detail, "Detail unavailable", MAX_DETAIL_CHARACTERS)));
39
+ }
40
+ if (values.length > visible.length) {
41
+ lines.push(status("info", `${values.length - visible.length} additional detail(s) omitted`));
42
+ }
43
+ return lines.join("\n");
44
+ }
@@ -20,6 +20,8 @@ const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
20
20
  export const MAX_UPSTREAM_ERROR_BODY_BYTES = 1024 * 1024;
21
21
  export const MAX_UPSTREAM_MODEL_CATALOG_BYTES = 8 * 1024 * 1024;
22
22
  export const MAX_UPSTREAM_RETRY_DRAIN_BYTES = 64 * 1024;
23
+ export const MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamChatResponseBytes;
24
+ export const MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamResponsesResponseBytes;
23
25
 
24
26
  export function httpError(message, statusCode) {
25
27
  const err = new Error(message);
@@ -57,6 +59,37 @@ async function cancelResponseBody(response) {
57
59
  try { await response?.body?.cancel?.(); } catch {}
58
60
  }
59
61
 
62
+ function responseAbortError(signal) {
63
+ if (signal?.reason instanceof Error) return signal.reason;
64
+ const error = new Error("The operation was aborted");
65
+ error.name = "AbortError";
66
+ return error;
67
+ }
68
+
69
+ function readResponseChunk(reader, signal) {
70
+ if (!signal) return reader.read();
71
+ if (signal.aborted) return Promise.reject(responseAbortError(signal));
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const finish = (callback, value) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ signal.removeEventListener("abort", onAbort);
78
+ callback(value);
79
+ };
80
+ const onAbort = () => {
81
+ const error = responseAbortError(signal);
82
+ try { Promise.resolve(reader.cancel(error)).catch(() => {}); } catch {}
83
+ finish(reject, error);
84
+ };
85
+ signal.addEventListener("abort", onAbort, { once: true });
86
+ reader.read().then(
87
+ (value) => finish(resolve, value),
88
+ (error) => finish(reject, error),
89
+ );
90
+ });
91
+ }
92
+
60
93
  function responseContentLength(response) {
61
94
  const raw = response?.headers?.get?.("content-length")
62
95
  ?? response?.headers?.["content-length"]
@@ -68,13 +101,23 @@ function responseContentLength(response) {
68
101
  export async function readBoundedResponseBuffer(response, {
69
102
  maxBytes,
70
103
  label = "Upstream response body",
104
+ signal,
71
105
  } = {}) {
72
106
  const limit = Number.isFinite(maxBytes) && maxBytes >= 0 ? Math.floor(maxBytes) : 0;
107
+ if (signal?.aborted) {
108
+ cancelResponseBody(response);
109
+ throw responseAbortError(signal);
110
+ }
73
111
  if (responseContentLength(response) > limit) {
74
112
  await cancelResponseBody(response);
75
113
  throw upstreamBodyTooLarge(label, limit);
76
114
  }
77
115
  if (!response?.body) {
116
+ if (typeof response?.text === "function") {
117
+ const buffer = Buffer.from(await response.text());
118
+ if (buffer.length > limit) throw upstreamBodyTooLarge(label, limit);
119
+ return buffer;
120
+ }
78
121
  if ((responseContentLength(response) || 0) > 0) throw unreadableUpstreamBody(label);
79
122
  return Buffer.alloc(0);
80
123
  }
@@ -84,7 +127,7 @@ export async function readBoundedResponseBuffer(response, {
84
127
  let total = 0;
85
128
  try {
86
129
  while (true) {
87
- const { done, value } = await reader.read();
130
+ const { done, value } = await readResponseChunk(reader, signal);
88
131
  if (done) break;
89
132
  const chunk = Buffer.from(value);
90
133
  total += chunk.length;
@@ -96,7 +139,11 @@ export async function readBoundedResponseBuffer(response, {
96
139
  }
97
140
  return Buffer.concat(chunks, total);
98
141
  } finally {
99
- reader.releaseLock();
142
+ try {
143
+ reader.releaseLock();
144
+ } catch (error) {
145
+ if (!signal?.aborted) throw error;
146
+ }
100
147
  }
101
148
  }
102
149
 
package/src/lock.mjs CHANGED
@@ -149,8 +149,12 @@ function maybeRemoveAbandonedGuard(guardPath, staleMs, nowMs, io, processAlive)
149
149
  restoreQuarantinedLock(guardPath, quarantinePath, io);
150
150
  return false;
151
151
  }
152
- io.unlinkSync(quarantinePath);
153
- return true;
152
+ try {
153
+ io.unlinkSync(quarantinePath);
154
+ return true;
155
+ } catch {
156
+ return recoverQuarantineUnlinkFailure(guardPath, quarantinePath, snapshot, io);
157
+ }
154
158
  } catch (error) {
155
159
  return error?.code === "ENOENT";
156
160
  }
@@ -167,6 +171,18 @@ function restoreQuarantinedLock(lockPath, quarantinePath, io) {
167
171
  } catch {}
168
172
  }
169
173
 
174
+ function recoverQuarantineUnlinkFailure(lockPath, quarantinePath, snapshot, io) {
175
+ restoreQuarantinedLock(lockPath, quarantinePath, io);
176
+ try {
177
+ const restored = readStaleLockSnapshot(lockPath, io);
178
+ if (!restored.safe || !sameLock(snapshot, restored)) return false;
179
+ io.unlinkSync(lockPath);
180
+ return true;
181
+ } catch (error) {
182
+ return error?.code === "ENOENT";
183
+ }
184
+ }
185
+
170
186
  function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
171
187
  if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
172
188
  try {
@@ -192,8 +208,12 @@ function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
192
208
  restoreQuarantinedLock(lockPath, quarantinePath, io);
193
209
  return false;
194
210
  }
195
- io.unlinkSync(quarantinePath);
196
- return true;
211
+ try {
212
+ io.unlinkSync(quarantinePath);
213
+ return true;
214
+ } catch {
215
+ return recoverQuarantineUnlinkFailure(lockPath, quarantinePath, current, io);
216
+ }
197
217
  } finally {
198
218
  removeOwnedLock(guard.guardPath, guard.owner, io);
199
219
  }
@@ -5,6 +5,10 @@ import {
5
5
  createRequestAdmission,
6
6
  httpError,
7
7
  isAbortLikeError,
8
+ MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
9
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
+ MAX_UPSTREAM_MODEL_CATALOG_BYTES,
11
+ readBoundedResponseBuffer,
8
12
  readJsonBody,
9
13
  sendJsonError,
10
14
  writeOrDrain,
@@ -150,12 +154,12 @@ function catalogData(catalog) {
150
154
  return Array.isArray(catalog?.data) ? catalog.data : null;
151
155
  }
152
156
 
153
- async function responseSnapshot(response) {
157
+ async function responseSnapshot(response, { maxBytes, label, signal }) {
154
158
  return {
155
159
  status: response.status,
156
160
  ok: response.ok,
157
161
  headers: new Headers(response.headers),
158
- body: Buffer.from(await response.arrayBuffer()),
162
+ body: await readBoundedResponseBuffer(response, { maxBytes, label, signal }),
159
163
  };
160
164
  }
161
165
 
@@ -209,7 +213,15 @@ function mergeModelCatalog(snapshot, modelRouter) {
209
213
  async function relayUpstreamResponse(response, reqBody, res, abort, streamIdleTimeoutMs) {
210
214
  const stream = reqBody?.stream === true;
211
215
  if (!stream || !response.ok) {
212
- const snapshot = await responseSnapshot(response);
216
+ const snapshot = await responseSnapshot(response, response.ok ? {
217
+ maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
218
+ label: "PM Studio Claude success body",
219
+ signal: abort.signal,
220
+ } : {
221
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
222
+ label: "PM Studio Claude error body",
223
+ signal: abort.signal,
224
+ });
213
225
  sendSnapshot(res, snapshot);
214
226
  return;
215
227
  }
@@ -345,9 +357,18 @@ export function createPmStudioRelayHandler(options = {}) {
345
357
  }
346
358
  activeModelRequests += 1;
347
359
  try {
348
- return await fetchEnterprise(
360
+ const response = await fetchEnterprise(
349
361
  "/models", "GET", inboundHeaders, token, undefined, signal,
350
- ).then(responseSnapshot);
362
+ );
363
+ return await responseSnapshot(response, response.ok ? {
364
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
365
+ label: "PM Studio model catalog",
366
+ signal,
367
+ } : {
368
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
369
+ label: "PM Studio model catalog error body",
370
+ signal,
371
+ });
351
372
  } finally {
352
373
  activeModelRequests -= 1;
353
374
  }
@@ -346,7 +346,7 @@ function statusInspectExecutableIntegrity({ appPath, infoPlistPath, recipe, proc
346
346
  };
347
347
  }
348
348
 
349
- export function createPmStudioStatusOperationOverrides() {
349
+ function createPmStudioFileInspectionOperationOverrides() {
350
350
  const evidenceCache = new Map();
351
351
  const bundleReadBuffer = Buffer.allocUnsafe(1024 * 1024);
352
352
  return {
@@ -370,6 +370,10 @@ export function createPmStudioStatusOperationOverrides() {
370
370
  };
371
371
  }
372
372
 
373
+ export function createPmStudioStatusOperationOverrides() {
374
+ return createPmStudioFileInspectionOperationOverrides();
375
+ }
376
+
373
377
  function defaultSignApp({ appPath, source, processRunner }) {
374
378
  const preserve = [];
375
379
  if (source?.codeSign?.displayValid === true) {
@@ -1043,7 +1047,7 @@ function patchRecordForState(manifest, recipe, state) {
1043
1047
  return patchRecordMatches(manifest?.patched, recipe, state) ? manifest.patched : null;
1044
1048
  }
1045
1049
 
1046
- function validateBackup({ backupDir, recipe, operations }) {
1050
+ function validateBackup({ backupDir, recipe, operations, evidence }) {
1047
1051
  const manifestPath = path.join(backupDir, "manifest.json");
1048
1052
  const backupAppPath = path.join(backupDir, "PM Studio.app");
1049
1053
  let manifest;
@@ -1079,6 +1083,9 @@ function validateBackup({ backupDir, recipe, operations }) {
1079
1083
  if (inspection.state !== "clean") {
1080
1084
  throw setupError("PM_STUDIO_BACKUP_INVALID", "Existing PM Studio backup is not an exact clean bundle", inspection.issues);
1081
1085
  }
1086
+ if (evidence) {
1087
+ evidence.inspection = inspection;
1088
+ }
1082
1089
  return { backupDir, backupAppPath, manifestPath, manifest, reused: true };
1083
1090
  }
1084
1091
 
@@ -1316,6 +1323,10 @@ function restoreStateFingerprint(inspection, bundleContent) {
1316
1323
 
1317
1324
  function inspectRestoreState({ appPath, recipe, operations }) {
1318
1325
  const inspection = inspectPmStudioApp({ appPath, recipe, operations });
1326
+ return restoreStateFromInspection({ appPath, inspection, recipe, operations });
1327
+ }
1328
+
1329
+ function restoreStateFromInspection({ appPath, inspection, recipe, operations }) {
1319
1330
  const bundleContent = inspection.bundleContent || operations.inspectBundleContent({
1320
1331
  ...appPaths(appPath, recipe),
1321
1332
  recipe,
@@ -1355,7 +1366,10 @@ export async function runPmStudioSetup({
1355
1366
  operations: operationOverrides = {},
1356
1367
  logger = (line) => console.log(line),
1357
1368
  } = {}) {
1358
- const operations = createPmStudioSetupOperations(operationOverrides);
1369
+ const operations = createPmStudioSetupOperations({
1370
+ ...createPmStudioFileInspectionOperationOverrides(),
1371
+ ...operationOverrides,
1372
+ });
1359
1373
  return operations.withSetupLock({ appPath }, async () => {
1360
1374
  const messages = [];
1361
1375
  const emit = (message) => {
@@ -1548,7 +1562,10 @@ export async function runPmStudioRestore({
1548
1562
  operations: operationOverrides = {},
1549
1563
  logger = (line) => console.log(line),
1550
1564
  } = {}) {
1551
- const operations = createPmStudioSetupOperations(operationOverrides);
1565
+ const operations = createPmStudioSetupOperations({
1566
+ ...createPmStudioFileInspectionOperationOverrides(),
1567
+ ...operationOverrides,
1568
+ });
1552
1569
  return operations.withSetupLock({ appPath }, async () => {
1553
1570
  const messages = [];
1554
1571
  const emit = (message) => {
@@ -1590,7 +1607,8 @@ export async function runPmStudioRestore({
1590
1607
 
1591
1608
  const manifestPath = pmStudioPatchManifestPath({ home, backupRoot, recipe });
1592
1609
  const backupDir = path.dirname(manifestPath);
1593
- const backup = validateBackup({ backupDir, recipe, operations });
1610
+ const backupEvidence = {};
1611
+ const backup = validateBackup({ backupDir, recipe, operations, evidence: backupEvidence });
1594
1612
  assertPatchedBinaryRecord({
1595
1613
  inspection: source.inspection,
1596
1614
  manifestPath: backup.manifestPath,
@@ -1604,7 +1622,12 @@ export async function runPmStudioRestore({
1604
1622
  });
1605
1623
  assertRestoreSpace({ appPath, backupAppPath: backup.backupAppPath, operations });
1606
1624
  const manifestSnapshot = fs.readFileSync(backup.manifestPath);
1607
- const backupSource = inspectRestoreState({ appPath: backup.backupAppPath, recipe, operations });
1625
+ const backupSource = restoreStateFromInspection({
1626
+ appPath: backup.backupAppPath,
1627
+ inspection: backupEvidence.inspection,
1628
+ recipe,
1629
+ operations,
1630
+ });
1608
1631
  if (backupSource.inspection.state !== "clean") {
1609
1632
  throw setupError("PM_STUDIO_BACKUP_INVALID", "Verified PM Studio backup is not an exact clean bundle",
1610
1633
  backupSource.inspection.issues);
@@ -1623,9 +1646,16 @@ export async function runPmStudioRestore({
1623
1646
  throw setupError("PM_STUDIO_BACKUP_CHANGED",
1624
1647
  "PM Studio backup manifest changed during restore; the verified staging bundle was not installed");
1625
1648
  }
1626
- const currentBackup = validateBackup({ backupDir, recipe, operations });
1627
- const unchangedBackup = inspectRestoreState({
1649
+ const currentBackupEvidence = {};
1650
+ const currentBackup = validateBackup({
1651
+ backupDir,
1652
+ recipe,
1653
+ operations,
1654
+ evidence: currentBackupEvidence,
1655
+ });
1656
+ const unchangedBackup = restoreStateFromInspection({
1628
1657
  appPath: currentBackup.backupAppPath,
1658
+ inspection: currentBackupEvidence.inspection,
1629
1659
  recipe,
1630
1660
  operations,
1631
1661
  });
@@ -3,6 +3,7 @@ import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy
3
3
  import {
4
4
  createRequestAbort,
5
5
  logRequestFailure,
6
+ MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
6
7
  MAX_UPSTREAM_ERROR_BODY_BYTES,
7
8
  readJsonBody,
8
9
  readBoundedResponseText,
@@ -136,7 +137,7 @@ export function createResponsesHandler(options) {
136
137
  if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
137
138
  return error;
138
139
  }
139
- const activated = imagePressure?.markTimeout?.(prepared?.historyRootId, {
140
+ const activated = imagePressure?.markTimeout?.(responseHistoryPressureRootId(prepared), {
140
141
  eligible: imagePressureResult?.pressureEligible === true,
141
142
  });
142
143
  if (!activated) return error;
@@ -310,7 +311,11 @@ export function createResponsesHandler(options) {
310
311
  });
311
312
  releaseChatPayload();
312
313
  const data = upstream.ok
313
- ? await upstream.text()
314
+ ? await readBoundedResponseText(upstream, {
315
+ maxBytes: MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES,
316
+ label: "Copilot Chat success body",
317
+ signal: abort.signal,
318
+ })
314
319
  : await readBoundedResponseText(upstream, {
315
320
  maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
316
321
  label: "Copilot Chat error body",
@@ -3,6 +3,7 @@ import {
3
3
  httpError,
4
4
  logRequestFailure,
5
5
  MAX_UPSTREAM_ERROR_BODY_BYTES,
6
+ MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES,
6
7
  readBoundedResponseText,
7
8
  sendUpstreamError,
8
9
  writeOrDrain,
@@ -265,7 +266,11 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
265
266
  }
266
267
  } else {
267
268
  const data = resp.ok
268
- ? await resp.text()
269
+ ? await readBoundedResponseText(resp, {
270
+ maxBytes: MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES,
271
+ label: "Copilot Responses success body",
272
+ signal: options.signal,
273
+ })
269
274
  : await readBoundedResponseText(resp, {
270
275
  maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
271
276
  label: "Copilot Responses error body",
@@ -7,6 +7,8 @@ export const RUNTIME_DEFAULTS = Object.freeze({
7
7
  maxBodyBytes: 64 * 1024 * 1024,
8
8
  maxDecodedBodyBytes: 128 * 1024 * 1024,
9
9
  maxInflightBodyBytes: 32 * 1024 * 1024,
10
+ maxUpstreamChatResponseBytes: 64 * 1024 * 1024,
11
+ maxUpstreamResponsesResponseBytes: 256 * 1024 * 1024,
10
12
  maxQueuedRequests: 16,
11
13
  requestQueueTimeoutMs: 120 * 1000,
12
14
  responseHistoryMaxBytes: 64 * 1024 * 1024,
@@ -30,6 +32,14 @@ export function loadRuntimeConfig(env = process.env) {
30
32
  maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
31
33
  maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),
32
34
  maxInflightBodyBytes: parsePositiveInteger(env.CCDX_MAX_INFLIGHT_BODY_BYTES, RUNTIME_DEFAULTS.maxInflightBodyBytes),
35
+ maxUpstreamChatResponseBytes: parsePositiveInteger(
36
+ env.CCDX_MAX_UPSTREAM_CHAT_RESPONSE_BYTES,
37
+ RUNTIME_DEFAULTS.maxUpstreamChatResponseBytes,
38
+ ),
39
+ maxUpstreamResponsesResponseBytes: parsePositiveInteger(
40
+ env.CCDX_MAX_UPSTREAM_RESPONSES_RESPONSE_BYTES,
41
+ RUNTIME_DEFAULTS.maxUpstreamResponsesResponseBytes,
42
+ ),
33
43
  maxQueuedRequests: parsePositiveInteger(env.CCDX_MAX_QUEUED_REQUESTS, RUNTIME_DEFAULTS.maxQueuedRequests),
34
44
  requestQueueTimeoutMs: parsePositiveInteger(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
35
45
  responseHistoryMaxBytes: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_BYTES, RUNTIME_DEFAULTS.responseHistoryMaxBytes),
@@ -0,0 +1,188 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { anthropicTokenText } from "./anthropic.mjs";
3
+
4
+ export const DEFAULT_TOKEN_COUNTER_TIMEOUT_MS = 120 * 1000;
5
+ export const DEFAULT_TOKEN_COUNTER_MAX_QUEUED = 16;
6
+
7
+ function serviceError(message, statusCode) {
8
+ return Object.assign(new Error(message), { statusCode });
9
+ }
10
+
11
+ function positiveInteger(value, fallback) {
12
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
13
+ }
14
+
15
+ function abortError(signal) {
16
+ if (signal?.reason instanceof Error) return signal.reason;
17
+ const error = new Error("The operation was aborted");
18
+ error.name = "AbortError";
19
+ return error;
20
+ }
21
+
22
+ export function tokenCounterWorkerExecArgv(execArgv = process.execArgv) {
23
+ const filtered = [];
24
+ for (let index = 0; index < execArgv.length; index += 1) {
25
+ const value = execArgv[index];
26
+ if (value === "--input-type") {
27
+ index += 1;
28
+ continue;
29
+ }
30
+ if (value.startsWith("--input-type=")) continue;
31
+ filtered.push(value);
32
+ }
33
+ return filtered;
34
+ }
35
+
36
+ function createTokenCounterWorker() {
37
+ const execArgv = tokenCounterWorkerExecArgv();
38
+ const options = execArgv.length === process.execArgv.length ? undefined : { execArgv };
39
+ return new Worker(new URL("./token-counter-worker.mjs", import.meta.url), options);
40
+ }
41
+
42
+ export function createTokenCounterService({
43
+ timeoutMs = DEFAULT_TOKEN_COUNTER_TIMEOUT_MS,
44
+ maxQueued = DEFAULT_TOKEN_COUNTER_MAX_QUEUED,
45
+ workerFactory = createTokenCounterWorker,
46
+ } = {}) {
47
+ timeoutMs = positiveInteger(timeoutMs, DEFAULT_TOKEN_COUNTER_TIMEOUT_MS);
48
+ maxQueued = positiveInteger(maxQueued, DEFAULT_TOKEN_COUNTER_MAX_QUEUED);
49
+ const queue = [];
50
+ let worker = null;
51
+ let active = null;
52
+ let nextId = 1;
53
+ let closed = false;
54
+
55
+ const finish = (job, callback, value) => {
56
+ if (job.finished) return;
57
+ job.finished = true;
58
+ if (job.timer) clearTimeout(job.timer);
59
+ job.signal?.removeEventListener("abort", job.onAbort);
60
+ callback(value);
61
+ };
62
+
63
+ const retireWorker = () => {
64
+ const retired = worker;
65
+ worker = null;
66
+ if (!retired) return;
67
+ retired.removeAllListeners();
68
+ retired.on("error", () => {});
69
+ Promise.resolve(retired.terminate()).catch(() => {});
70
+ };
71
+
72
+ const pump = () => {
73
+ if (closed || active || queue.length === 0) return;
74
+ if (!worker) {
75
+ try {
76
+ worker = workerFactory();
77
+ } catch {
78
+ const job = queue.shift();
79
+ if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
80
+ queueMicrotask(pump);
81
+ return;
82
+ }
83
+ worker.on("message", (message) => {
84
+ if (!active || message?.id !== active.id) return;
85
+ const job = active;
86
+ active = null;
87
+ if (message.error) {
88
+ retireWorker();
89
+ finish(job, job.reject, serviceError("Token counter worker failed", 500));
90
+ } else {
91
+ finish(job, job.resolve, message.result);
92
+ }
93
+ pump();
94
+ });
95
+ worker.on("error", () => {
96
+ const job = active;
97
+ active = null;
98
+ retireWorker();
99
+ if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
100
+ pump();
101
+ });
102
+ worker.on("exit", () => {
103
+ const job = active;
104
+ active = null;
105
+ worker = null;
106
+ if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
107
+ pump();
108
+ });
109
+ worker.unref?.();
110
+ }
111
+ const job = queue.shift();
112
+ if (!job || job.finished) return pump();
113
+ active = job;
114
+ try {
115
+ worker.postMessage({ id: job.id, text: job.text });
116
+ job.text = null;
117
+ } catch {
118
+ active = null;
119
+ retireWorker();
120
+ finish(job, job.reject, serviceError("Token counter worker failed", 500));
121
+ pump();
122
+ }
123
+ };
124
+
125
+ const cancel = (job, error) => {
126
+ if (job.finished) return;
127
+ const queuedIndex = queue.indexOf(job);
128
+ if (queuedIndex >= 0) queue.splice(queuedIndex, 1);
129
+ if (active === job) {
130
+ active = null;
131
+ retireWorker();
132
+ }
133
+ finish(job, job.reject, error);
134
+ pump();
135
+ };
136
+
137
+ return {
138
+ count(body, { signal, deadlineMs = timeoutMs } = {}) {
139
+ if (closed) return Promise.reject(serviceError("Token counter service is closed", 503));
140
+ if (signal?.aborted) return Promise.reject(abortError(signal));
141
+ let text;
142
+ try {
143
+ text = anthropicTokenText(body);
144
+ } catch {
145
+ return Promise.reject(serviceError("Invalid count_tokens request", 400));
146
+ }
147
+ if (active && queue.length >= maxQueued) {
148
+ return Promise.reject(serviceError(`Token counter queue is full (${maxQueued} waiting)`, 503));
149
+ }
150
+ return new Promise((resolve, reject) => {
151
+ deadlineMs = positiveInteger(deadlineMs, timeoutMs);
152
+ const job = {
153
+ id: nextId++,
154
+ text,
155
+ signal,
156
+ resolve,
157
+ reject,
158
+ finished: false,
159
+ timer: null,
160
+ onAbort: null,
161
+ };
162
+ job.onAbort = () => cancel(job, abortError(signal));
163
+ signal?.addEventListener("abort", job.onAbort, { once: true });
164
+ job.timer = setTimeout(() => {
165
+ cancel(job, serviceError(`Token counting timed out after ${deadlineMs}ms`, 504));
166
+ }, deadlineMs);
167
+ queue.push(job);
168
+ if (signal?.aborted) job.onAbort();
169
+ else pump();
170
+ });
171
+ },
172
+ close() {
173
+ if (closed) return;
174
+ closed = true;
175
+ const error = serviceError("Token counter service is closed", 503);
176
+ if (active) {
177
+ const job = active;
178
+ active = null;
179
+ finish(job, job.reject, error);
180
+ }
181
+ for (const job of queue.splice(0)) finish(job, job.reject, error);
182
+ retireWorker();
183
+ },
184
+ stats() {
185
+ return { active: active ? 1 : 0, queued: queue.length, worker: worker ? 1 : 0 };
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,10 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { countTokenText } from "./anthropic.mjs";
3
+
4
+ parentPort.on("message", async ({ id, text }) => {
5
+ try {
6
+ parentPort.postMessage({ id, result: await countTokenText(text) });
7
+ } catch {
8
+ parentPort.postMessage({ id, error: true });
9
+ }
10
+ });