forked from MAXeaglet/commandcode-proxy
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.mjs
More file actions
3557 lines (3303 loc) · 155 KB
/
Copy pathproxy.mjs
File metadata and controls
3557 lines (3303 loc) · 155 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 对齐,1.54.0 复核未变) ──
// CPU 型号与核数对应表(仅 Windows x64)。
// ⚠️ 上网的 components.cpuCount 必须是 **threads** 而不是 cores ——
// CLI 的 gatherRawSignals 取的是 `os.cpus().length`,即**逻辑处理器数**。
// 这一对是明文上传的(components 里只有 machineId/mac/osUser/hostname/gitEmail 走哈希),
// 所以 cpuModel 与 cpuCount 可以被服务端交叉核对:填物理核数等于宣称「这台机器关了超线程」,
// 而原表 15 项全是物理核数 —— 100% 的指纹都落在这个罕见表述上,是群体分布层面的特征。
// (实现参考 @jinyu2022 的 PR #35,数字逐项复核过。)
const FINGERPRINT_CPUS = [
{ model: '12th Gen Intel(R) Core(TM) i7-12650H', cores: 10, threads: 16 }, // 6P+4E
{ model: '12th Gen Intel(R) Core(TM) i5-12400F', cores: 6, threads: 12 },
{ model: '12th Gen Intel(R) Core(TM) i9-12900K', cores: 16, threads: 24 }, // 8P+8E
{ model: '13th Gen Intel(R) Core(TM) i7-13700K', cores: 16, threads: 24 }, // 8P+8E
{ model: '13th Gen Intel(R) Core(TM) i5-13600K', cores: 14, threads: 20 }, // 6P+8E
{ model: '13th Gen Intel(R) Core(TM) i9-13900K', cores: 24, threads: 32 }, // 8P+16E
{ model: 'Intel(R) Core(TM) Ultra 7 155H', cores: 16, threads: 22 }, // 6P+8E+2LPE(Meteor Lake 有超线程)
{ model: 'Intel(R) Core(TM) Ultra 9 285H', cores: 16, threads: 16 }, // 6P+8E+2LPE(Arrow Lake 取消超线程)
{ model: 'Intel(R) Core(TM) i9-14900K', cores: 24, threads: 32 }, // 8P+16E
{ model: 'Intel(R) Core(TM) i7-14700K', cores: 20, threads: 28 }, // 8P+12E
{ model: 'AMD Ryzen 7 7800X3D', cores: 8, threads: 16 },
{ model: 'AMD Ryzen 9 7950X', cores: 16, threads: 32 },
{ model: 'AMD Ryzen 5 7600', cores: 6, threads: 12 },
{ model: 'AMD Ryzen 9 7900X', cores: 12, threads: 24 },
{ model: 'AMD Ryzen 7 5800X3D', cores: 8, threads: 16 },
];
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
// ── 为什么指纹必须由 apiKey 确定性派生(而不是随机) ──────────────
// 指纹代表「这个账号对应的那台设备」。原实现每进程随机生成一台新机器,
// 会在三个场景下毫无理由地漂移:① 进程重启 ② 多实例各算各的
// ③ session 过期清理连带 keyStateStore.delete。设备身份无故更换本身就是信号。
//
// 也不能退化成「按 key 取哈希桶选设备」:桶方案的熵上限就是桶数,key 数一旦
// 超过桶数必然出现多个 key 共用指纹,而上游 device_fingerprints 表对
// (userId, thumbmark) 建了唯一索引 —— 共用指纹等于「多账号同机」的直接证据。
// 派生方案每个 key 都是独立设备,碰撞概率 2^-256。
//
// 原始信号值(MachineGuid / MAC / 主机名 / git 邮箱)只存在于本进程内存,
// 出网的永远只有它们的哈希。
// 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.threads, // 逻辑处理器数,对齐 CLI 的 os.cpus().length
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)。
// 默认值保持 100MB(573e260 为修 issue #7 设定)——不要下调,理由是有实际证据的:
// #7 的真实触发场景是多模态长会话,21 张 Base64 图片累积到约 10.11 MiB 的合法请求,
// 下调到 4~8MB 会把这类请求整体挡在门外。#7 的「Connection error」症状由
// 「超限返回 413 + 排空连接」这条路径解决,与阈值取值无关;但阈值决定了功能边界,
// 所以默认值必须容纳真实的多模态上下文。
// ⚠️ 内存特性(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,且该上限是每请求的、不是全局的。
// 正因为阈值必须留足,#20 的第二半必须补齐:并发侧要有约束。
// 进程内可用 CC_MAX_INFLIGHT,边缘侧用反向代理 limit_conn
// (见 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,防止 Map 无限增长。
// 注意:这里**不再**连带删除 keyStateStore。原实现写的是「同时清理该 key 的指纹状态」,
// 但指纹是随机生成的,删掉就等于该 key 每 12h 换一台"电脑" —— 真实用户不会这样。
// 指纹状态现在有自己的空闲淘汰(见下方),且因为指纹是派生出来的,
// 即使被淘汰、下次重新派生得到的仍是同一台设备,不构成漂移。
setInterval(() => {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of sessionStore) {
if (now >= entry.expiresAt) {
sessionStore.delete(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);
}
// CC 线上信封的 threadId 只接受合法 UUID —— 对应 CLI 的 toWireThreadId:
// uuid.safeParse 失败即返回 undefined,该键随之被 JSON.stringify 丢弃。
// 非 UUID 的 session 值一律不发,避免上游看到真 CLI 永远不会产生的 threadId。
function isWireUuid(v) {
return typeof v === 'string'
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
// ── 每 Key 独立状态(fingerprint + 初始化节流) ──
// 指纹现在是确定性派生的,所以这个 Map 只是缓存:淘汰它不会改变该 key 的设备身份,
// 只是下次多算一次 HMAC。淘汰按「空闲」而非「session 过期」判定,
// 这样活跃 key 的 nextInitAt 不会被重置(否则会重复发送指纹/lifecycle 预请求)。
const KEY_STATE_IDLE_MS = 24 * 60 * 60 * 1000; // 24h 未活动即淘汰
const keyStateStore = new Map(); // apiKey → { fingerprint, nextInitAt, lastSeen }
function getOrCreateKeyState(apiKey) {
let state = keyStateStore.get(apiKey);
if (!state) {
state = {
fingerprint: generateFingerprint(apiKey),
nextInitAt: 0,
lastSeen: 0,
};
keyStateStore.set(apiKey, state);
log('info', 'Fingerprint derived for key', {
keyPrefix: apiKey.slice(0, 8),
thumbmark: state.fingerprint.thumbmark.slice(0, 12),
});
}
state.lastSeen = Date.now();
return state;
}
// 空闲淘汰:纯内存卫生,不影响设备身份(见上)
setInterval(() => {
const now = Date.now();
let evicted = 0;
for (const [key, state] of keyStateStore) {
if (now - state.lastSeen > KEY_STATE_IDLE_MS) { keyStateStore.delete(key); evicted++; }
}
if (evicted > 0) log('info', 'Key state evicted (idle)', { evicted, remaining: keyStateStore.size });
}, 60 * 60 * 1000); // 每小时
// ── 初始化预请求(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',
// 官方 CLI 的指纹预请求与生成请求共用同一个 header 常量表(lb = vy = "cli"),
// 因此预请求同样是 User-Agent: cli。此前只有 forwardToCC 设了它,
// 预请求走 Node 默认的 "node" —— 同一账号的指纹注册与生成请求来自两种 UA,
// 是可直接观测的破绽。
'User-Agent': 'cli',
'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
// 名字原样透传(理由见 toWireToolName 删除处的注释)。
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') {
// toolName 与上面 tool-call 里的一致(同一张 map),不重命名
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: 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;
}
// ── 工具名为什么一个都不重命名(已删掉的别名表的墓志铭) ──────────────
// 上游 d063b47 曾引入一张 4 项别名表并作用于 params.tools[].name。查 CLI 源码后
// (command-code@1.54.0 dist/cli.mjs)确认:**wire 协议里没有工具重命名这回事**。
//
// CLI 里确实存在两个改名的函数,但都不适用于反代:
//
// toWireToolName(e){return e===rw?nw:e} // rw="tool_search" → nw="search_tools"
// —— 只作用在 toWireMessages(tool-call 与 tool-result,两边同名),
// 不作用在 toWireTools(声明原样下发)。它的存在前提是 CLI 自己退役过
// tool_search 这个名字:createRetiredToolSearchTool 给的 visible:()=>false,
// 该工具从不进 params.tools,只有重放旧会话时历史里才会残留这个旧名。
//
// resolveToolNameAlias(ow 表) // bash_output/task_output/read_multiple_files
// —— 被工具执行器调用:模型喊了退役名时本地按新名跑,回一句给模型看的
// "Repair note",并按 defaults 补参(task_output 会补 wait:"exit")。
// 这是执行语义、不是 wire 变换。
//
// 反代没有这个前提:params.tools 由下游客户端给出,proxy 没有 catalog、没有退役名,
// 请求里出现的每个名字对 proxy 来说都是当前名。若强行重命名,一旦客户端恰好声明了
// 一个叫 tool_search 的工具,就会变成「声明 tool_search、消息 search_tools」——
// 下游按自己声明的名字派发不到工具(issue #36 / #37 的根因)。
//
// 因此这里全程原样透传。若将来真要支持「重放真实 CLI 旧会话」,正确做法是**入站**
// 归一化 + 显式开关,并连 defaults 一起补,与 resolveToolNameAlias 同语义;
// 绝不要做成「上行改、下行不改」。
// 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;