codex-copilot-dx 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,16 @@ Environment variables:
47
47
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
48
48
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
49
49
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
50
+ | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
51
+ | `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
52
+
53
+ ### Usage logging
54
+
55
+ The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usage.jsonl` when upstream responses include usage fields. It logs counts, model names, API surface, and response IDs only; it does not log prompts, completions, tool arguments, or image content.
56
+
57
+ ```bash
58
+ codex-copilot-dx usage
59
+ ```
50
60
 
51
61
  ### Image optimization
52
62
 
package/bin/cli.mjs CHANGED
@@ -6,9 +6,15 @@ import { ensureClaudeConfig } from "../src/claude-config.mjs";
6
6
  import { startAdapter } from "../src/adapter.mjs";
7
7
  import { refreshVSCodeVersion } from "../src/copilot.mjs";
8
8
  import { status } from "../src/status.mjs";
9
+ import { printUsageSummary } from "../src/usage.mjs";
9
10
 
10
11
  const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
11
12
 
13
+ if (process.argv[2] === "usage") {
14
+ await printUsageSummary();
15
+ process.exit(0);
16
+ }
17
+
12
18
  console.log(`
13
19
  codex-copilot-dx
14
20
  Use Codex Desktop with GitHub Copilot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Use Codex Desktop app with your GitHub Copilot subscription",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adapter.mjs CHANGED
@@ -4,6 +4,7 @@ import { chatCompletions, listModels, responses as copilotResponses, responsesCo
4
4
  import { webStreamLines } from "./stream.mjs";
5
5
  import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
6
6
  import { status } from "./status.mjs";
7
+ import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
7
8
 
8
9
  // Models that only support Responses API (not chat/completions)
9
10
  const RESPONSES_ONLY = new Set([
@@ -81,7 +82,16 @@ function storeCompletedResponseFromSse(reqContext, data) {
81
82
  try {
82
83
  const event = JSON.parse(data);
83
84
  const response = event.response;
84
- if (response?.id && response.status === "completed") rememberResponseHistory(reqContext, response);
85
+ if (response?.id && response.status === "completed") {
86
+ rememberResponseHistory(reqContext, response);
87
+ recordResponsesUsage({
88
+ surface: reqContext.surface,
89
+ mode: "stream",
90
+ model: reqContext.body?.model,
91
+ response,
92
+ event,
93
+ });
94
+ }
85
95
  } catch {
86
96
  // Ignore non-JSON stream fragments.
87
97
  }
@@ -131,7 +141,17 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
131
141
  res.writeHead(resp.status, { "Content-Type": "application/json" });
132
142
  res.end(data);
133
143
  if (resp.ok) {
134
- try { rememberResponseHistory(reqContext, JSON.parse(data)); } catch {}
144
+ try {
145
+ const response = JSON.parse(data);
146
+ rememberResponseHistory(reqContext, response);
147
+ recordResponsesUsage({
148
+ surface: reqContext.surface,
149
+ mode: "json",
150
+ model: reqContext.body?.model,
151
+ response,
152
+ event: response,
153
+ });
154
+ } catch {}
135
155
  }
136
156
  }
137
157
  }
@@ -264,6 +284,7 @@ export function startAdapter(port = 2026) {
264
284
  try {
265
285
  const parsed = JSON.parse(body);
266
286
  const prepared = prepareResponsesRequest(parsed);
287
+ prepared.surface = "responses";
267
288
  const model = parsed.model || "unknown";
268
289
  console.log(status("info", `responses model=${model} stream=${!!parsed.stream}`));
269
290
  if (RESPONSES_ONLY.has(model)) {
@@ -274,7 +295,10 @@ export function startAdapter(port = 2026) {
274
295
  forwardToChat(chatReq, (event, data) => {
275
296
  if (!res.headersSent) res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
276
297
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
277
- if (event === "response.completed") rememberResponseHistory(prepared, data.response);
298
+ if (event === "response.completed") {
299
+ rememberResponseHistory(prepared, data.response);
300
+ recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
301
+ }
278
302
  }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); });
279
303
  } else {
280
304
  chatReq.stream = false;
@@ -284,6 +308,7 @@ export function startAdapter(port = 2026) {
284
308
  const data = await upstream.text();
285
309
  const resp = chatToResponses(JSON.parse(data), model);
286
310
  rememberResponseHistory(prepared, resp);
311
+ recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response: resp, event: resp });
287
312
  res.writeHead(200, { "Content-Type": "application/json" });
288
313
  res.end(JSON.stringify(resp));
289
314
  } catch (e) {
@@ -308,6 +333,7 @@ export function startAdapter(port = 2026) {
308
333
  try {
309
334
  const parsed = JSON.parse(body);
310
335
  const prepared = prepareResponsesRequest(parsed);
336
+ prepared.surface = "responses_compact";
311
337
  const model = parsed.model || "unknown";
312
338
  console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
313
339
  await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact);
@@ -360,16 +386,24 @@ export function startAdapter(port = 2026) {
360
386
  return;
361
387
  }
362
388
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
389
+ let messageId;
390
+ let usage;
363
391
  await streamAnthropicFromLines(
364
392
  webStreamLines(upstream),
365
- (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
393
+ (event, data) => {
394
+ if (event === "message_start") messageId = data.message?.id;
395
+ if (event === "message_delta") usage = data.usage;
396
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
397
+ },
366
398
  model,
367
399
  );
400
+ recordAnthropicUsage({ surface: "messages", mode: "stream", model, responseId: messageId, usage });
368
401
  if (!res.writableEnded) res.end();
369
402
  } else {
370
403
  const upstream = await chatCompletions({ ...chatReq, stream: false });
371
404
  const data = await upstream.text();
372
405
  const anthropicMsg = chatToAnthropic(JSON.parse(data), model);
406
+ recordAnthropicUsage({ surface: "messages", mode: "json", model: anthropicMsg.model, responseId: anthropicMsg.id, usage: anthropicMsg.usage });
373
407
  res.writeHead(200, { "Content-Type": "application/json" });
374
408
  res.end(JSON.stringify(anthropicMsg));
375
409
  }
package/src/usage.mjs ADDED
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
6
+
7
+ let writeQueue = Promise.resolve();
8
+
9
+ export function usageLogPath() {
10
+ return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
11
+ }
12
+
13
+ function numberOrUndefined(value) {
14
+ return Number.isFinite(value) ? value : undefined;
15
+ }
16
+
17
+ function positiveNumber(value) {
18
+ const n = numberOrUndefined(value);
19
+ return n && n > 0 ? n : undefined;
20
+ }
21
+
22
+ function compactObject(obj) {
23
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null));
24
+ }
25
+
26
+ function hasPositiveTokenValue(usage) {
27
+ return Object.entries(usage || {}).some(([k, v]) => k.endsWith("_tokens") && Number.isFinite(v) && v > 0);
28
+ }
29
+
30
+ function normalizeResponsesUsage(usage) {
31
+ if (!usage || typeof usage !== "object") return undefined;
32
+ const out = compactObject({
33
+ input_tokens: positiveNumber(usage.input_tokens),
34
+ cached_input_tokens: positiveNumber(usage.cached_input_tokens ?? usage.input_tokens_details?.cached_tokens),
35
+ output_tokens: positiveNumber(usage.output_tokens),
36
+ reasoning_output_tokens: positiveNumber(usage.reasoning_output_tokens ?? usage.output_tokens_details?.reasoning_tokens),
37
+ total_tokens: positiveNumber(usage.total_tokens),
38
+ });
39
+ if (out.total_tokens === undefined && (out.input_tokens !== undefined || out.output_tokens !== undefined)) {
40
+ out.total_tokens = (out.input_tokens || 0) + (out.output_tokens || 0);
41
+ }
42
+ return hasPositiveTokenValue(out) ? out : undefined;
43
+ }
44
+
45
+ function normalizeAnthropicUsage(usage) {
46
+ if (!usage || typeof usage !== "object") return undefined;
47
+ const out = compactObject({
48
+ input_tokens: positiveNumber(usage.input_tokens),
49
+ cache_read_input_tokens: positiveNumber(usage.cache_read_input_tokens),
50
+ cache_creation_input_tokens: positiveNumber(usage.cache_creation_input_tokens),
51
+ output_tokens: positiveNumber(usage.output_tokens),
52
+ });
53
+ if (out.input_tokens !== undefined || out.cache_read_input_tokens !== undefined || out.cache_creation_input_tokens !== undefined || out.output_tokens !== undefined) {
54
+ out.total_tokens = (out.input_tokens || 0) + (out.cache_read_input_tokens || 0) + (out.cache_creation_input_tokens || 0) + (out.output_tokens || 0);
55
+ }
56
+ return hasPositiveTokenValue(out) ? out : undefined;
57
+ }
58
+
59
+ function normalizeCopilotUsage(copilotUsage) {
60
+ if (!copilotUsage || typeof copilotUsage !== "object") return undefined;
61
+ const out = {};
62
+ for (const detail of copilotUsage.token_details || []) {
63
+ const tokens = positiveNumber(detail?.token_count);
64
+ if (!tokens) continue;
65
+ switch (detail.token_type) {
66
+ case "input":
67
+ out.input_tokens = (out.input_tokens || 0) + tokens;
68
+ break;
69
+ case "cache_read":
70
+ out.cache_read_tokens = (out.cache_read_tokens || 0) + tokens;
71
+ break;
72
+ case "output":
73
+ out.output_tokens = (out.output_tokens || 0) + tokens;
74
+ break;
75
+ default:
76
+ out[`${detail.token_type || "unknown"}_tokens`] = (out[`${detail.token_type || "unknown"}_tokens`] || 0) + tokens;
77
+ break;
78
+ }
79
+ }
80
+ out.total_tokens = (out.input_tokens || 0) + (out.cache_read_tokens || 0) + (out.output_tokens || 0);
81
+ if (Number.isFinite(copilotUsage.total_nano_aiu)) out.total_nano_aiu = copilotUsage.total_nano_aiu;
82
+ return hasPositiveTokenValue(out) ? out : undefined;
83
+ }
84
+
85
+ export function buildResponsesUsageRecord({ surface = "responses", mode, model, response, event } = {}) {
86
+ const responseObj = response || event?.response;
87
+ const usage = normalizeResponsesUsage(responseObj?.usage);
88
+ const copilotUsage = normalizeCopilotUsage(event?.copilot_usage || responseObj?.copilot_usage);
89
+ if (!usage && !copilotUsage) return null;
90
+ return compactObject({
91
+ ts: new Date().toISOString(),
92
+ surface,
93
+ mode,
94
+ model: responseObj?.model || model,
95
+ response_id: responseObj?.id,
96
+ usage,
97
+ copilot_usage: copilotUsage,
98
+ });
99
+ }
100
+
101
+ export function buildAnthropicUsageRecord({ surface = "messages", mode, model, responseId, usage } = {}) {
102
+ const normalized = normalizeAnthropicUsage(usage);
103
+ if (!normalized) return null;
104
+ return compactObject({
105
+ ts: new Date().toISOString(),
106
+ surface,
107
+ mode,
108
+ model,
109
+ response_id: responseId,
110
+ usage: normalized,
111
+ });
112
+ }
113
+
114
+ export function recordUsage(record) {
115
+ if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
116
+ const filePath = usageLogPath();
117
+ const line = `${JSON.stringify(record)}\n`;
118
+ writeQueue = writeQueue
119
+ .catch(() => {})
120
+ .then(async () => {
121
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
122
+ await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
123
+ })
124
+ .catch((e) => console.error(`codex-copilot-dx usage log write failed: ${e.message}`));
125
+ return writeQueue;
126
+ }
127
+
128
+ export function recordResponsesUsage(args) {
129
+ return recordUsage(buildResponsesUsageRecord(args));
130
+ }
131
+
132
+ export function recordAnthropicUsage(args) {
133
+ return recordUsage(buildAnthropicUsageRecord(args));
134
+ }
135
+
136
+ export async function flushUsageWritesForTests() {
137
+ await writeQueue;
138
+ }
139
+
140
+ export async function readUsageRecords(filePath = usageLogPath()) {
141
+ try {
142
+ const text = await fs.promises.readFile(filePath, "utf8");
143
+ return text.split(/\n/).filter(Boolean).map((line) => JSON.parse(line));
144
+ } catch (e) {
145
+ if (e.code === "ENOENT") return [];
146
+ throw e;
147
+ }
148
+ }
149
+
150
+ function addUsageTotals(target, usage = {}) {
151
+ for (const [key, value] of Object.entries(usage)) {
152
+ if (Number.isFinite(value)) target[key] = (target[key] || 0) + value;
153
+ }
154
+ }
155
+
156
+ export function summarizeUsage(records) {
157
+ const totals = {};
158
+ const byModel = new Map();
159
+ for (const record of records) {
160
+ addUsageTotals(totals, record.usage);
161
+ if (record.copilot_usage) {
162
+ totals.copilot_total_tokens = (totals.copilot_total_tokens || 0) + (record.copilot_usage.total_tokens || 0);
163
+ totals.total_nano_aiu = (totals.total_nano_aiu || 0) + (record.copilot_usage.total_nano_aiu || 0);
164
+ }
165
+ const model = record.model || "unknown";
166
+ if (!byModel.has(model)) byModel.set(model, { requests: 0 });
167
+ const modelTotals = byModel.get(model);
168
+ modelTotals.requests += 1;
169
+ addUsageTotals(modelTotals, record.usage);
170
+ }
171
+ return { requests: records.length, totals, byModel: Object.fromEntries(byModel) };
172
+ }
173
+
174
+ function fmt(n) {
175
+ return Number.isFinite(n) ? n.toLocaleString("en-US") : "0";
176
+ }
177
+
178
+ export async function printUsageSummary() {
179
+ const records = await readUsageRecords();
180
+ const summary = summarizeUsage(records);
181
+ console.log(`Usage log: ${usageLogPath()}`);
182
+ if (summary.requests === 0) {
183
+ console.log("No usage records yet.");
184
+ return;
185
+ }
186
+ console.log(`Requests: ${fmt(summary.requests)}`);
187
+ console.log(`Tokens: input=${fmt(summary.totals.input_tokens)} cache_read=${fmt(summary.totals.cache_read_input_tokens || summary.totals.cached_input_tokens)} output=${fmt(summary.totals.output_tokens)} total=${fmt(summary.totals.total_tokens)}`);
188
+ console.log("\nBy model:");
189
+ for (const [model, row] of Object.entries(summary.byModel)) {
190
+ console.log(` ${model}: requests=${fmt(row.requests)} input=${fmt(row.input_tokens)} cache_read=${fmt(row.cache_read_input_tokens || row.cached_input_tokens)} output=${fmt(row.output_tokens)} total=${fmt(row.total_tokens)}`);
191
+ }
192
+ }