@dianshuv/copilot-api 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/main.mjs +154 -21
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  - **Graceful shutdown**: 4-phase shutdown sequence — stops accepting requests, waits for in-flight requests to complete, sends abort signal, then force-closes. Configurable via `--shutdown-graceful-wait` and `--shutdown-abort-wait`.
16
16
  - **Stream repetition detection**: Detects when models get stuck in repetitive output loops using KMP-based pattern matching and logs a warning.
17
17
  - **Stale request reaping**: Automatically force-fails requests that exceed a configurable maximum age (default 600s) to prevent resource leaks.
18
+ - **PostHog analytics**: Optional PostHog Cloud integration (`--posthog-key`) sends per-request token usage events for long-term trend analysis. Free tier (1M events/month) is more than sufficient for individual use.
18
19
 
19
20
  ## Quick Start
20
21
 
@@ -66,6 +67,7 @@ copilot-api start
66
67
  | `--redirect-anthropic` | Force Anthropic through OpenAI translation | false |
67
68
  | `--no-rewrite-anthropic-tools` | Don't rewrite server-side tools | false |
68
69
  | `--timezone-offset` | Timezone offset in hours from UTC for log timestamps (e.g., +8, -5, 0) | +8 |
70
+ | `--posthog-key` | PostHog API key for token usage analytics (opt-in) | none |
69
71
 
70
72
  ### Patch-Claude Command Options
71
73
 
package/dist/main.mjs CHANGED
@@ -4,11 +4,12 @@ import consola from "consola";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path, { dirname, join } from "node:path";
7
- import { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
9
9
  import clipboard from "clipboardy";
10
10
  import { serve } from "srvx";
11
11
  import invariant from "tiny-invariant";
12
+ import { PostHog } from "posthog-node";
12
13
  import { getProxyForUrl } from "proxy-from-env";
13
14
  import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
14
15
  import { execSync } from "node:child_process";
@@ -1035,7 +1036,7 @@ const patchClaude = defineCommand({
1035
1036
 
1036
1037
  //#endregion
1037
1038
  //#region package.json
1038
- var version = "0.5.0";
1039
+ var version = "0.6.0";
1039
1040
 
1040
1041
  //#endregion
1041
1042
  //#region src/lib/adaptive-rate-limiter.ts
@@ -1947,6 +1948,55 @@ function exportHistory(format = "json") {
1947
1948
  return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
1948
1949
  }
1949
1950
 
1951
+ //#endregion
1952
+ //#region src/lib/posthog.ts
1953
+ let client = null;
1954
+ let distinctId = "";
1955
+ function initPostHog(apiKey) {
1956
+ if (!apiKey) return;
1957
+ try {
1958
+ client = new PostHog(apiKey, {
1959
+ host: "https://us.i.posthog.com",
1960
+ flushAt: 20,
1961
+ flushInterval: 1e4
1962
+ });
1963
+ distinctId = createHash("sha256").update(os.hostname() + os.userInfo().username).digest("hex");
1964
+ } catch (error) {
1965
+ consola.warn("Failed to initialize PostHog:", error instanceof Error ? error.message : error);
1966
+ client = null;
1967
+ }
1968
+ }
1969
+ function isPostHogEnabled() {
1970
+ return client !== null;
1971
+ }
1972
+ function captureRequest(params) {
1973
+ if (!client) return;
1974
+ const properties = {
1975
+ model: params.model,
1976
+ input_tokens: params.inputTokens,
1977
+ output_tokens: params.outputTokens,
1978
+ duration_ms: params.durationMs,
1979
+ success: params.success,
1980
+ stream: params.stream,
1981
+ tool_count: params.toolCount
1982
+ };
1983
+ if (params.reasoningTokens !== void 0) properties.reasoning_tokens = params.reasoningTokens;
1984
+ if (params.stopReason !== void 0) properties.stop_reason = params.stopReason;
1985
+ client.capture({
1986
+ distinctId,
1987
+ event: "copilot_api_request",
1988
+ properties
1989
+ });
1990
+ }
1991
+ async function shutdownPostHog() {
1992
+ if (!client) return;
1993
+ try {
1994
+ await client.shutdown();
1995
+ } catch (error) {
1996
+ consola.warn("Failed to flush PostHog events:", error instanceof Error ? error.message : error);
1997
+ }
1998
+ }
1999
+
1950
2000
  //#endregion
1951
2001
  //#region src/lib/proxy.ts
1952
2002
  /**
@@ -2157,7 +2207,7 @@ async function gracefulShutdown(signal, deps) {
2157
2207
  try {
2158
2208
  if (await drainActiveRequests(gracefulWaitMs, tracker, drainOpts) === "drained") {
2159
2209
  consola.info("All requests completed naturally");
2160
- finalize(tracker);
2210
+ await finalize(tracker);
2161
2211
  return;
2162
2212
  }
2163
2213
  } catch (error) {
@@ -2169,7 +2219,7 @@ async function gracefulShutdown(signal, deps) {
2169
2219
  try {
2170
2220
  if (await drainActiveRequests(abortWaitMs, tracker, drainOpts) === "drained") {
2171
2221
  consola.info("All requests completed after abort signal");
2172
- finalize(tracker);
2222
+ await finalize(tracker);
2173
2223
  return;
2174
2224
  }
2175
2225
  } catch (error) {
@@ -2183,13 +2233,15 @@ async function gracefulShutdown(signal, deps) {
2183
2233
  consola.error("Error force-closing server:", error);
2184
2234
  }
2185
2235
  }
2186
- finalize(tracker);
2236
+ await finalize(tracker);
2187
2237
  } else {
2238
+ await shutdownPostHog();
2188
2239
  consola.info("Shutdown complete");
2189
2240
  shutdownResolve?.();
2190
2241
  }
2191
2242
  }
2192
- function finalize(tracker) {
2243
+ async function finalize(tracker) {
2244
+ await shutdownPostHog();
2193
2245
  tracker.destroy();
2194
2246
  consola.info("Shutdown complete");
2195
2247
  shutdownResolve?.();
@@ -3503,8 +3555,8 @@ function recordErrorResponse(ctx, model, error) {
3503
3555
  content: null
3504
3556
  }, Date.now() - ctx.startTime);
3505
3557
  }
3506
- /** Complete TUI tracking */
3507
- function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens) {
3558
+ /** Complete TUI tracking and send PostHog analytics */
3559
+ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics) {
3508
3560
  if (!trackingId) return;
3509
3561
  requestTracker.updateRequest(trackingId, {
3510
3562
  inputTokens,
@@ -3517,6 +3569,17 @@ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, re
3517
3569
  outputTokens,
3518
3570
  reasoningTokens
3519
3571
  });
3572
+ if (analytics) captureRequest({
3573
+ model: analytics.model,
3574
+ inputTokens,
3575
+ outputTokens,
3576
+ durationMs: analytics.durationMs,
3577
+ success: true,
3578
+ stream: analytics.stream,
3579
+ toolCount: analytics.toolCount ?? 0,
3580
+ reasoningTokens,
3581
+ stopReason: analytics.stopReason
3582
+ });
3520
3583
  }
3521
3584
  /** Fail TUI tracking */
3522
3585
  function failTracking(trackingId, error) {
@@ -3685,7 +3748,7 @@ async function executeRequest(opts) {
3685
3748
  try {
3686
3749
  const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(payload));
3687
3750
  ctx.queueWaitMs = queueWaitMs;
3688
- if (isNonStreaming(response)) return handleNonStreamingResponse$1(c, response, ctx);
3751
+ if (isNonStreaming(response)) return handleNonStreamingResponse$1(c, response, ctx, payload);
3689
3752
  consola.debug("Streaming response");
3690
3753
  updateTrackerStatus(trackingId, "streaming");
3691
3754
  return streamSSE(c, async (stream) => {
@@ -3712,7 +3775,7 @@ async function logTokenCount(payload, selectedModel) {
3712
3775
  consola.debug("Failed to calculate token count:", error);
3713
3776
  }
3714
3777
  }
3715
- function handleNonStreamingResponse$1(c, originalResponse, ctx) {
3778
+ function handleNonStreamingResponse$1(c, originalResponse, ctx, payload) {
3716
3779
  consola.debug("Non-streaming response:", JSON.stringify(originalResponse));
3717
3780
  let response = originalResponse;
3718
3781
  if (state.verbose && ctx.truncateResult?.wasCompacted && response.choices[0]?.message.content) {
@@ -3731,6 +3794,7 @@ function handleNonStreamingResponse$1(c, originalResponse, ctx) {
3731
3794
  const choice = response.choices[0];
3732
3795
  const usage = response.usage;
3733
3796
  const reasoningTokens = getReasoningTokensFromOpenAIUsage(usage);
3797
+ const durationMs = Date.now() - ctx.startTime;
3734
3798
  recordResponse(ctx.historyId, {
3735
3799
  success: true,
3736
3800
  model: response.model,
@@ -3742,13 +3806,24 @@ function handleNonStreamingResponse$1(c, originalResponse, ctx) {
3742
3806
  stop_reason: choice.finish_reason,
3743
3807
  content: buildResponseContent(choice),
3744
3808
  toolCalls: extractToolCalls(choice)
3745
- }, Date.now() - ctx.startTime);
3809
+ }, durationMs);
3746
3810
  if (ctx.trackingId && usage) requestTracker.updateRequest(ctx.trackingId, {
3747
3811
  inputTokens: usage.prompt_tokens,
3748
3812
  outputTokens: usage.completion_tokens,
3749
3813
  queueWaitMs: ctx.queueWaitMs,
3750
3814
  reasoningTokens
3751
3815
  });
3816
+ captureRequest({
3817
+ model: response.model,
3818
+ inputTokens: usage?.prompt_tokens ?? 0,
3819
+ outputTokens: usage?.completion_tokens ?? 0,
3820
+ durationMs,
3821
+ success: true,
3822
+ stream: false,
3823
+ toolCount: payload.tools?.length ?? 0,
3824
+ reasoningTokens,
3825
+ stopReason: choice.finish_reason
3826
+ });
3752
3827
  return c.json(response);
3753
3828
  }
3754
3829
  function buildResponseContent(choice) {
@@ -3815,7 +3890,13 @@ async function handleStreamingResponse$1(opts) {
3815
3890
  await stream.writeSSE(chunk);
3816
3891
  }
3817
3892
  recordStreamSuccess(acc, payload.model, ctx);
3818
- completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs, acc.reasoningTokens);
3893
+ completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs, acc.reasoningTokens, {
3894
+ model: acc.model || payload.model,
3895
+ stream: true,
3896
+ durationMs: Date.now() - ctx.startTime,
3897
+ stopReason: acc.finishReason || void 0,
3898
+ toolCount: payload.tools?.length ?? 0
3899
+ });
3819
3900
  } catch (error) {
3820
3901
  recordStreamError({
3821
3902
  acc,
@@ -6849,7 +6930,7 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
6849
6930
  });
6850
6931
  });
6851
6932
  }
6852
- return handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateResult);
6933
+ return handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateResult, effectivePayload);
6853
6934
  } catch (error) {
6854
6935
  if (error instanceof HTTPError && error.status === 413) logPayloadSizeInfoAnthropic(effectivePayload, selectedModel);
6855
6936
  recordErrorResponse(ctx, anthropicPayload.model, error);
@@ -6874,7 +6955,7 @@ function logPayloadSizeInfoAnthropic(payload, model) {
6874
6955
  /**
6875
6956
  * Handle non-streaming direct Anthropic response
6876
6957
  */
6877
- function handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateResult) {
6958
+ function handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateResult, payload) {
6878
6959
  consola.debug("Non-streaming response from Copilot (direct Anthropic):", JSON.stringify(response).slice(-400));
6879
6960
  recordResponse(ctx.historyId, {
6880
6961
  success: true,
@@ -6910,6 +6991,16 @@ function handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateRes
6910
6991
  outputTokens: response.usage.output_tokens,
6911
6992
  queueWaitMs: ctx.queueWaitMs
6912
6993
  });
6994
+ captureRequest({
6995
+ model: response.model,
6996
+ inputTokens: response.usage.input_tokens,
6997
+ outputTokens: response.usage.output_tokens,
6998
+ durationMs: Date.now() - ctx.startTime,
6999
+ success: true,
7000
+ stream: false,
7001
+ toolCount: payload.tools?.length ?? 0,
7002
+ stopReason: response.stop_reason ?? void 0
7003
+ });
6913
7004
  let finalResponse = response;
6914
7005
  if (state.verbose && truncateResult?.wasCompacted) finalResponse = prependMarkerToAnthropicResponse$1(response, createTruncationMarker$1(truncateResult));
6915
7006
  return c.json(finalResponse);
@@ -6963,7 +7054,13 @@ async function handleDirectAnthropicStreamingResponse(opts) {
6963
7054
  });
6964
7055
  }
6965
7056
  recordAnthropicStreamingResponse(acc, anthropicPayload.model, ctx);
6966
- completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs);
7057
+ completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs, void 0, {
7058
+ model: acc.model || anthropicPayload.model,
7059
+ stream: true,
7060
+ durationMs: Date.now() - ctx.startTime,
7061
+ stopReason: acc.stopReason || void 0,
7062
+ toolCount: anthropicPayload.tools?.length ?? 0
7063
+ });
6967
7064
  } catch (error) {
6968
7065
  consola.error("Direct Anthropic stream error:", error);
6969
7066
  recordStreamError({
@@ -7046,7 +7143,8 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
7046
7143
  c,
7047
7144
  response,
7048
7145
  toolNameMapping,
7049
- ctx
7146
+ ctx,
7147
+ anthropicPayload
7050
7148
  });
7051
7149
  consola.debug("Streaming response from Copilot");
7052
7150
  updateTrackerStatus(ctx.trackingId, "streaming");
@@ -7066,7 +7164,7 @@ async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOve
7066
7164
  }
7067
7165
  }
7068
7166
  function handleNonStreamingResponse(opts) {
7069
- const { c, response, toolNameMapping, ctx } = opts;
7167
+ const { c, response, toolNameMapping, ctx, anthropicPayload } = opts;
7070
7168
  consola.debug("Non-streaming response from Copilot:", JSON.stringify(response).slice(-400));
7071
7169
  let anthropicResponse = translateToAnthropic(response, toolNameMapping);
7072
7170
  consola.debug("Translated Anthropic response:", JSON.stringify(anthropicResponse));
@@ -7102,6 +7200,16 @@ function handleNonStreamingResponse(opts) {
7102
7200
  outputTokens: anthropicResponse.usage.output_tokens,
7103
7201
  queueWaitMs: ctx.queueWaitMs
7104
7202
  });
7203
+ captureRequest({
7204
+ model: anthropicResponse.model,
7205
+ inputTokens: anthropicResponse.usage.input_tokens,
7206
+ outputTokens: anthropicResponse.usage.output_tokens,
7207
+ durationMs: Date.now() - ctx.startTime,
7208
+ success: true,
7209
+ stream: false,
7210
+ toolCount: anthropicPayload.tools?.length ?? 0,
7211
+ stopReason: anthropicResponse.stop_reason ?? void 0
7212
+ });
7105
7213
  return c.json(anthropicResponse);
7106
7214
  }
7107
7215
  function prependMarkerToAnthropicResponse(response, marker) {
@@ -7147,7 +7255,13 @@ async function handleStreamingResponse(opts) {
7147
7255
  checkRepetition
7148
7256
  });
7149
7257
  recordAnthropicStreamingResponse(acc, anthropicPayload.model, ctx);
7150
- completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs);
7258
+ completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs, void 0, {
7259
+ model: acc.model || anthropicPayload.model,
7260
+ stream: true,
7261
+ durationMs: Date.now() - ctx.startTime,
7262
+ stopReason: acc.stopReason || void 0,
7263
+ toolCount: anthropicPayload.tools?.length ?? 0
7264
+ });
7151
7265
  } catch (error) {
7152
7266
  consola.error("Stream error:", error);
7153
7267
  recordStreamError({
@@ -7670,7 +7784,12 @@ const handleResponses = async (c) => {
7670
7784
  if (finalResult) {
7671
7785
  recordResponseResult(finalResult, model, historyId, startTime);
7672
7786
  const usage = finalResult.usage;
7673
- completeTracking(trackingId, usage?.input_tokens ?? 0, usage?.output_tokens ?? 0, queueWaitMs, usage?.output_tokens_details?.reasoning_tokens);
7787
+ completeTracking(trackingId, usage?.input_tokens ?? 0, usage?.output_tokens ?? 0, queueWaitMs, usage?.output_tokens_details?.reasoning_tokens, {
7788
+ model: finalResult.model || model,
7789
+ stream: true,
7790
+ durationMs: Date.now() - startTime,
7791
+ toolCount: tools.length
7792
+ });
7674
7793
  } else if (streamErrorMessage) {
7675
7794
  recordResponse(historyId, {
7676
7795
  success: false,
@@ -7699,7 +7818,12 @@ const handleResponses = async (c) => {
7699
7818
  const result = response;
7700
7819
  const usage = result.usage;
7701
7820
  recordResponseResult(result, model, historyId, startTime);
7702
- completeTracking(trackingId, usage?.input_tokens ?? 0, usage?.output_tokens ?? 0, ctx.queueWaitMs, usage?.output_tokens_details?.reasoning_tokens);
7821
+ completeTracking(trackingId, usage?.input_tokens ?? 0, usage?.output_tokens ?? 0, ctx.queueWaitMs, usage?.output_tokens_details?.reasoning_tokens, {
7822
+ model: result.model || model,
7823
+ stream: false,
7824
+ durationMs: Date.now() - startTime,
7825
+ toolCount: tools.length
7826
+ });
7703
7827
  consola.debug("Forwarding native Responses result:", JSON.stringify(result).slice(-400));
7704
7828
  return c.json(result);
7705
7829
  } catch (error) {
@@ -7890,6 +8014,10 @@ async function runServer(options) {
7890
8014
  const limitText = options.historyLimit === 0 ? "unlimited" : `max ${options.historyLimit}`;
7891
8015
  consola.info(`History recording enabled (${limitText} entries)`);
7892
8016
  }
8017
+ if (options.posthogKey) {
8018
+ initPostHog(options.posthogKey);
8019
+ if (isPostHogEnabled()) consola.info("PostHog analytics enabled");
8020
+ }
7893
8021
  initTui({ enabled: true });
7894
8022
  initRequestContextManager(state.staleRequestMaxAge).startReaper();
7895
8023
  await ensurePaths();
@@ -8064,6 +8192,10 @@ const start = defineCommand({
8064
8192
  type: "string",
8065
8193
  default: "+8",
8066
8194
  description: "Timezone offset in hours from UTC for log timestamps (e.g., +8, -5, 0)"
8195
+ },
8196
+ "posthog-key": {
8197
+ type: "string",
8198
+ description: "PostHog API key for token usage analytics (opt-in, no key = disabled)"
8067
8199
  }
8068
8200
  },
8069
8201
  run({ args }) {
@@ -8088,7 +8220,8 @@ const start = defineCommand({
8088
8220
  compressToolResults: args["compress-tool-results"],
8089
8221
  redirectAnthropic: args["redirect-anthropic"],
8090
8222
  rewriteAnthropicTools: !args["no-rewrite-anthropic-tools"],
8091
- timezoneOffset: parseTimezoneOffset(args["timezone-offset"])
8223
+ timezoneOffset: parseTimezoneOffset(args["timezone-offset"]),
8224
+ posthogKey: args["posthog-key"]
8092
8225
  });
8093
8226
  }
8094
8227
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
5
  "author": "dianshuv",
6
6
  "type": "module",
@@ -41,6 +41,7 @@
41
41
  "gpt-tokenizer": "^3.4.0",
42
42
  "hono": "^4.11.7",
43
43
  "picocolors": "^1.1.1",
44
+ "posthog-node": "^5.28.6",
44
45
  "proxy-from-env": "^1.1.0",
45
46
  "srvx": "^0.10.1",
46
47
  "tiny-invariant": "^1.3.3",