-
Notifications
You must be signed in to change notification settings - Fork 84
Expand file tree
/
Copy pathproxy.mjs
More file actions
3490 lines (3235 loc) · 149 KB
/
Copy pathproxy.mjs
File metadata and controls
3490 lines (3235 loc) · 149 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Command Code → OpenAI 兼容代理
* 基于真实 CLI 流量抓包数据构建
*/
import http from 'http';
import https from 'https';
import tls from 'tls';
import { Readable } from 'stream';
import crypto from 'crypto';
import { randomUUID } from 'crypto';
import { readFileSync, existsSync, appendFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
// ── 配置加载 ──────────────────────────────────────
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadConfig() {
const defaults = {
port: 3000,
host: '0.0.0.0',
apiBase: 'https://api.commandcode.ai',
projectSlug: 'cc-proxy',
logFile: '',
logLevel: 'info',
useProviderModels: true,
modelRefreshIntervalMs: 5 * 60 * 1000, // 5 minutes
zdr: false,
cliMode: 'agent', // 信封 mode。服务端枚举(真机 400 报出来的):agent|learning|custom-agent|custom-agent-create|title-gen|tool-desc|compact|vision
cliSessionMode: 'interactive', // lifecycle metadata 的 mode —— 注意这是另一个枚举:interactive | non-interactive
fingerprintSalt: '',
deviceProjectDir: '', // 伪造的项目目录(留空则用内置的 C:\Users\dev\projects\app) // 改这个值 = 让所有账号换一台设备(见设备指纹注释)
emptySystemPlaceholder: true, // 无 system prompt 时发空格占位,阻止 CC 上游注入 ~7.5K token 默认提示词(issue #17)
upstreamProxy: '', // 上游 HTTP 代理,如 http://127.0.0.1:7890(issue #18)
};
const configPath = resolve(__dirname, 'config.json');
if (existsSync(configPath)) {
try {
const user = JSON.parse(readFileSync(configPath, 'utf-8'));
Object.assign(defaults, user);
} catch (e) {
console.error('[config] Failed to parse config.json:', e.message);
}
}
// 环境变量覆写
if (process.env.PORT) defaults.port = parseInt(process.env.PORT);
if (process.env.HOST) defaults.host = process.env.HOST;
if (process.env.CC_API_BASE) defaults.apiBase = process.env.CC_API_BASE;
if (process.env.PROJECT_SLUG) defaults.projectSlug = process.env.PROJECT_SLUG;
if (process.env.LOG_FILE) defaults.logFile = process.env.LOG_FILE;
if (process.env.CC_USE_PROVIDER_MODELS) defaults.useProviderModels = process.env.CC_USE_PROVIDER_MODELS !== 'false';
if (process.env.CMD_ZDR !== undefined) defaults.zdr = process.env.CMD_ZDR === '1';
if (process.env.CC_FINGERPRINT_SALT !== undefined) defaults.fingerprintSalt = process.env.CC_FINGERPRINT_SALT;
if (process.env.CC_DEVICE_PROJECT_DIR) defaults.deviceProjectDir = process.env.CC_DEVICE_PROJECT_DIR;
if (process.env.CC_CLI_MODE) defaults.cliMode = process.env.CC_CLI_MODE;
if (process.env.CC_CLI_SESSION_MODE) defaults.cliSessionMode = process.env.CC_CLI_SESSION_MODE;
if (process.env.CC_EMPTY_SYSTEM_PLACEHOLDER) defaults.emptySystemPlaceholder = process.env.CC_EMPTY_SYSTEM_PLACEHOLDER !== 'false';
if (process.env.CC_UPSTREAM_PROXY) defaults.upstreamProxy = process.env.CC_UPSTREAM_PROXY;
return defaults;
}
const CFG = loadConfig();
// ── 设备指纹(形态与哈希逐字对齐官方 CLI 1.53.1) ──────
// CPU 型号与核心数对应表(仅 Windows x64)
const FINGERPRINT_CPUS = [
{ model: '12th Gen Intel(R) Core(TM) i7-12650H', cores: 10 }, // TEMP-REVERT
{ model: '12th Gen Intel(R) Core(TM) i5-12400F', cores: 6 },
{ model: '12th Gen Intel(R) Core(TM) i9-12900K', cores: 16 },
{ model: '13th Gen Intel(R) Core(TM) i7-13700K', cores: 16 },
{ model: '13th Gen Intel(R) Core(TM) i5-13600K', cores: 14 },
{ model: '13th Gen Intel(R) Core(TM) i9-13900K', cores: 24 },
{ model: 'Intel(R) Core(TM) Ultra 7 155H', cores: 16 },
{ model: 'Intel(R) Core(TM) Ultra 9 285H', cores: 16 },
{ model: 'Intel(R) Core(TM) i9-14900K', cores: 24 },
{ model: 'Intel(R) Core(TM) i7-14700K', cores: 20 },
{ model: 'AMD Ryzen 7 7800X3D', cores: 8 },
{ model: 'AMD Ryzen 9 7950X', cores: 16 },
{ model: 'AMD Ryzen 5 7600', cores: 6 },
{ model: 'AMD Ryzen 9 7900X', cores: 12 },
{ model: 'AMD Ryzen 7 5800X3D', cores: 8 },
];
const FINGERPRINT_MEMS = [8, 16, 24, 32, 48, 64];
const FINGERPRINT_TZS = [
'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'America/Toronto',
'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Moscow',
'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Singapore', 'Asia/Seoul', 'Asia/Hong_Kong',
'Australia/Sydney', 'Pacific/Auckland',
];
const FINGERPRINT_MAC_COUNT_RANGE = [2, 3, 4, 5]; // 随机 2~5 个 MAC
// CLI 的根盐(buildMachineFingerprint 常量 sb)
const FP_SALT = 'command-code:device-fingerprint:v1';
// 设备档案:指纹 / config.environment / config.workingDir / x-project-slug / lifecycle.os 共用同一份,
// 避免出现「指纹说 win32、环境说 linux」这类自相矛盾,也避免把宿主机真实信息(平台、Node 版本、cwd)交给上游。
const DEVICE_PROFILE = {
platform: 'win32',
arch: 'x64',
osRelease: '10.0.22631',
isContainer: false,
// 伪造的项目目录:与 x-project-slug 同源(真机里 slug = slugify(workingDir))
projectDir: CFG.deviceProjectDir || 'C:\\Users\\dev\\projects\\app',
};
const FP_OS_USERS = ['dev', 'user', 'admin', 'coder', 'engineer', 'work'];
const FP_MAIL_DOMAINS = ['gmail.com', 'outlook.com', 'qq.com', '163.com'];
// 伪造信号的派生源。加 CC_FINGERPRINT_SALT 可成批换身份 —— 真实账号的 key 动不了,这是逃生口。
// 注意:哈希阶段用的是 CLI 的固定盐(FP_SALT),salt 只影响「伪造出哪台机器」。
function fpDigest(apiKey, field) {
return crypto.createHash('sha256')
.update(`${CFG.fingerprintSalt || ''}\0${apiKey}\0${field}`)
.digest();
}
// 从候选池确定性地挑一项:打分取最大。以后往池里加候选只影响「新候选恰好胜出」的那部分 key,
// 不会像取模那样因为池长度变化让所有 key 一起换设备。
function fpPickIndex(apiKey, field, items, labelOf) {
let bestIdx = 0;
let bestScore = null;
for (let i = 0; i < items.length; i++) {
const score = fpDigest(apiKey, `${field}\0${labelOf(i)}`);
if (!bestScore || Buffer.compare(score, bestScore) > 0) { bestScore = score; bestIdx = i; }
}
return bestIdx;
}
// CLI 的 hashSignal:sha256(FP_SALT + "\0" + value.toLowerCase()),空值返回 undefined(JSON 里被丢掉)
function fingerprintHash(value) {
const v = String(value ?? '').trim();
if (!v) return undefined;
return crypto.createHash('sha256').update(`${FP_SALT}\0${v.toLowerCase()}`).digest('hex');
}
// 与 CLI 的唯一区别是「信号值」:CLI 读真实机器(注册表 / ioreg / machine-id、网卡 MAC、
// os.userInfo、git config),这里按 apiKey 确定性地伪造一组逼真值。
// 为什么必须由 apiKey 派生而不是随机:指纹代表「这个账号对应的那台设备」,重启、内存回收、
// 多实例、月额度用尽停用数周后恢复,上游都应看到同一台设备;换指纹本身就是可疑信号。
function generateFingerprint(apiKey) {
const cpuEntry = FINGERPRINT_CPUS[fpPickIndex(apiKey, 'cpu', FINGERPRINT_CPUS, i => `${FINGERPRINT_CPUS[i].model}|${FINGERPRINT_CPUS[i].cores}`)];
const memGiB = FINGERPRINT_MEMS[fpPickIndex(apiKey, 'mem', FINGERPRINT_MEMS, i => String(FINGERPRINT_MEMS[i]))];
const tz = FINGERPRINT_TZS[fpPickIndex(apiKey, 'timezone', FINGERPRINT_TZS, i => FINGERPRINT_TZS[i])];
const macCount = FINGERPRINT_MAC_COUNT_RANGE[fpPickIndex(apiKey, 'macCount', FINGERPRINT_MAC_COUNT_RANGE, i => String(FINGERPRINT_MAC_COUNT_RANGE[i]))];
const osUser = FP_OS_USERS[fpPickIndex(apiKey, 'osUser', FP_OS_USERS, i => FP_OS_USERS[i])];
const mailDomain = FP_MAIL_DOMAINS[fpPickIndex(apiKey, 'mailDomain', FP_MAIL_DOMAINS, i => FP_MAIL_DOMAINS[i])];
const hex = (field, bytes) => fpDigest(apiKey, field).subarray(0, bytes).toString('hex');
// Windows MachineGuid 形状:8-4-4-4-12
const mid = hex('machineId', 16);
const machineId = `${mid.slice(0, 8)}-${mid.slice(8, 12)}-${mid.slice(12, 16)}-${mid.slice(16, 20)}-${mid.slice(20, 32)}`;
const macs = [];
for (let i = 0; i < macCount; i++) {
const b = fpDigest(apiKey, `mac${i}`).subarray(0, 6);
macs.push([...b].map(x => x.toString(16).padStart(2, '0')).join(':'));
}
macs.sort(); // CLI 对 MAC 去重后排序
const hostname = `DESKTOP-${hex('hostname', 4).toUpperCase()}`;
const gitEmail = `${osUser}.${hex('gitEmail', 3)}@${mailDomain}`;
const machineIdHash = fingerprintHash(machineId);
const macHashes = macs.map(fingerprintHash).filter(Boolean);
const osUserHash = fingerprintHash(osUser);
const hostnameHash = fingerprintHash(hostname);
const gitEmailHash = fingerprintHash(gitEmail);
// CLI 的 thumbmark:主盐 + "\0machine\0" + join([machineId, macs.join(",")])
// (machineId 非空时不再拼 hostname/cpuModel)
const thumbSeed = [machineId.trim(), macs.join(','), machineId.trim() ? '' : hostname, machineId.trim() ? '' : cpuEntry.model].filter(Boolean);
const thumbmark = crypto.createHash('sha256').update(`${FP_SALT}\0machine\0${thumbSeed.join('|') || 'unknown'}`).digest('hex');
return {
thumbmark,
components: {
machineIdHash,
macHashes,
osUserHash,
hostnameHash,
gitEmailHash,
platform: DEVICE_PROFILE.platform,
arch: DEVICE_PROFILE.arch,
osRelease: DEVICE_PROFILE.osRelease,
cpuModel: cpuEntry.model,
cpuCount: cpuEntry.cores,
memGiB,
isContainer: DEVICE_PROFILE.isContainer,
timezone: tz,
runtime: 'cli',
collectorVersion: 1,
},
};
}
// 本代理**实际实现**的 wire 协议版本(对齐 command-code@1.53.1 源码)。
// 真机发的永远是「形状 + 版本号」自洽的组合;如果版本号跟着 npm 走而形状没变,
// 就变成「自称最新版、却说旧方言」—— 这比版本号过期更容易被行为分析挑出来。
// 因此这里报的是协议版本,npm 上更新了只告警、不自动改。
const CC_PROTOCOL_VERSION = '1.53.1';
let CC_VERSION = CC_PROTOCOL_VERSION;
const CC_VERSION_REFRESH_MS = 24 * 60 * 60 * 1000; // 24h — 检查一次是否发生漂移
// ── 协议漂移检测(只告警,不改版本号) ─────────────
// 上游 CLI 更新可能带来协议变化。这里只负责提醒「该重新读包对齐了」,
// 绝不会把 x-command-code-version 改成一个我们并未实现的版本。
async function checkProtocolDrift() {
try {
const url = 'https://registry.npmjs.org/command-code/latest';
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`npm responded with ${res.status}`);
const pkg = await res.json();
const latest = typeof pkg?.version === 'string' ? pkg.version : null;
if (latest && latest !== CC_PROTOCOL_VERSION) {
log('warn', 'CC CLI version drift: protocol may have changed, re-align from the npm package', {
implemented: CC_PROTOCOL_VERSION, latest,
});
} else if (latest) {
log('info', 'CC CLI version in sync', { version: latest });
}
} catch (e) {
log('warn', 'CC version check failed', { error: e.message });
}
}
checkProtocolDrift(); // 启动时立即检查
setInterval(checkProtocolDrift, CC_VERSION_REFRESH_MS);
// 请求体大小上限:默认 100MB,可用环境变量 CC_MAX_BODY_MB 覆盖(正整数,单位 MB)
// ⚠️ 内存特性(issue #20 实测):请求体在转发到上游前会同时存在多份副本 ——
// chunks[] / Buffer.concat / utf8 字符串 / JSON.parse 对象树 / buildCcRequest 重建对象树 / JSON.stringify 序列化体。
// 实测峰值 ≈ body 大小 × 5.1~7.4(7MB→+52MB,20MB→+116MB;而 413 拒绝路径只要 ×1.05)。
// 故 100MB 上限意味着「单个请求」最坏可吃 ~550MB,且该上限是每请求的、不是全局的。
// 公网/多用户部署请在反向代理层同时限制 body 大小与在途请求数(见 README「内存与部署」)。
const MAX_BODY_SIZE = (() => {
const mb = Number.parseInt(process.env.CC_MAX_BODY_MB ?? '', 10);
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024;
})();
// 上游读空闲超时(issue #19):只计「reader.read() 的等待」,每收到一个 chunk 重置,
// 不是整个请求的总时长。默认值保持不变(30s / 90s),可用环境变量覆盖 ——
// 官方 CLI 对上游没有任何 idle timeout(反编译 command-code@1.50.0 已验证,
// createApiClient 调用点均未传 timeout),合法的长思考停顿可达数百秒,
// 遇到推理模型被 30s 误杀 / 触发 429 重试放大时,调大这两个值即可。
const STREAM_IDLE_TIMEOUT_MS = (() => {
const ms = Number.parseInt(process.env.CC_STREAM_IDLE_MS ?? '', 10);
return Number.isFinite(ms) && ms > 0 ? ms : 30000; // 默认 30s — 流式无新数据中断
})();
const NONSTREAM_IDLE_TIMEOUT_MS = (() => {
const ms = Number.parseInt(process.env.CC_NONSTREAM_IDLE_MS ?? '', 10);
return Number.isFinite(ms) && ms > 0 ? ms : 90000; // 默认 90s — 非流式超时更宽容
})();
// 客户端「僵死」保护:既不读也不断开时,该请求会连带上游连接一直挂着(背压修复后的残留)。
// 实测残留在途成本约 5MB/连接 —— 有界、不泄漏、断开即回收,但连接数本身无上限。
// 默认 0 = 禁用,保持既有行为不变:僵死客户端与「卡在工具执行的合法客户端」在协议层无法
// 区分,而官方 CLI 对上游没有任何 idle timeout(issue #19),贸然加超时会误杀健康请求。
// 在途请求上限(可选,默认关闭)。项目定位是纯反代层,并发控制属于下游(nginx
// limit_conn,per-IP / per-key);本项仅为「不挂反代裸跑」的场景提供一个可选的
// 进程内全局兜底,不替代下游方案,也不感知客户端身份。
// 内存 = 在途数 × (0.13MB + 5.5 × body_MB):body 上限只管住单请求量级,乘数由本项封顶。
// 超限返回 503 + Retry-After(SDK 会自行退避重试),而不是放任进程被 OOM 杀掉。
// 默认 0 = 关闭,不限制并发(既有的反代层定位不变,行为零变化);需要时按需开启:
// CC_MAX_INFLIGHT=32 npm start
// 注意:body 上限只管住单请求量级,乘数由本项封顶。默认 body 上限 100MB 时,
// N × 最坏 550MB —— 要硬性内存上界需同时下调 CC_MAX_BODY_MB。
const MAX_INFLIGHT = (() => {
const n = Number.parseInt(process.env.CC_MAX_INFLIGHT ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 0; // 默认 0 = 不限
})();
let inflightCount = 0; // 当前在途请求数(不含 /health)
const CLIENT_DRAIN_TIMEOUT_MS = (() => {
const ms = Number.parseInt(process.env.CC_CLIENT_DRAIN_TIMEOUT_MS ?? '', 10);
return Number.isFinite(ms) && ms > 0 ? ms : 0;
})();
// 连续超时计数:连续 3 次超时才提醒压缩上下文,任意成功请求后重置
let consecutiveTimeouts = 0;
const TIMEOUT_REDUCE_CONTEXT_THRESHOLD = 3;
// ── 日志 ─────────────────────────────────────────────
function log(level, msg, data) {
const line = `[${new Date().toISOString()}] [${level}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}`;
console.log(line);
if (CFG.logFile) {
try { appendFileSync(CFG.logFile, line + '\n', 'utf-8'); } catch {}
}
}
// 把上游错误体摘要成单行,便于日志排查。
// 之前 CC API error 只记 status,不记 body —— 遇到 400 只能靠猜(问题来源见 hk_sji 排查)。
// 截断到 500 字符,避免异常大的 body 刷爆日志;同时压掉换行,保证一条日志一行。
function summarizeUpstreamError(text, limit = 500) {
if (!text) return '';
const flat = String(text).replace(/\s+/g, ' ').trim();
return flat.length > limit ? flat.slice(0, limit) + '…(' + (flat.length - limit) + ' more)' : flat;
}
// ── 会话管理 ───────────────────────────────────────
// 每个 API Key 独立一个 session,12h 过期 + 1h 随机抖动
// 同一 Key 在同一周期内复用,到期自动换新
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000; // 12h
const SESSION_JITTER_MS = 60 * 60 * 1000; // 1h 抖动范围
const sessionStore = new Map(); // apiKey → { sessionId, expiresAt }
function ensureSession(apiKey) {
const now = Date.now();
const entry = sessionStore.get(apiKey);
if (entry && now < entry.expiresAt) {
return entry.sessionId;
}
// 过期或第一次:生成新 session
const jitter = Math.floor(Math.random() * SESSION_JITTER_MS);
const sessionId = randomUUID();
sessionStore.set(apiKey, { sessionId, expiresAt: now + SESSION_DURATION_MS + jitter });
log('info', 'Session created', { sessionId: sessionId.slice(0, 8), storeSize: sessionStore.size });
return sessionId;
}
// 定期清理过期 session 和 key 状态,防止 Map 无限增长
setInterval(() => {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of sessionStore) {
if (now >= entry.expiresAt) {
sessionStore.delete(key);
keyStateStore.delete(key); // 同时清理该 key 的指纹状态
cleaned++;
}
}
if (cleaned > 0) log('info', 'Session cleanup', { cleaned, remaining: sessionStore.size });
}, 60 * 60 * 1000); // 每小时
function getSessionId(incomingHeaders, apiKey, promptCacheKey) {
// 优先从客户端传来的 session 类 header 获取
const candidates = [
incomingHeaders['x-session-id'],
incomingHeaders['x-claude-code-session-id'],
incomingHeaders['session_id'],
promptCacheKey,
];
for (const id of candidates) {
if (id && typeof id === 'string' && id.length >= 8) return id;
}
// 按 API Key 分 session
return ensureSession(apiKey);
}
// 每个请求独立 thread ID
function newThreadId() { return randomUUID(); }
// ── 每 Key 独立状态(fingerprint + 初始化节流) ──
// 每个 API Key 拥有自己的设备指纹和初始化定时器
const keyStateStore = new Map(); // apiKey → { fingerprint, nextInitAt }
function getOrCreateKeyState(apiKey) {
let state = keyStateStore.get(apiKey);
if (!state) {
state = {
fingerprint: generateFingerprint(apiKey),
nextInitAt: 0,
};
keyStateStore.set(apiKey, state);
log('info', 'Fingerprint generated for key', { keyPrefix: apiKey.slice(0, 8) });
}
return state;
}
// ── 初始化预请求(fingerprint + lifecycle,首次 + 每 8h+2h 抖动) ────
const INIT_REFRESH_MS = 8 * 60 * 60 * 1000; // 8h
const INIT_JITTER_MS = 2 * 60 * 60 * 1000; // 2h 抖动
async function ensureInitialized(apiKey, signal) {
const state = getOrCreateKeyState(apiKey);
const now = Date.now();
if (now < state.nextInitAt) return;
try {
// 并行发两个预请求
const headers = {
'Content-Type': 'application/json',
'x-cli-environment': 'production',
'Authorization': `Bearer ${apiKey}`,
'x-command-code-version': CC_VERSION,
...(CFG.zdr ? { 'x-cmd-zdr': '1' } : {}),
};
const fingerprint = state.fingerprint || {};
await Promise.all([
upstreamFetch(`${CFG.apiBase}/alpha/fingerprint/record`, {
method: 'POST', headers, signal,
body: JSON.stringify(fingerprint),
}).then(r => {
if (!r.ok) log('warn', 'Fingerprint record failed', { status: r.status });
else log('info', 'Fingerprint recorded');
}).catch(e => {
if (e.name !== 'AbortError') log('warn', 'Fingerprint record error', { error: e.message });
}),
upstreamFetch(`${CFG.apiBase}/alpha/lifecycle-events`, {
method: 'POST', headers, signal,
body: JSON.stringify({
eventType: 'cli_session_exists',
metadata: {
sessionId: `sess_${crypto.randomBytes(8).toString('hex')}`,
cliVersion: CC_VERSION,
mode: CFG.cliSessionMode || 'interactive',
os: `${fingerprint.components.platform}-${fingerprint.components.arch}`,
},
}),
}).then(r => {
if (!r.ok) log('warn', 'Lifecycle event failed', { status: r.status });
else log('info', 'Lifecycle event sent');
}).catch(e => {
if (e.name !== 'AbortError') log('warn', 'Lifecycle event error', { error: e.message });
}),
]);
// 成功:8h + 2h 随机抖动
const jitter = Math.floor(Math.random() * INIT_JITTER_MS);
state.nextInitAt = Date.now() + INIT_REFRESH_MS + jitter;
log('info', 'Fingerprint/lifecycle next refresh', { nextIn: `${(INIT_REFRESH_MS + jitter) / 3600000}h` });
} catch (e) {
if (e.name !== 'AbortError') log('warn', 'Fingerprint/lifecycle refresh error, will retry next request', { error: e.message });
}
}
// ── 模型列表 ───────────────────────────────────────
const MODELS = [
// Anthropic
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
{ id: 'claude-opus-4-8', name: 'Claude Opus 4.8' },
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
// OpenAI
{ id: 'gpt-5.5', name: 'GPT-5.5' },
{ id: 'gpt-5.4', name: 'GPT-5.4' },
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' },
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' },
// DeepSeek
{ id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
{ id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
// Kimi
{ id: 'moonshotai/Kimi-K2.6', name: 'Kimi K2.6' },
{ id: 'moonshotai/Kimi-K2.5', name: 'Kimi K2.5' },
// GLM
{ id: 'zai-org/GLM-5.1', name: 'GLM 5.1' },
{ id: 'zai-org/GLM-5', name: 'GLM 5' },
// MiniMax
{ id: 'MiniMaxAI/MiniMax-M3', name: 'MiniMax M3' },
{ id: 'MiniMaxAI/MiniMax-M2.7', name: 'MiniMax M2.7' },
{ id: 'MiniMaxAI/MiniMax-M2.5', name: 'MiniMax M2.5' },
// Qwen
{ id: 'Qwen/Qwen3.6-Max-Preview', name: 'Qwen 3.6 Max Preview' },
{ id: 'Qwen/Qwen3.6-Plus', name: 'Qwen 3.6 Plus' },
{ id: 'Qwen/Qwen3.7-Max', name: 'Qwen 3.7 Max' },
// Step
{ id: 'stepfun/Step-3.7-Flash', name: 'Step 3.7 Flash' },
{ id: 'stepfun/Step-3.5-Flash', name: 'Step 3.5 Flash' },
// Xiaomi
{ id: 'xiaomi/mimo-v2.5-pro', name: 'MiMo V2.5 Pro' },
{ id: 'xiaomi/mimo-v2.5', name: 'MiMo V2.5' },
// Gemini
{ id: 'google/gemini-3.5-flash', name: 'Gemini 3.5 Flash' },
{ id: 'google/gemini-3.1-flash-lite', name: 'Gemini 3.1 Flash Lite' },
];
// ── 工具函数 ───────────────────────────────────────
// CLI 的 slug 规则:对**完整工作目录**做 slugify(@sindresorhus/slugify),空则 "root",无随机后缀;
// 同一个 slug 也是 CLI 本地会话目录名。所以 slug 与 config.workingDir 同源:slug = slugify(workingDir)。
function slugifyProjectPath(p) {
const s = String(p || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return s || 'root';
}
function generateTraceparent() {
const traceId = crypto.randomBytes(16).toString('hex');
const parentId = crypto.randomBytes(8).toString('hex');
return `00-${traceId}-${parentId}-01`;
}
function nowUnix() {
return Math.floor(Date.now() / 1000);
}
function getDateStr() {
return new Date().toISOString().slice(0, 10);
}
// ── CC 请求体构建 ─────────────────────────────────
function buildCcRequest(openaiReq) {
const { model, messages, max_tokens, temperature, tools, stream, reasoning_effort, tool_choice, parallel_tool_calls, prompt_cache_key } = openaiReq;
// 提取系统提示:OpenAI 的 system / developer 都映射为系统提示。
// 形态对齐 CLI 的 toWireSystem —— **块数组**,非最后一块补 \n,cache_control 逐块保留。
// (CLI 的 composeSystemPrompt:基础提示词是字符串时发字符串、是 sections 时发块数组;
// 真机验证两种形态服务端都接受,见 PROTOCOL-FACTS-1.53.1.md。这里统一用块数组,
// 才能把客户端标在 system 上的缓存断点原样送上去。)
const systemMsgs = messages.filter(m => m.role === 'system' || m.role === 'developer');
const systemBlocks = [];
for (const m of systemMsgs) {
if (typeof m.content === 'string') {
if (m.content) systemBlocks.push({ type: 'text', text: m.content });
} else if (Array.isArray(m.content)) {
for (const c of m.content) {
const text = c?.text ?? c?.content ?? '';
if (text === '' && !c?.cache_control) continue;
const block = { type: 'text', text: String(text) };
if (c?.cache_control) block.cache_control = c.cache_control;
systemBlocks.push(block);
}
} else if (m.content != null) {
systemBlocks.push({ type: 'text', text: String(m.content) });
}
}
for (let i = 0; i < systemBlocks.length - 1; i++) systemBlocks[i].text += '\n';
const chatMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer');
// Build tool_call_id → tool_name reverse lookup
const toolNameMap = {};
for (const msg of chatMessages) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.id) {
toolNameMap[tc.id] = tc.function?.name || '';
}
}
}
}
// 转换 messages 为 CC 格式
const ccMessages = chatMessages.map(msg => {
if (msg.role === 'user') {
if (typeof msg.content === 'string') {
return { role: 'user', content: [{ type: 'text', text: msg.content }] };
}
// 多模态:数组 content 原样透传(text + image_url → CC image 格式)
if (Array.isArray(msg.content)) {
const parts = msg.content.map(part => {
if (part.type === 'image_url') {
const url = part.image_url?.url || '';
// CC CLI 真实格式: { type: "image", image: "data:<mime>;base64,...", mimeType: "<mime>" }
const mediaType = /^data:([^;,]+)/.exec(url)?.[1];
const imagePart = { type: 'image', image: url };
if (mediaType) imagePart.mimeType = mediaType;
return imagePart;
}
return part;
}).filter(Boolean);
return { role: 'user', content: parts };
}
return { role: 'user', content: [{ type: 'text', text: String(msg.content) }] };
}
if (msg.role === 'assistant') {
const parts = [];
// 思考内容必须回传:CC 在 thinking 模式下校验 reasoning 是否随历史带回,
// 丢弃会让上游直接拒绝。次序也必须与 CC CLI 的抓包格式一致 ——
// [reasoning, text, tool-call],reasoning 在最前。
if (msg.reasoning_content) {
parts.push({ type: 'reasoning', text: msg.reasoning_content });
}
if (msg.content && typeof msg.content === 'string') {
if (msg.content) parts.push({ type: 'text', text: msg.content });
} else if (msg.content && Array.isArray(msg.content)) {
for (const part of msg.content) {
if (!part) continue;
if (part.type === 'text') parts.push(part);
// 客户端直接把 reasoning 放在 content 数组里时同样透传;
// 已有 reasoning_content 字段则不重复
else if (part.type === 'reasoning' && !msg.reasoning_content) parts.push(part);
}
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
parts.push({
type: 'tool-call',
toolCallId: tc.id,
toolName: tc.function?.name || '',
input: (typeof tc.function?.arguments === 'string' ? tryParseJSON(tc.function.arguments) : (tc.function?.arguments || {})),
});
}
}
return { role: 'assistant', content: parts };
}
if (msg.role === 'tool') {
return {
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: msg.tool_call_id,
toolName: toolNameMap[msg.tool_call_id] || msg.name || '',
output: { type: 'text', value: toWireToolOutputValue(msg.content) },
}],
};
}
// 未知 role 兜底:归一化为 user 并保证 content 为数组,避免 CC 校验拒绝
return { role: 'user', content: [{ type: 'text', text: String(msg.content ?? '') }] };
});
// 缓存断点:system 是块数组,断点可以原样留在 system 上(CLI 的 systemSections[].cache 同义)。
// 客户端已在任意消息块 / system 块上打过断点就保留;否则若给了 OpenAI 系的 prompt_cache_key,
// 把断点落在 system 最后一块 —— 缓存按前缀计算,system 正是最前的那段前缀。
const hasCacheMarker = systemBlocks.some(b => b.cache_control) || ccMessages.some(msg =>
Array.isArray(msg.content) && msg.content.some(part => part?.cache_control));
if (prompt_cache_key && !hasCacheMarker && systemBlocks.length) {
systemBlocks[systemBlocks.length - 1].cache_control = { type: 'ephemeral' };
}
const body = {
config: {
// 伪造的项目目录(不再发宿主真实 cwd);environment 用伪装的平台词,与指纹保持自洽
workingDir: DEVICE_PROFILE.projectDir,
date: getDateStr(),
environment: DEVICE_PROFILE.platform,
structure: [],
isGitRepo: false,
currentBranch: '',
mainBranch: '',
gitStatus: '',
recentCommits: [],
},
memory: null,
taste: null,
skills: null, // CLI 发 null,不是空串
permissionMode: 'standard',
mode: CFG.cliMode || 'agent',
// threadId 需为合法 UUID,否则整键省略(CLI 的 toWireThreadId)—— 在 forwardToCC 拿到 sessionId 后补
params: {
model: model || 'deepseek/deepseek-v4-flash',
messages: ccMessages,
max_tokens: Math.min(max_tokens || 64000, 200000),
stream: true, // CC API 总是 stream
},
};
// 条件字段
if (systemBlocks.length) {
body.params.system = systemBlocks;
} else if (CFG.emptySystemPlaceholder) {
// CC 上游在 params.system 缺省时会注入自身约 7.5K token 的默认提示词(进入
// 默认上下文/前缀路径),既产生大量 cached tokens 又污染对话(模型会以为
// 自己在 CC 的可执行目录里,见 issue #17)。发一个空格占位即可绕过,
// 真机验证 prompt_tokens 从 7653 降到 85。
// 默认开启;config.json 设 "emptySystemPlaceholder": false 或环境变量
// CC_EMPTY_SYSTEM_PLACEHOLDER=false 可关闭(回到原生的缺省行为)。
body.params.system = [{ type: 'text', text: ' ' }];
}
if (temperature !== undefined) {
body.params.temperature = temperature;
}
if (reasoning_effort !== undefined) {
body.params.reasoning_effort = reasoning_effort;
}
// CLI 总是下发 tools(没有工具时是空数组)—— 空数组与缺键在 wire 上可观测,这里对齐
// CLI 的 toWireTools:只有 name / description / input_schema,没有 type 字段
body.params.tools = (tools || []).map(t => ({
name: toWireToolName(t.function?.name || t.name || ''),
description: t.function?.description || t.description || '',
input_schema: t.function?.parameters || t.input_schema || { type: 'object', properties: {} },
}));
if (tool_choice !== undefined) {
// OpenAI 格式 → CC (Anthropic 风格) 格式
if (typeof tool_choice === 'string') {
const map = { 'auto': 'auto', 'none': 'none', 'required': 'any' };
body.params.tool_choice = { type: map[tool_choice] || 'auto' };
} else if (tool_choice.type === 'function') {
// OpenAI object → Anthropic object
body.params.tool_choice = { type: 'tool', name: tool_choice.function?.name };
} else {
body.params.tool_choice = tool_choice;
}
}
if (parallel_tool_calls !== undefined) {
body.params.parallel_tool_calls = parallel_tool_calls;
}
return body;
}
// CLI 发送前会重写部分工具名(resolveToolNameAlias / ow 表)
const TOOL_NAME_ALIASES = {
bash_output: 'shell_output',
task_output: 'shell_output',
tool_search: 'search_tools',
read_multiple_files: 'read_file',
};
function toWireToolName(name) { return TOOL_NAME_ALIASES[name] || name; }
// CLI 的 toWireToolOutput:只取文本块,用 '\n' 拼接
function toWireToolOutputValue(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.filter(c => c && c.type === 'text').map(c => c.text ?? '').join('\n');
}
return content == null ? '' : String(content);
}
function tryParseJSON(str) {
try { return JSON.parse(str); } catch { return {}; }
}
// ── CC NDJSON → OpenAI SSE 转换 ────────────────────
function createSseTranslator(model, completionId, created) {
// 是否见过终态 finish 事件。CLI 用同一个标志判定「流是不是被截断了」。
let sawFinish = false;
let chunkIndex = 0;
let sentRole = false;
let finishReason = null;
let usage = null;
let toolCallIndex = 0;
return {
lastCcEvent: '',
upstreamError: null,
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
/** 解析一行 NDJSON,返回 OpenAI chunk 数组 */
parseLine(line) {
const trimmed = line.trim();
if (!trimmed || trimmed === '[DONE]' || trimmed.startsWith(':')) return null;
let event;
try { event = JSON.parse(trimmed); } catch { return null; }
if (!event.type) return null;
this.lastCcEvent = event.type;
const out = [];
switch (event.type) {
case 'text-start':
case 'reasoning-start':
case 'start':
case 'start-step':
// 忽略,无用户可见内容
break;
case 'text-delta': {
const text = event.text || event.delta || '';
if (!text) break;
const delta = chunkIndex === 0 ? { role: 'assistant', content: text } : { content: text };
chunkIndex++;
sentRole = true;
out.push(makeChunk(completionId, created, model, delta, null, null));
break;
}
case 'reasoning-delta': {
const text = event.text || '';
if (!text) break;
const delta = chunkIndex === 0
? { role: 'assistant', reasoning_content: text }
: { reasoning_content: text };
chunkIndex++;
out.push(makeChunk(completionId, created, model, delta, null, null));
break;
}
case 'tool-call': {
const id = event.toolCallId || `call_${Date.now()}_${toolCallIndex}`;
const name = event.toolName || '';
const args = typeof event.input === 'string' ? event.input : JSON.stringify(event.input || {});
const tcEntry = { index: toolCallIndex, id, type: 'function', function: { name, arguments: args } };
const delta = chunkIndex === 0
? { role: 'assistant', content: null, tool_calls: [tcEntry] }
: { tool_calls: [tcEntry] };
chunkIndex++;
toolCallIndex++;
out.push(makeChunk(completionId, created, model, delta, null, null));
break;
}
case 'finish-step': {
sawFinish = true;
if (event.finishReason) finishReason = mapFinishReason(event.finishReason);
if (event.usage) {
usage = event.usage;
this.inputTokens = event.usage.inputTokens ?? 0;
this.outputTokens = event.usage.outputTokens ?? 0;
this.cachedInputTokens = event.usage.cachedInputTokens ?? 0;
}
break;
}
case 'finish': {
sawFinish = true;
const fr = toOpenAIFinishReason(finishReason || mapFinishReason(event.finishReason || 'stop'));
const u = event.totalUsage || usage || {};
normalizeUsage(u);
this.inputTokens = u.inputTokens ?? 0;
this.outputTokens = u.outputTokens ?? 0;
this.cachedInputTokens = u.cachedInputTokens ?? 0;
const openaiUsage = u ? {
prompt_tokens: u.inputTokens ?? 0,
completion_tokens: u.outputTokens ?? 0,
total_tokens: (u.inputTokens ?? 0) + (u.outputTokens ?? 0),
prompt_tokens_details: { cached_tokens: u.cachedInputTokens ?? 0 },
} : undefined;
out.push(makeChunk(completionId, created, model, {}, fr, openaiUsage));
break;
}
case 'error': {
const msg = event.error?.message || event.message || 'Unknown error';
this.upstreamError = mapCcEventError(event);
// 先映射再记日志,并把上游自带的状态/可重试性一并打出 ——
// 排查容量/限流类问题时,真正需要的就是这两个字段
log('warn', 'CC stream error', {
message: msg,
upstreamStatus: this.upstreamError.reportedStatus,
upstreamRetryable: event.error?.isRetryable,
code: this.upstreamError.code,
mappedTo: this.upstreamError.status,
});
// Don't emit a finish_reason chunk — let the natural stream termination
// handle it. Otherwise a subsequent finish(tool_calls) would be ignored
// by downstream agent loops that stop at the first finish_reason.
break;
}
case 'reasoning-end': case 'provider-metadata': case 'tool-input-start': case 'tool-input-delta': case 'tool-input-end': case 'tool-error': case 'text-end':
// Silent - no user-visible content
break;
default:
log('warn', 'Unknown CC event type', { type: event.type });
break;
}
return out.length > 0 ? out : null;
},
/** 这次上游流若没有正常走完 finish,返回可读原因;正常则为 null。 */
incompleteDetail() {
return incompleteUpstreamDetail(sawFinish, finishReason);
},
/** 获取 SSE 结束标记 */
getDoneEvent() {
return 'data: [DONE]\n\n';
},
};
}
function makeChunk(id, created, model, delta, finishReason, usage) {
const chunk = {
id,
object: 'chat.completion.chunk',
created,
model,
choices: [{ index: 0, delta, finish_reason: finishReason || null }],
};
if (usage) chunk.usage = usage;
return `data: ${JSON.stringify(chunk)}\n\n`;
}
// normalize CC usage stats:
// - outputTokens=0 → zero everything (anti false billing)
function normalizeUsage(u) {
if (!u) return;
const ot = Number(u.outputTokens);
if (!ot) { // 0, null, undefined, NaN → zero input + cached (anti false billing)
u.inputTokens = 0;
u.cachedInputTokens = 0;
}
}
// CC 的 inputTokens 是「总数」(含缓存命中部分),而 Anthropic 的 input_tokens 只计
// 非缓存部分 —— 官方 SDK 注释:Total input tokens in a request is the summation of
// `input_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens`。
// 直接把 CC 的 inputTokens 当 input_tokens 转发,会让下游把两者当成互不重叠的两部分,
// 相加后约为真实输入的两倍(issue #25)。
//
// CC 实际已经算好:inputTokenDetails.noCacheTokens(实测 noCacheTokens + cacheReadTokens
// === inputTokens)。优先采用该字段;缺失时回退到减法,保证老版本上游也能得到正确值。
function anthropicInputTokens(usage, noCacheOverride) {
const u = usage || {};
if (typeof noCacheOverride === 'number' && noCacheOverride >= 0) return noCacheOverride;
const noCache = u.inputTokenDetails && u.inputTokenDetails.noCacheTokens;
if (typeof noCache === 'number' && noCache >= 0) return noCache;
const cacheRead = u.cachedInputTokens || (u.inputTokenDetails && u.inputTokenDetails.cacheReadTokens) || 0;
const cacheWrite = (u.inputTokenDetails && u.inputTokenDetails.cacheWriteTokens) || 0;
return Math.max(0, (u.inputTokens || 0) - cacheRead - cacheWrite);
}
// 上游 finishReason → 本代理内部规范化取值。
// 对齐 CLI 的 normalizeStopReason2 / isNetworkFailureFinish(command-code@1.54.0):
// tool_use | tool-calls | tool_calls → tool_calls
// length | max_tokens | max_output_tokens
// | model_context_window_exceeded → length
// /^(network|connection|upstream)[-_\s]?error$/i → upstream_error
// pause_turn → pause_turn(原样保留)
// 关键点:'length' 家族**不止 'length' 一个值**。max_output_tokens 与
// model_context_window_exceeded 都是「输出被截断」,折成 stop/end_turn 等于
// 把半截回答谎报成完整回答。未知值一律原样返回,宁可让它露出来也不要静默折成 stop。
function mapFinishReason(reason) {
const r = String(reason ?? '').trim().toLowerCase();
if (!r) return 'stop';
if (r === 'tool-calls' || r === 'tool_calls' || r === 'tool_use') return 'tool_calls';
if (r === 'length' || r === 'max_tokens'
|| r === 'max_output_tokens' || r === 'model_context_window_exceeded') return 'length';
if (/^(?:network|connection|upstream)[-_\s]?error$/.test(r)) return 'upstream_error';
return r;
}
// 上游「没有正常走完」的两种情形,CLI 都当成可重试的 502:
// · 流里根本没有 finish 事件 —— "Stream ended unexpectedly before completion
// (no finish event) — response was truncated"
// · provider 报 network/connection/upstream-error —— isNetworkFailureFinish
// 返回 null 表示这次流是正常结束的。
//
// sawFinish 的口径是「上游给过任何完成信号」:终态 finish,以及本代理一直在处理的
// finish-step。('finish-step' 在 CLI 的事件集里不存在 —— 见 proxy.mjs 各处注释 ——
// 但既然代理认它,就不能让它变成「没完成」,否则会把原本正常的响应误判成 502。
// 真正要拦的是「一个完成信号都没有就断了」。)
function incompleteUpstreamDetail(sawFinish, finishReason) {
if (!sawFinish) return 'no finish event';
if (finishReason === 'upstream_error') return 'provider reported an upstream connection failure';
return null;
}
function incompleteUpstreamError(detail) {
return {
status: 502,
// retry_after 同时放在 body 里与顶层:sendJSON 只发 body,
// 而 sendAnthropicError / sendResponsesError 需要单独的形参。
body: {
error: {
message: `Upstream stream ended without a completion finish (${detail}) — response was truncated`,
type: 'upstream_error',
},
retry_after: 10,
},
retry_after: 10,
};
}
// ── 错误映射 ───────────────────────────────────────
const CC_STATUS_MAP = {
400: { status: 400, type: 'invalid_request_error' },
401: { status: 401, type: 'authentication_error' },
402: { status: 429, type: 'rate_limit_error' }, // payment required → rate limit
403: { status: 401, type: 'authentication_error' },
404: { status: 404, type: 'not_found' },
422: { status: 400, type: 'invalid_request_error' },
429: { status: 429, type: 'rate_limit_error' },
500: { status: 502, type: 'upstream_error' },
502: { status: 502, type: 'upstream_error' },
503: { status: 503, type: 'temporarily_unavailable' },
};
function mapCcError(ccStatus, ccBody) {
const mapped = CC_STATUS_MAP[ccStatus] || { status: 502, type: 'upstream_error' };
let message = `CC API error (${ccStatus})`;
let code = null;
if (ccBody) {
try {
const parsed = JSON.parse(ccBody);
message = parsed.error?.message || parsed.message || message;
// 上游错误体:{"success":false,"error":{"code":"BAD_REQUEST"|"USAGE_EXCEEDED",...}}
// code 是上游的机器可读错误分类(BAD_REQUEST / USAGE_EXCEEDED 等),透出来便于下游 SDK 与运维判定
code = parsed.error?.code || parsed.code || null;
} catch {
message = ccBody.slice(0, 200) || message;
}
}
// CC 429 响应可能带 retry-after
if (ccStatus === 429) {
return {
status: 429,
code,
body: {
error: { message, type: 'rate_limit_error', ...(code ? { code } : {}) },
retry_after: 30,
},
};
}
return { status: mapped.status, code, body: { error: { message, type: mapped.type, ...(code ? { code } : {}) } } };
}
function mapCcEventError(event) {
const message = event.error?.message || event.message || 'Unknown CC error';
const code = event.error?.code || event.code || null;
// 上游 error 事件除了 message 还可能自带 statusCode / isRetryable ——
// CLI 的 readStreamErrorEvent 读的正是这两个字段,取值链是
// parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null
// 原实现只看 message 里的 "<NNN>" 前缀,statusCode 一律被丢掉,
// 于是 429 / 503 这类「该退避重试」的信号在代理这一层被抹平成 502「服务端错误」:
// 客户端不再按限流退避,监控也会把它错误归类成后端故障。
const statusMatch = message.match(/^<(\d{3})>/);
const reportedStatus = statusMatch
? Number(statusMatch[1])