copillm 0.1.5 → 0.2.0

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/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { acquireLock, inspectLock, LockAlreadyRunningError, releaseLock } from "
16
16
  import { startProxyServer } from "./server/proxy.js";
17
17
  import { defaultOutputDir, generateCodexHome } from "./codex/init.js";
18
18
  import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "./pi/init.js";
19
- import { getCopillmHome } from "./config/home.js";
19
+ import { debugLogPath, getCopillmHome } from "./config/home.js";
20
20
  import { clearClaudeGatewayCache } from "./claude/cache.js";
21
21
  import { detectClaudeSettingsConflicts, formatSettingsConflictWarning } from "./claude/settingsConflict.js";
22
22
  import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "./models/anthropicDefaults.js";
@@ -30,6 +30,7 @@ const logger = createLogger();
30
30
  const program = new Command();
31
31
  program.name("copillm").description("Local Copilot proxy").version("0.1.0");
32
32
  program.enablePositionalOptions();
33
+ program.option("--debug", "Enable copillm debug mode (debug endpoint plus verbose daemon diagnostics)");
33
34
  program
34
35
  .command("login")
35
36
  .description("[deprecated] Use `copillm auth login`")
@@ -129,6 +130,8 @@ program
129
130
  .option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
130
131
  .option("--json", "JSON output")
131
132
  .action(async (opts) => {
133
+ const debug = resolveCopillmDebug(opts.debug);
134
+ enableRuntimeDebug(debug);
132
135
  if (opts.detach) {
133
136
  // Fail fast on missing credentials rather than letting the detached
134
137
  // child die silently and surface as a generic "start timed out" error.
@@ -138,6 +141,7 @@ program
138
141
  }
139
142
  const existingLock = await readLiveLock();
140
143
  if (existingLock) {
144
+ const activeDebug = await warnIfDebugRequestedButInactive(debug, existingLock.port);
141
145
  const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
142
146
  const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
143
147
  const claude = buildClaudeExportCommand(existingLock.port, null);
@@ -145,7 +149,8 @@ program
145
149
  port: existingLock.port,
146
150
  pid: existingLock.pid,
147
151
  mode: "already_running",
148
- debug: false,
152
+ debug: activeDebug,
153
+ debugLogPath: null,
149
154
  codex,
150
155
  pi
151
156
  });
@@ -153,6 +158,7 @@ program
153
158
  status: "already_running",
154
159
  pid: existingLock.pid,
155
160
  port: existingLock.port,
161
+ debug: activeDebug,
156
162
  url: `http://127.0.0.1:${existingLock.port}`,
157
163
  codex_home: codex?.outDir ?? null,
158
164
  codex_export_command: codex?.exportCommand ?? null,
@@ -169,12 +175,13 @@ program
169
175
  return;
170
176
  }
171
177
  const daemonArgs = [process.argv[1], "daemon"];
172
- if (opts.debug) {
178
+ if (debug) {
173
179
  daemonArgs.push("--debug");
174
180
  }
175
181
  const child = spawn(process.execPath, daemonArgs, {
176
182
  detached: true,
177
- stdio: "ignore"
183
+ stdio: "ignore",
184
+ env: daemonSpawnEnv(debug)
178
185
  });
179
186
  child.unref();
180
187
  const started = await waitForDaemonReady(child.pid ?? null, 8_000);
@@ -188,7 +195,8 @@ program
188
195
  port: started.port,
189
196
  pid: started.pid,
190
197
  mode: "detached",
191
- debug: Boolean(opts.debug),
198
+ debug,
199
+ debugLogPath: currentDebugLogPath(debug),
192
200
  codex,
193
201
  pi
194
202
  });
@@ -197,7 +205,8 @@ program
197
205
  mode: "detached",
198
206
  pid: started.pid,
199
207
  port: started.port,
200
- debug: Boolean(opts.debug),
208
+ debug,
209
+ debug_log_path: currentDebugLogPath(debug),
201
210
  url: `http://127.0.0.1:${started.port}`,
202
211
  codex_home: codex?.outDir ?? null,
203
212
  codex_export_command: codex?.exportCommand ?? null,
@@ -217,8 +226,9 @@ program
217
226
  }
218
227
  // Foreground path: interactively prompt for login if needed.
219
228
  await ensureAuthenticatedInteractive();
220
- const started = await runDaemon({ debug: Boolean(opts.debug) });
229
+ const started = await runDaemon({ debug });
221
230
  if (started.kind === "already_running") {
231
+ const activeDebug = await warnIfDebugRequestedButInactive(debug, started.lock.port);
222
232
  const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
223
233
  const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
224
234
  const claude = buildClaudeExportCommand(started.lock.port, null);
@@ -226,7 +236,8 @@ program
226
236
  port: started.lock.port,
227
237
  pid: started.lock.pid,
228
238
  mode: "already_running",
229
- debug: false,
239
+ debug: activeDebug,
240
+ debugLogPath: null,
230
241
  codex,
231
242
  pi
232
243
  });
@@ -234,6 +245,7 @@ program
234
245
  status: "already_running",
235
246
  pid: started.lock.pid,
236
247
  port: started.lock.port,
248
+ debug: activeDebug,
237
249
  url: `http://127.0.0.1:${started.lock.port}`,
238
250
  codex_home: codex?.outDir ?? null,
239
251
  codex_export_command: codex?.exportCommand ?? null,
@@ -256,7 +268,8 @@ program
256
268
  port: started.port,
257
269
  pid: process.pid,
258
270
  mode: "foreground",
259
- debug: Boolean(opts.debug),
271
+ debug,
272
+ debugLogPath: currentDebugLogPath(debug),
260
273
  codex,
261
274
  pi
262
275
  });
@@ -265,7 +278,8 @@ program
265
278
  mode: "foreground",
266
279
  pid: process.pid,
267
280
  port: started.port,
268
- debug: Boolean(opts.debug),
281
+ debug,
282
+ debug_log_path: currentDebugLogPath(debug),
269
283
  url: `http://127.0.0.1:${started.port}`,
270
284
  caller_secret: started.callerSecret,
271
285
  codex_home: codex?.outDir ?? null,
@@ -288,11 +302,13 @@ program
288
302
  .description("Internal background command")
289
303
  .option("--debug", "Enable debug endpoints")
290
304
  .action(async (opts) => {
291
- const started = await runDaemon({ debug: Boolean(opts.debug) });
305
+ const debug = resolveCopillmDebug(opts.debug);
306
+ enableRuntimeDebug(debug);
307
+ const started = await runDaemon({ debug });
292
308
  if (started.kind === "already_running") {
293
309
  process.exit(0);
294
310
  }
295
- process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${opts.debug ? " [debug]" : ""}\n`);
311
+ process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${debug ? " [debug]" : ""}\n`);
296
312
  });
297
313
  program
298
314
  .command("stop")
@@ -655,7 +671,9 @@ program
655
671
  .helpOption(false)
656
672
  .argument("[args...]", "Args forwarded to codex")
657
673
  .action(async (forwardedArgs, opts) => {
658
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
674
+ const debug = resolveCopillmDebug(opts.copillmDebug);
675
+ enableRuntimeDebug(debug);
676
+ const lock = await ensureDaemonRunningForLauncher({ debug });
659
677
  const codex = await refreshCodexHome(lock.port, null);
660
678
  if (!codex) {
661
679
  throw new Error("Failed to prepare Codex home (see warning above).");
@@ -693,7 +711,9 @@ program
693
711
  .helpOption(false)
694
712
  .argument("[args...]", "Args forwarded to claude")
695
713
  .action(async (forwardedArgs, opts) => {
696
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
714
+ const debug = resolveCopillmDebug(opts.copillmDebug);
715
+ enableRuntimeDebug(debug);
716
+ const lock = await ensureDaemonRunningForLauncher({ debug });
697
717
  const claude = buildClaudeExportCommand(lock.port, null);
698
718
  const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
699
719
  const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
@@ -730,7 +750,9 @@ program
730
750
  .helpOption(false)
731
751
  .argument("[args...]", "Args forwarded to pi")
732
752
  .action(async (forwardedArgs, opts) => {
733
- const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
753
+ const debug = resolveCopillmDebug(opts.copillmDebug);
754
+ enableRuntimeDebug(debug);
755
+ const lock = await ensureDaemonRunningForLauncher({ debug });
734
756
  const pi = await refreshPiHome(lock.port);
735
757
  if (!pi) {
736
758
  throw new Error("Failed to prepare pi models.json (see warning above).");
@@ -985,6 +1007,32 @@ function writeCommandOutput(opts, humanLine, payload) {
985
1007
  }
986
1008
  process.stdout.write(`${humanLine}\n`);
987
1009
  }
1010
+ function resolveCopillmDebug(commandDebug) {
1011
+ return Boolean(commandDebug) || Boolean(program.opts().debug);
1012
+ }
1013
+ function enableRuntimeDebug(debug) {
1014
+ if (!debug) {
1015
+ return;
1016
+ }
1017
+ process.env.COPILLM_LOG_LEVEL = "debug";
1018
+ logger.level = "debug";
1019
+ }
1020
+ function currentDebugLogPath(debug) {
1021
+ if (!debug) {
1022
+ return null;
1023
+ }
1024
+ return process.env.COPILLM_LOG_FILE ?? debugLogPath();
1025
+ }
1026
+ function daemonSpawnEnv(debug) {
1027
+ if (!debug) {
1028
+ return process.env;
1029
+ }
1030
+ return {
1031
+ ...process.env,
1032
+ COPILLM_LOG_LEVEL: "debug",
1033
+ COPILLM_LOG_FILE: currentDebugLogPath(true) ?? debugLogPath()
1034
+ };
1035
+ }
988
1036
  function formatStopHumanLine(primary, cache) {
989
1037
  if (cache.cleared) {
990
1038
  return `${primary} Cleared Claude Code gateway cache.`;
@@ -1057,6 +1105,9 @@ function formatStartBanner(input) {
1057
1105
  if (input.codex) {
1058
1106
  lines.push(` ${input.codex.modelCount} Copilot models discovered \u00B7 default: ${input.codex.defaultModel}`);
1059
1107
  }
1108
+ if (input.debugLogPath) {
1109
+ lines.push(` debug log: ${displayHomePath(input.debugLogPath)}`);
1110
+ }
1060
1111
  if (input.pi) {
1061
1112
  lines.push(` pi: wrote ${input.pi.modelCount} models to ${displayHomePath(input.pi.configPath)}${input.pi.backupPath ? ` (backed up prior config to ${displayHomePath(input.pi.backupPath)})` : ""}`);
1062
1113
  }
@@ -1096,6 +1147,25 @@ async function probeLivez(port) {
1096
1147
  return false;
1097
1148
  }
1098
1149
  }
1150
+ async function warnIfDebugRequestedButInactive(debugRequested, port) {
1151
+ if (!debugRequested) {
1152
+ return false;
1153
+ }
1154
+ const active = await probeDebugEndpoint(port);
1155
+ if (!active) {
1156
+ process.stderr.write(`warning: copillm is already running without debug mode; run \`copillm stop\` then \`copillm --debug start --detach\` to enable daemon diagnostics.\n`);
1157
+ }
1158
+ return active;
1159
+ }
1160
+ async function probeDebugEndpoint(port) {
1161
+ try {
1162
+ const response = await fetch(`http://127.0.0.1:${port}/_debug`, { signal: AbortSignal.timeout(1_200) });
1163
+ return response.ok;
1164
+ }
1165
+ catch {
1166
+ return false;
1167
+ }
1168
+ }
1099
1169
  async function probeHealth(port) {
1100
1170
  try {
1101
1171
  const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
@@ -1200,15 +1270,21 @@ function parseAgentName(raw) {
1200
1270
  }
1201
1271
  async function ensureDaemonRunningForLauncher(opts) {
1202
1272
  const live = await readLiveLock();
1203
- if (live)
1273
+ if (live) {
1274
+ await warnIfDebugRequestedButInactive(opts.debug, live.port);
1204
1275
  return live;
1205
- process.stderr.write(`Starting copillm in background...\n`);
1276
+ }
1277
+ const debugLog = currentDebugLogPath(opts.debug);
1278
+ process.stderr.write(opts.debug && debugLog
1279
+ ? `Starting copillm in background with debug logging at ${displayHomePath(debugLog)}...\n`
1280
+ : `Starting copillm in background...\n`);
1206
1281
  const daemonArgs = [process.argv[1], "daemon"];
1207
1282
  if (opts.debug)
1208
1283
  daemonArgs.push("--debug");
1209
1284
  const child = spawn(process.execPath, daemonArgs, {
1210
1285
  detached: true,
1211
- stdio: "ignore"
1286
+ stdio: "ignore",
1287
+ env: daemonSpawnEnv(opts.debug)
1212
1288
  });
1213
1289
  child.unref();
1214
1290
  const started = await waitForDaemonReady(child.pid ?? null, 10_000);
@@ -32,6 +32,9 @@ export function modelsCachePath() {
32
32
  export function modelsCacheReadPath() {
33
33
  return resolveReadablePath("models.cache.json");
34
34
  }
35
+ export function debugLogPath() {
36
+ return path.join(getCopillmHome(), "debug.log");
37
+ }
35
38
  function resolveReadablePath(fileName) {
36
39
  const canonical = path.join(getCopillmHome(), fileName);
37
40
  if (fs.existsSync(canonical)) {
@@ -1,7 +1,11 @@
1
1
  import pino from "pino";
2
- export function createLogger() {
3
- return pino({
4
- level: process.env.COPILLM_LOG_LEVEL ?? "info",
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { applyModeIfSupported } from "./fsSecurity.js";
5
+ export function createLogger(input) {
6
+ const destinationPath = input?.destinationPath ?? process.env.COPILLM_LOG_FILE;
7
+ const options = {
8
+ level: input?.level ?? process.env.COPILLM_LOG_LEVEL ?? "info",
5
9
  redact: {
6
10
  paths: [
7
11
  "req.headers.authorization",
@@ -27,7 +31,25 @@ export function createLogger() {
27
31
  remove: true
28
32
  },
29
33
  transport: process.env.COPILLM_LOG_PRETTY === "1"
30
- ? { target: "pino-pretty", options: { colorize: true } }
34
+ ? { target: "pino-pretty", options: { colorize: destinationPath ? false : true, destination: destinationPath ?? 2 } }
31
35
  : undefined
32
- });
36
+ };
37
+ if (process.env.COPILLM_LOG_PRETTY === "1") {
38
+ if (destinationPath) {
39
+ prepareLogFile(destinationPath);
40
+ }
41
+ return pino(options);
42
+ }
43
+ if (destinationPath) {
44
+ prepareLogFile(destinationPath);
45
+ return pino(options, pino.destination(destinationPath));
46
+ }
47
+ return pino(options, pino.destination(2));
48
+ }
49
+ function prepareLogFile(filePath) {
50
+ const resolvedPath = path.resolve(filePath);
51
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
52
+ const fd = fs.openSync(resolvedPath, "a", 0o600);
53
+ fs.closeSync(fd);
54
+ applyModeIfSupported(resolvedPath, 0o600);
33
55
  }
@@ -8,6 +8,7 @@ const SUFFIX_BLOCKLIST = [
8
8
  "-low",
9
9
  "-min",
10
10
  "-1m",
11
+ "[1m]",
11
12
  "-internal",
12
13
  "-preview",
13
14
  "-beta",
@@ -150,6 +150,7 @@ export async function startProxyServer(input) {
150
150
  }
151
151
  await handleDebug(res, {
152
152
  config: input.config,
153
+ logger: input.logger,
153
154
  tokenManager: input.tokenManager,
154
155
  githubToken: input.githubToken,
155
156
  port: input.port
@@ -194,6 +195,17 @@ export async function startProxyServer(input) {
194
195
  beginAnthropicSseResponse(res, req);
195
196
  prelude = writeAnthropicPrelude(res, requestedModel ?? "");
196
197
  }
198
+ input.logger.debug({
199
+ event: "request_prepared",
200
+ request_id: requestId,
201
+ route: route.kind,
202
+ anthro_shape: route.anthroShape,
203
+ requested_model: requestedModel,
204
+ upstream_model: readRequestedModel(upstreamBody),
205
+ model_resolution_rule: resolvedModel?.rule ?? null,
206
+ upstream_path: upstreamPath,
207
+ ...summarizeUpstreamPayload(upstreamBody)
208
+ }, "prepared upstream request");
197
209
  try {
198
210
  const upstream = await postToCopilot({
199
211
  tokenManager: input.tokenManager,
@@ -204,7 +216,12 @@ export async function startProxyServer(input) {
204
216
  upstreamPath,
205
217
  signal: lifecycle.signal
206
218
  });
207
- await forwardResponse(upstream, route.anthroShape, res, requestedModel ?? undefined, prelude);
219
+ await forwardResponse(upstream, route.anthroShape, res, {
220
+ requestedModel: requestedModel ?? undefined,
221
+ prelude,
222
+ logger: input.logger,
223
+ requestId
224
+ });
208
225
  }
209
226
  catch (error) {
210
227
  if (isBenignSocketError(error)) {
@@ -291,7 +308,25 @@ async function postToCopilot(input) {
291
308
  throw abortErrorFromSignal(input.signal);
292
309
  }
293
310
  try {
311
+ const attemptStartedAt = Date.now();
312
+ input.logger.debug({
313
+ event: "upstream_request",
314
+ request_id: input.requestId,
315
+ attempt,
316
+ upstream_path: input.upstreamPath,
317
+ force_refresh: forceRefresh
318
+ }, "posting upstream request");
294
319
  const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
320
+ input.logger.debug({
321
+ event: "upstream_response",
322
+ request_id: input.requestId,
323
+ attempt,
324
+ upstream_path: input.upstreamPath,
325
+ status_code: response.status,
326
+ duration_ms: Date.now() - attemptStartedAt,
327
+ content_type: response.headers.get("content-type"),
328
+ retry_after: response.headers.get("retry-after")
329
+ }, "received upstream response");
295
330
  forceRefresh = false;
296
331
  if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
297
332
  authRefreshRetried = true;
@@ -344,43 +379,57 @@ function abortErrorFromSignal(signal) {
344
379
  err.name = "AbortError";
345
380
  return err;
346
381
  }
347
- async function forwardResponse(upstream, anthroShape, res, requestedModel, prelude) {
382
+ async function forwardResponse(upstream, anthroShape, res, diagnostics) {
348
383
  if (!upstream.ok) {
349
- await discardUpstreamBody(upstream);
384
+ const upstreamError = await readUpstreamError(upstream);
385
+ const category = upstreamStatusCategory(upstream.status);
386
+ diagnostics.logger.warn({
387
+ event: "upstream_non_ok",
388
+ request_id: diagnostics.requestId,
389
+ status_code: upstream.status,
390
+ error: category,
391
+ upstream_content_type: upstreamError.contentType,
392
+ upstream_error_code: upstreamError.code,
393
+ upstream_error_type: upstreamError.type,
394
+ upstream_error_message: upstreamError.message,
395
+ upstream_response_bytes: upstreamError.responseBytes
396
+ }, "upstream request failed");
397
+ const message = formatUpstreamErrorMessage(category, upstreamError);
398
+ const prelude = diagnostics.prelude ?? null;
350
399
  if (prelude) {
351
- writeAnthropicSseError(res, prelude, upstreamStatusCategory(upstream.status));
400
+ writeAnthropicSseError(res, prelude, message);
352
401
  return;
353
402
  }
354
- sendJson(res, upstream.status, { error: upstreamStatusCategory(upstream.status) });
403
+ sendJson(res, upstream.status, buildUpstreamErrorPayload(category, upstream.status, diagnostics.requestId, upstreamError, anthroShape));
355
404
  return;
356
405
  }
357
406
  if (isEventStream(upstream)) {
358
407
  if (anthroShape) {
359
408
  if (!upstream.body) {
360
- if (prelude) {
361
- writeAnthropicSseError(res, prelude, "invalid_upstream_response");
409
+ if (diagnostics.prelude) {
410
+ writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
362
411
  return;
363
412
  }
364
413
  sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
365
414
  return;
366
415
  }
367
- if (!prelude) {
416
+ if (!diagnostics.prelude) {
368
417
  beginAnthropicSseResponse(res);
369
418
  }
370
419
  const upstreamReadable = Readable.fromWeb(upstream.body);
371
420
  await translateOpenAIStreamToAnthropic({
372
421
  upstream: upstreamReadable,
373
422
  downstream: res,
374
- fallbackModel: requestedModel,
375
- preEmittedMessageId: prelude?.messageId
423
+ fallbackModel: diagnostics.requestedModel,
424
+ preEmittedMessageId: diagnostics.prelude?.messageId
376
425
  });
377
426
  return;
378
427
  }
379
428
  await pipeEventStream(upstream, res);
380
429
  return;
381
430
  }
382
- if (prelude) {
383
- writeAnthropicSseError(res, prelude, "invalid_upstream_response");
431
+ if (diagnostics.prelude) {
432
+ writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
384
433
  return;
385
434
  }
386
435
  let json;
@@ -604,7 +653,9 @@ async function handleDebug(res, input) {
604
653
  uptime_seconds: uptimeSeconds,
605
654
  account_type: input.config.accountType,
606
655
  selected_models: input.config.selectedModels,
607
- require_caller_secret: input.config.requireCallerSecret
656
+ require_caller_secret: input.config.requireCallerSecret,
657
+ log_level: input.logger.level,
658
+ log_file: process.env.COPILLM_LOG_FILE ?? null
608
659
  },
609
660
  auth: {
610
661
  bearer_ttl_seconds: bearerTtlSeconds,
@@ -712,6 +763,165 @@ function upstreamStatusCategory(status) {
712
763
  }
713
764
  return "upstream_error";
714
765
  }
766
+ async function readUpstreamError(response) {
767
+ const contentType = response.headers.get("content-type");
768
+ let text;
769
+ try {
770
+ text = await response.text();
771
+ }
772
+ catch {
773
+ return { contentType, code: null, type: null, message: null, responseBytes: null };
774
+ }
775
+ const trimmed = text.trim();
776
+ const responseBytes = Buffer.byteLength(text, "utf8");
777
+ if (trimmed.length === 0) {
778
+ return { contentType, code: null, type: null, message: null, responseBytes };
779
+ }
780
+ try {
781
+ const parsed = JSON.parse(trimmed);
782
+ const extracted = extractErrorFields(parsed);
783
+ if (extracted.message || extracted.code || extracted.type) {
784
+ return { contentType, responseBytes, ...extracted };
785
+ }
786
+ }
787
+ catch {
788
+ // Fall through to a plain-text snippet below.
789
+ }
790
+ return {
791
+ contentType,
792
+ code: null,
793
+ type: null,
794
+ message: truncateForDiagnostics(trimmed),
795
+ responseBytes
796
+ };
797
+ }
798
+ function extractErrorFields(payload) {
799
+ if (!payload || typeof payload !== "object") {
800
+ return { code: null, type: null, message: typeof payload === "string" ? truncateForDiagnostics(payload) : null };
801
+ }
802
+ const record = payload;
803
+ const nested = record.error;
804
+ if (typeof nested === "string") {
805
+ return {
806
+ code: readStringField(record, "code"),
807
+ type: readStringField(record, "type"),
808
+ message: truncateForDiagnostics(nested)
809
+ };
810
+ }
811
+ if (nested && typeof nested === "object") {
812
+ const errorRecord = nested;
813
+ return {
814
+ code: readStringField(errorRecord, "code") ?? readStringField(record, "code"),
815
+ type: readStringField(errorRecord, "type") ?? readStringField(record, "type"),
816
+ message: readTruncatedStringField(errorRecord, "message") ??
817
+ readTruncatedStringField(errorRecord, "detail") ??
818
+ readTruncatedStringField(record, "message") ??
819
+ readTruncatedStringField(record, "detail")
820
+ };
821
+ }
822
+ return {
823
+ code: readStringField(record, "code"),
824
+ type: readStringField(record, "type"),
825
+ message: readTruncatedStringField(record, "message") ?? readTruncatedStringField(record, "detail")
826
+ };
827
+ }
828
+ function buildUpstreamErrorPayload(category, statusCode, requestId, upstreamError, anthroShape) {
829
+ const code = upstreamError.code ?? category;
830
+ const type = upstreamError.type ?? category;
831
+ const message = formatUserFacingUpstreamErrorMessage(category, upstreamError);
832
+ if (anthroShape) {
833
+ return {
834
+ type: "error",
835
+ error: {
836
+ type,
837
+ message,
838
+ code,
839
+ upstream_status_code: statusCode,
840
+ request_id: requestId
841
+ }
842
+ };
843
+ }
844
+ return {
845
+ error: {
846
+ type,
847
+ code,
848
+ message,
849
+ upstream_status_code: statusCode,
850
+ request_id: requestId
851
+ }
852
+ };
853
+ }
854
+ function formatUpstreamErrorMessage(category, upstreamError) {
855
+ const parts = [upstreamError.code, upstreamError.type, upstreamError.message].filter((part) => typeof part === "string" && part.length > 0);
856
+ return parts.length > 0 ? `${category}: ${parts.join(": ")}` : category;
857
+ }
858
+ function formatUserFacingUpstreamErrorMessage(category, upstreamError) {
859
+ if (upstreamError.code && upstreamError.message) {
860
+ return `${upstreamError.code}: ${upstreamError.message}`;
861
+ }
862
+ if (upstreamError.message) {
863
+ return upstreamError.message;
864
+ }
865
+ return upstreamError.code ?? upstreamError.type ?? category;
866
+ }
867
+ function readStringField(record, key) {
868
+ const value = record[key];
869
+ return typeof value === "string" && value.length > 0 ? value : null;
870
+ }
871
+ function readTruncatedStringField(record, key) {
872
+ const value = readStringField(record, key);
873
+ return value ? truncateForDiagnostics(value) : null;
874
+ }
875
+ function truncateForDiagnostics(value) {
876
+ const maxChars = 500;
877
+ return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value;
878
+ }
879
+ function summarizeUpstreamPayload(payload) {
880
+ let requestBytes = null;
881
+ try {
882
+ requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
883
+ }
884
+ catch {
885
+ requestBytes = null;
886
+ }
887
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
888
+ return { upstream_request_bytes: requestBytes };
889
+ }
890
+ const record = payload;
891
+ const messages = Array.isArray(record.messages) ? record.messages : null;
892
+ const input = Array.isArray(record.input) ? record.input : null;
893
+ return {
894
+ upstream_request_bytes: requestBytes,
895
+ stream: record.stream === true,
896
+ max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
897
+ message_count: messages?.length ?? null,
898
+ input_item_count: input?.length ?? null,
899
+ tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
900
+ text_characters: sumTextCharacters(payload)
901
+ };
902
+ }
903
+ function sumTextCharacters(value) {
904
+ if (typeof value === "string") {
905
+ return value.length;
906
+ }
907
+ if (!value || typeof value !== "object") {
908
+ return 0;
909
+ }
910
+ if (Array.isArray(value)) {
911
+ return value.reduce((total, item) => total + sumTextCharacters(item), 0);
912
+ }
913
+ let total = 0;
914
+ const record = value;
915
+ for (const [key, nested] of Object.entries(record)) {
916
+ if (key === "text" || key === "content" || key === "arguments" || key === "input") {
917
+ total += sumTextCharacters(nested);
918
+ }
919
+ else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
920
+ total += sumTextCharacters(nested);
921
+ }
922
+ }
923
+ return total;
924
+ }
715
925
  function healthFailure(error) {
716
926
  if (error instanceof CopilotTokenExchangeError) {
717
927
  if (error.statusCode === 401 || error.statusCode === 403) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",