copillm 0.2.3 → 0.2.4

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.
Files changed (45) hide show
  1. package/README.md +1 -0
  2. package/dist/agentconfig/apply.js +4 -3
  3. package/dist/agentconfig/load.js +34 -1
  4. package/dist/agentconfig/schema.js +22 -0
  5. package/dist/agents/registry.js +80 -6
  6. package/dist/cli/auth/ensure.js +30 -0
  7. package/dist/cli/auth/runAuth.js +38 -0
  8. package/dist/cli/commands/agents/claude.js +47 -0
  9. package/dist/cli/commands/agents/codex.js +48 -0
  10. package/dist/cli/commands/agents/copilot.js +49 -0
  11. package/dist/cli/commands/agents/pi.js +47 -0
  12. package/dist/cli/commands/agents/shared.js +28 -0
  13. package/dist/cli/commands/auth.js +99 -0
  14. package/dist/cli/commands/daemon.js +357 -0
  15. package/dist/cli/commands/env.js +135 -0
  16. package/dist/cli/commands/models.js +80 -0
  17. package/dist/cli/configCommands.js +10 -0
  18. package/dist/cli/copillmFlags.js +101 -0
  19. package/dist/cli/daemon/ensureRunning.js +66 -0
  20. package/dist/cli/daemon/lifecycle.js +61 -0
  21. package/dist/cli/daemon/probes.js +68 -0
  22. package/dist/cli/daemon/runDaemon.js +102 -0
  23. package/dist/cli/daemon/spawnEnv.js +12 -0
  24. package/dist/cli/index.js +43 -0
  25. package/dist/cli/integrations/banner.js +51 -0
  26. package/dist/cli/integrations/claudeExport.js +14 -0
  27. package/dist/cli/integrations/refreshCodex.js +19 -0
  28. package/dist/cli/integrations/refreshPi.js +17 -0
  29. package/dist/cli/shared/backends.js +31 -0
  30. package/dist/cli/shared/debug.js +44 -0
  31. package/dist/cli/shared/deprecation.js +7 -0
  32. package/dist/cli/shared/exitCodes.js +9 -0
  33. package/dist/cli/shared/output.js +14 -0
  34. package/dist/cli/shared/parseAgent.js +6 -0
  35. package/dist/cli.js +1 -1355
  36. package/dist/server/errors.js +195 -0
  37. package/dist/server/proxy.js +50 -885
  38. package/dist/server/routes/debug.js +65 -0
  39. package/dist/server/routes/health.js +32 -0
  40. package/dist/server/routes/models.js +41 -0
  41. package/dist/server/routes/proxyForward.js +108 -0
  42. package/dist/server/routes/shared.js +161 -0
  43. package/dist/server/upstream/copilotClient.js +137 -0
  44. package/dist/server/upstream/streaming.js +146 -0
  45. package/package.json +7 -2
@@ -1,49 +1,13 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
- import { setTimeout as sleep } from "node:timers/promises";
4
- import { Readable } from "node:stream";
5
- import { pipeline } from "node:stream/promises";
6
- import { accountBaseUrl, listModels, listModelsUnion, resolveModelId } from "../models/discovery.js";
7
- import { CopilotTokenExchangeError, CopilotTokenManagerError } from "../auth/copilotToken.js";
8
- import { anthropicToOpenAI, openAIToAnthropic, ProtocolTranslationError, stripOneMillionAlias } from "../translation/openaiAnthropic.js";
9
- import { translateOpenAIStreamToAnthropic, writeAnthropicPrelude } from "../translation/streamingOpenAIToAnthropic.js";
10
- import { buildCodexCatalog } from "./codexSchema.js";
11
- import { getGithubUserSummary, GithubUserFetchError } from "./debugInfo.js";
12
- import { buildAnthropicModelsResponse } from "./anthropicModelsResponse.js";
13
- import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson, safeWrite } from "./requestLifecycle.js";
14
- const COPILOT_HEADERS = {
15
- "Content-Type": "application/json",
16
- "Copilot-Integration-Id": "vscode-chat",
17
- "Editor-Version": "vscode/1.95.0",
18
- "Editor-Plugin-Version": "copilot-chat/0.26.7",
19
- "User-Agent": "GitHubCopilotChat/0.26.7",
20
- "Openai-Intent": "conversation-panel",
21
- "X-GitHub-Api-Version": "2025-04-01",
22
- "X-VScode-User-Agent-Library-Version": "electron-fetch",
23
- // Disable gzip/br on the upstream response. Compressed SSE streams get
24
- // buffered at gzip flush boundaries by undici's decoder, which causes
25
- // visible "freeze then dump a paragraph" behaviour in Claude Code and
26
- // other Anthropic-shape clients. Identity encoding lets each SSE event
27
- // flow through immediately.
28
- "Accept-Encoding": "identity"
29
- };
30
- const HEALTH_REFRESH_THRESHOLD_SECONDS = 60;
31
- const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
32
- const MAX_UPSTREAM_ATTEMPTS = 3;
33
- const BASE_BACKOFF_MS = 200;
34
- const DAEMON_STARTED_AT_ISO = new Date().toISOString();
35
- class JsonRequestParseError extends Error {
36
- constructor(message) {
37
- super(message);
38
- this.name = "JsonRequestParseError";
39
- }
40
- }
41
- class InvalidRequestShapeError extends Error {
42
- constructor(message) {
43
- super(message);
44
- this.name = "InvalidRequestShapeError";
45
- }
46
- }
3
+ import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
4
+ import { InvalidRequestShapeError, JsonRequestParseError } from "./errors.js";
5
+ import { ProtocolTranslationError } from "../translation/openaiAnthropic.js";
6
+ import { handleHealthz, handleLivez } from "./routes/health.js";
7
+ import { handleModels } from "./routes/models.js";
8
+ import { handleDebug } from "./routes/debug.js";
9
+ import { handleProxyForward } from "./routes/proxyForward.js";
10
+ import { isLocalRequest, resolveRoute, safePathname } from "./routes/shared.js";
47
11
  export async function startProxyServer(input) {
48
12
  const debugEnabled = input.debug === true;
49
13
  const server = createServer(async (req, res) => {
@@ -63,198 +27,72 @@ export async function startProxyServer(input) {
63
27
  });
64
28
  try {
65
29
  if (!isLocalRequest(req)) {
66
- sendJson(res, 403, { error: "non_loopback_request_rejected" });
30
+ safeSendJson(res, 403, { error: "non_loopback_request_rejected" });
67
31
  return;
68
32
  }
69
33
  const route = resolveRoute(req.method, req.url);
70
34
  if (input.config.requireCallerSecret && route.kind !== "livez" && route.kind !== "healthz") {
71
35
  const auth = req.headers.authorization;
72
36
  if (!input.callerSecret || auth !== `Bearer ${input.callerSecret}`) {
73
- sendJson(res, 401, { error: "invalid_caller_secret" });
37
+ safeSendJson(res, 401, { error: "invalid_caller_secret" });
74
38
  return;
75
39
  }
76
40
  }
77
- if (route.kind === "livez") {
78
- sendJson(res, 200, { status: "ok", uptime_seconds: Math.floor(process.uptime()) });
79
- return;
80
- }
81
- if (route.kind === "healthz") {
82
- const ttl = input.tokenManager.expiresInSeconds();
83
- if (ttl !== null && ttl > HEALTH_REFRESH_THRESHOLD_SECONDS) {
84
- sendJson(res, 200, {
85
- status: "ok",
86
- token_state: "fresh",
87
- refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
88
- bearer_ttl_seconds: ttl
89
- });
41
+ switch (route.kind) {
42
+ case "livez":
43
+ handleLivez(res);
90
44
  return;
91
- }
92
- try {
93
- await input.tokenManager.ensureToken({ refreshThresholdSeconds: HEALTH_REFRESH_THRESHOLD_SECONDS });
94
- const refreshedTtl = input.tokenManager.expiresInSeconds() ?? 0;
95
- sendJson(res, 200, {
96
- status: "ok",
97
- token_state: "refreshed",
98
- refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
99
- bearer_ttl_seconds: refreshedTtl
100
- });
101
- }
102
- catch (error) {
103
- const failed = healthFailure(error);
104
- sendJson(res, failed.httpStatus, failed.payload);
105
- }
106
- return;
107
- }
108
- if (route.kind === "models" || route.kind === "codex_models" || route.kind === "anthropic_models") {
109
- try {
110
- await input.tokenManager.ensureToken(false);
111
- const githubToken = input.githubToken;
112
- if (!githubToken) {
113
- sendJson(res, 503, { error: "github_token_unavailable" });
114
- return;
115
- }
116
- const result = route.kind === "codex_models" || route.kind === "anthropic_models"
117
- ? await listModelsUnion(input.config.accountType, githubToken, 3)
118
- : await listModels(input.config.accountType, githubToken);
119
- if (route.kind === "codex_models") {
120
- sendJson(res, 200, buildCodexCatalog(result.models));
121
- return;
122
- }
123
- if (route.kind === "anthropic_models") {
124
- sendJson(res, 200, buildAnthropicModelsResponse(result.models));
125
- return;
126
- }
127
- sendJson(res, 200, {
128
- models: result.models,
129
- discovery: {
130
- source: result.source,
131
- stale: result.stale,
132
- cache_age_seconds: result.cacheAgeSeconds,
133
- warning: result.warning
134
- }
135
- });
136
- }
137
- catch (error) {
138
- if (error instanceof CopilotTokenManagerError) {
139
- sendJson(res, 503, { error: "token_refresh_failed" });
140
- return;
141
- }
142
- throw error;
143
- }
144
- return;
145
- }
146
- if (route.kind === "debug") {
147
- if (!debugEnabled) {
148
- sendJson(res, 404, { error: "not_found" });
45
+ case "healthz":
46
+ await handleHealthz(res, input.tokenManager);
149
47
  return;
150
- }
151
- await handleDebug(res, {
152
- config: input.config,
153
- logger: input.logger,
154
- tokenManager: input.tokenManager,
155
- githubToken: input.githubToken,
156
- port: input.port
157
- });
158
- return;
159
- }
160
- if (route.kind === "not_found") {
161
- sendJson(res, 404, { error: "not_found" });
162
- return;
163
- }
164
- const requestBody = await readJson(req);
165
- const translatedBody = translateRequestBody(route.kind, requestBody);
166
- const requestedModel = readRequestedModel(translatedBody);
167
- if (input.config.selectedModels.length > 0 && !requestedModel) {
168
- sendJson(res, 400, {
169
- error: "model_not_selected",
170
- detail: "Requested model is not enabled in local selection."
171
- });
172
- return;
173
- }
174
- let resolvedModel = null;
175
- try {
176
- resolvedModel = requestedModel ? resolveModelId(requestedModel, input.config.selectedModels) : null;
177
- }
178
- catch (error) {
179
- const detail = error instanceof Error ? error.message : "Model resolution failed.";
180
- sendJson(res, 400, { error: "ambiguous_model_selection", detail });
181
- return;
182
- }
183
- if (input.config.selectedModels.length > 0 && !resolvedModel) {
184
- sendJson(res, 400, {
185
- error: "model_not_selected",
186
- detail: "Requested model is not enabled in local selection."
187
- });
188
- return;
189
- }
190
- const upstreamBody = resolvedModel ? rewriteRequestedModel(translatedBody, resolvedModel.id) : translatedBody;
191
- const upstreamPath = route.kind === "codex_responses" ? "/responses" : "/chat/completions";
192
- const isAnthropicStreaming = route.anthroShape && isStreamingRequestBody(translatedBody);
193
- let prelude = null;
194
- if (isAnthropicStreaming) {
195
- beginAnthropicSseResponse(res, req);
196
- prelude = writeAnthropicPrelude(res, requestedModel ?? "");
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");
209
- try {
210
- const upstream = await postToCopilot({
211
- tokenManager: input.tokenManager,
212
- accountType: input.config.accountType,
213
- body: upstreamBody,
214
- requestId,
215
- logger: input.logger,
216
- upstreamPath,
217
- signal: lifecycle.signal
218
- });
219
- await forwardResponse(upstream, route.anthroShape, res, {
220
- requestedModel: requestedModel ?? undefined,
221
- prelude,
222
- logger: input.logger,
223
- requestId
224
- });
225
- }
226
- catch (error) {
227
- if (isBenignSocketError(error)) {
228
- input.logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
229
- safeEnd(res);
48
+ case "models":
49
+ case "codex_models":
50
+ case "anthropic_models":
51
+ await handleModels(res, route.kind, input.config, input.tokenManager, input.githubToken);
230
52
  return;
231
- }
232
- if (error instanceof CopilotTokenManagerError) {
233
- if (prelude) {
234
- writeAnthropicSseError(res, prelude, "token_refresh_failed");
53
+ case "debug":
54
+ if (!debugEnabled) {
55
+ safeSendJson(res, 404, { error: "not_found" });
235
56
  return;
236
57
  }
237
- sendJson(res, 503, { error: "token_refresh_failed" });
58
+ await handleDebug(res, {
59
+ config: input.config,
60
+ logger: input.logger,
61
+ tokenManager: input.tokenManager,
62
+ githubToken: input.githubToken,
63
+ port: input.port
64
+ });
238
65
  return;
239
- }
240
- if (prelude) {
241
- writeAnthropicSseError(res, prelude, "internal_error");
66
+ case "not_found":
67
+ safeSendJson(res, 404, { error: "not_found" });
68
+ return;
69
+ case "openai":
70
+ case "anthropic":
71
+ case "codex_responses":
72
+ await handleProxyForward({
73
+ req,
74
+ res,
75
+ route,
76
+ config: input.config,
77
+ tokenManager: input.tokenManager,
78
+ logger: input.logger,
79
+ requestId,
80
+ signal: lifecycle.signal
81
+ });
242
82
  return;
243
- }
244
- throw error;
245
83
  }
246
84
  }
247
85
  catch (error) {
248
86
  if (error instanceof JsonRequestParseError) {
249
- sendJson(res, 400, { error: "invalid_request_json", detail: error.message });
87
+ safeSendJson(res, 400, { error: "invalid_request_json", detail: error.message });
250
88
  return;
251
89
  }
252
90
  if (error instanceof InvalidRequestShapeError) {
253
- sendJson(res, 400, { error: "invalid_request_shape", detail: error.message });
91
+ safeSendJson(res, 400, { error: "invalid_request_shape", detail: error.message });
254
92
  return;
255
93
  }
256
94
  if (error instanceof ProtocolTranslationError) {
257
- sendJson(res, 400, { error: error.code, detail: error.message });
95
+ safeSendJson(res, 400, { error: error.code, detail: error.message });
258
96
  return;
259
97
  }
260
98
  if (isBenignSocketError(error)) {
@@ -263,7 +101,7 @@ export async function startProxyServer(input) {
263
101
  return;
264
102
  }
265
103
  input.logger.error({ err: error, request_id: requestId }, "request failed");
266
- sendJson(res, 500, { error: "internal_error" });
104
+ safeSendJson(res, 500, { error: "internal_error" });
267
105
  safeEnd(res);
268
106
  }
269
107
  });
@@ -300,677 +138,4 @@ export async function startProxyServer(input) {
300
138
  })
301
139
  };
302
140
  }
303
- async function postToCopilot(input) {
304
- let forceRefresh = false;
305
- let authRefreshRetried = false;
306
- for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
307
- if (input.signal?.aborted) {
308
- throw abortErrorFromSignal(input.signal);
309
- }
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");
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");
330
- forceRefresh = false;
331
- if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
332
- authRefreshRetried = true;
333
- forceRefresh = true;
334
- input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "upstream_auth_401" }, "retrying upstream request after forced token refresh");
335
- await discardUpstreamBody(response);
336
- continue;
337
- }
338
- if (isRetryableStatus(response.status) && attempt < MAX_UPSTREAM_ATTEMPTS) {
339
- input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, status_code: response.status }, "retrying upstream request");
340
- await discardUpstreamBody(response);
341
- await sleep(retryDelayMs(attempt));
342
- continue;
343
- }
344
- return response;
345
- }
346
- catch (error) {
347
- if (isBenignSocketError(error)) {
348
- // Client disconnected — propagate so the request handler can clean up.
349
- throw error;
350
- }
351
- if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
352
- throw error;
353
- }
354
- input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "transport_error" }, "retrying upstream request after transport error");
355
- await sleep(retryDelayMs(attempt));
356
- }
357
- }
358
- throw new Error("Upstream retry budget exhausted unexpectedly.");
359
- }
360
- async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
361
- const bearer = await tokenManager.ensureToken({ forceRefresh });
362
- return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
363
- method: "POST",
364
- headers: {
365
- ...COPILOT_HEADERS,
366
- Authorization: `Bearer ${bearer}`,
367
- "X-Request-Id": requestId
368
- },
369
- body: JSON.stringify(body),
370
- signal
371
- });
372
- }
373
- function abortErrorFromSignal(signal) {
374
- const reason = signal.reason;
375
- if (reason instanceof Error) {
376
- return reason;
377
- }
378
- const err = new Error("Request aborted by client");
379
- err.name = "AbortError";
380
- return err;
381
- }
382
- async function forwardResponse(upstream, anthroShape, res, diagnostics) {
383
- if (!upstream.ok) {
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;
399
- if (prelude) {
400
- writeAnthropicSseError(res, prelude, message);
401
- return;
402
- }
403
- sendJson(res, upstream.status, buildUpstreamErrorPayload(category, upstream.status, diagnostics.requestId, upstreamError, anthroShape));
404
- return;
405
- }
406
- if (isEventStream(upstream)) {
407
- if (anthroShape) {
408
- if (!upstream.body) {
409
- if (diagnostics.prelude) {
410
- writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
411
- return;
412
- }
413
- sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
414
- return;
415
- }
416
- if (!diagnostics.prelude) {
417
- beginAnthropicSseResponse(res);
418
- }
419
- const upstreamReadable = Readable.fromWeb(upstream.body);
420
- await translateOpenAIStreamToAnthropic({
421
- upstream: upstreamReadable,
422
- downstream: res,
423
- fallbackModel: diagnostics.requestedModel,
424
- preEmittedMessageId: diagnostics.prelude?.messageId
425
- });
426
- return;
427
- }
428
- await pipeEventStream(upstream, res);
429
- return;
430
- }
431
- if (diagnostics.prelude) {
432
- writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
433
- return;
434
- }
435
- let json;
436
- try {
437
- json = (await upstream.json());
438
- }
439
- catch {
440
- sendJson(res, 502, { error: "invalid_upstream_response" });
441
- return;
442
- }
443
- let payload = json;
444
- if (anthroShape) {
445
- try {
446
- payload = openAIToAnthropic(json);
447
- }
448
- catch (error) {
449
- if (error instanceof ProtocolTranslationError) {
450
- sendJson(res, 502, { error: error.code, detail: error.message });
451
- return;
452
- }
453
- throw error;
454
- }
455
- }
456
- sendJson(res, 200, payload);
457
- }
458
- async function pipeEventStream(upstream, res) {
459
- if (!upstream.body) {
460
- sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
461
- return;
462
- }
463
- res.statusCode = upstream.status;
464
- res.setHeader("Content-Type", "text/event-stream");
465
- const cacheControl = upstream.headers.get("cache-control");
466
- if (cacheControl) {
467
- res.setHeader("Cache-Control", cacheControl);
468
- }
469
- else {
470
- res.setHeader("Cache-Control", "no-cache");
471
- }
472
- const connection = upstream.headers.get("connection");
473
- if (connection) {
474
- res.setHeader("Connection", connection);
475
- }
476
- else {
477
- res.setHeader("Connection", "keep-alive");
478
- }
479
- res.setHeader("X-Accel-Buffering", "no");
480
- if (typeof res.flushHeaders === "function") {
481
- res.flushHeaders();
482
- }
483
- if (res.socket && typeof res.socket.setNoDelay === "function") {
484
- res.socket.setNoDelay(true);
485
- }
486
- try {
487
- await pipeline(Readable.fromWeb(upstream.body), res);
488
- }
489
- catch (error) {
490
- if (isBenignSocketError(error)) {
491
- // Client went away mid-stream — normal for SSE consumers (Codex,
492
- // Claude Code, pi) that cancel pending responses on user input.
493
- return;
494
- }
495
- throw error;
496
- }
497
- }
498
- function isEventStream(upstream) {
499
- const contentType = upstream.headers.get("content-type");
500
- return typeof contentType === "string" && contentType.toLowerCase().includes("text/event-stream");
501
- }
502
- function isStreamingRequestBody(body) {
503
- return typeof body === "object" && body !== null && body.stream === true;
504
- }
505
- function beginAnthropicSseResponse(res, req) {
506
- if (res.headersSent) {
507
- return;
508
- }
509
- res.statusCode = 200;
510
- res.setHeader("Content-Type", "text/event-stream");
511
- res.setHeader("Cache-Control", "no-cache");
512
- res.setHeader("Connection", "keep-alive");
513
- res.setHeader("X-Accel-Buffering", "no");
514
- if (typeof res.flushHeaders === "function") {
515
- res.flushHeaders();
516
- }
517
- const socket = res.socket ?? req?.socket;
518
- if (socket && typeof socket.setNoDelay === "function") {
519
- socket.setNoDelay(true);
520
- }
521
- }
522
- export function writeAnthropicSseError(res, prelude, code) {
523
- void prelude;
524
- try {
525
- safeWrite(res, `event: message_delta\ndata: ${JSON.stringify({
526
- type: "message_delta",
527
- delta: { stop_reason: "end_turn", stop_sequence: null },
528
- usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }
529
- })}\n\n`);
530
- safeWrite(res, `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
531
- safeWrite(res, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
532
- }
533
- finally {
534
- safeEnd(res);
535
- }
536
- }
537
- function isLocalRequest(req) {
538
- const remote = req.socket.remoteAddress ?? "";
539
- const local = req.socket.localAddress ?? "";
540
- return isLoopbackAddress(remote) && (local.length === 0 || isLoopbackAddress(local));
541
- }
542
- function isLoopbackAddress(value) {
543
- return value === "127.0.0.1" || value === "::1" || value === "::ffff:127.0.0.1";
544
- }
545
- async function readJson(req) {
546
- const chunks = [];
547
- for await (const chunk of req) {
548
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
549
- }
550
- if (chunks.length === 0) {
551
- return {};
552
- }
553
- const text = Buffer.concat(chunks).toString("utf8");
554
- try {
555
- return JSON.parse(text);
556
- }
557
- catch (error) {
558
- if (error instanceof SyntaxError) {
559
- throw new JsonRequestParseError("Failed to parse JSON body.");
560
- }
561
- throw error;
562
- }
563
- }
564
- function sendJson(res, status, payload) {
565
- safeSendJson(res, status, payload);
566
- }
567
- function readRequestedModel(payload) {
568
- if (!payload || typeof payload !== "object") {
569
- return null;
570
- }
571
- const maybeModel = payload.model;
572
- return typeof maybeModel === "string" ? maybeModel : null;
573
- }
574
- function rewriteRequestedModel(payload, model) {
575
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
576
- return payload;
577
- }
578
- return {
579
- ...payload,
580
- model
581
- };
582
- }
583
- function translateRequestBody(routeKind, body) {
584
- if (routeKind !== "anthropic") {
585
- return normaliseAliasedModelInPlace(body);
586
- }
587
- try {
588
- return anthropicToOpenAI(body);
589
- }
590
- catch (error) {
591
- if (error instanceof Error) {
592
- throw new InvalidRequestShapeError(error.message);
593
- }
594
- throw new InvalidRequestShapeError("Invalid Anthropic request body.");
595
- }
596
- }
597
- /**
598
- * Defensive strip of the `[1m]` alias for pass-through routes (OpenAI chat
599
- * completions, Codex responses). The Anthropic route already strips inside
600
- * `anthropicToOpenAI`; this catches anything that might land on the
601
- * pass-through paths with a hand-pasted aliased id so upstream Copilot
602
- * always receives the canonical model id.
603
- */
604
- function normaliseAliasedModelInPlace(body) {
605
- if (body && typeof body === "object" && !Array.isArray(body)) {
606
- const record = body;
607
- if (typeof record.model === "string") {
608
- const stripped = stripOneMillionAlias(record.model);
609
- if (stripped !== record.model) {
610
- record.model = stripped;
611
- }
612
- }
613
- }
614
- return body;
615
- }
616
- async function handleDebug(res, input) {
617
- const bearerTtlSeconds = input.tokenManager.expiresInSeconds();
618
- const uptimeSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(DAEMON_STARTED_AT_ISO)) / 1_000));
619
- let user = null;
620
- let userError = null;
621
- if (input.githubToken) {
622
- try {
623
- const summary = await getGithubUserSummary(input.githubToken);
624
- user = {
625
- login: summary.login,
626
- id: summary.id,
627
- type: summary.type
628
- };
629
- }
630
- catch (error) {
631
- if (error instanceof GithubUserFetchError) {
632
- userError = `github_user_lookup_failed_${error.status}`;
633
- }
634
- else {
635
- userError = error instanceof Error ? error.message : "unknown_error";
636
- }
637
- }
638
- }
639
- else {
640
- userError = "github_token_unavailable_in_proxy";
641
- }
642
- sendJson(res, 200, {
643
- server: {
644
- port: input.port,
645
- pid: process.pid,
646
- node_version: process.version,
647
- started_at_iso: DAEMON_STARTED_AT_ISO,
648
- uptime_seconds: uptimeSeconds,
649
- account_type: input.config.accountType,
650
- selected_models: input.config.selectedModels,
651
- require_caller_secret: input.config.requireCallerSecret,
652
- log_level: input.logger.level,
653
- log_file: process.env.COPILLM_LOG_FILE ?? null
654
- },
655
- auth: {
656
- bearer_ttl_seconds: bearerTtlSeconds,
657
- bearer_present: input.tokenManager.current !== null,
658
- bearer_expires_at_unix: input.tokenManager.current?.expiresAtUnix ?? null
659
- },
660
- user,
661
- user_error: userError,
662
- routes: [
663
- "GET /livez",
664
- "GET /healthz",
665
- "GET /models",
666
- "GET /v1/models",
667
- "GET /codex/v1/models",
668
- "GET /anthropic/v1/models",
669
- "POST /codex/v1/responses",
670
- "POST /v1/chat/completions",
671
- "POST /v1/messages",
672
- "POST /anthropic/v1/messages",
673
- "GET /_debug"
674
- ],
675
- debug_enabled: true
676
- });
677
- }
678
- function resolveRoute(method, rawUrl) {
679
- if (!method || !rawUrl) {
680
- return { kind: "not_found", anthroShape: false };
681
- }
682
- let pathname;
683
- try {
684
- pathname = new URL(rawUrl, "http://127.0.0.1").pathname;
685
- }
686
- catch {
687
- return { kind: "not_found", anthroShape: false };
688
- }
689
- if (method === "GET" && pathname === "/livez") {
690
- return { kind: "livez", anthroShape: false };
691
- }
692
- if (method === "GET" && pathname === "/healthz") {
693
- return { kind: "healthz", anthroShape: false };
694
- }
695
- if (method === "GET" && (pathname === "/models" || pathname === "/v1/models")) {
696
- return { kind: "models", anthroShape: false };
697
- }
698
- if (method === "GET" && pathname === "/codex/v1/models") {
699
- return { kind: "codex_models", anthroShape: false };
700
- }
701
- if (method === "GET" && pathname === "/anthropic/v1/models") {
702
- return { kind: "anthropic_models", anthroShape: false };
703
- }
704
- if (method === "POST" && pathname === "/codex/v1/responses") {
705
- return { kind: "codex_responses", anthroShape: false };
706
- }
707
- if (method === "GET" && pathname === "/_debug") {
708
- return { kind: "debug", anthroShape: false };
709
- }
710
- if (method === "POST" && pathname === "/v1/chat/completions") {
711
- return { kind: "openai", anthroShape: false };
712
- }
713
- if (method === "POST" && (pathname === "/anthropic/v1/messages" || pathname === "/v1/messages")) {
714
- return { kind: "anthropic", anthroShape: true };
715
- }
716
- return { kind: "not_found", anthroShape: false };
717
- }
718
- function isRetryableStatus(status) {
719
- return RETRYABLE_UPSTREAM_STATUSES.has(status);
720
- }
721
- function retryDelayMs(attempt) {
722
- return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
723
- }
724
- function isRetryableTransportError(error) {
725
- if (!error || typeof error !== "object") {
726
- return false;
727
- }
728
- const typedError = error;
729
- const directCode = typedError.code?.toUpperCase();
730
- const causeCode = typedError.cause?.code?.toUpperCase();
731
- if (directCode === "ECONNRESET" || directCode === "ECONNREFUSED" || directCode === "ETIMEDOUT") {
732
- return true;
733
- }
734
- if (causeCode === "ECONNRESET" || causeCode === "ECONNREFUSED" || causeCode === "ETIMEDOUT") {
735
- return true;
736
- }
737
- if (!(typedError instanceof Error)) {
738
- return false;
739
- }
740
- const message = typedError.message.toLowerCase();
741
- if (message.includes("timed out") || message.includes("timeout")) {
742
- return true;
743
- }
744
- return message.includes("econnreset") || message.includes("econnrefused") || message.includes("enotfound");
745
- }
746
- function upstreamStatusCategory(status) {
747
- if (status === 401 || status === 403) {
748
- return "upstream_auth_error";
749
- }
750
- if (status === 429) {
751
- return "upstream_rate_limited";
752
- }
753
- if (status >= 500) {
754
- return "upstream_server_error";
755
- }
756
- if (status >= 400) {
757
- return "upstream_request_error";
758
- }
759
- return "upstream_error";
760
- }
761
- async function readUpstreamError(response) {
762
- const contentType = response.headers.get("content-type");
763
- let text;
764
- try {
765
- text = await response.text();
766
- }
767
- catch {
768
- return { contentType, code: null, type: null, message: null, responseBytes: null };
769
- }
770
- const trimmed = text.trim();
771
- const responseBytes = Buffer.byteLength(text, "utf8");
772
- if (trimmed.length === 0) {
773
- return { contentType, code: null, type: null, message: null, responseBytes };
774
- }
775
- try {
776
- const parsed = JSON.parse(trimmed);
777
- const extracted = extractErrorFields(parsed);
778
- if (extracted.message || extracted.code || extracted.type) {
779
- return { contentType, responseBytes, ...extracted };
780
- }
781
- }
782
- catch {
783
- // Fall through to a plain-text snippet below.
784
- }
785
- return {
786
- contentType,
787
- code: null,
788
- type: null,
789
- message: truncateForDiagnostics(trimmed),
790
- responseBytes
791
- };
792
- }
793
- function extractErrorFields(payload) {
794
- if (!payload || typeof payload !== "object") {
795
- return { code: null, type: null, message: typeof payload === "string" ? truncateForDiagnostics(payload) : null };
796
- }
797
- const record = payload;
798
- const nested = record.error;
799
- if (typeof nested === "string") {
800
- return {
801
- code: readStringField(record, "code"),
802
- type: readStringField(record, "type"),
803
- message: truncateForDiagnostics(nested)
804
- };
805
- }
806
- if (nested && typeof nested === "object") {
807
- const errorRecord = nested;
808
- return {
809
- code: readStringField(errorRecord, "code") ?? readStringField(record, "code"),
810
- type: readStringField(errorRecord, "type") ?? readStringField(record, "type"),
811
- message: readTruncatedStringField(errorRecord, "message") ??
812
- readTruncatedStringField(errorRecord, "detail") ??
813
- readTruncatedStringField(record, "message") ??
814
- readTruncatedStringField(record, "detail")
815
- };
816
- }
817
- return {
818
- code: readStringField(record, "code"),
819
- type: readStringField(record, "type"),
820
- message: readTruncatedStringField(record, "message") ?? readTruncatedStringField(record, "detail")
821
- };
822
- }
823
- function buildUpstreamErrorPayload(category, statusCode, requestId, upstreamError, anthroShape) {
824
- const code = upstreamError.code ?? category;
825
- const type = upstreamError.type ?? category;
826
- const message = formatUserFacingUpstreamErrorMessage(category, upstreamError);
827
- if (anthroShape) {
828
- return {
829
- type: "error",
830
- error: {
831
- type,
832
- message,
833
- code,
834
- upstream_status_code: statusCode,
835
- request_id: requestId
836
- }
837
- };
838
- }
839
- return {
840
- error: {
841
- type,
842
- code,
843
- message,
844
- upstream_status_code: statusCode,
845
- request_id: requestId
846
- }
847
- };
848
- }
849
- function formatUpstreamErrorMessage(category, upstreamError) {
850
- const parts = [upstreamError.code, upstreamError.type, upstreamError.message].filter((part) => typeof part === "string" && part.length > 0);
851
- return parts.length > 0 ? `${category}: ${parts.join(": ")}` : category;
852
- }
853
- function formatUserFacingUpstreamErrorMessage(category, upstreamError) {
854
- if (upstreamError.code && upstreamError.message) {
855
- return `${upstreamError.code}: ${upstreamError.message}`;
856
- }
857
- if (upstreamError.message) {
858
- return upstreamError.message;
859
- }
860
- return upstreamError.code ?? upstreamError.type ?? category;
861
- }
862
- function readStringField(record, key) {
863
- const value = record[key];
864
- return typeof value === "string" && value.length > 0 ? value : null;
865
- }
866
- function readTruncatedStringField(record, key) {
867
- const value = readStringField(record, key);
868
- return value ? truncateForDiagnostics(value) : null;
869
- }
870
- function truncateForDiagnostics(value) {
871
- const maxChars = 500;
872
- return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value;
873
- }
874
- function summarizeUpstreamPayload(payload) {
875
- let requestBytes = null;
876
- try {
877
- requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
878
- }
879
- catch {
880
- requestBytes = null;
881
- }
882
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
883
- return { upstream_request_bytes: requestBytes };
884
- }
885
- const record = payload;
886
- const messages = Array.isArray(record.messages) ? record.messages : null;
887
- const input = Array.isArray(record.input) ? record.input : null;
888
- return {
889
- upstream_request_bytes: requestBytes,
890
- stream: record.stream === true,
891
- max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
892
- message_count: messages?.length ?? null,
893
- input_item_count: input?.length ?? null,
894
- tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
895
- text_characters: sumTextCharacters(payload)
896
- };
897
- }
898
- function sumTextCharacters(value) {
899
- if (typeof value === "string") {
900
- return value.length;
901
- }
902
- if (!value || typeof value !== "object") {
903
- return 0;
904
- }
905
- if (Array.isArray(value)) {
906
- return value.reduce((total, item) => total + sumTextCharacters(item), 0);
907
- }
908
- let total = 0;
909
- const record = value;
910
- for (const [key, nested] of Object.entries(record)) {
911
- if (key === "text" || key === "content" || key === "arguments" || key === "input") {
912
- total += sumTextCharacters(nested);
913
- }
914
- else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
915
- total += sumTextCharacters(nested);
916
- }
917
- }
918
- return total;
919
- }
920
- function healthFailure(error) {
921
- if (error instanceof CopilotTokenExchangeError) {
922
- if (error.statusCode === 401 || error.statusCode === 403) {
923
- return {
924
- httpStatus: 401,
925
- payload: {
926
- status: "unauthenticated",
927
- error: "github_auth_invalid",
928
- upstream_status_code: error.statusCode
929
- }
930
- };
931
- }
932
- return {
933
- httpStatus: 503,
934
- payload: {
935
- status: "upstream_unreachable",
936
- error: "token_exchange_failed",
937
- upstream_status_code: error.statusCode
938
- }
939
- };
940
- }
941
- if (error instanceof CopilotTokenManagerError) {
942
- return {
943
- httpStatus: 401,
944
- payload: {
945
- status: "unauthenticated",
946
- error: "token_refresh_failed"
947
- }
948
- };
949
- }
950
- return {
951
- httpStatus: 503,
952
- payload: {
953
- status: "upstream_unreachable",
954
- error: "token_refresh_unavailable"
955
- }
956
- };
957
- }
958
- function safePathname(rawUrl) {
959
- if (!rawUrl) {
960
- return "/";
961
- }
962
- try {
963
- return new URL(rawUrl, "http://127.0.0.1").pathname;
964
- }
965
- catch {
966
- return "/";
967
- }
968
- }
969
- async function discardUpstreamBody(response) {
970
- try {
971
- await response.arrayBuffer();
972
- }
973
- catch {
974
- // ignore body drain failures
975
- }
976
- }
141
+ export { writeAnthropicSseError } from "./errors.js";