letmecode 0.1.28 → 0.1.30

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.
@@ -6,68 +6,25 @@ import readline from "node:readline";
6
6
  import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
7
7
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
8
8
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
9
- import { resolveUsageRate } from "./pricing.js";
10
- // One credit equals $0.01 (see CODEX_CREDIT_COST_USD in index.tsx), so credits
11
- // equal USD * 100. Rate cards are expressed in the model's actual API price in
12
- // USD per 1M tokens and scaled to credits in creditsFor, matching the Claude
13
- // provider. These are the real gpt-5.* API prices, not the (4x cheaper) Codex
14
- // subscription credit prices.
15
- const USD_TO_CREDITS = 100;
16
- const GPT_5_6_SOL_RATE = {
17
- input: 5,
18
- cacheRead: 0.5,
19
- cacheWrite: 6.25,
20
- cacheWrite5m: 6.25,
21
- cacheWrite1h: 6.25,
22
- output: 30,
23
- longContext: {
24
- thresholdTokens: 272000,
25
- rate: { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 12.5, output: 45 }
26
- }
27
- };
28
- const RATE_CARD = {
29
- "gpt-5.6-sol": GPT_5_6_SOL_RATE,
30
- "gpt-5.6-terra": {
31
- input: 2.5,
32
- cacheRead: 0.25,
33
- cacheWrite: 3.125,
34
- cacheWrite5m: 3.125,
35
- cacheWrite1h: 3.125,
36
- output: 15,
37
- longContext: {
38
- thresholdTokens: 272000,
39
- rate: { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 6.25, output: 22.5 }
40
- }
41
- },
42
- "gpt-5.6-luna": {
43
- input: 1,
44
- cacheRead: 0.1,
45
- cacheWrite: 1.25,
46
- cacheWrite5m: 1.25,
47
- cacheWrite1h: 1.25,
48
- output: 6,
49
- longContext: {
50
- thresholdTokens: 272000,
51
- rate: { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 2.5, output: 9 }
52
- }
53
- },
54
- "gpt-5.5": { input: 5, cacheRead: 0.5, cacheWrite: 5, cacheWrite5m: 5, cacheWrite1h: 5, output: 30 },
55
- "gpt-5.4": { input: 2.5, cacheRead: 0.25, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 2.5, output: 15 },
56
- "gpt-5.4-mini": { input: 0.75, cacheRead: 0.075, cacheWrite: 0.75, cacheWrite5m: 0.75, cacheWrite1h: 0.75, output: 4.5 }
57
- };
9
+ import { fetchModelPricing, modelCostCredits } from "./pricing.js";
58
10
  export class CodexUsageProvider extends UsageProviderBase {
59
11
  constructor(options = {}) {
60
12
  super("codex", "Codex");
61
13
  this.root = path.resolve(options.root ?? os.homedir());
62
14
  }
63
15
  async getStats(_options = {}) {
64
- const sessionsRoot = path.join(this.root, ".codex", "sessions");
16
+ const codexRoot = path.join(this.root, ".codex");
17
+ const sessionRoots = [
18
+ path.join(codexRoot, "sessions"),
19
+ path.join(codexRoot, "archived_sessions")
20
+ ];
65
21
  const knownModels = await readCodexModelMetadata(this.root);
66
22
  const userIdHash = await readCodexUserIdHash(this.root, this.label);
67
23
  const byModel = new Map();
68
24
  const byDay = createDailyUsageAggregates();
69
25
  const windows = createLimitWindowAggregates();
70
26
  const planTypes = new Set();
27
+ const events = [];
71
28
  const warnings = [];
72
29
  const parseTotals = {
73
30
  filesScanned: 0,
@@ -75,12 +32,45 @@ export class CodexUsageProvider extends UsageProviderBase {
75
32
  tokenEvents: 0,
76
33
  malformedLines: 0
77
34
  };
78
- for await (const file of walkSessionFiles(sessionsRoot)) {
79
- parseTotals.filesScanned += 1;
80
- const fileStats = await parseSessionFile(file, byModel, byDay, windows, planTypes, knownModels);
81
- parseTotals.linesRead += fileStats.linesRead;
82
- parseTotals.tokenEvents += fileStats.tokenEvents;
83
- parseTotals.malformedLines += fileStats.malformedLines;
35
+ const seenSessionFiles = new Set();
36
+ for (const sessionRoot of sessionRoots) {
37
+ for await (const file of walkSessionFiles(sessionRoot)) {
38
+ // Archiving normally moves a rollout, but guard against a transient copy
39
+ // existing in both locations while Codex updates its session index.
40
+ const sessionFileId = path.basename(file);
41
+ if (seenSessionFiles.has(sessionFileId)) {
42
+ continue;
43
+ }
44
+ seenSessionFiles.add(sessionFileId);
45
+ parseTotals.filesScanned += 1;
46
+ const fileStats = await parseSessionFile(file, events);
47
+ parseTotals.linesRead += fileStats.linesRead;
48
+ parseTotals.tokenEvents += fileStats.tokenEvents;
49
+ parseTotals.malformedLines += fileStats.malformedLines;
50
+ }
51
+ }
52
+ let pricing = new Map();
53
+ try {
54
+ pricing = await fetchModelPricing(events
55
+ .map((event) => pricingModelId(event.modelId))
56
+ .filter((modelId) => !isAssumedZeroRatedCodexModel(modelId, knownModels)), "codex");
57
+ }
58
+ catch {
59
+ warnings.push("Model pricing API is unavailable.");
60
+ }
61
+ for (const event of events) {
62
+ const deltaTotals = createUsageTotalsForModel(event.modelId, event.usage, knownModels, pricing, event.serviceTier);
63
+ deltaTotals.estimatedCredits += event.webSearchCalls;
64
+ if (!hasCountedRawUsage(event.usage) && event.webSearchCalls > 0) {
65
+ deltaTotals.estimatedCreditsStatus = "known";
66
+ }
67
+ const planType = typeof event.rateLimits?.plan_type === "string"
68
+ ? event.rateLimits.plan_type
69
+ : undefined;
70
+ const safeEventTimeMs = Number.isFinite(event.eventTimeMs) ? event.eventTimeMs : 0;
71
+ addModelUsage(byModel, event.modelId, deltaTotals);
72
+ addDailyUsage(byDay, event.eventTimeMs, event.modelId, planType, deltaTotals);
73
+ applyRateLimits(windows, event.rateLimits, safeEventTimeMs, event.modelId, deltaTotals, planTypes);
84
74
  }
85
75
  if (parseTotals.malformedLines > 0) {
86
76
  warnings.push(`Skipped ${parseTotals.malformedLines} malformed JSONL line(s).`);
@@ -89,13 +79,14 @@ export class CodexUsageProvider extends UsageProviderBase {
89
79
  .map(([modelId, totals]) => ({ modelId, totals }))
90
80
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
91
81
  const unknownPricedModels = modelUsage
92
- .map((row) => row.modelId)
93
- .filter((modelId) => !rateForCodexModel(modelId) && !isAssumedZeroRatedCodexModel(modelId, knownModels));
82
+ .filter((row) => row.totals.totalTokens > 0)
83
+ .filter((row) => row.totals.estimatedCreditsStatus === "unavailable")
84
+ .map((row) => row.modelId);
94
85
  if (unknownPricedModels.length > 0) {
95
- warnings.push(`No credit rate configured for: ${unknownPricedModels.join(", ")}.`);
86
+ warnings.push(`No complete API-equivalent pricing returned for: ${unknownPricedModels.join(", ")}.`);
96
87
  }
97
88
  if (parseTotals.filesScanned === 0) {
98
- warnings.push(`No Codex session files found under ${sessionsRoot}.`);
89
+ warnings.push(`No Codex session files found under ${codexRoot}.`);
99
90
  }
100
91
  const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
101
92
  const dayUsage = buildDailyUsageRows(byDay);
@@ -110,8 +101,8 @@ export class CodexUsageProvider extends UsageProviderBase {
110
101
  totals: summaryTotals,
111
102
  distinctModels: modelUsage.map((row) => row.modelId),
112
103
  distinctPlanTypes: [...planTypes].sort(),
113
- rootLabel: "~/.codex/sessions",
114
- rootPath: sessionsRoot
104
+ rootLabel: "~/.codex",
105
+ rootPath: codexRoot
115
106
  },
116
107
  modelUsage,
117
108
  dayUsage,
@@ -247,6 +238,7 @@ function createEmptyRawUsage() {
247
238
  return {
248
239
  inputTokens: 0,
249
240
  cachedInputTokens: 0,
241
+ cacheWriteInputTokens: 0,
250
242
  outputTokens: 0,
251
243
  reasoningOutputTokens: 0,
252
244
  totalTokens: 0
@@ -257,60 +249,78 @@ function normalizeRawUsage(value) {
257
249
  return {
258
250
  inputTokens: numberOrZero(usage.input_tokens),
259
251
  cachedInputTokens: numberOrZero(usage.cached_input_tokens),
252
+ cacheWriteInputTokens: numberOrZero(usage.cache_write_input_tokens),
260
253
  outputTokens: numberOrZero(usage.output_tokens),
261
254
  reasoningOutputTokens: numberOrZero(usage.reasoning_output_tokens),
262
255
  totalTokens: numberOrZero(usage.total_tokens)
263
256
  };
264
257
  }
265
- function subtractRawUsage(current, previous) {
266
- return {
267
- inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
268
- cachedInputTokens: Math.max(0, current.cachedInputTokens - previous.cachedInputTokens),
269
- outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
270
- reasoningOutputTokens: Math.max(0, current.reasoningOutputTokens - previous.reasoningOutputTokens),
271
- totalTokens: Math.max(0, current.totalTokens - previous.totalTokens)
272
- };
258
+ function creditsFor(modelId, usage, pricing, serviceTier) {
259
+ const { inputTokens, cacheReadInputTokens, cacheWriteInputTokens } = resolveCodexInputBreakdown(usage);
260
+ const credits = modelCostCredits(pricing.get(pricingModelId(modelId)), {
261
+ inputTokens,
262
+ outputTokens: usage.outputTokens,
263
+ cacheReadInputTokens,
264
+ cacheWrite5mInputTokens: cacheWriteInputTokens,
265
+ cacheWrite1hInputTokens: 0
266
+ });
267
+ return credits === undefined
268
+ ? undefined
269
+ : credits * serviceTierPriceMultiplier(serviceTier);
273
270
  }
274
- function creditsFor(modelId, usage) {
275
- const rate = rateForCodexModel(modelId, usage.inputTokens);
276
- if (!rate) {
277
- return 0;
271
+ function serviceTierPriceMultiplier(serviceTier) {
272
+ switch (serviceTier?.trim().toLowerCase()) {
273
+ case "fast":
274
+ case "priority":
275
+ return 2;
276
+ case "flex":
277
+ return 0.5;
278
+ default:
279
+ return 1;
278
280
  }
279
- const cachedInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
280
- const nonCachedInputTokens = Math.max(0, usage.inputTokens - cachedInputTokens);
281
- return (((nonCachedInputTokens / 1000000) * rate.input +
282
- (cachedInputTokens / 1000000) * rate.cacheRead +
283
- (usage.outputTokens / 1000000) * rate.output) *
284
- USD_TO_CREDITS);
285
281
  }
286
- function rateForCodexModel(modelId, inputTokens = 0) {
287
- // The unsuffixed API alias routes to Sol. Normalize only the exact alias so
288
- // an unknown future gpt-5.6-* tier is not accidentally charged at Sol rates.
289
- const pricedModelId = modelId === "gpt-5.6" ? "gpt-5.6-sol" : modelId;
290
- return resolveUsageRate(RATE_CARD, pricedModelId, inputTokens, { prefixMatch: true });
282
+ function isSupportedServiceTier(serviceTier) {
283
+ return ["default", "priority", "fast", "flex"].includes(serviceTier?.trim().toLowerCase() ?? "");
284
+ }
285
+ function pricingModelId(modelId) {
286
+ return (modelId === "gpt-5.6"
287
+ ? "gpt-5.6-sol"
288
+ : modelId === "codex-auto-review"
289
+ ? "gpt-5.4"
290
+ : modelId);
291
291
  }
292
292
  function rawUsageToTotals(usage) {
293
- const cacheReadInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
294
- const inputTokens = Math.max(0, usage.inputTokens - cacheReadInputTokens);
293
+ const { inputTokens, cacheReadInputTokens, cacheWriteInputTokens } = resolveCodexInputBreakdown(usage);
295
294
  return {
296
295
  inputTokens,
297
296
  outputTokens: usage.outputTokens,
298
297
  cacheReadInputTokens,
299
- cacheWriteInputTokens: 0,
300
- cacheWrite5mInputTokens: 0,
298
+ cacheWriteInputTokens,
299
+ cacheWrite5mInputTokens: cacheWriteInputTokens,
301
300
  cacheWrite1hInputTokens: 0,
302
301
  reasoningOutputTokens: usage.reasoningOutputTokens,
303
- totalTokens: inputTokens + cacheReadInputTokens + usage.outputTokens,
302
+ totalTokens: inputTokens + cacheReadInputTokens + cacheWriteInputTokens + usage.outputTokens,
304
303
  estimatedCredits: 0,
305
304
  eventCount: 0
306
305
  };
307
306
  }
308
- function createUsageTotalsForModel(modelId, usage, knownModels) {
307
+ function resolveCodexInputBreakdown(usage) {
308
+ const cacheReadInputTokens = Math.min(Math.max(0, usage.cachedInputTokens), usage.inputTokens);
309
+ const inputAfterCacheRead = Math.max(0, usage.inputTokens - cacheReadInputTokens);
310
+ const cacheWriteInputTokens = Math.min(Math.max(0, usage.cacheWriteInputTokens), inputAfterCacheRead);
311
+ return {
312
+ inputTokens: Math.max(0, inputAfterCacheRead - cacheWriteInputTokens),
313
+ cacheReadInputTokens,
314
+ cacheWriteInputTokens
315
+ };
316
+ }
317
+ function createUsageTotalsForModel(modelId, usage, knownModels, pricing, serviceTier) {
309
318
  const resolvedModelId = modelId || "unknown";
310
319
  const deltaTotals = rawUsageToTotals(usage);
311
- deltaTotals.estimatedCredits = creditsFor(resolvedModelId, usage);
320
+ const estimatedCredits = creditsFor(resolvedModelId, usage, pricing, serviceTier);
321
+ deltaTotals.estimatedCredits = estimatedCredits ?? 0;
312
322
  deltaTotals.eventCount = 1;
313
- if (!rateForCodexModel(resolvedModelId, usage.inputTokens) && !isAssumedZeroRatedCodexModel(resolvedModelId, knownModels)) {
323
+ if (estimatedCredits === undefined && !isAssumedZeroRatedCodexModel(resolvedModelId, knownModels)) {
314
324
  deltaTotals.estimatedCreditsStatus = "unavailable";
315
325
  }
316
326
  return deltaTotals;
@@ -325,12 +335,13 @@ function isHiddenCodexModel(modelId, knownModels) {
325
335
  return knownModels.get(modelId)?.visibility === "hide";
326
336
  }
327
337
  function isAssumedZeroRatedCodexModel(modelId, knownModels) {
328
- // Hidden internal Codex models do not have a public rate card entry. For dashboard
329
- // rollups we treat them as zero-rated so they do not turn aggregate totals unknown.
330
- return isHiddenCodexModel(modelId, knownModels);
338
+ // Synthetic bookkeeping rows carry no billable model call. Other hidden
339
+ // models are not assumed free: Codex auto-review is mapped to GPT-5.4 above,
340
+ // and an unknown hidden model must make the estimate explicitly unavailable.
341
+ return modelId === "<synthetic>" && isHiddenCodexModel(modelId, knownModels);
331
342
  }
332
343
  function isSessionFile(filePath) {
333
- return filePath.endsWith(".jsonl") && filePath.includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
344
+ return filePath.endsWith(".jsonl");
334
345
  }
335
346
  async function* walkSessionFiles(directory) {
336
347
  let entries;
@@ -350,14 +361,50 @@ async function* walkSessionFiles(directory) {
350
361
  }
351
362
  }
352
363
  }
353
- async function parseSessionFile(filePath, byModel, byDay, windows, planTypes, knownModels) {
364
+ async function parseSessionFile(filePath, events) {
354
365
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
355
366
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
356
367
  let currentModel = "unknown";
357
- let previousTotal;
368
+ let currentServiceTier;
369
+ let pendingUsageRecord;
370
+ let firstSessionMetadataSeen = false;
371
+ let forkStartedAtMs = null;
372
+ let isOwnForkHistory = true;
358
373
  let linesRead = 0;
359
374
  let tokenEvents = 0;
360
375
  let malformedLines = 0;
376
+ let pendingWebSearchCalls = 0;
377
+ let lastSeenTimestampMs = 0;
378
+ const recordUsage = (modelId, usage, eventTimeMs, rateLimits = null, serviceTier = currentServiceTier) => {
379
+ const webSearchCalls = pendingWebSearchCalls;
380
+ pendingWebSearchCalls = 0;
381
+ // Current-format-only policy: without the applied tier the exact cost is
382
+ // unknowable. Silently ignore the event instead of guessing Standard or
383
+ // carrying compatibility warnings for legacy rollout formats.
384
+ if (!isSupportedServiceTier(serviceTier)) {
385
+ return;
386
+ }
387
+ if (!hasCountedRawUsage(usage) && webSearchCalls === 0) {
388
+ return;
389
+ }
390
+ const resolvedModelId = modelId || "unknown";
391
+ tokenEvents += 1;
392
+ events.push({
393
+ modelId: resolvedModelId,
394
+ usage,
395
+ eventTimeMs,
396
+ rateLimits,
397
+ serviceTier,
398
+ webSearchCalls
399
+ });
400
+ };
401
+ const flushPendingUsageRecord = () => {
402
+ if (!pendingUsageRecord) {
403
+ return;
404
+ }
405
+ recordUsage(pendingUsageRecord.modelId, pendingUsageRecord.usage, pendingUsageRecord.eventTimeMs, null, pendingUsageRecord.serviceTier);
406
+ pendingUsageRecord = undefined;
407
+ };
361
408
  for await (const line of lineReader) {
362
409
  linesRead += 1;
363
410
  if (!line.trim()) {
@@ -371,35 +418,117 @@ async function parseSessionFile(filePath, byModel, byDay, windows, planTypes, kn
371
418
  malformedLines += 1;
372
419
  continue;
373
420
  }
421
+ const parsedTimestampMs = Date.parse(String(payloadObject.timestamp ?? ""));
422
+ if (Number.isFinite(parsedTimestampMs)) {
423
+ lastSeenTimestampMs = parsedTimestampMs;
424
+ }
425
+ if (payloadObject.type === "session_meta" && !firstSessionMetadataSeen) {
426
+ firstSessionMetadataSeen = true;
427
+ const payload = asRecord(payloadObject.payload);
428
+ const sessionStartedAtMs = parseSortableCodexIdTimestamp(payload?.id);
429
+ if (typeof payload?.forked_from_id === "string" && sessionStartedAtMs !== null) {
430
+ forkStartedAtMs = sessionStartedAtMs;
431
+ isOwnForkHistory = false;
432
+ }
433
+ continue;
434
+ }
374
435
  if (payloadObject.type === "turn_context") {
375
- const payload = payloadObject.payload;
376
- const collaborationMode = payload?.collaboration_mode;
377
- const settings = collaborationMode?.settings;
436
+ const payload = asRecord(payloadObject.payload);
437
+ const turnStartedAtMs = parseSortableCodexIdTimestamp(payload?.turn_id);
438
+ if (forkStartedAtMs !== null && turnStartedAtMs !== null && turnStartedAtMs < forkStartedAtMs) {
439
+ continue;
440
+ }
441
+ if (!isOwnForkHistory) {
442
+ if (forkStartedAtMs !== null && turnStartedAtMs === null) {
443
+ continue;
444
+ }
445
+ isOwnForkHistory = true;
446
+ }
447
+ flushPendingUsageRecord();
448
+ const collaborationMode = asRecord(payload?.collaboration_mode);
449
+ const settings = asRecord(collaborationMode?.settings);
378
450
  currentModel = String(payload?.model ?? settings?.model ?? currentModel);
379
451
  continue;
380
452
  }
453
+ // A fork inherits the latest thread settings from its parent history. Keep
454
+ // tracking those settings while skipping the inherited usage itself, so a
455
+ // child request is priced with the tier that was actually in force.
456
+ if (payloadObject.type === "event_msg") {
457
+ const payload = asRecord(payloadObject.payload);
458
+ if (payload?.type === "thread_settings_applied") {
459
+ // Compacted/background sessions can begin with a cumulative usage
460
+ // snapshot before their first model and tier settings. Flush it while
461
+ // the captured settings are still incomplete so the current-contract
462
+ // guard below ignores it instead of attributing it to "unknown" with
463
+ // settings that arrived later.
464
+ flushPendingUsageRecord();
465
+ const threadSettings = asRecord(payload.thread_settings);
466
+ if (typeof threadSettings?.model === "string" && threadSettings.model.trim()) {
467
+ currentModel = threadSettings.model;
468
+ }
469
+ if (typeof threadSettings?.service_tier === "string" && threadSettings.service_tier.trim()) {
470
+ currentServiceTier = threadSettings.service_tier;
471
+ }
472
+ continue;
473
+ }
474
+ }
475
+ if (!isOwnForkHistory) {
476
+ continue;
477
+ }
478
+ if (payloadObject.type === "token_usage_record") {
479
+ flushPendingUsageRecord();
480
+ const payload = asRecord(payloadObject.payload);
481
+ const usage = normalizeRawUsage(payload?.usage);
482
+ if (hasCountedRawUsage(usage)) {
483
+ pendingUsageRecord = {
484
+ modelId: currentModel || "unknown",
485
+ usage,
486
+ eventTimeMs: Date.parse(String(payloadObject.timestamp ?? "")),
487
+ serviceTier: currentServiceTier
488
+ };
489
+ }
490
+ continue;
491
+ }
381
492
  if (payloadObject.type !== "event_msg") {
382
493
  continue;
383
494
  }
384
- const payload = payloadObject.payload;
495
+ const payload = asRecord(payloadObject.payload);
496
+ if (payload?.type === "web_search_end") {
497
+ pendingWebSearchCalls += 1;
498
+ continue;
499
+ }
385
500
  if (payload?.type !== "token_count") {
386
501
  continue;
387
502
  }
388
- const info = payload.info;
389
- const totalUsage = normalizeRawUsage(info?.total_token_usage);
390
- const lastUsage = info?.last_token_usage;
391
- const usage = lastUsage ? normalizeRawUsage(lastUsage) : previousTotal ? subtractRawUsage(totalUsage, previousTotal) : totalUsage;
392
- previousTotal = totalUsage;
393
- const resolvedModelId = currentModel || "unknown";
394
- const deltaTotals = createUsageTotalsForModel(resolvedModelId, usage, knownModels);
395
- tokenEvents += 1;
396
- addModelUsage(byModel, resolvedModelId, deltaTotals);
397
503
  const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
398
- const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
399
504
  const rateLimits = asRecord(payload.rate_limits);
400
- const planType = typeof rateLimits?.plan_type === "string" ? rateLimits.plan_type : undefined;
401
- addDailyUsage(byDay, eventTimeMs, resolvedModelId, planType, deltaTotals);
402
- applyRateLimits(windows, rateLimits, safeEventTimeMs, resolvedModelId, deltaTotals, planTypes);
505
+ if (pendingUsageRecord) {
506
+ const pending = pendingUsageRecord;
507
+ pendingUsageRecord = undefined;
508
+ recordUsage(pending.modelId, pending.usage, pending.eventTimeMs, rateLimits, pending.serviceTier);
509
+ }
510
+ }
511
+ flushPendingUsageRecord();
512
+ if (pendingWebSearchCalls > 0) {
513
+ recordUsage(currentModel, createEmptyRawUsage(), lastSeenTimestampMs);
403
514
  }
404
515
  return { linesRead, tokenEvents, malformedLines };
405
516
  }
517
+ function hasCountedRawUsage(usage) {
518
+ return (usage.inputTokens > 0 ||
519
+ usage.cachedInputTokens > 0 ||
520
+ usage.cacheWriteInputTokens > 0 ||
521
+ usage.outputTokens > 0 ||
522
+ usage.reasoningOutputTokens > 0);
523
+ }
524
+ function parseSortableCodexIdTimestamp(value) {
525
+ if (typeof value !== "string") {
526
+ return null;
527
+ }
528
+ const timestampHex = value.replace(/-/g, "").slice(0, 12);
529
+ if (!/^[0-9a-f]{12}$/i.test(timestampHex)) {
530
+ return null;
531
+ }
532
+ const timestamp = Number.parseInt(timestampHex, 16);
533
+ return Number.isFinite(timestamp) ? timestamp : null;
534
+ }
@@ -1,56 +1,35 @@
1
- import { resolveUsageRate } from "../pricing.js";
2
- /**
3
- * Copilot-specific estimated API-equivalent rate card (micro-credits per
4
- * million tokens). This is intentionally separate from the Codex and
5
- * Antigravity rate cards Copilot bills the same model families at different
6
- * effective rates, so there is no single shared source of truth to reuse.
7
- */
8
- export const RATE_CARD = {
9
- "gpt-5-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 },
10
- "gpt-5.3-codex": { input: 175, cacheRead: 17.5, cacheWrite: 175, cacheWrite5m: 175, cacheWrite1h: 175, output: 1400 },
11
- "gpt-5.4": { input: 250, cacheRead: 25, cacheWrite: 250, cacheWrite5m: 250, cacheWrite1h: 250, output: 1500, longContext: { thresholdTokens: 272000, rate: { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 2250 } } },
12
- "gpt-5.4-mini": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
13
- "gpt-5.4-nano": { input: 20, cacheRead: 2, cacheWrite: 20, cacheWrite5m: 20, cacheWrite1h: 20, output: 125 },
14
- "gpt-5.5": { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 3000, longContext: { thresholdTokens: 272000, rate: { input: 1000, cacheRead: 100, cacheWrite: 1000, cacheWrite5m: 1000, cacheWrite1h: 1000, output: 4500 } } },
15
- "claude-haiku-4-5": { input: 100, cacheRead: 10, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 200, output: 500 },
16
- "claude-sonnet-4-5": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
17
- "claude-sonnet-4-6": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
18
- "claude-opus-4-5": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
19
- "claude-opus-4-6": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
20
- "claude-opus-4-7": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
21
- "claude-opus-4-8": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
22
- "claude-opus-5": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
23
- "claude-fable-5": { input: 1000, cacheRead: 100, cacheWrite: 1250, cacheWrite5m: 1250, cacheWrite1h: 2000, output: 5000 },
24
- "gemini-2.5-pro": { input: 125, cacheRead: 12.5, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 125, output: 1000 },
25
- "gemini-3-flash": { input: 50, cacheRead: 5, cacheWrite: 50, cacheWrite5m: 50, cacheWrite1h: 50, output: 300 },
26
- "gemini-3.1-pro": { input: 200, cacheRead: 20, cacheWrite: 200, cacheWrite5m: 200, cacheWrite1h: 200, output: 1200, longContext: { thresholdTokens: 200000, rate: { input: 400, cacheRead: 40, cacheWrite: 400, cacheWrite5m: 400, cacheWrite1h: 400, output: 1800 } } },
27
- "gemini-3.5-flash": { input: 150, cacheRead: 15, cacheWrite: 150, cacheWrite5m: 150, cacheWrite1h: 150, output: 900 },
28
- "mai-code-1-flash": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
29
- "raptor-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 }
30
- };
31
- /**
32
- * Model id prefixes that Copilot does not bill (inline completions / next-edit
33
- * suggestions). These are zero-rated rather than "unknown" so they never turn
34
- * aggregate credit totals unknown.
35
- */
1
+ /*
2
+ Previous local prices in credits per 1M tokens, kept temporarily as requested:
3
+ gpt-5-mini 25 / 2.5 / 0 / 200; gpt-5.3-codex 175 / 17.5 / 0 / 1400
4
+ gpt-5.4 250 / 25 / 0 / 1500; gpt-5.4-mini 75 / 7.5 / 0 / 450
5
+ gpt-5.4-nano 20 / 2 / 0 / 125; gpt-5.5 500 / 50 / 0 / 3000
6
+ gpt-5.6-luna 20 / 2 / 25 / 120; gpt-5.6-sol 400 / 40 / 500 / 2000
7
+ gpt-5.6-terra 200 / 20 / 250 / 1200; gpt-6-astra 1000 / 100 / 1250 / 5000
8
+ claude-haiku-4-5 100 / 10 / 125 / 200 / 500
9
+ claude-sonnet-4-5 and 4-6 300 / 30 / 375 / 600 / 1500
10
+ claude-opus-4-5, 4-6, 4-7, 4-8, 5 500 / 50 / 625 / 1000 / 2500
11
+ claude-opus-4-8-fast 1000 / 100 / 1250 / 2000 / 5000
12
+ claude-fable-5 1000 / 100 / 1250 / 2000 / 5000
13
+ claude-fable-5-1 1000 / 25 / 1250 / 2000 / 5000
14
+ claude-sonnet-5 200 / 20 / 250 / 400 / 1000
15
+ gemini-2.5-pro 125 / 12.5 / 0 / 1000; gemini-3-flash 50 / 5 / 0 / 300
16
+ gemini-3.1-pro 200 / 20 / 0 / 1200; gemini-3.5-flash 150 / 15 / 0 / 900
17
+ gemini-3.6-flash, 3.7-flash, 3.8-flash 75 / 7.5 / 0 / 375
18
+ mai-code-1-flash 75 / 7.5 / 0 / 450; mai-code-1.1-flash 20 / 2 / 0 / 120
19
+ grok-4.5 and 4.6 200 / 50 / 0 / 600
20
+ kimi-k2.7-code 95 / 19 / 0 / 400; kimi-k3 300 / 30 / 0 / 1500
21
+ raptor-mini 25 / 2.5 / 0 / 200
22
+ Columns with four values: input / cache read / cache write / output.
23
+ Columns with five values: input / cache read / cache write 5m / cache write 1h / output.
24
+ */
36
25
  export const NON_BILLABLE_MODEL_PREFIXES = [
37
26
  "copilot-nes",
38
27
  "copilot-suggestion",
39
28
  "copilot-suggestions"
40
29
  ];
41
- /**
42
- * Canonicalize a Copilot model id. The exporter already emits stable,
43
- * human-readable ids (including dated suffixes like `gpt-5.4-2026-03-01`), and
44
- * the dashboard surfaces those verbatim, so this only guards the empty case.
45
- * Prefix-based rate resolution (see {@link rateForCopilotModel}) handles dated
46
- * suffixes without collapsing the displayed id.
47
- */
48
30
  export function normalizeCopilotModelId(modelId) {
49
31
  return modelId || "unknown";
50
32
  }
51
- export function rateForCopilotModel(modelId, inputTokens) {
52
- return resolveUsageRate(RATE_CARD, modelId, inputTokens, { prefixMatch: true });
53
- }
54
33
  export function isNonBillableCopilotModel(modelId) {
55
34
  return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
56
35
  }
@@ -6,6 +6,8 @@ import { discoverCopilotOtelFiles } from "./otel/discover.js";
6
6
  import { parseCopilotOtelFiles } from "./otel/parse.js";
7
7
  import { getCopilotUserInfo, subtractOneUtcCalendarMonth } from "./quota.js";
8
8
  import { aggregateCopilotUsage, filterCopilotUsageEvents } from "./usage/aggregate.js";
9
+ import { isNonBillableCopilotModel } from "./models.js";
10
+ import { fetchModelPricing } from "../pricing.js";
9
11
  // The token-metered bucket that maps to the "AI Credits" window.
10
12
  const AI_CREDITS_QUOTA_ID = "premium_interactions";
11
13
  export { configureCopilotVsCodeLogging, getCopilotCliOtelEnv };
@@ -48,11 +50,12 @@ export class CopilotUsageProvider extends UsageProviderBase {
48
50
  linesRead: 0,
49
51
  events: [],
50
52
  aggregated: aggregateCopilotUsage([]),
53
+ pricing: new Map(),
51
54
  warnings: ["Copilot OTEL usage is unavailable."]
52
55
  };
53
56
  warnings.push(...usage.warnings);
54
57
  const { windows, unknownLabels, windowWarnings } = quotaInfo
55
- ? buildLimitWindows(quotaInfo, usage.events)
58
+ ? buildLimitWindows(quotaInfo, usage.events, usage.pricing)
56
59
  : { windows: [], unknownLabels: [], windowWarnings: [] };
57
60
  if (unknownLabels.length > 0) {
58
61
  warnings.push(`Copilot quota usage is unknown for: ${unknownLabels.join(", ")}.`);
@@ -83,8 +86,25 @@ export class CopilotUsageProvider extends UsageProviderBase {
83
86
  async loadUsage() {
84
87
  const discovery = await discoverCopilotOtelFiles({ root: this.root, env: this.env });
85
88
  const parsed = await parseCopilotOtelFiles(discovery.files);
86
- const aggregated = aggregateCopilotUsage(parsed.events);
87
89
  const warnings = [...discovery.warnings, ...parsed.warnings];
90
+ let pricing = new Map();
91
+ try {
92
+ pricing = await fetchModelPricing(parsed.events
93
+ .map((event) => event.modelId)
94
+ .filter((modelId) => !isNonBillableCopilotModel(modelId)), "github_copilot");
95
+ }
96
+ catch {
97
+ warnings.push("Model pricing API is unavailable.");
98
+ }
99
+ const aggregated = aggregateCopilotUsage(parsed.events, pricing);
100
+ const unpricedModels = [
101
+ ...new Set(parsed.events
102
+ .map((event) => event.modelId)
103
+ .filter((modelId) => !isNonBillableCopilotModel(modelId) && !pricing.has(modelId)))
104
+ ];
105
+ if (unpricedModels.length > 0) {
106
+ warnings.push(`No Copilot API-equivalent pricing returned for: ${unpricedModels.join(", ")}.`);
107
+ }
88
108
  if (parsed.malformedLines > 0) {
89
109
  warnings.push(`Skipped ${parsed.malformedLines} malformed Copilot JSONL line(s).`);
90
110
  }
@@ -99,6 +119,7 @@ export class CopilotUsageProvider extends UsageProviderBase {
99
119
  linesRead: parsed.linesRead,
100
120
  events: parsed.events,
101
121
  aggregated,
122
+ pricing,
102
123
  warnings
103
124
  };
104
125
  }
@@ -120,7 +141,7 @@ async function describeMissingOtelFile(root) {
120
141
  ? `VS Code Copilot logging is enabled, but ${missing.path} has not been created yet. Reload VS Code and send a Copilot Chat request.`
121
142
  : undefined;
122
143
  }
123
- function buildLimitWindows(quotaInfo, events) {
144
+ function buildLimitWindows(quotaInfo, events, pricing) {
124
145
  const planType = quotaInfo.plan ?? "unknown";
125
146
  const billing = deriveBillingWindow(quotaInfo.resetAt);
126
147
  const windows = [];
@@ -152,7 +173,7 @@ function buildLimitWindows(quotaInfo, events) {
152
173
  // local OTEL token usage that falls inside this billing window.
153
174
  if (isAiCredits && billing) {
154
175
  const windowEvents = filterCopilotUsageEvents(events, billing.startMs, billing.endMs);
155
- const windowUsage = aggregateCopilotUsage(windowEvents);
176
+ const windowUsage = aggregateCopilotUsage(windowEvents, pricing);
156
177
  totals = windowUsage.summaryTotals;
157
178
  modelUsage = windowUsage.modelUsage;
158
179
  eventCount = windowUsage.tokenEvents;