ai-zero-token 2.0.4 → 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 (37) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +23 -5
  3. package/README.zh-CN.md +24 -6
  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-oNIugCIL.js → docs--eK_2fzC.js} +1 -1
  7. package/admin-ui/dist/assets/{image-bed-CQtIhjg_.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-B-2Zdz9m.js → launch-BiD1Khtg.js} +1 -1
  11. package/admin-ui/dist/assets/{logs-JFuSf56b.js → logs-BdoKDqh2.js} +1 -1
  12. package/admin-ui/dist/assets/{network-detect-SfvK6uhx.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-ocpF053C.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 +247 -3
  20. package/dist/core/providers/openai-codex/chat.js +84 -23
  21. package/dist/core/providers/openai-codex/chatgpt-web-image.js +1404 -0
  22. package/dist/core/services/auth-service.js +64 -8
  23. package/dist/core/services/config-service.js +24 -5
  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 +429 -4
  27. package/dist/core/store/settings-store.js +62 -26
  28. package/dist/core/store/state-paths.js +17 -1
  29. package/dist/server/app.js +1278 -119
  30. package/docs/API_USAGE.md +48 -1
  31. package/docs/DESKTOP_RELEASE.md +12 -1
  32. package/package.json +1 -1
  33. package/admin-ui/dist/assets/accounts-CTjk9c4F.js +0 -4
  34. package/admin-ui/dist/assets/index-By4r-wy3.css +0 -1
  35. package/admin-ui/dist/assets/index-rgcJgVAu.js +0 -10
  36. package/admin-ui/dist/assets/overview-X_WodIqE.js +0 -1
  37. package/admin-ui/dist/assets/settings-0eXUAvcm.js +0 -1
@@ -15,8 +15,10 @@ import {
15
15
  } from "../providers/openai-codex/oauth.js";
16
16
  import { askOpenAICodex } from "../providers/openai-codex/chat.js";
17
17
  import {
18
+ applyGatewayToCodexProviderConfig,
18
19
  applyProfileToCodexAuth,
19
- getCodexAuthStatus
20
+ getCodexAuthStatus,
21
+ removeGatewayFromCodexProviderConfig
20
22
  } from "../store/codex-auth-store.js";
21
23
  import {
22
24
  exportProfilesToJson,
@@ -25,7 +27,7 @@ import {
25
27
  importProfileFromJson,
26
28
  importProfilesFromJson
27
29
  } from "../store/profile-transfer.js";
28
- const DEFAULT_QUOTA_SYNC_CONCURRENCY = 16;
30
+ const DEFAULT_QUOTA_SYNC_CONCURRENCY = 3;
29
31
  function getQuotaSyncConcurrency(configured) {
30
32
  const raw = process.env.AZT_QUOTA_SYNC_CONCURRENCY;
31
33
  const parsed = raw ? Number.parseInt(raw, 10) : configured ?? DEFAULT_QUOTA_SYNC_CONCURRENCY;
@@ -149,12 +151,59 @@ class AuthService {
149
151
  const percents = this.getQuotaPercents(profile);
150
152
  return percents.length > 0 && percents.some((value) => value >= 100);
151
153
  }
152
- 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) {
153
199
  if (profile.authStatus?.state === "token_invalidated" || profile.authStatus?.state === "auth_error") {
154
200
  return false;
155
201
  }
156
202
  const percents = this.getQuotaPercents(profile);
157
- 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);
158
207
  }
159
208
  hasInvalidAuthStatus(profile) {
160
209
  return profile.authStatus?.state === "token_invalidated" || profile.authStatus?.state === "auth_error";
@@ -209,7 +258,8 @@ class AuthService {
209
258
  }
210
259
  async maybeAutoSwitchProfile(profile, provider) {
211
260
  const settings = await this.configService.getSettings();
212
- if (!settings.autoSwitch.enabled || !this.isQuotaExhausted(profile)) {
261
+ const excludedProfileIds = new Set(settings.autoSwitch.excludedProfileIds);
262
+ if (!settings.autoSwitch.enabled || excludedProfileIds.has(profile.profileId) || !this.isQuotaExhausted(profile)) {
213
263
  return profile;
214
264
  }
215
265
  const [profiles, codexStatus] = await Promise.all([
@@ -222,7 +272,7 @@ class AuthService {
222
272
  profile: item,
223
273
  index,
224
274
  distance: currentIndex >= 0 ? (index - currentIndex + profiles.length) % profiles.length : index + 1
225
- })).filter((item) => item.profile.provider === provider && item.profile.profileId !== 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) => {
226
276
  const leftCodexConflict = codexAccountId && left.profile.accountId === codexAccountId ? 1 : 0;
227
277
  const rightCodexConflict = codexAccountId && right.profile.accountId === codexAccountId ? 1 : 0;
228
278
  const codexDiff = leftCodexConflict - rightCodexConflict;
@@ -326,6 +376,12 @@ class AuthService {
326
376
  const profile = await this.requireFreshProfileWithIdToken(profileId, provider);
327
377
  return applyProfileToCodexAuth(profile);
328
378
  }
379
+ async applyGatewayToCodexProvider(params) {
380
+ return applyGatewayToCodexProviderConfig(params);
381
+ }
382
+ async removeGatewayFromCodexProvider(params) {
383
+ return removeGatewayFromCodexProviderConfig(params);
384
+ }
329
385
  async getActiveProfile(provider = "openai-codex") {
330
386
  const profile = await getActiveProfile();
331
387
  if (!profile || profile.provider !== provider) {
@@ -517,9 +573,9 @@ class AuthService {
517
573
  async recordProfileRequestFailure(profileId, error, quota, provider = "openai-codex", options) {
518
574
  const authStatus = this.createAuthStatusFromError(error);
519
575
  if (!quota && !authStatus) {
520
- return;
576
+ return null;
521
577
  }
522
- await this.applyProfileRuntimeUpdate(
578
+ return this.applyProfileRuntimeUpdate(
523
579
  profileId,
524
580
  provider,
525
581
  (profile) => ({
@@ -31,14 +31,22 @@ function normalizeNetworkProxy(settings, params) {
31
31
  noProxy
32
32
  };
33
33
  }
34
+ function normalizeProfileIdList(value, fallback = []) {
35
+ if (!value) {
36
+ return fallback;
37
+ }
38
+ return Array.from(
39
+ new Set(
40
+ value.map((item) => item.trim()).filter(Boolean)
41
+ )
42
+ );
43
+ }
34
44
  class ConfigService {
35
45
  async getSettings() {
36
46
  return this.ensureSettings();
37
47
  }
38
48
  async ensureSettings() {
39
- const settings = await loadSettings();
40
- await saveSettings(settings);
41
- return settings;
49
+ return loadSettings();
42
50
  }
43
51
  async getDefaultProvider() {
44
52
  const settings = await this.getSettings();
@@ -81,7 +89,8 @@ class ConfigService {
81
89
  const next = {
82
90
  ...settings,
83
91
  autoSwitch: {
84
- enabled: params.enabled
92
+ enabled: params.enabled ?? settings.autoSwitch.enabled,
93
+ excludedProfileIds: normalizeProfileIdList(params.excludedProfileIds, settings.autoSwitch.excludedProfileIds)
85
94
  }
86
95
  };
87
96
  await saveSettings(next);
@@ -138,7 +147,8 @@ class ConfigService {
138
147
  next = {
139
148
  ...next,
140
149
  autoSwitch: {
141
- enabled: params.autoSwitch.enabled
150
+ enabled: params.autoSwitch.enabled ?? next.autoSwitch.enabled,
151
+ excludedProfileIds: normalizeProfileIdList(params.autoSwitch.excludedProfileIds, next.autoSwitch.excludedProfileIds)
142
152
  }
143
153
  };
144
154
  }
@@ -151,6 +161,15 @@ class ConfigService {
151
161
  }
152
162
  };
153
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
+ }
154
173
  if (params.server) {
155
174
  next = {
156
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
+ };