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 +2 -0
- package/bin/cli.mjs +2 -1
- package/package.json +1 -1
- package/src/adapter.mjs +19 -3
- package/src/anthropic-handler.mjs +9 -4
- package/src/anthropic.mjs +10 -3
- package/src/cli-auth.mjs +97 -24
- package/src/cli-error.mjs +44 -0
- package/src/http-transport.mjs +49 -2
- package/src/lock.mjs +24 -4
- package/src/pm-studio-relay.mjs +26 -5
- package/src/pm-studio-setup.mjs +38 -8
- package/src/responses-handler.mjs +7 -2
- package/src/responses-proxy.mjs +6 -1
- package/src/runtime-config.mjs +10 -0
- package/src/token-counter-service.mjs +188 -0
- package/src/token-counter-worker.mjs +10 -0
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(
|
|
156
|
+
console.error(formatCliError(e));
|
|
156
157
|
process.exit(1);
|
|
157
158
|
}
|
|
158
159
|
}
|
package/package.json
CHANGED
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
package/src/http-transport.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
}
|
package/src/pm-studio-relay.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
360
|
+
const response = await fetchEnterprise(
|
|
349
361
|
"/models", "GET", inboundHeaders, token, undefined, signal,
|
|
350
|
-
)
|
|
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
|
}
|
package/src/pm-studio-setup.mjs
CHANGED
|
@@ -346,7 +346,7 @@ function statusInspectExecutableIntegrity({ appPath, infoPlistPath, recipe, proc
|
|
|
346
346
|
};
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
1627
|
-
const
|
|
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
|
|
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
|
|
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",
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -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
|
|
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",
|
package/src/runtime-config.mjs
CHANGED
|
@@ -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
|
+
});
|