ai-zero-token 2.0.5 → 2.0.6

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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +15 -12
  3. package/README.zh-CN.md +15 -12
  4. package/admin-ui/dist/assets/StatCard-7TEzqn2i.js +1 -0
  5. package/admin-ui/dist/assets/accounts-bCDKXGg9.js +4 -0
  6. package/admin-ui/dist/assets/{docs-Dh0aFha_.js → docs--eK_2fzC.js} +1 -1
  7. package/admin-ui/dist/assets/{image-bed-C1M7-0q1.js → image-bed-7wBZ1GhS.js} +1 -1
  8. package/admin-ui/dist/assets/index-C22_3Mxq.css +1 -0
  9. package/admin-ui/dist/assets/index-CdFYy5j6.js +10 -0
  10. package/admin-ui/dist/assets/{launch-pB7YlWFI.js → launch-BiD1Khtg.js} +1 -1
  11. package/admin-ui/dist/assets/{logs-B7McijSi.js → logs-BdoKDqh2.js} +1 -1
  12. package/admin-ui/dist/assets/{network-detect-Bx3XmXPk.js → network-detect-BvKns5nQ.js} +1 -1
  13. package/admin-ui/dist/assets/overview-wm6M45fu.js +1 -0
  14. package/admin-ui/dist/assets/settings-DOOu7Kd8.js +5 -0
  15. package/admin-ui/dist/assets/{tester-BG-up8qP.js → tester-NrARmlis.js} +1 -1
  16. package/admin-ui/dist/assets/usage-CdWRVMDV.js +1 -0
  17. package/admin-ui/dist/index.html +2 -2
  18. package/dist/core/context.js +3 -0
  19. package/dist/core/providers/http-client.js +21 -2
  20. package/dist/core/providers/openai-codex/chat.js +2 -1
  21. package/dist/core/providers/openai-codex/chatgpt-web-image.js +1404 -0
  22. package/dist/core/services/auth-service.js +51 -4
  23. package/dist/core/services/config-service.js +9 -0
  24. package/dist/core/services/image-service.js +31 -1
  25. package/dist/core/services/usage-service.js +349 -0
  26. package/dist/core/store/codex-auth-store.js +149 -15
  27. package/dist/core/store/settings-store.js +8 -2
  28. package/dist/core/store/state-paths.js +17 -1
  29. package/dist/server/app.js +848 -50
  30. package/docs/API_USAGE.md +33 -3
  31. package/package.json +1 -1
  32. package/admin-ui/dist/assets/accounts-ABMyXo4H.js +0 -4
  33. package/admin-ui/dist/assets/index--rNjdmzf.js +0 -10
  34. package/admin-ui/dist/assets/index-DjtN30PC.css +0 -1
  35. package/admin-ui/dist/assets/overview-CV0H2Nsq.js +0 -1
  36. package/admin-ui/dist/assets/settings-ynCIdUvZ.js +0 -7
@@ -27,7 +27,7 @@ import {
27
27
  importProfileFromJson,
28
28
  importProfilesFromJson
29
29
  } from "../store/profile-transfer.js";
30
- const DEFAULT_QUOTA_SYNC_CONCURRENCY = 16;
30
+ const DEFAULT_QUOTA_SYNC_CONCURRENCY = 3;
31
31
  function getQuotaSyncConcurrency(configured) {
32
32
  const raw = process.env.AZT_QUOTA_SYNC_CONCURRENCY;
33
33
  const parsed = raw ? Number.parseInt(raw, 10) : configured ?? DEFAULT_QUOTA_SYNC_CONCURRENCY;
@@ -151,12 +151,59 @@ class AuthService {
151
151
  const percents = this.getQuotaPercents(profile);
152
152
  return percents.length > 0 && percents.some((value) => value >= 100);
153
153
  }
154
- hasKnownAvailableQuota(profile) {
154
+ timestampToMillis(value) {
155
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
156
+ return void 0;
157
+ }
158
+ return value < 1e12 ? value * 1e3 : value;
159
+ }
160
+ getQuotaResetAt(profile, slot) {
161
+ const quota = profile.quota;
162
+ if (!quota) {
163
+ return void 0;
164
+ }
165
+ const direct = slot === "primary" ? quota.primaryResetAt : quota.secondaryResetAt;
166
+ const directMillis = this.timestampToMillis(direct);
167
+ if (directMillis) {
168
+ return directMillis;
169
+ }
170
+ const after = slot === "primary" ? quota.primaryResetAfterSeconds : quota.secondaryResetAfterSeconds;
171
+ if (typeof after === "number" && Number.isFinite(after) && after > 0) {
172
+ const capturedAt = this.timestampToMillis(quota.capturedAt) ?? Date.now();
173
+ return capturedAt + after * 1e3;
174
+ }
175
+ return void 0;
176
+ }
177
+ hasResetElapsedForExhaustedQuota(profile) {
178
+ const quota = profile.quota;
179
+ if (!quota) {
180
+ return false;
181
+ }
182
+ const exhaustedSlots = [];
183
+ if (typeof quota.primaryUsedPercent === "number" && quota.primaryUsedPercent >= 100) {
184
+ exhaustedSlots.push("primary");
185
+ }
186
+ if (typeof quota.secondaryUsedPercent === "number" && quota.secondaryUsedPercent >= 100) {
187
+ exhaustedSlots.push("secondary");
188
+ }
189
+ if (exhaustedSlots.length === 0) {
190
+ return false;
191
+ }
192
+ const now = Date.now();
193
+ return exhaustedSlots.every((slot) => {
194
+ const resetAt = this.getQuotaResetAt(profile, slot);
195
+ return typeof resetAt === "number" && resetAt <= now;
196
+ });
197
+ }
198
+ canEnterAutoSwitchPool(profile) {
155
199
  if (profile.authStatus?.state === "token_invalidated" || profile.authStatus?.state === "auth_error") {
156
200
  return false;
157
201
  }
158
202
  const percents = this.getQuotaPercents(profile);
159
- return percents.length > 0 && percents.every((value) => value < 100);
203
+ if (percents.length === 0) {
204
+ return false;
205
+ }
206
+ return percents.every((value) => value < 100) || this.hasResetElapsedForExhaustedQuota(profile);
160
207
  }
161
208
  hasInvalidAuthStatus(profile) {
162
209
  return profile.authStatus?.state === "token_invalidated" || profile.authStatus?.state === "auth_error";
@@ -225,7 +272,7 @@ class AuthService {
225
272
  profile: item,
226
273
  index,
227
274
  distance: currentIndex >= 0 ? (index - currentIndex + profiles.length) % profiles.length : index + 1
228
- })).filter((item) => item.profile.provider === provider && item.profile.profileId !== profile.profileId).filter((item) => !excludedProfileIds.has(item.profile.profileId)).filter((item) => this.hasKnownAvailableQuota(item.profile)).sort((left, right) => {
275
+ })).filter((item) => item.profile.provider === provider && item.profile.profileId !== profile.profileId).filter((item) => !excludedProfileIds.has(item.profile.profileId)).filter((item) => this.canEnterAutoSwitchPool(item.profile)).sort((left, right) => {
229
276
  const leftCodexConflict = codexAccountId && left.profile.accountId === codexAccountId ? 1 : 0;
230
277
  const rightCodexConflict = codexAccountId && right.profile.accountId === codexAccountId ? 1 : 0;
231
278
  const codexDiff = leftCodexConflict - rightCodexConflict;
@@ -161,6 +161,15 @@ class ConfigService {
161
161
  }
162
162
  };
163
163
  }
164
+ if (params.image) {
165
+ next = {
166
+ ...next,
167
+ image: {
168
+ ...next.image,
169
+ freeAccountWebGenerationEnabled: params.image.freeAccountWebGenerationEnabled ?? next.image.freeAccountWebGenerationEnabled
170
+ }
171
+ };
172
+ }
164
173
  if (params.server) {
165
174
  next = {
166
175
  ...next,
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { askOpenAICodex } from "../providers/openai-codex/chat.js";
3
+ import { generateChatGPTWebImage } from "../providers/openai-codex/chatgpt-web-image.js";
3
4
  const SUPPORTED_IMAGE_MODELS = /* @__PURE__ */ new Set([
4
5
  "gpt-image-1",
5
6
  "gpt-image-1-mini",
@@ -248,6 +249,9 @@ function extractImageUsage(raw) {
248
249
  total_tokens: imageGen.total_tokens
249
250
  };
250
251
  }
252
+ function isFreePlan(profile) {
253
+ return profile.quota?.planType?.toLowerCase() === "free";
254
+ }
251
255
  class ImageService {
252
256
  constructor(deps) {
253
257
  this.deps = deps;
@@ -262,9 +266,12 @@ class ImageService {
262
266
  return model;
263
267
  }
264
268
  async generate(request) {
265
- const profile = await this.deps.authService.requireUsableProfile("openai-codex");
269
+ const profile = await this.deps.authService.requireUsableProfile("openai-codex", {
270
+ skipAutoSwitch: true
271
+ });
266
272
  const orchestratorModel = IMAGE_ORCHESTRATOR_MODEL;
267
273
  const requestedImageModel = this.resolveRequestedImageModel(request.model);
274
+ const settings = await this.deps.configService.getSettings();
268
275
  const requestSummary = {
269
276
  requestedImageModel,
270
277
  orchestratorModel,
@@ -279,6 +286,29 @@ class ImageService {
279
286
  inputImageCount: request.inputImages?.length ?? 0
280
287
  };
281
288
  console.info("[gateway:image] upstream request", requestSummary);
289
+ if (isFreePlan(profile) && settings.image.freeAccountWebGenerationEnabled) {
290
+ try {
291
+ console.info("[gateway:image] using ChatGPT web image route for Free profile", requestSummary);
292
+ const response = await generateChatGPTWebImage({
293
+ profile,
294
+ prompt: request.prompt,
295
+ model: requestedImageModel,
296
+ inputImages: request.inputImages,
297
+ size: request.size,
298
+ responseFormat: "b64_json"
299
+ });
300
+ await this.deps.authService.recordProfileRequestSuccess(profile.profileId, void 0, "openai-codex");
301
+ console.info("[gateway:image] ChatGPT web image response", {
302
+ ...requestSummary,
303
+ imageCount: response.data.length,
304
+ firstImageBase64Length: response.data[0]?.b64_json.length ?? 0
305
+ });
306
+ return response;
307
+ } catch (error) {
308
+ await this.deps.authService.recordProfileRequestFailure(profile.profileId, error, void 0, "openai-codex");
309
+ throw error;
310
+ }
311
+ }
282
312
  const tool = {
283
313
  type: "image_generation",
284
314
  model: requestedImageModel
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ ensureStateMigrated,
7
+ getUsageDailyPath,
8
+ getUsageDir,
9
+ getUsageEventsDir,
10
+ getUsageLifetimePath
11
+ } from "../store/state-paths.js";
12
+ const durationBucketLimits = [100, 300, 500, 1e3, 2e3, 5e3, 1e4, 3e4, 6e4, 12e4, Number.POSITIVE_INFINITY];
13
+ function createAggregate() {
14
+ return {
15
+ requestCount: 0,
16
+ successCount: 0,
17
+ failureCount: 0,
18
+ inputTokens: 0,
19
+ outputTokens: 0,
20
+ totalTokens: 0,
21
+ unknownTokenCount: 0,
22
+ imageCount: 0,
23
+ totalDurationMs: 0,
24
+ averageDurationMs: 0,
25
+ p95DurationMs: 0,
26
+ durationBuckets: {}
27
+ };
28
+ }
29
+ function cloneAggregate(value) {
30
+ return {
31
+ ...value,
32
+ durationBuckets: { ...value.durationBuckets }
33
+ };
34
+ }
35
+ function normalizeNumber(value, fallback = 0) {
36
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
37
+ }
38
+ function normalizeAggregate(value) {
39
+ if (!value || typeof value !== "object") {
40
+ return createAggregate();
41
+ }
42
+ const record = value;
43
+ const aggregate = {
44
+ requestCount: Math.max(0, Math.trunc(normalizeNumber(record.requestCount))),
45
+ successCount: Math.max(0, Math.trunc(normalizeNumber(record.successCount))),
46
+ failureCount: Math.max(0, Math.trunc(normalizeNumber(record.failureCount))),
47
+ inputTokens: Math.max(0, Math.trunc(normalizeNumber(record.inputTokens))),
48
+ outputTokens: Math.max(0, Math.trunc(normalizeNumber(record.outputTokens))),
49
+ totalTokens: Math.max(0, Math.trunc(normalizeNumber(record.totalTokens))),
50
+ unknownTokenCount: Math.max(0, Math.trunc(normalizeNumber(record.unknownTokenCount))),
51
+ imageCount: Math.max(0, Math.trunc(normalizeNumber(record.imageCount))),
52
+ totalDurationMs: Math.max(0, normalizeNumber(record.totalDurationMs)),
53
+ averageDurationMs: 0,
54
+ p95DurationMs: 0,
55
+ durationBuckets: {}
56
+ };
57
+ if (record.durationBuckets && typeof record.durationBuckets === "object") {
58
+ for (const [key, item] of Object.entries(record.durationBuckets)) {
59
+ aggregate.durationBuckets[key] = Math.max(0, Math.trunc(normalizeNumber(item)));
60
+ }
61
+ }
62
+ refreshDerivedMetrics(aggregate);
63
+ return aggregate;
64
+ }
65
+ function normalizeDimensionStore(value) {
66
+ if (!value || typeof value !== "object") {
67
+ return {};
68
+ }
69
+ return Object.fromEntries(
70
+ Object.entries(value).map(([key, item]) => {
71
+ const row = item && typeof item === "object" ? item : {};
72
+ return [
73
+ key,
74
+ {
75
+ key,
76
+ label: typeof row.label === "string" && row.label.trim() ? row.label : key,
77
+ aggregate: normalizeAggregate(row.aggregate)
78
+ }
79
+ ];
80
+ })
81
+ );
82
+ }
83
+ function createDailyStore() {
84
+ return {
85
+ version: 1,
86
+ updatedAt: Date.now(),
87
+ days: {}
88
+ };
89
+ }
90
+ function createLifetimeStore() {
91
+ return {
92
+ version: 1,
93
+ updatedAt: Date.now(),
94
+ aggregate: createAggregate(),
95
+ byAccount: {},
96
+ byModel: {},
97
+ byEndpoint: {},
98
+ byError: {},
99
+ byImageRoute: {},
100
+ bySource: {}
101
+ };
102
+ }
103
+ function normalizeDailyStore(value) {
104
+ if (!value || typeof value !== "object") {
105
+ return createDailyStore();
106
+ }
107
+ const record = value;
108
+ return {
109
+ version: 1,
110
+ updatedAt: normalizeNumber(record.updatedAt, Date.now()),
111
+ days: Object.fromEntries(
112
+ Object.entries(record.days ?? {}).map(([date, aggregate]) => [date, normalizeAggregate(aggregate)])
113
+ )
114
+ };
115
+ }
116
+ function normalizeLifetimeStore(value) {
117
+ if (!value || typeof value !== "object") {
118
+ return createLifetimeStore();
119
+ }
120
+ const record = value;
121
+ return {
122
+ version: 1,
123
+ updatedAt: normalizeNumber(record.updatedAt, Date.now()),
124
+ aggregate: normalizeAggregate(record.aggregate),
125
+ byAccount: normalizeDimensionStore(record.byAccount),
126
+ byModel: normalizeDimensionStore(record.byModel),
127
+ byEndpoint: normalizeDimensionStore(record.byEndpoint),
128
+ byError: normalizeDimensionStore(record.byError),
129
+ byImageRoute: normalizeDimensionStore(record.byImageRoute),
130
+ bySource: normalizeDimensionStore(record.bySource)
131
+ };
132
+ }
133
+ function formatLocalDate(timestamp) {
134
+ const date = new Date(timestamp);
135
+ const year = date.getFullYear();
136
+ const month = String(date.getMonth() + 1).padStart(2, "0");
137
+ const day = String(date.getDate()).padStart(2, "0");
138
+ return `${year}-${month}-${day}`;
139
+ }
140
+ function durationBucketKey(durationMs) {
141
+ const normalized = Math.max(0, durationMs);
142
+ for (const limit of durationBucketLimits) {
143
+ if (normalized <= limit) {
144
+ return Number.isFinite(limit) ? String(limit) : "inf";
145
+ }
146
+ }
147
+ return "inf";
148
+ }
149
+ function bucketKeyToDuration(key) {
150
+ if (key === "inf") {
151
+ return 12e4;
152
+ }
153
+ const parsed = Number.parseInt(key, 10);
154
+ return Number.isFinite(parsed) ? parsed : 0;
155
+ }
156
+ function estimateP95Duration(buckets, total) {
157
+ if (total <= 0) {
158
+ return 0;
159
+ }
160
+ const target = Math.ceil(total * 0.95);
161
+ let seen = 0;
162
+ for (const limit of durationBucketLimits) {
163
+ const key = Number.isFinite(limit) ? String(limit) : "inf";
164
+ seen += buckets[key] ?? 0;
165
+ if (seen >= target) {
166
+ return bucketKeyToDuration(key);
167
+ }
168
+ }
169
+ return 0;
170
+ }
171
+ function refreshDerivedMetrics(aggregate) {
172
+ aggregate.averageDurationMs = aggregate.requestCount > 0 ? aggregate.totalDurationMs / aggregate.requestCount : 0;
173
+ aggregate.p95DurationMs = estimateP95Duration(aggregate.durationBuckets, aggregate.requestCount);
174
+ }
175
+ function tokenNumber(value) {
176
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
177
+ }
178
+ function addToAggregate(aggregate, event) {
179
+ const success = event.success ?? (event.statusCode >= 200 && event.statusCode < 400);
180
+ const inputTokens = tokenNumber(event.tokenUsage?.inputTokens);
181
+ const outputTokens = tokenNumber(event.tokenUsage?.outputTokens);
182
+ const totalTokens = tokenNumber(event.tokenUsage?.totalTokens) ?? (inputTokens !== null || outputTokens !== null ? (inputTokens ?? 0) + (outputTokens ?? 0) : null);
183
+ aggregate.requestCount += 1;
184
+ aggregate.successCount += success ? 1 : 0;
185
+ aggregate.failureCount += success ? 0 : 1;
186
+ aggregate.inputTokens += inputTokens ?? 0;
187
+ aggregate.outputTokens += outputTokens ?? 0;
188
+ aggregate.totalTokens += totalTokens ?? 0;
189
+ aggregate.unknownTokenCount += totalTokens === null ? 1 : 0;
190
+ aggregate.imageCount += Math.max(0, Math.trunc(event.imageCount ?? 0));
191
+ aggregate.totalDurationMs += Math.max(0, event.durationMs);
192
+ const bucket = durationBucketKey(event.durationMs);
193
+ aggregate.durationBuckets[bucket] = (aggregate.durationBuckets[bucket] ?? 0) + 1;
194
+ refreshDerivedMetrics(aggregate);
195
+ }
196
+ function imageRouteLabel(route) {
197
+ if (route === "codex-tool") {
198
+ return "Codex \u56FE\u7247\u5DE5\u5177";
199
+ }
200
+ if (route === "chatgpt-web") {
201
+ return "ChatGPT \u7F51\u9875\u751F\u56FE";
202
+ }
203
+ return "\u975E\u751F\u56FE";
204
+ }
205
+ function bumpDimension(store, key, label, event) {
206
+ const normalizedKey = key.trim() || "-";
207
+ const existing = store[normalizedKey] ?? {
208
+ key: normalizedKey,
209
+ label: label.trim() || normalizedKey,
210
+ aggregate: createAggregate()
211
+ };
212
+ existing.label = label.trim() || existing.label;
213
+ addToAggregate(existing.aggregate, event);
214
+ store[normalizedKey] = existing;
215
+ }
216
+ function topRows(store, limit = 12) {
217
+ return Object.values(store).sort((a, b) => b.aggregate.requestCount - a.aggregate.requestCount || b.aggregate.totalTokens - a.aggregate.totalTokens || a.label.localeCompare(b.label, "zh-CN")).slice(0, limit).map((row) => ({
218
+ key: row.key,
219
+ label: row.label,
220
+ aggregate: cloneAggregate(row.aggregate)
221
+ }));
222
+ }
223
+ async function readJsonFile(filePath) {
224
+ try {
225
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+ async function writeJsonAtomic(filePath, value) {
231
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
232
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
233
+ try {
234
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}
235
+ `, "utf8");
236
+ await fs.rename(tempPath, filePath);
237
+ } catch (error) {
238
+ await fs.rm(tempPath, { force: true }).catch(() => void 0);
239
+ throw error;
240
+ }
241
+ }
242
+ class UsageService {
243
+ startedAt = Date.now();
244
+ startupAggregate = createAggregate();
245
+ dailyStore = null;
246
+ lifetimeStore = null;
247
+ loadPromise = null;
248
+ saveQueue = Promise.resolve();
249
+ async record(event) {
250
+ await this.ensureLoaded();
251
+ const timestamp = event.timestamp ?? Date.now();
252
+ const date = formatLocalDate(timestamp);
253
+ const normalized = {
254
+ ...event,
255
+ id: event.id ?? randomUUID(),
256
+ timestamp,
257
+ statusCode: event.statusCode,
258
+ durationMs: Math.max(0, event.durationMs),
259
+ endpoint: event.endpoint || "-",
260
+ method: event.method || "-",
261
+ model: event.model || "-",
262
+ source: event.source || "-",
263
+ imageRoute: event.imageRoute ?? "none",
264
+ imageCount: Math.max(0, Math.trunc(event.imageCount ?? 0)),
265
+ success: event.success ?? (event.statusCode >= 200 && event.statusCode < 400)
266
+ };
267
+ const daily = this.dailyStore ?? createDailyStore();
268
+ const lifetime = this.lifetimeStore ?? createLifetimeStore();
269
+ daily.days[date] = daily.days[date] ? normalizeAggregate(daily.days[date]) : createAggregate();
270
+ addToAggregate(this.startupAggregate, normalized);
271
+ addToAggregate(daily.days[date], normalized);
272
+ addToAggregate(lifetime.aggregate, normalized);
273
+ bumpDimension(lifetime.byAccount, normalized.profileId || normalized.accountId || normalized.accountLabel || "-", normalized.accountLabel || normalized.accountId || normalized.profileId || "-", normalized);
274
+ bumpDimension(lifetime.byModel, normalized.model || "-", normalized.model || "-", normalized);
275
+ bumpDimension(lifetime.byEndpoint, `${normalized.method} ${normalized.endpoint}`, `${normalized.method} ${normalized.endpoint}`, normalized);
276
+ bumpDimension(lifetime.bySource, normalized.source || "-", normalized.source || "-", normalized);
277
+ bumpDimension(lifetime.byImageRoute, normalized.imageRoute ?? "none", imageRouteLabel(normalized.imageRoute ?? "none"), normalized);
278
+ if (!normalized.success) {
279
+ const errorType = normalized.errorType?.trim() || `HTTP ${normalized.statusCode}`;
280
+ bumpDimension(lifetime.byError, errorType, errorType, normalized);
281
+ }
282
+ daily.updatedAt = timestamp;
283
+ lifetime.updatedAt = timestamp;
284
+ this.dailyStore = daily;
285
+ this.lifetimeStore = lifetime;
286
+ const eventPath = path.join(getUsageEventsDir(), `${date}.jsonl`);
287
+ const dailyPath = getUsageDailyPath();
288
+ const lifetimePath = getUsageLifetimePath();
289
+ this.saveQueue = this.saveQueue.then(async () => {
290
+ await fs.mkdir(getUsageEventsDir(), { recursive: true });
291
+ await fs.appendFile(eventPath, `${JSON.stringify(normalized)}
292
+ `, "utf8");
293
+ await Promise.all([
294
+ writeJsonAtomic(dailyPath, daily),
295
+ writeJsonAtomic(lifetimePath, lifetime)
296
+ ]);
297
+ }, async () => {
298
+ await fs.mkdir(getUsageEventsDir(), { recursive: true });
299
+ await fs.appendFile(eventPath, `${JSON.stringify(normalized)}
300
+ `, "utf8");
301
+ await Promise.all([
302
+ writeJsonAtomic(dailyPath, daily),
303
+ writeJsonAtomic(lifetimePath, lifetime)
304
+ ]);
305
+ });
306
+ await this.saveQueue;
307
+ }
308
+ async getSummary() {
309
+ await this.ensureLoaded();
310
+ const daily = this.dailyStore ?? createDailyStore();
311
+ const lifetime = this.lifetimeStore ?? createLifetimeStore();
312
+ const todayDate = formatLocalDate(Date.now());
313
+ return {
314
+ generatedAt: Date.now(),
315
+ startedAt: this.startedAt,
316
+ todayDate,
317
+ storageDir: getUsageDir(),
318
+ startup: cloneAggregate(this.startupAggregate),
319
+ today: cloneAggregate(daily.days[todayDate] ?? createAggregate()),
320
+ lifetime: cloneAggregate(lifetime.aggregate),
321
+ daily: Object.entries(daily.days).sort(([left], [right]) => right.localeCompare(left)).slice(0, 30).map(([date, aggregate]) => ({ date, aggregate: cloneAggregate(aggregate) })),
322
+ byAccount: topRows(lifetime.byAccount, 16),
323
+ byModel: topRows(lifetime.byModel, 16),
324
+ byEndpoint: topRows(lifetime.byEndpoint, 16),
325
+ byError: topRows(lifetime.byError, 16),
326
+ byImageRoute: topRows(lifetime.byImageRoute, 8),
327
+ bySource: topRows(lifetime.bySource, 8)
328
+ };
329
+ }
330
+ async ensureLoaded() {
331
+ if (!this.loadPromise) {
332
+ this.loadPromise = (async () => {
333
+ await ensureStateMigrated();
334
+ await fs.mkdir(getUsageDir(), { recursive: true });
335
+ await fs.mkdir(getUsageEventsDir(), { recursive: true });
336
+ const [dailyRaw, lifetimeRaw] = await Promise.all([
337
+ readJsonFile(getUsageDailyPath()),
338
+ readJsonFile(getUsageLifetimePath())
339
+ ]);
340
+ this.dailyStore = normalizeDailyStore(dailyRaw);
341
+ this.lifetimeStore = normalizeLifetimeStore(lifetimeRaw);
342
+ })();
343
+ }
344
+ await this.loadPromise;
345
+ }
346
+ }
347
+ export {
348
+ UsageService
349
+ };