codex-slot 0.1.26 → 0.1.27

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
+ var _a;
2
3
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.proxyResponsesWithRetry = void 0;
4
+ exports.proxyResponsesWithRetry = exports.proxyCodexWithRetry = void 0;
4
5
  exports.createProxyRetryService = createProxyRetryService;
5
6
  const account_store_1 = require("./account-store");
6
7
  const config_1 = require("./config");
@@ -14,6 +15,7 @@ const usage_sync_1 = require("./usage-sync");
14
15
  /**
15
16
  * 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
16
17
  *
18
+ * @param dependencies 代理服务依赖集合。
17
19
  * @param accountId 账号标识。
18
20
  * @param reason 本地状态中记录的失败原因。
19
21
  * @param blockSeconds 熔断持续秒数。
@@ -60,24 +62,61 @@ function buildSendResult(statusCode, payload, headers) {
60
62
  };
61
63
  }
62
64
  /**
63
- * 对单个候选账号发送上游请求。
65
+ * 解析本地 Codex-compatible 代理请求,并转换成上游 codex path。
64
66
  *
67
+ * 业务含义:
68
+ * 1. 对外暴露的 `/v1/*` 请求需要统一映射到上游 `codex_base_url` 的同名子路径,避免继续按接口逐个补洞。
69
+ * 2. 为兼容历史入口,也保留 `/backend-api/codex/*` 映射到同一上游 path 的能力。
70
+ *
71
+ * @param request 原始本地代理请求。
72
+ * @returns 可发往上游的 codex path;不属于代理范围时返回错误结果。
73
+ * @throws 当 URL 解析失败时返回错误结果,不向上游发请求。
74
+ */
75
+ function resolveCodexPath(request) {
76
+ const parsedUrl = new URL(request.url, "http://127.0.0.1");
77
+ const openAiPrefix = "/v1";
78
+ const legacyBackendPrefix = "/backend-api/codex";
79
+ if (parsedUrl.pathname.startsWith(`${openAiPrefix}/`)) {
80
+ return {
81
+ pathWithQuery: `${parsedUrl.pathname.slice(openAiPrefix.length)}${parsedUrl.search}`
82
+ };
83
+ }
84
+ if (parsedUrl.pathname.startsWith(`${legacyBackendPrefix}/`)) {
85
+ return {
86
+ pathWithQuery: `${parsedUrl.pathname.slice(legacyBackendPrefix.length)}${parsedUrl.search}`
87
+ };
88
+ }
89
+ return {
90
+ error: buildSendResult(404, {
91
+ error: {
92
+ message: (0, text_1.bi)("不支持的 Codex 代理路径", "Unsupported Codex proxy path"),
93
+ type: "unsupported_codex_proxy_path"
94
+ }
95
+ })
96
+ };
97
+ }
98
+ /**
99
+ * 对单个候选账号发送通用的 codex 上游请求。
100
+ *
101
+ * @param dependencies 代理服务依赖集合。
65
102
  * @param picked 当前候选账号。
66
103
  * @param accessToken 可用 access token。
67
- * @param requestHeaders 原始请求头。
68
- * @param requestBody 原始请求体。
104
+ * @param pathWithQuery 已解析的 codex path 与 query。
105
+ * @param request 原始本地代理请求。
69
106
  * @returns 上游响应。
70
107
  * @throws 当网络层或 undici 请求失败时透传底层异常。
71
108
  */
72
- async function sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody) {
109
+ async function sendWithAccount(dependencies, picked, accessToken, pathWithQuery, request) {
73
110
  const config = dependencies.loadConfig();
74
111
  const auth = dependencies.readAuthFile(picked.account.codex_home);
75
- return await dependencies.sendCodexResponsesRequest({
112
+ return await dependencies.sendCodexRequest({
76
113
  codexBaseUrl: config.upstream.codex_base_url,
77
- requestHeaders,
114
+ method: request.method.toUpperCase(),
115
+ pathWithQuery,
116
+ requestHeaders: request.headers,
78
117
  accessToken,
79
118
  accountIdHeader: auth?.tokens?.account_id,
80
- body: requestBody
119
+ body: request.body
81
120
  });
82
121
  }
83
122
  /**
@@ -85,7 +124,7 @@ async function sendWithAccount(dependencies, picked, accessToken, requestHeaders
85
124
  *
86
125
  * 业务含义:
87
126
  * 1. 默认依赖绑定真实配置、账号、状态和上游请求。
88
- * 2. 测试或未来扩展可注入替代依赖,避免业务重试逻辑硬绑 I/O 实现。
127
+ * 2. `/v1/*` 与历史 `/backend-api/codex/*` 都复用同一套账号调度、401 刷新与异常兜底语义。
89
128
  *
90
129
  * @param overrides 可选依赖覆盖项。
91
130
  * @returns 代理重试服务实例。
@@ -96,47 +135,71 @@ function createProxyRetryService(overrides) {
96
135
  loadConfig: config_1.loadConfig,
97
136
  listCandidateAccounts: scheduler_1.listCandidateAccounts,
98
137
  readAuthFile: account_store_1.readAuthFile,
99
- sendCodexResponsesRequest: upstream_client_1.sendCodexResponsesRequest,
138
+ sendCodexRequest: upstream_client_1.sendCodexRequest,
100
139
  refreshAccountTokens: usage_sync_1.refreshAccountTokens,
101
140
  setAccountBlock: state_1.setAccountBlock,
102
141
  recordAccountScheduleSuccess: state_repository_1.recordAccountScheduleSuccess,
103
142
  ...overrides
104
143
  };
105
- return {
106
- async proxyResponsesWithRetry(requestHeaders, requestBody) {
107
- const candidates = dependencies.listCandidateAccounts();
108
- if (candidates.length === 0) {
109
- return buildSendResult(503, {
144
+ const proxyCodexWithRetry = async (request) => {
145
+ const route = resolveCodexPath(request);
146
+ if (route.error) {
147
+ return route.error;
148
+ }
149
+ const candidates = dependencies.listCandidateAccounts();
150
+ if (candidates.length === 0) {
151
+ return buildSendResult(503, {
152
+ error: {
153
+ message: (0, text_1.bi)("当前没有可用账号", "No available account"),
154
+ type: "no_available_account"
155
+ }
156
+ });
157
+ }
158
+ let lastErrorPayload = {
159
+ error: {
160
+ message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
161
+ type: "all_accounts_failed"
162
+ }
163
+ };
164
+ let lastStatusCode = 503;
165
+ for (const picked of candidates) {
166
+ const auth = dependencies.readAuthFile(picked.account.codex_home);
167
+ let accessToken = auth?.tokens?.access_token;
168
+ if (!accessToken) {
169
+ markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
170
+ lastStatusCode = 503;
171
+ lastErrorPayload = {
110
172
  error: {
111
- message: (0, text_1.bi)("当前没有可用账号", "No available account"),
112
- type: "no_available_account"
173
+ message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
174
+ type: "invalid_account_auth"
113
175
  }
114
- });
176
+ };
177
+ continue;
115
178
  }
116
- let lastErrorPayload = {
117
- error: {
118
- message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
119
- type: "all_accounts_failed"
120
- }
121
- };
122
- let lastStatusCode = 503;
123
- for (const picked of candidates) {
124
- const auth = dependencies.readAuthFile(picked.account.codex_home);
125
- let accessToken = auth?.tokens?.access_token;
126
- if (!accessToken) {
127
- markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
128
- lastStatusCode = 503;
129
- lastErrorPayload = {
130
- error: {
131
- message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
132
- type: "invalid_account_auth"
133
- }
134
- };
179
+ let upstream;
180
+ try {
181
+ upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
182
+ }
183
+ catch (error) {
184
+ lastStatusCode = 503;
185
+ if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
186
+ lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
135
187
  continue;
136
188
  }
137
- let upstream;
189
+ markAccountFailure(dependencies, picked.account.id, "request_failed", 60);
190
+ lastErrorPayload = {
191
+ error: {
192
+ message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
193
+ type: "account_request_failed"
194
+ }
195
+ };
196
+ continue;
197
+ }
198
+ if (upstream.statusCode === 401) {
138
199
  try {
139
- upstream = await sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody);
200
+ const refreshed = await dependencies.refreshAccountTokens(picked.account.id);
201
+ accessToken = refreshed.tokens?.access_token ?? accessToken;
202
+ upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
140
203
  }
141
204
  catch (error) {
142
205
  lastStatusCode = 503;
@@ -144,94 +207,82 @@ function createProxyRetryService(overrides) {
144
207
  lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
145
208
  continue;
146
209
  }
147
- markAccountFailure(dependencies, picked.account.id, "request_failed", 60);
210
+ markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
148
211
  lastErrorPayload = {
149
212
  error: {
150
- message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
151
- type: "account_request_failed"
213
+ message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
214
+ type: "account_token_refresh_failed"
152
215
  }
153
216
  };
154
217
  continue;
155
218
  }
156
- if (upstream.statusCode === 401) {
157
- try {
158
- const refreshed = await dependencies.refreshAccountTokens(picked.account.id);
159
- accessToken = refreshed.tokens?.access_token ?? accessToken;
160
- upstream = await sendWithAccount(dependencies, picked, accessToken, requestHeaders, requestBody);
161
- }
162
- catch (error) {
163
- lastStatusCode = 503;
164
- if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
165
- lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
166
- continue;
167
- }
168
- markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
169
- lastErrorPayload = {
170
- error: {
171
- message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
172
- type: "account_token_refresh_failed"
173
- }
174
- };
175
- continue;
219
+ }
220
+ const responseHeaders = pickResponseHeaders(upstream.headers);
221
+ if (upstream.statusCode === 429 || upstream.statusCode === 403) {
222
+ const errorText = await upstream.body.text();
223
+ const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
224
+ dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
225
+ lastStatusCode = upstream.statusCode;
226
+ lastErrorPayload = {
227
+ error: {
228
+ message: `账号 ${picked.account.id} 受限: ${errorText}`,
229
+ type: "account_rate_limited"
176
230
  }
177
- }
178
- const responseHeaders = pickResponseHeaders(upstream.headers);
179
- if (upstream.statusCode === 429 || upstream.statusCode === 403) {
180
- const errorText = await upstream.body.text();
231
+ };
232
+ continue;
233
+ }
234
+ if (upstream.statusCode >= 400) {
235
+ const errorText = await upstream.body.text();
236
+ if ((0, upstream_error_policy_1.isUsageLimitErrorText)(errorText)) {
181
237
  const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
182
238
  dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
183
239
  lastStatusCode = upstream.statusCode;
184
240
  lastErrorPayload = {
185
241
  error: {
186
- message: `账号 ${picked.account.id} 受限: ${errorText}`,
187
- type: "account_rate_limited"
242
+ message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
243
+ type: "account_usage_limited"
188
244
  }
189
245
  };
190
246
  continue;
191
247
  }
192
- if (upstream.statusCode >= 400) {
193
- const errorText = await upstream.body.text();
194
- if ((0, upstream_error_policy_1.isUsageLimitErrorText)(errorText)) {
195
- const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
196
- dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
197
- lastStatusCode = upstream.statusCode;
198
- lastErrorPayload = {
199
- error: {
200
- message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
201
- type: "account_usage_limited"
202
- }
203
- };
204
- continue;
205
- }
206
- if (upstream.statusCode >= 500) {
207
- markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
208
- lastStatusCode = upstream.statusCode;
209
- lastErrorPayload = {
210
- error: {
211
- message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
212
- type: "account_upstream_failed"
213
- }
214
- };
215
- continue;
216
- }
217
- return buildSendResult(upstream.statusCode, errorText, {
218
- "content-type": responseHeaders["content-type"] ?? "application/json",
219
- ...responseHeaders
220
- });
248
+ if (upstream.statusCode >= 500) {
249
+ markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
250
+ lastStatusCode = upstream.statusCode;
251
+ lastErrorPayload = {
252
+ error: {
253
+ message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
254
+ type: "account_upstream_failed"
255
+ }
256
+ };
257
+ continue;
221
258
  }
222
- dependencies.recordAccountScheduleSuccess(picked.account.id);
223
- return {
224
- type: "proxy",
225
- statusCode: upstream.statusCode,
226
- headers: {
227
- ...responseHeaders,
228
- connection: "keep-alive"
229
- },
230
- body: upstream.body
231
- };
259
+ return buildSendResult(upstream.statusCode, errorText, {
260
+ "content-type": responseHeaders["content-type"] ?? "application/json",
261
+ ...responseHeaders
262
+ });
232
263
  }
233
- return buildSendResult(lastStatusCode, lastErrorPayload);
264
+ dependencies.recordAccountScheduleSuccess(picked.account.id);
265
+ return {
266
+ type: "proxy",
267
+ statusCode: upstream.statusCode,
268
+ headers: {
269
+ ...responseHeaders,
270
+ connection: "keep-alive"
271
+ },
272
+ body: upstream.body
273
+ };
234
274
  }
275
+ return buildSendResult(lastStatusCode, lastErrorPayload);
276
+ };
277
+ const proxyResponsesWithRetry = async (requestHeaders, requestBody) => await proxyCodexWithRetry({
278
+ method: "POST",
279
+ url: "/v1/responses",
280
+ headers: requestHeaders,
281
+ body: requestBody
282
+ });
283
+ return {
284
+ proxyCodexWithRetry,
285
+ proxyResponsesWithRetry
235
286
  };
236
287
  }
237
- exports.proxyResponsesWithRetry = createProxyRetryService().proxyResponsesWithRetry;
288
+ _a = createProxyRetryService(), exports.proxyCodexWithRetry = _a.proxyCodexWithRetry, exports.proxyResponsesWithRetry = _a.proxyResponsesWithRetry;
package/dist/server.js CHANGED
@@ -14,12 +14,15 @@ const usage_sync_1 = require("./usage-sync");
14
14
  /**
15
15
  * 读取代理请求的原始 body 字节,供多账号重试时重复发送同一份载荷。
16
16
  *
17
- * @param stream 客户端发到代理路由的原始可读流。
17
+ * @param stream 客户端发到代理路由的原始可读流;无 body 时允许为空。
18
18
  * @returns 完整请求体的 Buffer;空请求体时返回空 Buffer。
19
19
  * @throws 当读取流失败时抛出底层 I/O 错误。
20
20
  */
21
21
  async function readRawRequestBody(stream) {
22
22
  const chunks = [];
23
+ if (!stream) {
24
+ return Buffer.alloc(0);
25
+ }
23
26
  for await (const chunk of stream) {
24
27
  // 统一转成 Buffer,避免不同 chunk 类型在后续重发时出现编码歧义。
25
28
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -86,9 +89,14 @@ async function startServer(port) {
86
89
  selected: (0, scheduler_1.pickBestAccount)()
87
90
  };
88
91
  });
89
- const proxyHandler = async (requestBodyStream, requestHeaders, reply) => {
90
- const requestBody = await readRawRequestBody(requestBodyStream);
91
- const result = await (0, proxy_retry_service_1.proxyResponsesWithRetry)(requestHeaders, requestBody);
92
+ const codexProxyHandler = async (request, reply) => {
93
+ const requestBody = await readRawRequestBody(request.body);
94
+ const result = await (0, proxy_retry_service_1.proxyCodexWithRetry)({
95
+ method: request.method,
96
+ url: request.url,
97
+ headers: request.headers,
98
+ body: requestBody
99
+ });
92
100
  if (result.type === "send") {
93
101
  reply.code(result.statusCode);
94
102
  for (const [headerName, headerValue] of Object.entries(result.headers ?? {})) {
@@ -123,11 +131,33 @@ async function startServer(port) {
123
131
  proxyApp.addContentTypeParser("*", (request, payload, done) => {
124
132
  done(null, payload);
125
133
  });
126
- proxyApp.post("/v1/responses", { bodyLimit: Number.MAX_SAFE_INTEGER }, async (request, reply) => {
127
- await proxyHandler(request.body, request.headers, reply);
134
+ proxyApp.route({
135
+ method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
136
+ url: "/v1/*",
137
+ bodyLimit: Number.MAX_SAFE_INTEGER,
138
+ handler: async (request, reply) => {
139
+ const body = request.body;
140
+ await codexProxyHandler({
141
+ method: request.method,
142
+ url: request.url,
143
+ headers: request.headers,
144
+ body
145
+ }, reply);
146
+ }
128
147
  });
129
- proxyApp.post("/backend-api/codex/responses", { bodyLimit: Number.MAX_SAFE_INTEGER }, async (request, reply) => {
130
- await proxyHandler(request.body, request.headers, reply);
148
+ proxyApp.route({
149
+ method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
150
+ url: "/backend-api/codex/*",
151
+ bodyLimit: Number.MAX_SAFE_INTEGER,
152
+ handler: async (request, reply) => {
153
+ const body = request.body;
154
+ await codexProxyHandler({
155
+ method: request.method,
156
+ url: request.url,
157
+ headers: request.headers,
158
+ body
159
+ }, reply);
160
+ }
131
161
  });
132
162
  proxyApp.route({
133
163
  method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildUpstreamHeaders = buildUpstreamHeaders;
4
+ exports.sendCodexRequest = sendCodexRequest;
4
5
  exports.sendCodexResponsesRequest = sendCodexResponsesRequest;
5
6
  exports.buildChatGptBackendHeaders = buildChatGptBackendHeaders;
6
7
  exports.sendChatGptBackendRequest = sendChatGptBackendRequest;
@@ -38,13 +39,38 @@ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountId
38
39
  if (!headers.accept) {
39
40
  headers.accept = "text/event-stream, application/json";
40
41
  }
41
- headers["content-length"] = String(bodyLength);
42
+ if (typeof bodyLength === "number") {
43
+ headers["content-length"] = String(bodyLength);
44
+ }
42
45
  headers["user-agent"] = "codex-slot/0.1.1";
43
46
  if (accountIdHeader) {
44
47
  headers["chatgpt-account-id"] = accountIdHeader;
45
48
  }
46
49
  return headers;
47
50
  }
51
+ /**
52
+ * 向 Codex-compatible 上游发送一次通用请求。
53
+ *
54
+ * 业务含义:
55
+ * 1. 本地 `/v1/*` 或旧 `/backend-api/codex/*` 路由都应复用同一条上游转发逻辑,避免再按接口逐个补洞。
56
+ * 2. 路由后缀与 query 原样拼接到 `codexBaseUrl` 后,仅由 cslot 负责替换官方 access token 与账号头。
57
+ *
58
+ * @param options 上游请求参数,包含方法、目标 path/query、原始请求头以及可选 body。
59
+ * @returns undici 上游响应对象。
60
+ * @throws 当网络层或 undici 请求失败时透传底层异常。
61
+ */
62
+ async function sendCodexRequest(options) {
63
+ const baseUrl = options.codexBaseUrl.replace(/\/+$/, "");
64
+ const pathWithQuery = options.pathWithQuery.startsWith("/")
65
+ ? options.pathWithQuery
66
+ : `/${options.pathWithQuery}`;
67
+ const bodyLength = options.body && options.body.length > 0 ? options.body.length : undefined;
68
+ return await (0, undici_1.request)(`${baseUrl}${pathWithQuery}`, {
69
+ method: options.method,
70
+ headers: buildUpstreamHeaders(options.requestHeaders, options.accessToken, bodyLength, options.accountIdHeader),
71
+ body: options.body && options.body.length > 0 ? options.body : undefined
72
+ });
73
+ }
48
74
  /**
49
75
  * 向 Codex responses 上游发送一次请求。
50
76
  *
@@ -57,9 +83,13 @@ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountId
57
83
  * @throws 当网络层或 undici 请求失败时透传底层异常。
58
84
  */
59
85
  async function sendCodexResponsesRequest(options) {
60
- return await (0, undici_1.request)(`${options.codexBaseUrl}/responses`, {
86
+ return await sendCodexRequest({
87
+ codexBaseUrl: options.codexBaseUrl,
61
88
  method: "POST",
62
- headers: buildUpstreamHeaders(options.requestHeaders, options.accessToken, options.body.length, options.accountIdHeader),
89
+ pathWithQuery: "/responses",
90
+ requestHeaders: options.requestHeaders,
91
+ accessToken: options.accessToken,
92
+ accountIdHeader: options.accountIdHeader,
63
93
  body: options.body
64
94
  });
65
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-slot",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "本地 Codex 多账号切换与状态管理工具",
5
5
  "type": "commonjs",
6
6
  "main": "dist/cli.js",