codex-slot 0.1.26 → 0.1.28
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/cli.js +1 -0
- package/dist/codex-config.js +36 -148
- package/dist/proxy-retry-service.js +160 -109
- package/dist/server.js +38 -8
- package/dist/upstream-client.js +33 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -81,6 +81,7 @@ function registerAccountCommands(program) {
|
|
|
81
81
|
function registerRuntimeCommands(program) {
|
|
82
82
|
program
|
|
83
83
|
.command("status")
|
|
84
|
+
.alias("usage")
|
|
84
85
|
.description((0, text_1.bi)("刷新并查看所有已录入账号或工作空间的最新额度", "Refresh usage for all managed slots"))
|
|
85
86
|
.option("--no-interactive", (0, text_1.bi)("仅输出状态表,不进入交互式切换", "Print only"))
|
|
86
87
|
.action(async (options) => {
|
package/dist/codex-config.js
CHANGED
|
@@ -325,53 +325,6 @@ function findTableHeaderOffset(content, header) {
|
|
|
325
325
|
}
|
|
326
326
|
return null;
|
|
327
327
|
}
|
|
328
|
-
/**
|
|
329
|
-
* 查找指定偏移之前最近的表头,供恢复原有表块位置时作为后备锚点。
|
|
330
|
-
*
|
|
331
|
-
* @param content 当前 `config.toml` 内容。
|
|
332
|
-
* @param offset 截止偏移。
|
|
333
|
-
* @returns 最近的表头文本;未命中返回 `null`。
|
|
334
|
-
*/
|
|
335
|
-
function findPreviousTableHeaderBeforeOffset(content, offset) {
|
|
336
|
-
const lines = content.split(/\r?\n/);
|
|
337
|
-
let currentOffset = 0;
|
|
338
|
-
let previousHeader = null;
|
|
339
|
-
for (const line of lines) {
|
|
340
|
-
const lineEnd = currentOffset + line.length;
|
|
341
|
-
const trimmed = line.trim();
|
|
342
|
-
if (currentOffset >= offset) {
|
|
343
|
-
break;
|
|
344
|
-
}
|
|
345
|
-
if (trimmed.startsWith("[") && !trimmed.startsWith("[[") && !trimmed.startsWith("#")) {
|
|
346
|
-
previousHeader = trimmed;
|
|
347
|
-
}
|
|
348
|
-
currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
349
|
-
}
|
|
350
|
-
return previousHeader;
|
|
351
|
-
}
|
|
352
|
-
/**
|
|
353
|
-
* 查找指定偏移之后的首个表头,供恢复原有表块位置时作为优先锚点。
|
|
354
|
-
*
|
|
355
|
-
* @param content 当前 `config.toml` 内容。
|
|
356
|
-
* @param offset 起始偏移。
|
|
357
|
-
* @returns 首个后续表头文本;未命中返回 `null`。
|
|
358
|
-
*/
|
|
359
|
-
function findNextTableHeaderAfterOffset(content, offset) {
|
|
360
|
-
const lines = content.split(/\r?\n/);
|
|
361
|
-
let currentOffset = 0;
|
|
362
|
-
for (const line of lines) {
|
|
363
|
-
const lineEnd = currentOffset + line.length;
|
|
364
|
-
const trimmed = line.trim();
|
|
365
|
-
if (currentOffset >= offset &&
|
|
366
|
-
trimmed.startsWith("[") &&
|
|
367
|
-
!trimmed.startsWith("[[") &&
|
|
368
|
-
!trimmed.startsWith("#")) {
|
|
369
|
-
return trimmed;
|
|
370
|
-
}
|
|
371
|
-
currentOffset = lineEnd + (content.slice(lineEnd, lineEnd + 2) === "\r\n" ? 2 : 1);
|
|
372
|
-
}
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
328
|
/**
|
|
376
329
|
* 清理文本中的所有 `model_provider` 配置块,确保每次接管都以单一稳定块重新写入。
|
|
377
330
|
*
|
|
@@ -414,6 +367,35 @@ function stripAllManagedBlocks(content) {
|
|
|
414
367
|
const withoutProviderBlock = stripMarkedBlocks(content, PROVIDER_BLOCK_START_MARKER, PROVIDER_BLOCK_END_MARKER);
|
|
415
368
|
return stripMarkedBlocks(withoutProviderBlock, MODEL_PROVIDER_START_MARKER, MODEL_PROVIDER_END_MARKER);
|
|
416
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* 清理文本中的所有 `model_provider = "cslot"` 根级配置,避免 stop 后残留 cslot 入口。
|
|
372
|
+
*
|
|
373
|
+
* @param content 当前 `config.toml` 内容。
|
|
374
|
+
* @returns 移除后的文本内容。
|
|
375
|
+
*/
|
|
376
|
+
function removeAllCslotModelProviderLines(content) {
|
|
377
|
+
let nextContent = content;
|
|
378
|
+
while (true) {
|
|
379
|
+
const range = findModelProviderLine(nextContent);
|
|
380
|
+
if (!range) {
|
|
381
|
+
return nextContent;
|
|
382
|
+
}
|
|
383
|
+
if (range.value.includes('model_provider = "cslot"')) {
|
|
384
|
+
nextContent = nextContent.slice(0, range.start) + nextContent.slice(range.end);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
return nextContent;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 在 stop 阶段只做去 cslot 化,移除所有受管块与残留的 cslot provider 配置。
|
|
392
|
+
*
|
|
393
|
+
* @param content 当前 `config.toml` 内容。
|
|
394
|
+
* @returns 已去除 cslot 接管痕迹的文本。
|
|
395
|
+
*/
|
|
396
|
+
function removeAllCslotManagedConfig(content) {
|
|
397
|
+
return removeAllProviderSections(removeAllCslotModelProviderLines(stripAllManagedBlocks(content)));
|
|
398
|
+
}
|
|
417
399
|
/**
|
|
418
400
|
* 查找根级配置区的尾部插入点。
|
|
419
401
|
*
|
|
@@ -487,86 +469,6 @@ function appendBlockToEnd(content, block, eol) {
|
|
|
487
469
|
}
|
|
488
470
|
return `${trimmed}${eol}${eol}${block}${eol}`;
|
|
489
471
|
}
|
|
490
|
-
/**
|
|
491
|
-
* 将表块尽量插回原有相邻表头附近;若锚点已不存在,则退回文件尾部追加。
|
|
492
|
-
*
|
|
493
|
-
* @param content 当前 `config.toml` 内容。
|
|
494
|
-
* @param block 待插入的表块。
|
|
495
|
-
* @param eol 目标换行符。
|
|
496
|
-
* @param preferredNextTableHeader 原始后续表头锚点,命中时优先插到该表之前。
|
|
497
|
-
* @param preferredPreviousTableHeader 原始前驱表头锚点,当前者失效时插到该表之后。
|
|
498
|
-
* @returns 插入后的完整文本。
|
|
499
|
-
*/
|
|
500
|
-
function insertTableBlock(content, block, eol, preferredNextTableHeader, preferredPreviousTableHeader) {
|
|
501
|
-
if (preferredNextTableHeader) {
|
|
502
|
-
const nextOffset = findTableHeaderOffset(content, preferredNextTableHeader);
|
|
503
|
-
if (nextOffset !== null) {
|
|
504
|
-
return insertBlockBetween(content.slice(0, nextOffset), block, content.slice(nextOffset), eol);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
if (preferredPreviousTableHeader) {
|
|
508
|
-
const previousRange = findTableSectionRange(content, preferredPreviousTableHeader);
|
|
509
|
-
if (previousRange) {
|
|
510
|
-
return insertBlockBetween(content.slice(0, previousRange.end), block, content.slice(previousRange.end), eol);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return appendBlockToEnd(content, block, eol);
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* 解析当前目标文件对应的上一轮接管快照。
|
|
517
|
-
*
|
|
518
|
-
* @param targetFile 当前准备接管或恢复的 `config.toml` 路径。
|
|
519
|
-
* @returns 命中同一目标文件时返回上一轮快照;否则返回 `null`。
|
|
520
|
-
*/
|
|
521
|
-
function resolveManagedStateForTarget(targetFile) {
|
|
522
|
-
const managedState = (0, state_1.getManagedCodexConfigState)();
|
|
523
|
-
if (!managedState || managedState.target_file !== targetFile) {
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
return managedState;
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* 基于当前未受管的配置文本与上一轮快照,生成本轮接管所需的最小恢复快照。
|
|
530
|
-
*
|
|
531
|
-
* 业务规则:
|
|
532
|
-
* 1. 优先记录当前文件里实际存在的原始 `model_provider` 与 `[model_providers.cslot]`。
|
|
533
|
-
* 2. 若当前文件只剩残留受管块,允许继承上一轮快照中的原始片段。
|
|
534
|
-
* 3. 仅保存 cslot 自己声明所有权的两块配置及其锚点,不保存整文件内容。
|
|
535
|
-
*
|
|
536
|
-
* @param targetFile 当前准备接管的 `config.toml` 路径。
|
|
537
|
-
* @param strippedCurrent 已移除受管标记块后的配置文本。
|
|
538
|
-
* @param previousManagedState 同一目标文件的上一轮快照;不存在时传 `null`。
|
|
539
|
-
* @returns 本轮接管后用于 stop 恢复的快照。
|
|
540
|
-
*/
|
|
541
|
-
function buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState) {
|
|
542
|
-
const originalModelProviderLine = findModelProviderLine(strippedCurrent);
|
|
543
|
-
const originalProviderSection = findProviderSectionRange(strippedCurrent);
|
|
544
|
-
return {
|
|
545
|
-
target_file: targetFile,
|
|
546
|
-
original_model_provider_block: originalModelProviderLine?.value ??
|
|
547
|
-
previousManagedState?.original_model_provider_block ??
|
|
548
|
-
null,
|
|
549
|
-
original_model_provider_next_table_header: (originalModelProviderLine
|
|
550
|
-
? findNextTableHeaderAfterOffset(strippedCurrent, originalModelProviderLine.end)
|
|
551
|
-
: null) ??
|
|
552
|
-
previousManagedState?.original_model_provider_next_table_header ??
|
|
553
|
-
null,
|
|
554
|
-
original_cslot_provider_block: (originalProviderSection ? sanitizeLegacyCslotProviderBlock(originalProviderSection.value) : null) ??
|
|
555
|
-
(previousManagedState?.original_cslot_provider_block
|
|
556
|
-
? sanitizeLegacyCslotProviderBlock(previousManagedState.original_cslot_provider_block)
|
|
557
|
-
: null),
|
|
558
|
-
original_cslot_provider_previous_table_header: (originalProviderSection
|
|
559
|
-
? findPreviousTableHeaderBeforeOffset(strippedCurrent, originalProviderSection.start)
|
|
560
|
-
: null) ??
|
|
561
|
-
previousManagedState?.original_cslot_provider_previous_table_header ??
|
|
562
|
-
null,
|
|
563
|
-
original_cslot_provider_next_table_header: (originalProviderSection
|
|
564
|
-
? findNextTableHeaderAfterOffset(strippedCurrent, originalProviderSection.end)
|
|
565
|
-
: null) ??
|
|
566
|
-
previousManagedState?.original_cslot_provider_next_table_header ??
|
|
567
|
-
null
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
472
|
/**
|
|
571
473
|
* 将 cslot 需要的 provider 配置写入指定 `config.toml`,并保存恢复快照。
|
|
572
474
|
*
|
|
@@ -579,21 +481,19 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
|
|
|
579
481
|
const rawTarget = targetPathOrDir ? (0, config_1.expandHome)(targetPathOrDir) : getDefaultCodexConfigPath();
|
|
580
482
|
const targetFile = rawTarget.endsWith(".toml") ? rawTarget : node_path_1.default.join(rawTarget, "config.toml");
|
|
581
483
|
const current = node_fs_1.default.existsSync(targetFile) ? node_fs_1.default.readFileSync(targetFile, "utf8") : "";
|
|
582
|
-
const
|
|
583
|
-
const
|
|
584
|
-
const eol = detectEol(strippedCurrent);
|
|
585
|
-
const snapshot = buildManagedSnapshot(targetFile, strippedCurrent, previousManagedState);
|
|
484
|
+
const cleanedCurrent = removeAllCslotManagedConfig(current);
|
|
485
|
+
const eol = detectEol(cleanedCurrent);
|
|
586
486
|
const config = options?.config ?? (0, config_1.loadConfig)();
|
|
587
487
|
const managedModelProviderBlock = buildManagedModelProviderBlock(eol);
|
|
588
488
|
const managedProviderBlock = buildManagedProviderBlock(eol, config);
|
|
589
|
-
const cleanedBaseContent = removeAllProviderSections(removeAllModelProviderLines(
|
|
590
|
-
let nextContent = insertRootBlock(cleanedBaseContent, managedModelProviderBlock, eol
|
|
489
|
+
const cleanedBaseContent = removeAllProviderSections(removeAllModelProviderLines(cleanedCurrent));
|
|
490
|
+
let nextContent = insertRootBlock(cleanedBaseContent, managedModelProviderBlock, eol);
|
|
591
491
|
nextContent = appendBlockToEnd(nextContent, managedProviderBlock, eol);
|
|
592
492
|
if (!nextContent.endsWith(eol)) {
|
|
593
493
|
nextContent = `${nextContent}${eol}`;
|
|
594
494
|
}
|
|
595
495
|
writeFileAtomic(targetFile, nextContent);
|
|
596
|
-
(0, state_1.setManagedCodexConfigState)(
|
|
496
|
+
(0, state_1.setManagedCodexConfigState)({ target_file: targetFile });
|
|
597
497
|
if (!options?.silent) {
|
|
598
498
|
console.log((0, text_1.bi)(`已写入: ${targetFile}`, `Written to: ${targetFile}`));
|
|
599
499
|
console.log(`base_url=http://${config.server.host}:${config.server.port}/v1`);
|
|
@@ -609,25 +509,13 @@ function applyManagedCodexConfig(targetPathOrDir, options) {
|
|
|
609
509
|
*/
|
|
610
510
|
function deactivateManagedCodexConfig() {
|
|
611
511
|
const managedState = (0, state_1.getManagedCodexConfigState)();
|
|
612
|
-
|
|
613
|
-
return null;
|
|
614
|
-
}
|
|
615
|
-
const targetFile = managedState.target_file;
|
|
512
|
+
const targetFile = managedState?.target_file ?? getDefaultCodexConfigPath();
|
|
616
513
|
if (!node_fs_1.default.existsSync(targetFile)) {
|
|
617
514
|
(0, state_1.clearManagedCodexConfigState)();
|
|
618
515
|
return null;
|
|
619
516
|
}
|
|
620
517
|
const current = node_fs_1.default.readFileSync(targetFile, "utf8");
|
|
621
|
-
const
|
|
622
|
-
let restored = sanitizeExistingCslotProviderSection(stripAllManagedBlocks(current));
|
|
623
|
-
const existingModelProviderLine = findModelProviderLine(restored);
|
|
624
|
-
if (!existingModelProviderLine && managedState.original_model_provider_block) {
|
|
625
|
-
restored = insertRootBlock(restored, managedState.original_model_provider_block, eol, managedState.original_model_provider_next_table_header);
|
|
626
|
-
}
|
|
627
|
-
const existingProviderSection = findProviderSectionRange(restored);
|
|
628
|
-
if (!existingProviderSection && managedState.original_cslot_provider_block) {
|
|
629
|
-
restored = insertTableBlock(restored, managedState.original_cslot_provider_block, eol, managedState.original_cslot_provider_next_table_header, managedState.original_cslot_provider_previous_table_header);
|
|
630
|
-
}
|
|
518
|
+
const restored = removeAllCslotManagedConfig(sanitizeExistingCslotProviderSection(current));
|
|
631
519
|
writeFileAtomic(targetFile, restored);
|
|
632
520
|
(0, state_1.clearManagedCodexConfigState)();
|
|
633
521
|
return targetFile;
|
|
@@ -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
|
|
68
|
-
* @param
|
|
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,
|
|
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.
|
|
112
|
+
return await dependencies.sendCodexRequest({
|
|
76
113
|
codexBaseUrl: config.upstream.codex_base_url,
|
|
77
|
-
|
|
114
|
+
method: request.method.toUpperCase(),
|
|
115
|
+
pathWithQuery,
|
|
116
|
+
requestHeaders: request.headers,
|
|
78
117
|
accessToken,
|
|
79
118
|
accountIdHeader: auth?.tokens?.account_id,
|
|
80
|
-
body:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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)(
|
|
112
|
-
type: "
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
210
|
+
markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
|
|
148
211
|
lastErrorPayload = {
|
|
149
212
|
error: {
|
|
150
|
-
message: `账号 ${picked.account.id}
|
|
151
|
-
type: "
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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}
|
|
187
|
-
type: "
|
|
242
|
+
message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
|
|
243
|
+
type: "account_usage_limited"
|
|
188
244
|
}
|
|
189
245
|
};
|
|
190
246
|
continue;
|
|
191
247
|
}
|
|
192
|
-
if (upstream.statusCode >=
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
const requestBody = await readRawRequestBody(
|
|
91
|
-
const result = await (0, proxy_retry_service_1.
|
|
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.
|
|
127
|
-
|
|
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.
|
|
130
|
-
|
|
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"],
|
package/dist/upstream-client.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
86
|
+
return await sendCodexRequest({
|
|
87
|
+
codexBaseUrl: options.codexBaseUrl,
|
|
61
88
|
method: "POST",
|
|
62
|
-
|
|
89
|
+
pathWithQuery: "/responses",
|
|
90
|
+
requestHeaders: options.requestHeaders,
|
|
91
|
+
accessToken: options.accessToken,
|
|
92
|
+
accountIdHeader: options.accountIdHeader,
|
|
63
93
|
body: options.body
|
|
64
94
|
});
|
|
65
95
|
}
|