-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.js
More file actions
3177 lines (3019 loc) · 147 KB
/
Copy pathproxy.js
File metadata and controls
3177 lines (3019 loc) · 147 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
#!/usr/bin/env node
/**
* cmc-proxy (new core) — commandcode GOAT 订阅本地反代网关
* =========================================================
* 把 commandcode Provider API 反代到本机, 供 Claude Code / Codex 使用:
* - /v1/chat/completions -> https://api.commandcode.ai/provider/v1/chat/completions (OpenAI 格式, 纯透传)
* - /v1/messages -> https://api.commandcode.ai/provider/v1/messages (Anthropic 格式, 仅 Claude 模型)
* - /v1/messages (转换模式)-> https://api.commandcode.ai/provider/v1/chat/completions (Anthropic -> OpenAI 协议转换)
* - /v1/responses -> https://api.commandcode.ai/provider/v1/chat/completions (Responses -> OpenAI 协议转换)
* - /v1/models -> 上游模型列表 (按配置过滤)
*
* 零第三方依赖, 仅需 Node.js >= 18 (内置 fetch / ReadableStream)。
*
* 启动: node proxy.js [--port 5411] [--config config.json]
*
* 相对旧版核心的修复 (输入/输出契约不变, config.json 兼容):
* 1. /v1/responses 工具历史丢失 (Codex 工具"带不上"的根因):
* 真实 Codex (wire_api="responses") 将 function_call / function_call_output 作为
* *顶层 input item* 发送; 旧版只识别嵌在 role 消息 content 里的形态, 导致模型每轮
* 都看不到自己已发起的工具调用与工具结果, 表现为重复调用/无视工具。新版完整支持:
* 顶层 function_call(合并进前一条 assistant 消息)、function_call_output(-> role:"tool")、
* reasoning(跳过)、local_shell_call 等(跳过并告警), 同时保留旧版嵌套形态兼容。
* 2. 缓存命中率间歇掉底:
* - 请求前缀逐字节稳定: Anthropic content block 与 OpenAI content part 1:1 映射,
* 不再做会随轮次改变结构的字符串/数组折叠, 转换纯函数化;
* - cache_control 透传: Claude Code 在 system/消息块上携带的 ephemeral 标记映射为
* OpenAI content part 的 cache_control (实测 commandcode 上游接受且不影响缓存键,
* 对支持显式缓存的后端则生效); 可用 config.cacheControlPassthrough=false 关闭;
* - 缓存亲和: 按会话注入稳定 user / prompt_cache_key (实测上游接受), 便于上游
* 按会话做缓存路由; 可用 config.cacheAffinity=false 关闭。
* 3. 流式转换块索引冲突修复: 旧版文本块固定占 index 0, 工具块从 0 开始分配,
* 文本+工具混合输出时两个块共用 index 0; 新版统一自增分配。
* 4. Responses 响应 usage 补全 input_tokens_details.cached_tokens /
* output_tokens_details.reasoning_tokens, Codex 侧缓存/思考量可见。
* 5. 会话跟踪升级: Codex 优先读 session-id (兼容旧版 session_id) / thread-id 请求头
* 作为会话标识, 同一 codex 会话跨 TCP 重连不再分裂编号, 与缓存亲和使用同一稳定标识;
* curl 等无会话头客户端仍回退 src:port + ua。
* 6. Codex 0.150+ 新工具形态支持 (修复 apply_patch 死循环 / 延迟工具失效):
* - {"type":"custom"} freeform 工具 (apply_patch grammar) -> 转成 {"input": "<原文>"}
* 单参数 function 供模型调用, 回传时还原为 custom_tool_call (codex 的 apply_patch
* handler 只接受 Custom payload, 收到 function_call 直接报错导致补丁反复失败);
* - {"type":"tool_search"} (nameless 延迟工具发现) -> 转成同名 function, codex 对
* FunctionCall 名为 tool_search 的调用走本地执行器, 打通 deferred 工具加载;
* - {"type":"web_search"} (nameless) 静默跳过 (链路无服务端执行环境);
* - {"type":"namespace"} 工具组展开为独立工具;
* - 历史条目 custom_tool_call / custom_tool_call_output / tool_search_call /
* tool_search_output 双向映射, 修复 freeform 工具调用历史丢失。
* 7. 轮换计数按模型归属: 客户端显式请求其他模型 (如 blockedModels 中的 gpt-5.6-luna
* 被上游 403) 的失败不再记到当前默认模型头上触发无谓轮换; blocked 模型转发时
* 打印一次性告警; additional_tools 历史条目 (codex 内联的附加工具定义) 合并进
* 请求 tools 并按名去重。
* 8. 前缀分叉探测器: RES 行 pfx~N/pfx~tools/pfx<N 标记 + 分叉内容预览, 定位缓存
* 命中率低的来源。借此确认 Claude Code 2.1.251 注入的 <total_tokens> 配额计数
* 会随轮次回溯改写, 是 Claude Code 路径缓存失效的元凶 —— 现将计数就近取整到
* 百万 (14977212 -> 15000000) 使前缀字节稳定, config.stabilizeCounters 可关;
* 同时修复 messages 内 system 提醒的块数组被 JSON.stringify 成乱码的问题
* (改为文本提取, 块数组与拼接字符串两种客户端形态产生相同字节)。
* 9. stripSystemReminders (默认 true): 整条剥离 history 中注入的 system 提醒
* (配额计数/任务催促), 提示性内容不影响编码能力; 请求落盘由环境变量
* CMC_LOGGING_FILE 分级控制 (见下方注释), 文件固定为 ROOT/fulllog.log。
* jsonlLog (可选): 独立的结构化 JSONL 请求日志开关 (与 CMC_LOGGING_FILE 无关),
* 记录与终端 REQ/RES 两行同源的当前次请求数据, 在 RES 输出时写一条 JSON,
* 供离线分析 (滚动/累计不入档)。详见 README「结构化请求日志 (JSONL)」。
* 10. switchOnFail (默认 false): 轮换总开关, 支持布尔或 {text, image} 对象 (单布尔统一
* 取值)。true 时失败 1 次即切换 + failTTL 冷却: 按请求类型选列表 (文本 defaultModels /
* 带图 defaultVisionModels, 带图 400 也轮换), 失败模型 TTL 内冷却跳过, 全部失效时返回
* 上游失败结果; false 时不轮换, 失败原样返回。模型决策见 pickModel (modelMap 优先 ->
* resolveModel 目录解析 -> 按请求类型回退默认)。**冷却只对回退到默认的模型生效**:
* 用户显式指定的模型 (modelMap/目录解析命中) 失败不冷却、下次请求仍从它开始 —— 不去
* 猜测用户指定模型的能力; 只有未带 model 或指定模型解析失败回退到 defaultForType 时,
* 失败才进入 TTL 冷却 (默认列表内后续候选无论何种情况都照常冷却)。
* 10a. visionAutoRoute (默认 true): 带图请求前置路由。true 时, 请求带图且决策出的模型
* 判定不支持视觉 (modelCatalog vision 字段与 defaultVisionModels 白名单并集: 在名单内
* 或 catalog vision:true 视为支持), 则不发该模型、改走 defaultVisionModels[0], 免上游
* 400/静默盲视; false 时保持现状 (先按原模型发, 失败靠 switchOnFail 轮换)。
* 11. modelCatalog (默认 goat-prices.json): 模型参数数据文件路径, 存在且解析成功时作为
* 模型参数数据源, 计算单次请求的额度 (credit) 消耗。成本按牌价 priceUsdPerMTok 直接
* 算 USD; 额度 = 成本 × plan.credits ÷ 模型 monthlyCredits。offPeak.windows 高峰窗口
* (UTC) 内以 peakUsdPerMTok 覆盖 input/output/cacheRead 牌价; 高峰仅工作日 (周一~周五,
* UTC) 生效, 周末整天按错峰价。RES 行输出 cost (橙) 与
* credit (黄), 高峰时段 cost/credit 前缀加 ^ (如 ^cost=); REQ/RES 行时间戳在高峰时段
* 改亮红 (醒目, 按本请求实际转发模型判定)。TOD/ALL stats 输出累计 cost / credit / avg。
* 文件缺失/解析失败静默跳过。
*/
"use strict";
const http = require("http");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// 访问日志颜色 (先于一切日志使用, 非 TTY/重定向时自动无色)
// ---------------------------------------------------------------------------
const C = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
dim: "\x1b[2m",
};
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const paint = (code) => (s) => (useColor ? `${code}${s}${C.reset}` : String(s));
const cRed = paint(C.red);
const cGreen = paint(C.green);
const cYellow = paint(C.yellow);
const cCyan = paint(C.cyan);
const cMagenta = paint(C.magenta);
const cDim = paint(C.dim);
const cBlue = paint(C.blue);
const cOrange = paint("\x1b[38;5;208m"); // 256 色橙
const cBrightGreen = paint("\x1b[92m"); // 亮绿
const cBrightCyan = paint("\x1b[96m"); // 亮青 (usage cr 缓存命中突出)
const cBrightRed = paint("\x1b[91m"); // 亮红 (bright red): 高峰时段时间戳着色, 比暗红醒目; 与 RES 行 ^cost/^credit 高峰标记同口径
// 日志标签 (前缀着色)
const TAGW = cYellow("[cmc-proxy]");
const TAGE = cRed("[cmc-proxy]");
const TAGI = cBlue("[cmc-proxy]");
// ---------------------------------------------------------------------------
// 配置加载 (与旧版完全兼容; 新增可选项见文件头注释)
// ---------------------------------------------------------------------------
const ROOT = __dirname;
const args = process.argv.slice(2);
function argVal(name, def) {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] ? args[i + 1] : def;
}
// --config 支持相对 (相对 proxy 脚本目录) 与绝对路径, 默认同目录 config.json
const configPath = path.resolve(ROOT, argVal("--config", "config.json"));
function loadConfig() {
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, "utf8"));
}
return {
port: 5411,
host: "127.0.0.1",
upstream: "https://api.commandcode.ai/provider",
apiKey: "",
switchOnFail: false,
defaultModels: ["deepseek/deepseek-v4-flash"],
modelMap: {},
blockedModels: [],
};
}
const config = loadConfig();
const PORT = parseInt(argVal("--port", config.port || 5411), 10);
const HOST = argVal("--host", config.host || "127.0.0.1");
const UPSTREAM = (config.upstream || "https://api.commandcode.ai/provider").replace(/\/+$/, "");
const API_KEY = process.env.CMDC_API_KEY || config.apiKey || "";
// 缓存优化开关 (可选配置, 默认开启; 关闭后回退为最朴素的转换行为)
const CC_PASSTHROUGH = config.cacheControlPassthrough !== false; // Anthropic cache_control -> OpenAI content part
const CACHE_AFFINITY = config.cacheAffinity !== false; // 按会话注入 user / prompt_cache_key
const STABILIZE_COUNTERS = config.stabilizeCounters !== false; // 易变配额计数器取整, 稳定前缀缓存
const STRIP_SYSTEM_REMINDERS = config.stripSystemReminders !== false; // 剥离 history 中注入的 system 提醒消息
const SERIALIZE_SESSION = config.serializeSessionRequests !== false; // 同会话上游请求串行化
const FIRST_BYTE_TIMEOUT = parseInt(config.firstByteTimeout ?? "120000", 10); // 上游响应头超时 ms (0=关闭; ?? 保证显式 0 不被默认值覆盖)
const TOOL_RESULT_IMAGES = config.toolResultImages !== false; // tool_result 内嵌图片保留 (注入随后的 user 消息透传上游)
const CLEAN_HISTORY_IMAGES = config.cleanHistoryImages === true; // 本轮无新图时清理历史图片, 使请求可回流纯文本模型
const RESOLVE_MODEL = config.resolveModel !== false; // modelMap 未命中时是否目录解析+回退默认 (false=原样向上游请求)
const VISION_AUTO_ROUTE = config.visionAutoRoute !== false; // 带图前置路由: 请求模型判定不支持视觉时, 不发它而改走 defaultVisionModels[0]
// 模型参数目录 (modelCatalog): 指向价格/额度 JSON 文件的路径, **仅在配置文件中显式指定时
// 才加载** (无默认值)。存在且解析成功时作为模型参数数据源, 用于计算单次请求的额度 (credit)
// 消耗; 文件不存在 / 读取或解析失败时静默跳过 —— 只是没有模型参数数据, 不影响转发与日志。
// 未配置时 modelCatalog 保持 null, 不统计额度。
const MODEL_CATALOG_PATH = config.modelCatalog ? path.resolve(ROOT, config.modelCatalog) : null;
let modelCatalog = null; // { plan, index: Map<slug, model>, norm: Map<去非字母数字小写, model>, byId: Map<id, model> }
try {
if (MODEL_CATALOG_PATH && fs.existsSync(MODEL_CATALOG_PATH)) {
const parsed = JSON.parse(fs.readFileSync(MODEL_CATALOG_PATH, "utf8"));
if (parsed && Array.isArray(parsed.models)) {
const index = new Map(parsed.models.map((m) => (m && m.slug ? [m.slug, m] : null)).filter(Boolean));
// 规范化索引: slug 去非字母数字后小写 —— 覆盖上游 id 与显示名派生 slug 的命名差异
// (如 Qwen3.8-27B vs qwen-3.8-27b)
const norm = new Map();
for (const m of index.values()) norm.set(m.slug.replace(/[^a-z0-9]+/g, ""), m);
// 上游 id 索引 (schema@2+ 新增): 上游转发的模型名就是 id, 精确命中优先于 slug 归一化
const byId = new Map(parsed.models.map((m) => (m && m.id ? [m.id, m] : null)).filter(Boolean));
// fetchedAt: 目录数据的时间戳 (抓取工具写入的顶层 fetchedAt); 缺失时退回文件 mtime
let fetchedAt = null;
if (parsed && parsed.fetchedAt) {
const t = new Date(parsed.fetchedAt);
if (!isNaN(t)) fetchedAt = t.getTime();
}
if (!fetchedAt) {
try { fetchedAt = fs.statSync(MODEL_CATALOG_PATH).mtimeMs; } catch { /* 取不到则保持 null */ }
}
modelCatalog = { plan: parsed.plan || {}, index, norm, byId, fetchedAt };
console.log(`[cmc-proxy] 模型目录已加载: ${MODEL_CATALOG_PATH} (${modelCatalog.index.size} 个模型)`);
}
}
} catch (e) {
console.warn(TAGW, "模型目录加载失败 (静默跳过):", e.message);
}
// 请求落盘分级 (环境变量 CMC_LOGGING_FILE, 未设置或 0 = 关闭):
// 1 = 严重事件落盘 (上游请求失败/超时、客户端中途断开 ABT)
// 2 = 1 + 前缀分叉时落盘该请求 (client/upstream 双 body, 便于定位分叉来源)
// 3 = 全部模型类请求落盘 (原 config.fulllog=true 行为)
// 文件固定为 ROOT/fulllog.log (已在 .gitignore)
const LOG_LEVEL = Math.max(0, parseInt(process.env.CMC_LOGGING_FILE || "0", 10) || 0);
const FULLLOG_PATH = LOG_LEVEL > 0 ? path.join(ROOT, "fulllog.log") : null;
let fulllogChain = Promise.resolve();
function fulllogDump(req, pathname, session, entries, note) {
if (!FULLLOG_PATH) return;
const ts = new Date().toISOString();
const head = `\n[${ts}]#${session ? session.id : "-"} ${req.method} ${pathname} src=${req.socket.remotePort || "-"}${note ? ` [${note}]` : ""}\n`;
const body = entries
.filter(([, text]) => text != null)
.map(([label, text]) => `---- ${label} (${Buffer.byteLength(text)}B) ----\n${text}\n`)
.join("\n");
// 串行追加, 保证写入顺序与到达顺序一致
fulllogChain = fulllogChain
.then(() => fs.promises.appendFile(FULLLOG_PATH, head + body))
.catch((e) => console.error(TAGE, "fulllog 写入失败:", e.message));
}
/** 请求落盘判断: 3=全部; 2=仅前缀分叉的请求 */
function shouldDumpRequest(pfxMark) {
return LOG_LEVEL >= 3 || (LOG_LEVEL >= 2 && !!pfxMark);
}
/** 严重事件落盘 (level>=1): 上游失败/超时、客户端断开等, 附请求双 body 便于定位 */
function logSevere(req, pathname, session, message) {
if (LOG_LEVEL < 1) return;
const c = req._cmdc || {};
fulllogDump(req, pathname, session, [["client", c.rawBody], ["upstream", c.upstreamBody]], message);
}
// 结构化 JSONL 请求日志 (config.json jsonlLog, 独立于 CMC_LOGGING_FILE 分级落盘):
// 与终端 REQ/RES 两行同源的**当前次**请求数据, 在 RES 输出时组织成一条 JSON 记录追加写入
// (滚动统计/累计数据不入档), 供后续离线分析。启用方式:
// jsonlLog: true -> 写 ROOT/requests.jsonl
// jsonlLog: "<路径>" -> 相对 proxy.js 目录解析 (如 "log/requests.jsonl", 自动建目录)
// 未设置 / false -> 关闭
//
// 按日切分 (rotation): 每日按**本地时区**切一个文件。当日记录写热文件 requests.jsonl;
// 检测到系统日期跨天时, 把热文件整体改名为 requests-YYYY-MM-DD.jsonl 归档 (归档日 = 热文件里
// 记录的实际日期, 由最近一次写入决定), 再新建热文件接续 —— 热文件恒为"当前日", 文件名稳定,
// 与 README/vislog 自动加载/配置说明对 requests.jsonl 的既有引用保持兼容, 历史日文件可下拉浏览。
// 归档保留 JSONL_ROTATE_KEEP 天, 跨日/启动时清理过期。
// 开关: config.jsonlRotateDays 或 CLI --jsonlRotateDays (默认 30; 0/"false" 关闭旋转, 保持原
// 单一 requests.jsonl 无限追加行为, 不做清理)。仅对**文件名为 requests.jsonl** 的日志生效
// (无论默认 ROOT 还是自定义目录如 "log/requests.jsonl"); 自定义为其他文件名属于精确文件,
// 切分语义不明, 一律按原无限追加处理。
const jsonlRotateRaw = argVal("--jsonlRotateDays", String(config.jsonlRotateDays ?? 30));
const JSONL_ROTATE_OFF = jsonlRotateRaw === "false" || jsonlRotateRaw === "0";
const JSONL_ROTATE_KEEP = JSONL_ROTATE_OFF ? 0 : Math.max(1, parseInt(jsonlRotateRaw, 10) || 30);
const JSONL_LOG = (() => {
const v = config.jsonlLog;
if (v === false || v == null) return null;
const p = typeof v === "string" ? path.resolve(ROOT, v) : path.join(ROOT, "requests.jsonl");
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch {}
return p;
})();
/** 是否启用"热文件 + 按日归档": 默认命名 requests.jsonl 且旋转未关 */
function jsonlRotateOn() {
return !!JSONL_LOG && !JSONL_ROTATE_OFF && path.basename(JSONL_LOG) === "requests.jsonl";
}
/** 当前本地日期 YYYY-MM-DD (切分键) */
function localDateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** 归档文件是否由本旋转机制命名 (可安全清理) */
function isRotateArchiveName(name) {
return /^requests-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name);
}
/** 启动时滚动一次 (幂等): 上次运行跨日遗留的热文件先归档, 再开始今日日志。启动失败不阻断 */
function startupJsonlRollover() {
if (!jsonlRotateOn()) return;
try { rollJsonlIfCrossed(); } catch { /* 启动不阻断 */ }
}
/** 热文件当前归属日: 取文件 mtime 的本地日期 (未写过/不存在按今日算, 免误归档) */
function hotFileDayKey() {
if (!JSONL_LOG) return localDateKey(new Date());
try {
if (fs.existsSync(JSONL_LOG)) return localDateKey(fs.statSync(JSONL_LOG).mtime);
} catch { /* stat 失败按今日算 */ }
return localDateKey(new Date());
}
/**
* 滚动编排 (原子): 若热文件归属日 < 今日 —— 跨日, 把热文件改名归档到归属日, 建空热文件。
* 同步自旋开关 jsonlRotating 防重入: 判定与改名在同一 tick 完成, 后续写入自然落到新热文件。
*/
let jsonlRotating = false;
function rollJsonlIfCrossed() {
if (!jsonlRotateOn()) return;
if (jsonlRotating) return;
const nowKey = localDateKey(new Date());
const fileKey = hotFileDayKey();
if (fileKey >= nowKey) return; // 归属日 == 今日 (或文件不存在按今日): 无需滚动
jsonlRotating = true;
try {
const arch = path.join(path.dirname(JSONL_LOG), "requests-" + fileKey + ".jsonl");
// 归档目标已存在 (如上次进程跨日已归档但未开新热文件): 去重再改名, 避免误合并两日
if (fs.existsSync(arch)) fs.rmSync(arch, { force: true });
if (fs.existsSync(JSONL_LOG)) fs.renameSync(JSONL_LOG, arch);
fs.closeSync(fs.openSync(JSONL_LOG, "a")); // 建空热文件, mtime 即今日
pruneJsonlArchives();
console.log(TAGI, `jsonl 按日切分: ${path.basename(arch)} 归档 (已保留历史)`);
} catch (e) {
console.error(TAGE, "jsonl 按日滚动失败:", e.message);
} finally {
jsonlRotating = false;
}
}
/** 删除超过 JSONL_ROTATE_KEEP 天的归档 requests-YYYY-MM-DD.jsonl */
function pruneJsonlArchives() {
if (!JSONL_LOG) return;
const dir = path.dirname(JSONL_LOG);
const keepMs = JSONL_ROTATE_KEEP * 86400e3;
const now = Date.now();
let removed = 0;
let entries = [];
try { entries = fs.readdirSync(dir); } catch { return; }
for (const name of entries) {
if (!isRotateArchiveName(name)) continue;
const p = path.join(dir, name);
try {
if (now - fs.statSync(p).mtimeMs > keepMs) { fs.rmSync(p, { force: true }); removed++; }
} catch { /* 单个文件失败不阻断 */ }
}
if (removed) console.log(TAGI, `jsonl 过期归档清理: 删除 ${removed} 个 (保留 ${JSONL_ROTATE_KEEP} 天)`);
}
let jsonlChain = Promise.resolve(); // 串行追加, 保证写入顺序与到达顺序一致
function jsonlWrite(obj) {
if (!JSONL_LOG) return;
const line = JSON.stringify(obj);
rollJsonlIfCrossed(); // 写前检查跨日: 首写完成启动遗留归档; 长跑跨日在此切到新热文件
jsonlChain = jsonlChain
.then(() => fs.promises.appendFile(JSONL_LOG, line + "\n"))
.catch((e) => console.error(TAGE, "jsonl 写入失败:", e.message));
}
if (!API_KEY) {
console.error(TAGE, "错误: 未配置 apiKey。请在 config.json 中填入你的 commandcode API key,");
console.error(TAGE, " 或通过环境变量 CMDC_API_KEY 传入。");
process.exit(1);
}
// ---------------------------------------------------------------------------
// 模型解析
// ---------------------------------------------------------------------------
const modelMap = config.modelMap || {};
// ---- 多模型轮换列表 ----
// defaultModels: 文本请求轮换列表; defaultVisionModels: 带图请求轮换列表。
// 兼容旧配置: 仅配置 defaultModel 时自动视为单元素数组。
const defaultModels = (Array.isArray(config.defaultModels) && config.defaultModels.length)
? config.defaultModels
: [config.defaultModel || "deepseek/deepseek-v4-flash"];
// 视觉模型列表: 带图请求的轮换范围。旧配置 imageCapableModels 自动迁移。
const defaultVisionModels = (Array.isArray(config.defaultVisionModels) && config.defaultVisionModels.length)
? config.defaultVisionModels
: (Array.isArray(config.imageCapableModels) && config.imageCapableModels.length)
? config.imageCapableModels
: [];
// ---- switchOnFail: 轮换总开关, 支持布尔或 {text, image} ----
// 单布尔值表示 text/image 统一取值。true 时开启"失败 1 次即切换 + TTL 冷却"轮换,
// false 时不轮换 (失败原样返回)。
const switchOnFailRaw = config.switchOnFail;
const SWITCH_ON_FAIL = switchOnFailRaw === true || (switchOnFailRaw && typeof switchOnFailRaw === "object")
? (typeof switchOnFailRaw === "object" ? !!(switchOnFailRaw.text ?? switchOnFailRaw.image) : true)
: false;
const switchOnFailFor = (isImage) => {
if (switchOnFailRaw === true) return true;
if (switchOnFailRaw && typeof switchOnFailRaw === "object") {
const v = isImage ? switchOnFailRaw.image : switchOnFailRaw.text;
return !!v;
}
return false;
};
// 失败模型冷却 (TTL): 模型失败后 TTL 毫秒内跳过该模型, 避免反复打已死模型。
// 0 表示不冷却 (失败即从当次轮换中剔除, 跨请求仍可重试)。
// ?? 保证显式 0 (不冷却) 不被默认值覆盖: 0 是合法配置值, 不能用 || 兜底
const FAIL_TTL = parseInt(config.failTTL ?? "30000", 10);
// 模型失败时间戳 (TTL 冷却): model -> 最近失败时刻; 成功清除
const modelFailAt = new Map();
function markModelFail(model) {
if (model) modelFailAt.set(model, Date.now());
}
function markModelOk(model) {
if (model) modelFailAt.delete(model);
}
function modelInCooldown(model) {
const t = modelFailAt.get(model);
return !!(t && FAIL_TTL > 0 && Date.now() - t < FAIL_TTL);
}
function cooldownRemainMs(model) {
const t = modelFailAt.get(model);
return t && FAIL_TTL > 0 ? Math.max(0, FAIL_TTL - (Date.now() - t)) : 0;
}
/** 当前默认模型: 文本列表第一个 (轮换指针语义已移除, 保持简单) */
function currentDefaultModel() {
return defaultModels[0];
}
/** 一次请求失败时调用 (非轮换模式下的计数, 现仅保留 TTL 记录) */
function onRequestFail(failedModel) {
markModelFail(failedModel);
}
/** 一次请求成功时调用 */
function onRequestOk(okModel) {
markModelOk(okModel);
}
// ---------------------------------------------------------------------------
// 上游模型列表缓存 (供 /v1/models 与模型目录匹配使用, 启动时异步刷新)
// ---------------------------------------------------------------------------
let upstreamModelsCache = { list: [], fetchedAt: 0 };
let upstreamModelsCacheFetched = false; // 首次成功获取后打印条数日志
async function refreshModels(force) {
const now = Date.now();
if (!force && upstreamModelsCache.list.length && now - upstreamModelsCache.fetchedAt < 60_000) {
return upstreamModelsCache.list;
}
try {
const r = await fetch(`${UPSTREAM}/v1/models`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!r.ok) throw new Error(`upstream models HTTP ${r.status}`);
const j = await r.json();
const list = j.data || [];
upstreamModelsCache = { list, fetchedAt: now };
// 首次获取打日志: 成功获取到多少条上游模型信息 (本地时间)
if (!upstreamModelsCacheFetched) {
upstreamModelsCacheFetched = true;
console.log(`[cmc-proxy] 刷新上游模型列表成功: ${list.length} 条 (${logTs(now)})`);
}
} catch (e) {
console.warn(TAGW, "刷新上游模型列表失败:", e.message);
}
return upstreamModelsCache.list;
}
const blockedSet = new Set(config.blockedModels || []);
function filterModels(models) {
if (!blockedSet.size) return models;
return models.filter((m) => !blockedSet.has(m.id));
}
/**
* 模型解析 helper (仅目录匹配, 不包含 modelMap):
* 1. 上游模型目录匹配, 全局按匹配级别分层按序尝试 (外层条件, 内层遍历目录):
* 精确 -> 大小写不敏感 -> 去 provider 前缀按裸名 -> 去 [*] 后缀,
* 任一层命中即用; 例: deepseek-v4-flash -> deepseek/deepseek-v4-flash,
* qwen3.8-max -> Qwen/Qwen3.8-Max
* 2. 无任何匹配 -> 返回 null, 由调用方 (pickModel) 按请求类型回退默认模型
*/
function resolveModel(requested) {
if (!requested) return null;
// 上游模型目录匹配 (分层按序: 高级别匹配优先于目录顺序)
const models = upstreamModelsCache.list;
if (models.length) {
const reqLower = requested.toLowerCase();
const bareLower = requested.replace(/^[^/]*\//, "").toLowerCase(); // 去掉 provider 前缀
// 去掉 [*] 后缀 (如 [1m]): 视为同模型的不同上下文窗口变体, 用基础名匹配
const bareNoSuffix = bareLower.replace(/\[[^\]]*\]$/, "");
for (const m of models) if (m.id === requested) return m.id; // 1. 精确
for (const m of models) if (m.id.toLowerCase() === reqLower) return m.id; // 2. 大小写不敏感精确
for (const m of models) if (m.id.toLowerCase().endsWith("/" + bareLower)) return m.id; // 3. 无前缀名匹配带前缀模型
if (bareNoSuffix && bareNoSuffix !== bareLower) {
for (const m of models) if (m.id.toLowerCase().endsWith("/" + bareNoSuffix)) return m.id; // 4. 去 [*] 后缀匹配
}
}
return null;
}
/** 请求类型对应的默认模型: 文本 -> defaultModels[0], 带图 -> defaultVisionModels[0] */
function defaultForType(isImage) {
return isImage && defaultVisionModels.length ? defaultVisionModels[0] : defaultModels[0];
}
/**
* 最终模型决策 (所有转发路径共用):
* 1. 请求未带 model -> 按请求类型取默认 (defaultModels[0] / defaultVisionModels[0])
* 2. 带 model -> modelMap 显式映射, 命中即用 (不区分请求类型, 优先级最高, 置空即关闭)
* 3. 未命中 modelMap -> 按 config.resolveModel 开关:
* true (默认): 目录匹配解析, 命中即用; 未命中按请求类型回退默认
* false : 原样向上游请求 (不解析不回退)
*/
function pickModel(requested, isImage) {
return pickModelWithFlag(requested, isImage).model;
}
/**
* 模型决策 + 是否"回退到默认"标志。isFallback=true 表示最终模型是系统回退选出的
* (未带 model, 或用户指定模型解析失败落到 defaultForType), 即非用户显式意图。
* 该标志驱动冷却范围: 只有回退到默认的模型失败才进入 failTTL 冷却 (用户显式指定的
* 模型不猜测、不冷却, 下次请求仍从它开始)。与 pickModel 唯一区别是附带 isFallback。
*/
function pickModelWithFlag(requested, isImage) {
if (!requested) return { model: defaultForType(isImage), isFallback: true };
if (modelMap[requested]) return { model: modelMap[requested], isFallback: false }; // 显式映射优先
if (!RESOLVE_MODEL) return { model: requested, isFallback: false }; // 解析关闭: 原样
const resolved = resolveModel(requested);
return resolved
? { model: resolved, isFallback: false }
: { model: defaultForType(isImage), isFallback: true };
}
/** 当前默认模型 (文本) */
function currentDefaultModel() {
return defaultModels[0];
}
/** 判断某模型是否需要走 Anthropic /messages 端点 (Claude 系) */
function isClaudeModel(model) {
return /^claude(-|$)/.test(model);
}
/** 判定某上游模型是否具备视觉 (图片) 能力 —— visionAutoRoute 的数据源 (并集):
* 1. defaultVisionModels 白名单成员视为支持 (用户配置即信任);
* 2. modelCatalog 已加载且有该模型记录 -> 以 vision 字段为准 (权威数据);
* 3. 其余 (catalog 无此模型 / catalog 未加载, 且不在白名单) -> 视为不支持。
* [1] 在 [2] 之前: 白名单是用户显式声明"带图用它", 优先于抓取数据的 vision 字段。 */
function modelVisionCapable(model) {
if (!model) return false;
if (defaultVisionModels.includes(model)) return true;
if (modelCatalog) {
const rec = catalogModel(model);
if (rec && typeof rec.vision === "boolean") return rec.vision;
}
return false;
}
/** 判定"为什么"某模型被 visionRoute 判为不支持视觉 (仅日志用)。
* catalog 有记录且 vision=false -> 'modelCatalog (vision:false)';
* 否则 (catalog 无此记录 / 未加载 / 非白名单) -> 'defaultVisionModels 白名单'。 */
function visionRouteReason(model) {
if (modelCatalog) {
const rec = catalogModel(model);
if (rec && typeof rec.vision === "boolean") return "modelCatalog (vision:false)";
}
return "defaultVisionModels 白名单";
}
/** 带图前置路由: 在带图请求 (isImage=true) 且最终决策的模型判定不支持视觉时,
* 不发它 (免上游 400/静默盲视), 改走 defaultVisionModels[0]。返回
* { ...decision, rerouted, from } —— rerouted=true 表示发生了前置路由 (from 为原模型),
* 调用方据此打 warn 提示。isFallback 置 true: 改走的是系统默认视觉模型, 失败进入冷却/
* 按视觉列表轮换。VISION_AUTO_ROUTE=false / 纯文本请求 / 无视觉列表时不动作 (保持现状)。 */
function visionRoute(decision, isImage) {
if (!VISION_AUTO_ROUTE || !isImage || !defaultVisionModels.length) {
return { ...decision, rerouted: false, from: decision.model };
}
if (modelVisionCapable(decision.model)) {
return { ...decision, rerouted: false, from: decision.model };
}
const target = defaultVisionModels[0];
return { model: target, isFallback: true, rerouted: true, from: decision.model, reason: visionRouteReason(decision.model) };
}
/** 带图前置路由 warn 提示 (三条转发路径共用) */
function warnVisionRoute(routed, sessTag) {
if (!routed || !routed.rerouted) return;
const tag = typeof sessTag === "function" ? sessTag() : "";
console.warn(TAGW, `${tag ? tag + " " : ""}带图请求模型 ${routed.from} 判定不支持视觉 (${routed.reason || "defaultVisionModels 白名单"}), 前置路由 → ${routed.model}`);
}
/** OpenAI messages 数组是否含 image_url part (任意角色) */
function openAIMessagesHaveImages(messages) {
if (!Array.isArray(messages)) return false;
return messages.some((m) => Array.isArray(m.content) && m.content.some((p) => p && p.type === "image_url"));
}
/** 深度统计请求体里的图片块: Anthropic image 块 + OpenAI image_url / input_image
* part。用于 REQ 行 img=N 标记与带图路由判断, 每请求一次 O(节点数) 遍历 */
function countImagesDeep(v) {
if (Array.isArray(v)) {
let n = 0;
for (const x of v) n += countImagesDeep(x);
return n;
}
if (!v || typeof v !== "object") return 0;
let n = v.type === "image" && v.source ? 1 : v.type === "image_url" || v.type === "input_image" ? 1 : 0;
for (const k in v) n += countImagesDeep(v[k]);
return n;
}
// 历史图片清理后的占位文本 (确定性替换, 保证前缀缓存稳定)
const STRIP_IMG_PLACEHOLDER = "[历史图片已清理]";
/** 最后一条 user 消息 (即本轮) 中的图片块数, 无 user 消息返回 0。
* Claude Code 每轮重发全量历史, 最后一条 user 消息即当前轮新产生的内容,
* 其中的图视为新图, 更早消息里的图视为历史图 */
function countLastUserImages(body) {
const msgs = body.messages;
if (!Array.isArray(msgs)) return 0;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i] && msgs[i].role === "user") return countImagesDeep(msgs[i]);
}
return 0;
}
/** 清理历史图片: 把最后一条 user 消息之前的所有 image 块原位替换为占位文本
* (直接 image 块与 tool_result 内嵌 image 块都处理)。上游对历史里的图片同样
* 400, 剥离后无新图的请求可安全发给纯文本模型, 带图路由随之只看新图 —— 会话
* 不再被历史图片钉死在视觉模型。替换是确定性的: 同一历史每轮剥出逐字节一致
* 的结果, 不影响前缀缓存。 */
function stripHistoryImages(body) {
const msgs = body.messages;
if (!Array.isArray(msgs)) return;
let lastUser = msgs.length;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i] && msgs[i].role === "user") {
lastUser = i;
break;
}
}
for (let i = 0; i < lastUser; i++) {
const m = msgs[i];
if (!m || !Array.isArray(m.content)) continue;
for (const b of m.content) {
if (!b || typeof b !== "object") continue;
if (b.type === "image" && b.source) {
b.type = "text";
b.text = STRIP_IMG_PLACEHOLDER;
delete b.source;
} else if (b.type === "tool_result" && Array.isArray(b.content)) {
for (let j = 0; j < b.content.length; j++) {
const x = b.content[j];
if (x && x.type === "image") b.content[j] = { type: "text", text: STRIP_IMG_PLACEHOLDER };
}
}
}
}
}
// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------
function estimateTokens(obj) {
try {
return Math.max(1, Math.ceil(JSON.stringify(obj).length / 4));
} catch {
return 1;
}
}
/** 估算 Anthropic/Responses 请求的输入 tokens (用于 message_start.usage.input_tokens) */
function estimateInputTokens(body) {
try {
let n = 0;
const count = (s) => {
n += Math.ceil((typeof s === "string" ? s : JSON.stringify(s || {})).length / 4);
};
if (body.system) count(body.system);
if (body.instructions) count(body.instructions);
for (const m of body.messages || []) count(m.content);
if (body.input) count(body.input);
if (body.tools && body.tools.length) count(body.tools);
return Math.max(1, n);
} catch {
return 1;
}
}
/**
* 从上游 usage 对象中提取日志关心的字段 (兼容 OpenAI / Anthropic / Responses 三种格式):
* input / output / reasoning(rt) / cacheRead(cr) / cacheWrite(cw)
* 注: input 返回"净输入"(总输入 - 缓存命中), 缓存命中量由 cacheRead 单独展示,
* 两者之和才是上游返回的总输入 tokens。
*/
function normalizeUsage(u) {
if (!u) return null;
const pd = u.prompt_tokens_details || {};
const cd = u.completion_tokens_details || {};
const itd = u.input_tokens_details || {}; // Responses 格式
const otd = u.output_tokens_details || {};
const out = {};
if (u.prompt_tokens != null) out.input = u.prompt_tokens; // OpenAI chat
if (u.input_tokens != null) out.input = u.input_tokens; // Anthropic / Responses
if (u.completion_tokens != null) out.output = u.completion_tokens; // OpenAI chat
if (u.output_tokens != null) out.output = u.output_tokens; // Anthropic / Responses
if (pd.cached_tokens !== undefined) out.cacheRead = pd.cached_tokens; // OpenAI chat
else if (itd.cached_tokens !== undefined) out.cacheRead = itd.cached_tokens; // Responses
else if (u.cache_read_input_tokens !== undefined) out.cacheRead = u.cache_read_input_tokens; // Anthropic
if (u.cache_creation_input_tokens !== undefined) out.cacheWrite = u.cache_creation_input_tokens; // 上游扩展/Anthropic
else if (pd.cache_creation_input_tokens !== undefined) out.cacheWrite = pd.cache_creation_input_tokens;
if (cd.reasoning_tokens !== undefined) out.reasoning = cd.reasoning_tokens; // OpenAI chat (DeepSeek 思考量)
else if (otd.reasoning_tokens !== undefined) out.reasoning = otd.reasoning_tokens; // Responses
// in = in - cr: 扣掉缓存命中的部分, 剩余才是按原价计费的输入; 仅当 in > cr 才减 (避免异常数据把 in 归零)
if (out.input != null && out.cacheRead != null) {
out.input = out.input > out.cacheRead ? out.input - out.cacheRead : out.input;
}
return out;
}
function mapStopReason(openaiReason) {
switch (openaiReason) {
case "tool_calls":
case "function_call":
return "tool_use";
case "length":
return "max_tokens";
case "stop":
case "end_turn":
default:
return "end_turn";
}
}
// ---------------------------------------------------------------------------
// SSE 基础
// ---------------------------------------------------------------------------
/** 解析单个 SSE 原始事件文本 (不含末尾空行) */
function parseSSEEvent(raw) {
let event = "message";
const dataLines = [];
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
else if (line.startsWith(":")) continue;
}
if (!dataLines.length) return null;
return { event, data: dataLines.join("\n") };
}
function sse(event, data) {
return `event: ${event}\ndata: ${data}\n\n`;
}
function sseResponses(data) {
return `data: ${JSON.stringify(data)}\n\n`;
}
function makeResponsesId(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
// ---------------------------------------------------------------------------
// Anthropic -> OpenAI 请求转换 (前缀逐字节稳定 + cache_control 透传)
// ---------------------------------------------------------------------------
/**
* content block -> OpenAI content part 的 1:1 映射。
* 稳定性关键: Anthropic 块数组一律输出 part 数组(不做"单文本折叠成字符串"),
* 这样 cache_control 标记位置随轮次移动时, 历史消息的 JSON 结构不会在字符串/数组间翻转,
* 上游看到的请求前缀保持逐字节稳定 —— 这是前缀缓存命中的前提。
*/
function anthropicBlocksToParts(blocks) {
const parts = [];
for (const b of blocks) {
if (b.type === "text" && b.text) {
const p = { type: "text", text: stabilizeVolatileText(b.text) };
if (CC_PASSTHROUGH && b.cache_control) p.cache_control = b.cache_control;
parts.push(p);
} else if (b.type === "image") {
const p = imageSourceToPart(b.source);
if (p) parts.push(p);
}
// 其他块类型 (thinking 等上游无对应概念) 跳过
}
return parts;
}
/** Anthropic image source -> OpenAI image_url part; 无效 source 返回 null */
function imageSourceToPart(src) {
if (src && src.type === "base64" && src.media_type && src.data) {
return { type: "image_url", image_url: { url: `data:${src.media_type};base64,${src.data}` } };
}
if (src && src.type === "url" && src.url) {
return { type: "image_url", image_url: { url: src.url } };
}
return null;
}
/** tool_result 块内容 -> tool 消息文本 (非文本块折叠为占位符) */
function toolResultToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((x) => (x && x.type === "text" ? x.text : `[${(x && x.type) || "unknown"}]`))
.join("");
}
return content == null ? "" : JSON.stringify(content);
}
/** tool_result 块内容中的 image 块 -> image_url part 数组。
* 上游 tool 消息不支持带图 (实测 400 Invalid input, vision 模型同样拒绝),
* 图片由 anthropicMessageToOpenAI 注入随后的 user 消息转发 */
function toolResultImageParts(content) {
if (!Array.isArray(content)) return [];
const parts = [];
for (const x of content) {
if (x && x.type === "image") {
const p = imageSourceToPart(x.source);
if (p) parts.push(p);
}
}
return parts;
}
function anthropicMessageToOpenAI(msg) {
const role = msg.role;
if (role === "user") {
const toolMsgs = [];
const parts = [];
const toolImgs = []; // tool_result 抽出的图片 (注入末尾 user 消息)
if (Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type === "tool_result") {
// OpenAI 要求 tool 消息独立成条; tool 消息不能带图 (上游 400), 图片抽出注入 user 消息
const c = toolResultToText(b.content);
toolMsgs.push({
role: "tool",
tool_call_id: b.tool_use_id,
content: typeof c === "string" && c.length ? c : JSON.stringify(c),
});
const imgs = TOOL_RESULT_IMAGES ? toolResultImageParts(b.content) : [];
if (imgs.length) toolImgs.push({ id: b.tool_use_id, imgs });
} else if (b.type === "text" || b.type === "image") {
const converted = anthropicBlocksToParts([b]);
parts.push(...converted);
}
}
} else if (typeof msg.content === "string" && msg.content) {
parts.push({ type: "text", text: msg.content });
}
const out = [...toolMsgs];
if (parts.length || toolImgs.length) {
// 图片以带来源标签的 part 追加在同一条 user 消息尾部; 历史回放时块内容不变,
// 注入结构确定, 不影响前缀缓存
const userParts = [...parts];
for (const { id, imgs } of toolImgs) {
userParts.push({ type: "text", text: id ? `[tool_result ${id} 附带的图片]` : "[tool_result 附带的图片]" });
userParts.push(...imgs);
}
out.push({ role: "user", content: userParts });
}
return out;
}
if (role === "assistant") {
// 文本折叠为字符串 + tool_use -> tool_calls。assistant 文本不携带 cache_control
// (Claude Code 的标记只出现在 system/tools/最近的 user 消息上), 折叠不影响前缀稳定性。
let text = "";
const toolCalls = [];
if (typeof msg.content === "string") {
text = msg.content;
} else if (Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type === "text" && b.text) text += b.text;
else if (b.type === "tool_use") {
toolCalls.push({
id: b.id,
type: "function",
function: { name: b.name, arguments: JSON.stringify(b.input || {}) },
});
}
}
}
const omsg = { role: "assistant" };
if (text) omsg.content = text;
if (toolCalls.length) omsg.tool_calls = toolCalls;
return omsg;
}
// system 等其他 role (Claude Code 2.1.251+ 会往 messages 里注入 system 角色的
// 上下文提醒, 如 <total_tokens> 配额计数): 默认整条剥离 —— 纯提示性内容, 每轮
// 变化且被回溯改写, 是前缀缓存杀手; 剥离后模型每轮仍能正常编码, CC 下一轮会
// 重新注入。config.stripSystemReminders=false 可保留 (保留时提取纯文本)。
if (role === "system") {
if (STRIP_SYSTEM_REMINDERS) return null;
const text = extractReminderText(msg.content);
if (text) return { role: "system", content: text };
return null;
}
return { role, content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content) };
}
/**
* 易变计数器稳定化: Claude Code 2.1.251 注入的 <total_tokens>N tokens left</total_tokens>
* 配额计数每轮回溯改写, 是前缀缓存失效的元凶。将数值就近取整到 100 万
* (14977212 -> 15000000), 回溯改写前后字节一致, 语义仅损失粗粒度精度。
* 可用 config.stabilizeCounters=false 关闭。
*/
const VOLATILE_COUNTER_RE = /(<total_tokens>)(\d+)( tokens left<\/total_tokens>)/g;
function stabilizeVolatileText(s) {
if (!STABILIZE_COUNTERS || typeof s !== "string" || s.indexOf("<total_tokens>") < 0) return s;
return s.replace(VOLATILE_COUNTER_RE, (_, p, num, q) => p + Math.round(parseInt(num, 10) / 1e6) * 1e6 + q);
}
/** system 提醒消息的文本提取: 字符串原样; 块数组取 text 块按 \n\n 拼接 */
function extractReminderText(content) {
if (typeof content === "string") return stabilizeVolatileText(content);
if (Array.isArray(content)) {
const text = content
.filter((b) => b && b.type === "text" && b.text)
.map((b) => stabilizeVolatileText(b.text))
.join("\n\n");
return text || null;
}
return content == null ? null : String(content);
}
/** 缓存亲和 user 字段: 仅取稳定的会话标识 (Claude Code 会话 UUID), 无则不注入 */
function affinityUser(sessionKey) {
if (!CACHE_AFFINITY || !sessionKey) return undefined;
if (sessionKey.startsWith("cc:")) {
const id = sessionKey.slice(3);
return id.length && id.length <= 64 ? id : `cc-${id.slice(0, 56)}`;
}
return undefined; // src:port|ua 形式的 key 含每次连接都变的端口, 注入反而破坏稳定性
}
function anthropicToOpenAIRequest(body, sessionKey) {
const mapped = body.model; // model 由调用方 (pickModel) 决策后覆盖
const messages = [];
// 顶层 system: 块数组 1:1 映射为 part 数组 (保结构稳定 + cache_control 透传)
if (body.system != null) {
if (typeof body.system === "string") {
if (body.system) messages.push({ role: "system", content: body.system });
} else if (Array.isArray(body.system)) {
const parts = anthropicBlocksToParts(body.system);
if (parts.length) messages.push({ role: "system", content: parts });
}
}
// 逐条转换 (user 消息可能展开为多条: tool_result 拆独立 tool 消息)
for (const m of body.messages || []) {
const converted = anthropicMessageToOpenAI(m);
if (Array.isArray(converted)) messages.push(...converted);
else if (converted) messages.push(converted);
}
const req = {
model: mapped,
messages,
stream: !!body.stream,
};
// 部分上游模型 (如 gpt-5.6-sol) 要求 max_tokens >= 16; Claude Code 的 /model
// 探测请求会发 max_tokens=1, 需钳制到最小值避免 400
if (body.max_tokens != null) req.max_tokens = Math.max(16, body.max_tokens);
if (body.temperature != null) req.temperature = body.temperature;
if (body.top_p != null) req.top_p = body.top_p;
if (Array.isArray(body.stop_sequences) && body.stop_sequences.length) req.stop = body.stop_sequences;
if (body.tools && Array.isArray(body.tools) && body.tools.length) {
req.tools = body.tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description || "",
parameters: t.input_schema || { type: "object", properties: {} },
},
}));
}
const tc = body.tool_choice;
if (tc && typeof tc === "object") {
if (tc.type === "tool" && tc.name) req.tool_choice = { type: "function", function: { name: tc.name } };
else if (tc.type === "auto") req.tool_choice = "auto";
else if (tc.type === "any") req.tool_choice = "required";
else if (tc.type === "none") req.tool_choice = "none";
} else if (tc === "auto" || tc === "none") {
req.tool_choice = tc;
}
if (body.stream) req.stream_options = { include_usage: true };
const user = affinityUser(sessionKey);
if (user) req.user = user;
return req;
}
// ---------------------------------------------------------------------------
// OpenAI -> Anthropic 响应转换 (非流式)
// ---------------------------------------------------------------------------
function openAIToAnthropic(obj, requestedModel) {
const choice = obj.choices && obj.choices[0] ? obj.choices[0] : {};
const msg = choice.message || {};
const contentBlocks = [];
if (msg.content) contentBlocks.push({ type: "text", text: msg.content });
for (const tc of msg.tool_calls || []) {
let input = {};
try {
input = JSON.parse(tc.function.arguments || "{}");
} catch {
input = { raw: tc.function.arguments };
}
contentBlocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
}
const u = obj.usage || {};
const pd = u.prompt_tokens_details || {};
const pt = u.prompt_tokens ?? 0;
const ct = pd.cached_tokens ?? 0;
const usage = {
// input_tokens 为净输入 (仅当 in > cr 才减, 避免异常数据归零), 与日志 in=in-cr 保持一致
input_tokens: pt > ct ? pt - ct : pt,
output_tokens: u.completion_tokens ?? 0,
};
const cr = pd.cached_tokens ?? u.cache_read_input_tokens;