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.
- package/CHANGELOG.md +16 -0
- package/README.md +15 -12
- package/README.zh-CN.md +15 -12
- package/admin-ui/dist/assets/StatCard-7TEzqn2i.js +1 -0
- package/admin-ui/dist/assets/accounts-bCDKXGg9.js +4 -0
- package/admin-ui/dist/assets/{docs-Dh0aFha_.js → docs--eK_2fzC.js} +1 -1
- package/admin-ui/dist/assets/{image-bed-C1M7-0q1.js → image-bed-7wBZ1GhS.js} +1 -1
- package/admin-ui/dist/assets/index-C22_3Mxq.css +1 -0
- package/admin-ui/dist/assets/index-CdFYy5j6.js +10 -0
- package/admin-ui/dist/assets/{launch-pB7YlWFI.js → launch-BiD1Khtg.js} +1 -1
- package/admin-ui/dist/assets/{logs-B7McijSi.js → logs-BdoKDqh2.js} +1 -1
- package/admin-ui/dist/assets/{network-detect-Bx3XmXPk.js → network-detect-BvKns5nQ.js} +1 -1
- package/admin-ui/dist/assets/overview-wm6M45fu.js +1 -0
- package/admin-ui/dist/assets/settings-DOOu7Kd8.js +5 -0
- package/admin-ui/dist/assets/{tester-BG-up8qP.js → tester-NrARmlis.js} +1 -1
- package/admin-ui/dist/assets/usage-CdWRVMDV.js +1 -0
- package/admin-ui/dist/index.html +2 -2
- package/dist/core/context.js +3 -0
- package/dist/core/providers/http-client.js +21 -2
- package/dist/core/providers/openai-codex/chat.js +2 -1
- package/dist/core/providers/openai-codex/chatgpt-web-image.js +1404 -0
- package/dist/core/services/auth-service.js +51 -4
- package/dist/core/services/config-service.js +9 -0
- package/dist/core/services/image-service.js +31 -1
- package/dist/core/services/usage-service.js +349 -0
- package/dist/core/store/codex-auth-store.js +149 -15
- package/dist/core/store/settings-store.js +8 -2
- package/dist/core/store/state-paths.js +17 -1
- package/dist/server/app.js +848 -50
- package/docs/API_USAGE.md +33 -3
- package/package.json +1 -1
- package/admin-ui/dist/assets/accounts-ABMyXo4H.js +0 -4
- package/admin-ui/dist/assets/index--rNjdmzf.js +0 -10
- package/admin-ui/dist/assets/index-DjtN30PC.css +0 -1
- package/admin-ui/dist/assets/overview-CV0H2Nsq.js +0 -1
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
};
|