ai-zero-token 1.0.8 → 1.0.9
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/README.md +10 -0
- package/README.zh-CN.md +10 -0
- package/dist/core/providers/http-client.js +1 -1
- package/dist/core/services/auth-service.js +98 -7
- package/dist/core/services/config-service.js +15 -3
- package/dist/core/services/version-service.js +18 -13
- package/dist/core/store/settings-store.js +6 -0
- package/dist/server/admin-page.js +1009 -94
- package/dist/server/app.js +66 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,9 +21,16 @@ AI Zero Token provides a local CLI, web console, and HTTP gateway that expose sa
|
|
|
21
21
|
- Account JSON import/export, including selected batch export.
|
|
22
22
|
- Apply a saved account to local Codex by backing up and updating `~/.codex/auth.json`.
|
|
23
23
|
- `gpt-image-2` image generation and JSON image editing through the ChatGPT internal Responses path.
|
|
24
|
+
- Optional quota-exhaustion auto switch to the next saved API account with available quota.
|
|
24
25
|
- Optional upstream proxy configuration for OAuth, model refresh, and gateway forwarding.
|
|
25
26
|
- Local model discovery from the Codex model cache with manual refresh support.
|
|
26
27
|
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
AI Zero Token keeps account tokens and gateway settings in local state, exposes OpenAI-compatible HTTP endpoints, and forwards requests to the selected ChatGPT/Codex OAuth account. The web console reads the same local state, so account switching, Codex auth application, proxy settings, and automatic switching are all controlled from one local place.
|
|
33
|
+
|
|
27
34
|
## Quick Start
|
|
28
35
|
|
|
29
36
|
```bash
|
|
@@ -50,6 +57,7 @@ The web console is the recommended entry point:
|
|
|
50
57
|
- Export one account or selected accounts.
|
|
51
58
|
- Apply a saved account to local Codex.
|
|
52
59
|
- Configure the default text model and upstream proxy.
|
|
60
|
+
- Enable automatic account switching when the active API account has exhausted its recorded quota.
|
|
53
61
|
- Test `models`, `responses`, `chat.completions`, `images.generations`, and `images.edits`.
|
|
54
62
|
|
|
55
63
|

|
|
@@ -187,6 +195,8 @@ The persistent state directory can be overridden with:
|
|
|
187
195
|
AI_ZERO_TOKEN_HOME=/path/to/home azt start
|
|
188
196
|
```
|
|
189
197
|
|
|
198
|
+
The web console settings are persisted in the same local state directory. The quota auto-switch option is stored as `autoSwitch.enabled`; when enabled, the gateway uses the latest saved quota snapshot and moves API traffic away from the active account once that snapshot shows a quota window is exhausted.
|
|
199
|
+
|
|
190
200
|
The default request body limit is `32 MiB`, which is intended to make JSON base64 image references practical for local image editing. You can override it with:
|
|
191
201
|
|
|
192
202
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -21,9 +21,16 @@ AI Zero Token 是一个本地优先的 OpenAI 兼容网关,用于把 ChatGPT/C
|
|
|
21
21
|
- 支持账号 JSON 导入/导出,以及复选框选择后的批量导出。
|
|
22
22
|
- 支持把已保存账号应用到本机 Codex,并自动备份 `~/.codex/auth.json`。
|
|
23
23
|
- 支持 `gpt-image-2` 文生图和 JSON 图生图。
|
|
24
|
+
- 支持 API 账号额度耗尽后自动切换到下一个仍有额度的账号。
|
|
24
25
|
- 支持上游代理配置,覆盖 OAuth、模型刷新和接口转发。
|
|
25
26
|
- 模型列表优先读取本机 Codex 模型缓存,并支持手动刷新。
|
|
26
27
|
|
|
28
|
+
## 技术架构
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
AI Zero Token 会把账号 token、网关配置和运行状态保存在本地,通过 OpenAI 兼容接口转发到当前选中的 ChatGPT/Codex OAuth 账号。管理页读取同一份本地状态,因此账号切换、应用到 Codex、代理配置和额度耗尽自动切换都可以在本地统一管理。
|
|
33
|
+
|
|
27
34
|
## 快速开始
|
|
28
35
|
|
|
29
36
|
```bash
|
|
@@ -50,6 +57,7 @@ http://127.0.0.1:8787/v1
|
|
|
50
57
|
- 导出单个账号或勾选导出多个账号。
|
|
51
58
|
- 将已保存账号应用到本机 Codex。
|
|
52
59
|
- 配置默认文本模型和上游代理。
|
|
60
|
+
- 开启当前 API 账号额度耗尽后的自动切换。
|
|
53
61
|
- 测试 `models`、`responses`、`chat.completions`、`images.generations`、`images.edits`。
|
|
54
62
|
|
|
55
63
|

|
|
@@ -187,6 +195,8 @@ AZT_CORS_ORIGIN=http://127.0.0.1:8124,http://localhost:3000 azt start
|
|
|
187
195
|
AI_ZERO_TOKEN_HOME=/path/to/home azt start
|
|
188
196
|
```
|
|
189
197
|
|
|
198
|
+
管理页里的配置会保存在同一个本地状态目录。额度耗尽自动切换会保存为 `autoSwitch.enabled`;开启后,网关会根据最近一次保存的额度快照判断当前 API 账号是否耗尽,并把 API 流量切到下一个仍有额度的账号。
|
|
199
|
+
|
|
190
200
|
默认请求体上限是 `32 MiB`,用于让 JSON base64 图片在本地图片编辑场景里更实用。可以用下面的环境变量覆盖:
|
|
191
201
|
|
|
192
202
|
```bash
|
|
@@ -168,7 +168,7 @@ async function loadNetworkProxySettings() {
|
|
|
168
168
|
}
|
|
169
169
|
async function requestText(init) {
|
|
170
170
|
const requestId = nextRequestId();
|
|
171
|
-
const proxy = await loadNetworkProxySettings();
|
|
171
|
+
const proxy = init.ignoreProxy ? void 0 : init.proxyOverride ?? await loadNetworkProxySettings();
|
|
172
172
|
const useCurlOnly = process.env.OAUTH_DEMO_USE_CURL === "1";
|
|
173
173
|
const useConfiguredProxy = !!proxy?.enabled && !!proxy.url.trim();
|
|
174
174
|
const timeoutMs = init.timeoutMs;
|
|
@@ -53,6 +53,83 @@ class AuthService {
|
|
|
53
53
|
isActive: profile.profileId === activeProfileId
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
getQuotaPercents(profile) {
|
|
57
|
+
const quota = profile.quota;
|
|
58
|
+
if (!quota) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return [quota.primaryUsedPercent, quota.secondaryUsedPercent].filter(
|
|
62
|
+
(value) => typeof value === "number" && Number.isFinite(value)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
isQuotaExhausted(profile) {
|
|
66
|
+
const percents = this.getQuotaPercents(profile);
|
|
67
|
+
return percents.length > 0 && percents.some((value) => value >= 100);
|
|
68
|
+
}
|
|
69
|
+
hasKnownAvailableQuota(profile) {
|
|
70
|
+
const percents = this.getQuotaPercents(profile);
|
|
71
|
+
return percents.length > 0 && percents.every((value) => value < 100);
|
|
72
|
+
}
|
|
73
|
+
getQuotaUsageScore(profile) {
|
|
74
|
+
const percents = this.getQuotaPercents(profile);
|
|
75
|
+
if (percents.length === 0) {
|
|
76
|
+
return 100;
|
|
77
|
+
}
|
|
78
|
+
return Math.max(...percents);
|
|
79
|
+
}
|
|
80
|
+
async maybeAutoSwitchProfile(profile, provider) {
|
|
81
|
+
const settings = await this.configService.getSettings();
|
|
82
|
+
if (!settings.autoSwitch.enabled || !this.isQuotaExhausted(profile)) {
|
|
83
|
+
return profile;
|
|
84
|
+
}
|
|
85
|
+
const [profiles, codexStatus] = await Promise.all([
|
|
86
|
+
listProfiles(),
|
|
87
|
+
getCodexAuthStatus()
|
|
88
|
+
]);
|
|
89
|
+
const currentIndex = profiles.findIndex((item) => item.profileId === profile.profileId);
|
|
90
|
+
const codexAccountId = codexStatus.accountId;
|
|
91
|
+
const candidates = profiles.map((item, index) => ({
|
|
92
|
+
profile: item,
|
|
93
|
+
index,
|
|
94
|
+
distance: currentIndex >= 0 ? (index - currentIndex + profiles.length) % profiles.length : index + 1
|
|
95
|
+
})).filter((item) => item.profile.provider === provider && item.profile.profileId !== profile.profileId).filter((item) => this.hasKnownAvailableQuota(item.profile)).sort((left, right) => {
|
|
96
|
+
const leftCodexConflict = codexAccountId && left.profile.accountId === codexAccountId ? 1 : 0;
|
|
97
|
+
const rightCodexConflict = codexAccountId && right.profile.accountId === codexAccountId ? 1 : 0;
|
|
98
|
+
const codexDiff = leftCodexConflict - rightCodexConflict;
|
|
99
|
+
if (codexDiff !== 0) {
|
|
100
|
+
return codexDiff;
|
|
101
|
+
}
|
|
102
|
+
const distanceDiff = left.distance - right.distance;
|
|
103
|
+
if (distanceDiff !== 0) {
|
|
104
|
+
return distanceDiff;
|
|
105
|
+
}
|
|
106
|
+
const usageDiff = this.getQuotaUsageScore(left.profile) - this.getQuotaUsageScore(right.profile);
|
|
107
|
+
if (usageDiff !== 0) {
|
|
108
|
+
return usageDiff;
|
|
109
|
+
}
|
|
110
|
+
const leftCapturedAt = left.profile.quota?.capturedAt ?? 0;
|
|
111
|
+
const rightCapturedAt = right.profile.quota?.capturedAt ?? 0;
|
|
112
|
+
if (leftCapturedAt !== rightCapturedAt) {
|
|
113
|
+
return leftCapturedAt - rightCapturedAt;
|
|
114
|
+
}
|
|
115
|
+
return right.profile.expires - left.profile.expires;
|
|
116
|
+
}).map((item) => item.profile);
|
|
117
|
+
const nextProfile = candidates[0];
|
|
118
|
+
if (!nextProfile) {
|
|
119
|
+
return profile;
|
|
120
|
+
}
|
|
121
|
+
const activated = await setActiveProfile(nextProfile.profileId);
|
|
122
|
+
if (!activated) {
|
|
123
|
+
return profile;
|
|
124
|
+
}
|
|
125
|
+
console.info("[auth] auto switched active profile after quota exhaustion", {
|
|
126
|
+
provider,
|
|
127
|
+
fromProfileId: profile.profileId,
|
|
128
|
+
toProfileId: activated.profileId,
|
|
129
|
+
avoidedCodexAccount: Boolean(codexAccountId && activated.accountId !== codexAccountId)
|
|
130
|
+
});
|
|
131
|
+
return this.toManagedProfile(activated);
|
|
132
|
+
}
|
|
56
133
|
async login(provider) {
|
|
57
134
|
if (provider !== "openai-codex") {
|
|
58
135
|
throw new Error(`\u6682\u4E0D\u652F\u6301 provider: ${provider}`);
|
|
@@ -151,8 +228,9 @@ class AuthService {
|
|
|
151
228
|
}
|
|
152
229
|
await removeProfile(profileId);
|
|
153
230
|
}
|
|
154
|
-
async requireUsableProfile(provider = "openai-codex") {
|
|
155
|
-
const
|
|
231
|
+
async requireUsableProfile(provider = "openai-codex", options) {
|
|
232
|
+
const activeProfile = await this.getActiveProfile(provider);
|
|
233
|
+
const profile = activeProfile ? options?.skipAutoSwitch ? activeProfile : await this.maybeAutoSwitchProfile(activeProfile, provider) : null;
|
|
156
234
|
if (!profile) {
|
|
157
235
|
throw new Error(`\u8FD8\u6CA1\u6709\u767B\u5F55 ${provider}\u3002\u5148\u8FD0\u884C azt login`);
|
|
158
236
|
}
|
|
@@ -185,7 +263,9 @@ class AuthService {
|
|
|
185
263
|
async syncActiveProfileQuota(provider = "openai-codex", options) {
|
|
186
264
|
let profile;
|
|
187
265
|
try {
|
|
188
|
-
profile = await this.requireUsableProfile(provider
|
|
266
|
+
profile = await this.requireUsableProfile(provider, {
|
|
267
|
+
skipAutoSwitch: options?.skipAutoSwitch
|
|
268
|
+
});
|
|
189
269
|
} catch (error) {
|
|
190
270
|
if (options?.suppressErrors) {
|
|
191
271
|
return;
|
|
@@ -203,10 +283,14 @@ class AuthService {
|
|
|
203
283
|
text: { verbosity: "low" }
|
|
204
284
|
}
|
|
205
285
|
});
|
|
206
|
-
await this.updateProfileQuota(profile.profileId, result.quota, provider
|
|
286
|
+
await this.updateProfileQuota(profile.profileId, result.quota, provider, {
|
|
287
|
+
skipAutoSwitch: options?.skipAutoSwitch
|
|
288
|
+
});
|
|
207
289
|
} catch (error) {
|
|
208
290
|
const quota = error.quota;
|
|
209
|
-
await this.updateProfileQuota(profile.profileId, quota, provider
|
|
291
|
+
await this.updateProfileQuota(profile.profileId, quota, provider, {
|
|
292
|
+
skipAutoSwitch: options?.skipAutoSwitch
|
|
293
|
+
});
|
|
210
294
|
if (!options?.suppressErrors) {
|
|
211
295
|
throw error;
|
|
212
296
|
}
|
|
@@ -217,11 +301,11 @@ class AuthService {
|
|
|
217
301
|
});
|
|
218
302
|
}
|
|
219
303
|
}
|
|
220
|
-
async updateProfileQuota(profileId, quota, provider = "openai-codex") {
|
|
304
|
+
async updateProfileQuota(profileId, quota, provider = "openai-codex", options) {
|
|
221
305
|
if (!quota) {
|
|
222
306
|
return;
|
|
223
307
|
}
|
|
224
|
-
await updateProfile(profileId, (profile) => {
|
|
308
|
+
const updated = await updateProfile(profileId, (profile) => {
|
|
225
309
|
if (profile.provider !== provider) {
|
|
226
310
|
return profile;
|
|
227
311
|
}
|
|
@@ -230,6 +314,13 @@ class AuthService {
|
|
|
230
314
|
quota
|
|
231
315
|
};
|
|
232
316
|
});
|
|
317
|
+
if (options?.skipAutoSwitch || !updated || updated.provider !== provider || !this.isQuotaExhausted(updated)) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const activeProfile = await this.getActiveProfile(provider);
|
|
321
|
+
if (activeProfile?.profileId === updated.profileId) {
|
|
322
|
+
await this.maybeAutoSwitchProfile(updated, provider);
|
|
323
|
+
}
|
|
233
324
|
}
|
|
234
325
|
async getStatus() {
|
|
235
326
|
const [profile, profiles] = await Promise.all([
|
|
@@ -42,8 +42,10 @@ class ConfigService {
|
|
|
42
42
|
return next;
|
|
43
43
|
}
|
|
44
44
|
async setNetworkProxy(params) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
45
|
+
const settings = await this.getSettings();
|
|
46
|
+
const requestedUrl = params.url?.trim() ?? "";
|
|
47
|
+
const url = requestedUrl || (!params.enabled ? settings.networkProxy.url : "");
|
|
48
|
+
const noProxy = params.noProxy?.trim() || settings.networkProxy.noProxy || "localhost,127.0.0.1,::1";
|
|
47
49
|
if (params.enabled) {
|
|
48
50
|
if (!url) {
|
|
49
51
|
throw new Error("\u542F\u7528\u4EE3\u7406\u65F6\u5FC5\u987B\u586B\u5199\u4EE3\u7406\u5730\u5740\u3002");
|
|
@@ -59,7 +61,6 @@ class ConfigService {
|
|
|
59
61
|
throw new Error("\u4EE3\u7406\u5730\u5740\u4EC5\u652F\u6301 http\u3001https\u3001socks4\u3001socks4a\u3001socks5 \u6216 socks5h\u3002");
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
|
-
const settings = await this.getSettings();
|
|
63
64
|
const next = {
|
|
64
65
|
...settings,
|
|
65
66
|
networkProxy: {
|
|
@@ -71,6 +72,17 @@ class ConfigService {
|
|
|
71
72
|
await saveSettings(next);
|
|
72
73
|
return next;
|
|
73
74
|
}
|
|
75
|
+
async setAutoSwitch(params) {
|
|
76
|
+
const settings = await this.getSettings();
|
|
77
|
+
const next = {
|
|
78
|
+
...settings,
|
|
79
|
+
autoSwitch: {
|
|
80
|
+
enabled: params.enabled
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await saveSettings(next);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
74
86
|
async getServerConfig() {
|
|
75
87
|
const settings = await this.getSettings();
|
|
76
88
|
return settings.server;
|
|
@@ -56,19 +56,7 @@ class VersionService {
|
|
|
56
56
|
const manifest = await readPackageManifest();
|
|
57
57
|
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(manifest.name)}/latest`;
|
|
58
58
|
try {
|
|
59
|
-
const
|
|
60
|
-
method: "GET",
|
|
61
|
-
url: registryUrl,
|
|
62
|
-
timeoutMs: 5e3
|
|
63
|
-
});
|
|
64
|
-
if (response.status < 200 || response.status >= 300) {
|
|
65
|
-
throw new Error(`npm registry returned ${response.status}`);
|
|
66
|
-
}
|
|
67
|
-
const parsed = JSON.parse(response.body);
|
|
68
|
-
const latestVersion = typeof parsed.version === "string" && parsed.version ? parsed.version : void 0;
|
|
69
|
-
if (!latestVersion) {
|
|
70
|
-
throw new Error("npm registry did not return a version");
|
|
71
|
-
}
|
|
59
|
+
const latestVersion = await this.fetchNpmLatestVersion(registryUrl);
|
|
72
60
|
const needsUpdate = compareSemver(manifest.version, latestVersion) < 0;
|
|
73
61
|
return {
|
|
74
62
|
packageName: manifest.name,
|
|
@@ -91,6 +79,23 @@ class VersionService {
|
|
|
91
79
|
};
|
|
92
80
|
}
|
|
93
81
|
}
|
|
82
|
+
async fetchNpmLatestVersion(registryUrl) {
|
|
83
|
+
const response = await requestText({
|
|
84
|
+
method: "GET",
|
|
85
|
+
url: registryUrl,
|
|
86
|
+
timeoutMs: 5e3,
|
|
87
|
+
ignoreProxy: true
|
|
88
|
+
});
|
|
89
|
+
if (response.status < 200 || response.status >= 300) {
|
|
90
|
+
throw new Error(`npm registry returned ${response.status}`);
|
|
91
|
+
}
|
|
92
|
+
const parsed = JSON.parse(response.body);
|
|
93
|
+
const latestVersion = typeof parsed.version === "string" && parsed.version ? parsed.version : void 0;
|
|
94
|
+
if (!latestVersion) {
|
|
95
|
+
throw new Error("npm registry did not return a version");
|
|
96
|
+
}
|
|
97
|
+
return latestVersion;
|
|
98
|
+
}
|
|
94
99
|
}
|
|
95
100
|
export {
|
|
96
101
|
VersionService
|
|
@@ -15,6 +15,9 @@ function createDefaultSettings() {
|
|
|
15
15
|
url: "",
|
|
16
16
|
noProxy: "localhost,127.0.0.1,::1"
|
|
17
17
|
},
|
|
18
|
+
autoSwitch: {
|
|
19
|
+
enabled: false
|
|
20
|
+
},
|
|
18
21
|
server: {
|
|
19
22
|
host: "0.0.0.0",
|
|
20
23
|
port: 8787
|
|
@@ -36,6 +39,9 @@ async function loadSettings() {
|
|
|
36
39
|
url: parsed.networkProxy?.url ?? defaults.networkProxy.url,
|
|
37
40
|
noProxy: parsed.networkProxy?.noProxy ?? defaults.networkProxy.noProxy
|
|
38
41
|
},
|
|
42
|
+
autoSwitch: {
|
|
43
|
+
enabled: parsed.autoSwitch?.enabled ?? defaults.autoSwitch.enabled
|
|
44
|
+
},
|
|
39
45
|
server: {
|
|
40
46
|
host: parsed.server?.host ?? defaults.server.host,
|
|
41
47
|
port: parsed.server?.port ?? defaults.server.port
|