codex-slot 0.1.0

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/dist/server.js ADDED
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startServer = startServer;
7
+ const fastify_1 = __importDefault(require("fastify"));
8
+ const undici_1 = require("undici");
9
+ const account_store_1 = require("./account-store");
10
+ const config_1 = require("./config");
11
+ const status_1 = require("./status");
12
+ const scheduler_1 = require("./scheduler");
13
+ const state_1 = require("./state");
14
+ const usage_sync_1 = require("./usage-sync");
15
+ function getBearerToken(headerValue) {
16
+ if (!headerValue) {
17
+ return null;
18
+ }
19
+ const match = /^Bearer\s+(.+)$/i.exec(headerValue);
20
+ return match?.[1] ?? null;
21
+ }
22
+ /**
23
+ * 根据错误文本与当前账号状态,决定本地禁用时长。
24
+ *
25
+ * 业务规则:
26
+ * 1. 周限制优先,直到周窗口重置时间。
27
+ * 2. 5 小时额度限制次之,直到 5 小时窗口重置时间。
28
+ * 3. 未能明确识别时,按 5 分钟临时熔断处理。
29
+ *
30
+ * @param picked 当前被选中的账号及状态。
31
+ * @param errorText 上游返回的错误文本。
32
+ * @returns 本地禁用窗口与原因。
33
+ */
34
+ function resolveBlockWindow(picked, errorText) {
35
+ const lowerText = errorText.toLowerCase();
36
+ if (lowerText.includes("weekly") ||
37
+ lowerText.includes("7 day") ||
38
+ lowerText.includes("7-day") ||
39
+ picked.status.isWeeklyLimited) {
40
+ return {
41
+ until: picked.status.weeklyResetsAt ?? Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
42
+ reason: "weekly_limited"
43
+ };
44
+ }
45
+ if (lowerText.includes("5 hour") ||
46
+ lowerText.includes("5-hour") ||
47
+ lowerText.includes("5h") ||
48
+ lowerText.includes("usage limit") ||
49
+ picked.status.isFiveHourLimited) {
50
+ return {
51
+ until: picked.status.fiveHourResetsAt ?? Math.floor(Date.now() / 1000) + 5 * 60,
52
+ reason: "5h_limited"
53
+ };
54
+ }
55
+ return {
56
+ until: Math.floor(Date.now() / 1000) + 5 * 60,
57
+ reason: "temporary_5m_limit"
58
+ };
59
+ }
60
+ /**
61
+ * 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
62
+ *
63
+ * @param accountId 账号标识。
64
+ * @param reason 本地状态中记录的失败原因。
65
+ * @param blockSeconds 熔断持续秒数。
66
+ * @returns 无返回值。
67
+ */
68
+ function markAccountFailure(accountId, reason, blockSeconds) {
69
+ // 请求链路中的短期失败通常是瞬时异常,记录一个较短的本地熔断窗口即可。
70
+ (0, state_1.setAccountBlock)(accountId, Math.floor(Date.now() / 1000) + blockSeconds, reason);
71
+ }
72
+ /**
73
+ * 提取错误对象中最接近底层网络层的错误码,便于区分网络不可达与上游业务异常。
74
+ *
75
+ * @param error 捕获到的异常对象。
76
+ * @returns 错误码;若无法识别则返回 `null`。
77
+ */
78
+ function extractErrorCode(error) {
79
+ if (!error || typeof error !== "object") {
80
+ return null;
81
+ }
82
+ const errnoError = error;
83
+ if (typeof errnoError.code === "string" && errnoError.code.length > 0) {
84
+ return errnoError.code;
85
+ }
86
+ return extractErrorCode(errnoError.cause);
87
+ }
88
+ /**
89
+ * 判断一次请求失败是否属于本机到上游之间的网络不可达场景。
90
+ *
91
+ * @param error 捕获到的异常对象。
92
+ * @returns `true` 表示网络层异常,不应写入账号熔断;否则返回 `false`。
93
+ */
94
+ function isNetworkUnavailableError(error) {
95
+ const errorCode = extractErrorCode(error);
96
+ return [
97
+ "ECONNREFUSED",
98
+ "ECONNRESET",
99
+ "EHOSTUNREACH",
100
+ "ENETDOWN",
101
+ "ENETUNREACH",
102
+ "ENOTFOUND",
103
+ "EAI_AGAIN",
104
+ "ETIMEDOUT",
105
+ "UND_ERR_CONNECT_TIMEOUT",
106
+ "UND_ERR_SOCKET"
107
+ ].includes(errorCode ?? "");
108
+ }
109
+ /**
110
+ * 将网络层异常转换为统一的响应体,避免误导成“当前没有可用账号”。
111
+ *
112
+ * @param accountId 当前尝试的账号标识。
113
+ * @param error 捕获到的异常对象。
114
+ * @returns 统一的网络异常响应体。
115
+ */
116
+ function buildNetworkUnavailablePayload(accountId, error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ return {
119
+ error: {
120
+ message: `网络不可用,账号 ${accountId} 无法连接上游: ${message}`,
121
+ type: "network_unavailable"
122
+ }
123
+ };
124
+ }
125
+ /**
126
+ * 构造发往上游的请求头,并移除仅属于本地代理链路的头信息。
127
+ *
128
+ * @param requestHeaders 客户端发到本地服务的原始请求头。
129
+ * @param accessToken 当前候选账号可用的上游访问令牌。
130
+ * @param accountIdHeader 可选的 ChatGPT 账号标识头。
131
+ * @returns 可直接传给上游请求的请求头对象。
132
+ */
133
+ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountIdHeader) {
134
+ const headers = {};
135
+ for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
136
+ const normalizedName = headerName.toLowerCase();
137
+ if (headerValue == null ||
138
+ normalizedName === "authorization" ||
139
+ normalizedName === "host" ||
140
+ normalizedName === "connection" ||
141
+ normalizedName === "content-length") {
142
+ continue;
143
+ }
144
+ headers[normalizedName] = Array.isArray(headerValue)
145
+ ? headerValue.join(", ")
146
+ : headerValue;
147
+ }
148
+ // 本地服务使用独立 api_key 鉴权,转发时必须替换为真实上游 access token。
149
+ headers.authorization = `Bearer ${accessToken}`;
150
+ // 未显式传入 Accept 时,补上兼容 SSE 与 JSON 的默认值。
151
+ if (!headers.accept) {
152
+ headers.accept = "text/event-stream, application/json";
153
+ }
154
+ // body 会在本地先读取成 Buffer 以支持失败后切换账号重试,因此这里重算长度。
155
+ headers["content-length"] = String(bodyLength);
156
+ headers["user-agent"] = "codex-slot/0.1.0";
157
+ if (accountIdHeader) {
158
+ headers["chatgpt-account-id"] = accountIdHeader;
159
+ }
160
+ return headers;
161
+ }
162
+ /**
163
+ * 读取代理请求的原始 body 字节,供多账号重试时重复发送同一份载荷。
164
+ *
165
+ * @param stream 客户端发到代理路由的原始可读流。
166
+ * @returns 完整请求体的 Buffer;空请求体时返回空 Buffer。
167
+ * @throws 当读取流失败时抛出底层 I/O 错误。
168
+ */
169
+ async function readRawRequestBody(stream) {
170
+ const chunks = [];
171
+ for await (const chunk of stream) {
172
+ // 统一转成 Buffer,避免不同 chunk 类型在后续重发时出现编码歧义。
173
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
174
+ }
175
+ return Buffer.concat(chunks);
176
+ }
177
+ /**
178
+ * 启动一个极轻量本地服务,供后续接入代理或脚本化查询使用。
179
+ *
180
+ * 当前阶段服务用途:
181
+ * 1. 暴露健康检查。
182
+ * 2. 暴露账号状态与当前推荐账号。
183
+ * 3. 为后续真正的 OpenAI-compatible proxy 预留入口。
184
+ *
185
+ * @param port 本地监听端口。
186
+ * @returns Fastify 实例,便于调用方在测试或脚本中复用。
187
+ * @throws 当端口占用或服务启动失败时抛出异常。
188
+ */
189
+ async function startServer(port) {
190
+ const config = (0, config_1.loadConfig)();
191
+ const app = (0, fastify_1.default)({
192
+ logger: false,
193
+ bodyLimit: Math.floor(config.server.body_limit_mb * 1024 * 1024)
194
+ });
195
+ app.addHook("onRequest", async (request, reply) => {
196
+ if (request.url === "/health") {
197
+ return;
198
+ }
199
+ const bearer = getBearerToken(request.headers.authorization);
200
+ if (bearer !== config.server.api_key) {
201
+ reply.code(401);
202
+ throw new Error("invalid local api key");
203
+ }
204
+ });
205
+ app.get("/health", async () => {
206
+ return { ok: true };
207
+ });
208
+ app.post("/refresh", async () => {
209
+ const results = await refreshAccountUsageForBestEffort();
210
+ return { refreshed: results };
211
+ });
212
+ app.get("/accounts", async () => {
213
+ return {
214
+ accounts: (0, status_1.collectAccountStatuses)(),
215
+ selected: (0, scheduler_1.pickBestAccount)()
216
+ };
217
+ });
218
+ const proxyHandler = async (requestBodyStream, requestHeaders, reply) => {
219
+ const requestBody = await readRawRequestBody(requestBodyStream);
220
+ const candidates = (0, scheduler_1.listCandidateAccounts)();
221
+ if (candidates.length === 0) {
222
+ reply.code(503);
223
+ reply.send({
224
+ error: {
225
+ message: "当前没有可用账号",
226
+ type: "no_available_account"
227
+ }
228
+ });
229
+ return;
230
+ }
231
+ let lastErrorPayload = {
232
+ error: {
233
+ message: "所有账号都请求失败",
234
+ type: "all_accounts_failed"
235
+ }
236
+ };
237
+ let lastStatusCode = 503;
238
+ for (const picked of candidates) {
239
+ const auth = (0, account_store_1.readAuthFile)(picked.account.codex_home);
240
+ let accessToken = auth?.tokens?.access_token;
241
+ const accountIdHeader = auth?.tokens?.account_id;
242
+ if (!accessToken) {
243
+ // 当前账号认证信息不完整时,先做短时熔断,再切到下一个账号。
244
+ markAccountFailure(picked.account.id, "invalid_account_auth", 10 * 60);
245
+ lastErrorPayload = {
246
+ error: {
247
+ message: `账号 ${picked.account.id} 缺少 access_token`,
248
+ type: "invalid_account_auth"
249
+ }
250
+ };
251
+ lastStatusCode = 503;
252
+ continue;
253
+ }
254
+ const sendUpstream = async (upstreamAccessToken) => await (0, undici_1.request)(`${config.upstream.codex_base_url}/responses`, {
255
+ method: "POST",
256
+ headers: buildUpstreamHeaders(requestHeaders, upstreamAccessToken, requestBody.length, accountIdHeader),
257
+ body: requestBody
258
+ });
259
+ let upstream;
260
+ try {
261
+ upstream = await sendUpstream(accessToken);
262
+ }
263
+ catch (error) {
264
+ lastStatusCode = 503;
265
+ if (isNetworkUnavailableError(error)) {
266
+ lastErrorPayload = buildNetworkUnavailablePayload(picked.account.id, error);
267
+ continue;
268
+ }
269
+ // 非网络层异常仍视为当前账号请求链路异常,短时熔断后继续尝试下一个账号。
270
+ markAccountFailure(picked.account.id, "request_failed", 60);
271
+ lastErrorPayload = {
272
+ error: {
273
+ message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
274
+ type: "account_request_failed"
275
+ }
276
+ };
277
+ continue;
278
+ }
279
+ if (upstream.statusCode === 401) {
280
+ try {
281
+ const refreshed = await (0, usage_sync_1.refreshAccountTokens)(picked.account.id);
282
+ accessToken = refreshed.tokens?.access_token ?? accessToken;
283
+ upstream = await sendUpstream(accessToken);
284
+ }
285
+ catch (error) {
286
+ lastStatusCode = 503;
287
+ if (isNetworkUnavailableError(error)) {
288
+ lastErrorPayload = buildNetworkUnavailablePayload(picked.account.id, error);
289
+ continue;
290
+ }
291
+ // token 刷新失败说明该账号短期内不可用,先熔断再切换。
292
+ markAccountFailure(picked.account.id, "token_refresh_failed", 10 * 60);
293
+ lastErrorPayload = {
294
+ error: {
295
+ message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
296
+ type: "account_token_refresh_failed"
297
+ }
298
+ };
299
+ continue;
300
+ }
301
+ }
302
+ if (upstream.statusCode === 429 || upstream.statusCode === 403) {
303
+ const errorText = await upstream.body.text();
304
+ const block = resolveBlockWindow(picked, errorText);
305
+ (0, state_1.setAccountBlock)(picked.account.id, block.until, block.reason);
306
+ lastStatusCode = upstream.statusCode;
307
+ lastErrorPayload = {
308
+ error: {
309
+ message: `账号 ${picked.account.id} 受限: ${errorText}`,
310
+ type: "account_rate_limited"
311
+ }
312
+ };
313
+ continue;
314
+ }
315
+ if (upstream.statusCode >= 400) {
316
+ const errorText = await upstream.body.text();
317
+ const lowerText = errorText.toLowerCase();
318
+ if (lowerText.includes("usage limit") || lowerText.includes("try again later")) {
319
+ const block = resolveBlockWindow(picked, errorText);
320
+ (0, state_1.setAccountBlock)(picked.account.id, block.until, block.reason);
321
+ lastStatusCode = upstream.statusCode;
322
+ lastErrorPayload = {
323
+ error: {
324
+ message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
325
+ type: "account_usage_limited"
326
+ }
327
+ };
328
+ continue;
329
+ }
330
+ if (upstream.statusCode >= 500) {
331
+ // 上游 5xx 先视为当前账号链路失败,短时熔断并切到下一个账号。
332
+ markAccountFailure(picked.account.id, "upstream_5xx", 60);
333
+ lastStatusCode = upstream.statusCode;
334
+ lastErrorPayload = {
335
+ error: {
336
+ message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
337
+ type: "account_upstream_failed"
338
+ }
339
+ };
340
+ continue;
341
+ }
342
+ reply.raw.writeHead(upstream.statusCode, {
343
+ "content-type": "application/json"
344
+ });
345
+ reply.raw.end(errorText);
346
+ return;
347
+ }
348
+ const headers = {};
349
+ const contentType = upstream.headers["content-type"];
350
+ const cacheControl = upstream.headers["cache-control"];
351
+ if (typeof contentType === "string") {
352
+ headers["content-type"] = contentType;
353
+ }
354
+ if (typeof cacheControl === "string") {
355
+ headers["cache-control"] = cacheControl;
356
+ }
357
+ headers.connection = "keep-alive";
358
+ reply.raw.writeHead(upstream.statusCode, headers);
359
+ for await (const chunk of upstream.body) {
360
+ reply.raw.write(chunk);
361
+ }
362
+ reply.raw.end();
363
+ return;
364
+ }
365
+ reply.code(lastStatusCode);
366
+ reply.send(lastErrorPayload);
367
+ };
368
+ await app.register(async (proxyApp) => {
369
+ // 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
370
+ proxyApp.removeAllContentTypeParsers();
371
+ proxyApp.addContentTypeParser("*", (request, payload, done) => {
372
+ done(null, payload);
373
+ });
374
+ proxyApp.post("/v1/responses", { bodyLimit: Number.MAX_SAFE_INTEGER }, async (request, reply) => {
375
+ await proxyHandler(request.body, request.headers, reply);
376
+ });
377
+ proxyApp.post("/backend-api/codex/responses", { bodyLimit: Number.MAX_SAFE_INTEGER }, async (request, reply) => {
378
+ await proxyHandler(request.body, request.headers, reply);
379
+ });
380
+ });
381
+ await app.listen({
382
+ host: config.server.host,
383
+ port: Number.isFinite(port) ? port : config.server.port
384
+ });
385
+ }
386
+ async function refreshAccountUsageForBestEffort() {
387
+ const statuses = (0, status_1.collectAccountStatuses)();
388
+ const refreshed = [];
389
+ for (const status of statuses) {
390
+ try {
391
+ const item = await (0, usage_sync_1.refreshAccountUsage)(status.id);
392
+ refreshed.push(item);
393
+ }
394
+ catch (error) {
395
+ refreshed.push({
396
+ accountId: status.id,
397
+ error: error instanceof Error ? error.message : String(error)
398
+ });
399
+ }
400
+ }
401
+ return refreshed;
402
+ }
package/dist/state.js ADDED
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadState = loadState;
7
+ exports.saveState = saveState;
8
+ exports.setAccountBlock = setAccountBlock;
9
+ exports.pruneExpiredBlocks = pruneExpiredBlocks;
10
+ exports.getAccountBlock = getAccountBlock;
11
+ exports.setUsageCache = setUsageCache;
12
+ exports.getUsageCache = getUsageCache;
13
+ const node_fs_1 = __importDefault(require("node:fs"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const config_1 = require("./config");
16
+ function getStatePath() {
17
+ return node_path_1.default.join((0, config_1.getCodexSwHome)(), "state.json");
18
+ }
19
+ /**
20
+ * 读取 cslot 的本地运行状态;文件不存在时返回默认空状态。
21
+ *
22
+ * @returns 当前持久化状态。
23
+ */
24
+ function loadState() {
25
+ const statePath = getStatePath();
26
+ if (!node_fs_1.default.existsSync(statePath)) {
27
+ return {
28
+ account_blocks: {},
29
+ usage_cache: {}
30
+ };
31
+ }
32
+ const raw = node_fs_1.default.readFileSync(statePath, "utf8");
33
+ const parsed = raw.trim()
34
+ ? JSON.parse(raw)
35
+ : {
36
+ account_blocks: {},
37
+ usage_cache: {}
38
+ };
39
+ return {
40
+ account_blocks: parsed.account_blocks ?? {},
41
+ usage_cache: parsed.usage_cache ?? {}
42
+ };
43
+ }
44
+ /**
45
+ * 持久化 cslot 的本地运行状态。
46
+ *
47
+ * @param state 待写入状态对象。
48
+ * @returns 无返回值。
49
+ */
50
+ function saveState(state) {
51
+ const statePath = getStatePath();
52
+ node_fs_1.default.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
53
+ }
54
+ /**
55
+ * 为指定账号设置本地禁用窗口,用于临时熔断或周限制冷却。
56
+ *
57
+ * @param accountId 账号标识。
58
+ * @param until 禁用截止时间,Unix 秒时间戳;为 `null` 时表示仅记录原因。
59
+ * @param reason 禁用原因。
60
+ * @returns 无返回值。
61
+ */
62
+ function setAccountBlock(accountId, until, reason) {
63
+ const state = loadState();
64
+ state.account_blocks[accountId] = {
65
+ until,
66
+ reason,
67
+ updated_at: new Date().toISOString()
68
+ };
69
+ saveState(state);
70
+ }
71
+ /**
72
+ * 清理已过期的账号禁用记录,并返回最新状态。
73
+ *
74
+ * @returns 清理后的状态对象。
75
+ */
76
+ function pruneExpiredBlocks() {
77
+ const state = loadState();
78
+ const now = Math.floor(Date.now() / 1000);
79
+ let changed = false;
80
+ for (const [accountId, block] of Object.entries(state.account_blocks)) {
81
+ if (block.until !== null && block.until <= now) {
82
+ delete state.account_blocks[accountId];
83
+ changed = true;
84
+ }
85
+ }
86
+ if (changed) {
87
+ saveState(state);
88
+ }
89
+ return state;
90
+ }
91
+ /**
92
+ * 读取指定账号当前的本地禁用状态;若已过期会自动清理。
93
+ *
94
+ * @param accountId 账号标识。
95
+ * @returns 账号禁用状态;不存在或已过期时返回 `null`。
96
+ */
97
+ function getAccountBlock(accountId) {
98
+ const state = pruneExpiredBlocks();
99
+ return state.account_blocks[accountId] ?? null;
100
+ }
101
+ /**
102
+ * 更新指定账号的最新额度缓存,仅写入 cslot 自己的状态文件。
103
+ *
104
+ * @param usage 最新额度结果。
105
+ * @returns 无返回值。
106
+ */
107
+ function setUsageCache(usage) {
108
+ const state = loadState();
109
+ state.usage_cache[usage.accountId] = usage;
110
+ saveState(state);
111
+ }
112
+ /**
113
+ * 读取指定账号最近一次成功刷新的额度缓存。
114
+ *
115
+ * @param accountId 账号标识。
116
+ * @returns 最新额度缓存;不存在时返回 `null`。
117
+ */
118
+ function getUsageCache(accountId) {
119
+ const state = loadState();
120
+ return state.usage_cache[accountId] ?? null;
121
+ }