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.
- package/CHANGELOG.md +26 -0
- package/README.md +23 -5
- package/README.zh-CN.md +24 -6
- 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-oNIugCIL.js → docs--eK_2fzC.js} +1 -1
- package/admin-ui/dist/assets/{image-bed-CQtIhjg_.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-B-2Zdz9m.js → launch-BiD1Khtg.js} +1 -1
- package/admin-ui/dist/assets/{logs-JFuSf56b.js → logs-BdoKDqh2.js} +1 -1
- package/admin-ui/dist/assets/{network-detect-SfvK6uhx.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-ocpF053C.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 +247 -3
- package/dist/core/providers/openai-codex/chat.js +84 -23
- package/dist/core/providers/openai-codex/chatgpt-web-image.js +1404 -0
- package/dist/core/services/auth-service.js +64 -8
- package/dist/core/services/config-service.js +24 -5
- 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 +429 -4
- package/dist/core/store/settings-store.js +62 -26
- package/dist/core/store/state-paths.js +17 -1
- package/dist/server/app.js +1278 -119
- package/docs/API_USAGE.md +48 -1
- package/docs/DESKTOP_RELEASE.md +12 -1
- package/package.json +1 -1
- package/admin-ui/dist/assets/accounts-CTjk9c4F.js +0 -4
- package/admin-ui/dist/assets/index-By4r-wy3.css +0 -1
- package/admin-ui/dist/assets/index-rgcJgVAu.js +0 -10
- package/admin-ui/dist/assets/overview-X_WodIqE.js +0 -1
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|