Repository navigation
Expand file tree
/
Copy pathzen-proxy.mjs
More file actions
2086 lines (1887 loc) · 77.3 KB
/
Copy pathzen-proxy.mjs
File metadata and controls
2086 lines (1887 loc) · 77.3 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
/**
* opencode2zcode — 把 OpenCode Zen 的匿名免费模型接进 ZCode。
*
* 移植自 JasonYu0822/opencode2pi-desktop 的 lib/zen.js + main.js(MIT)。
* 那边的伪装细节又来自 FishBottle7/opencode2dsh 与 jasonxu114514/opencode2api。
* 这里保留的是:CLI 一致的伪装头、规范形状的 ses_ 会话 id、免费通道体门禁、
* reasoning_effort 归一、S1∩S2 模型目录、SSE 逐块透传、磁盘目录缓存。
* 有意不移植:IP 池 / 代理轮换子系统、Go sidecar。
*
* 为什么不能用 ZCode 插件做:
* ZCode 的 .zcode-plugin/plugin.json 只能贡献 skills / commands / hooks /
* mcpServers,既没有 pi-desktop 的 contributes.providers(宿主静态声明
* provider),也没有 contributes.services(常驻回环服务)。所以这里改成
* ZCode 官方支持的那条路:本进程跑回环代理,并把 provider 声明写进 ZCode 的
* 「个人 Provider 配置」{~}/.zcode/v2/provider_config.json。ZCode 对该文件
* 每 1 秒轮询一次,改完热生效,不用重启。
*
* 用法:
* node zen-proxy.mjs # 起代理 + 写配置
* node zen-proxy.mjs --print-config # 只打印将要写入的配置,不落盘不起服务
* node zen-proxy.mjs --write-config-only
* node zen-proxy.mjs --port 41930 --config <path>
*/
import { createHash, randomBytes } from 'node:crypto';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
/**
* 本脚本所在目录;旁路文件与配置定位都相对它。
*
* 要同时兼容三种形态:ESM 源码直跑、打成单文件 exe(SEA 里 import.meta 是空
* 对象,esbuild 的 CJS 兜底也给不出 url)、以及被 SEA 塞进 exe 的 CJS 包。
* SEA 里 __dirname 正好等于 exe 所在目录,所以拿它当兜底最省事。
*/
function resolveScriptDir(metaUrl, dirnameFallback) {
if (typeof metaUrl === 'string' && metaUrl.length > 0) {
try {
return path.dirname(fileURLToPath(metaUrl));
} catch {
/* 落到下面 */
}
}
if (typeof dirnameFallback === 'string' && dirnameFallback.length > 0) return dirnameFallback;
return process.cwd();
}
// eslint-disable-next-line no-undef
const SCRIPT_DIR = resolveScriptDir(import.meta?.url, typeof __dirname === 'string' ? __dirname : '');
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
/** 上游根地址;可用 ZEN_BASE_URL 覆盖(指向自建网关或本地 mock 便于自测)。 */
const ZEN_BASE_URL = (process.env.ZEN_BASE_URL?.trim() || 'https://opencode.ai/zen').replace(
/\/+$/,
'',
);
const METADATA_URL = 'https://models.dev/api.json';
// OpenCode 官方机器可读目录,和 models.dev 是同一份数据的两个镜像。
// 线路判定以它为准(每个模型自带 provider.npm),models.dev 只是镜像兜底。
const CAPABILITIES_URL = 'https://models.opencode.ai/api.json';
/** 匿名通道的固定 Bearer,不是密钥,PI-Desktop 那边也一样写死。 */
const ANONYMOUS_KEY = 'public';
/** 伪装成哪个版本的官方 CLI。上游若收紧,改这里。 */
const OPENCODE_CLIENT_VERSION = '1.18.31';
const DEFAULT_PORT = 41930;
const PORT_CANDIDATES = [DEFAULT_PORT, 41931, 41932, 41933];
const CATALOG_TIMEOUT_MS = 30_000;
const CATALOG_REFRESH_MS = 300_000;
const CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const UPSTREAM_HEADER_TIMEOUT_MS = 60_000;
/** 上游因 body 不合规拒绝时的状态码;命中则摘掉 reasoning_effort 重试一次。 */
const RETRY_WITHOUT_EFFORT_STATUSES = [400, 422, 500];
const PROVIDER_ID = 'opencode-free';
const PROVIDER_NAME = 'OpenCode 免费模型';
const RESPONSES_PROVIDER_ID = 'opencode-free-responses';
const RESPONSES_PROVIDER_NAME = 'OpenCode 免费模型 · Responses';
const CACHE_FILE_NAME = 'opencode2zcode-catalog.json';
/**
* 断网兜底不再用编译期写死的名单:那份名单随上游增减而陈旧,写进代码等于
* 让程序永远按某个历史快照说话。拿不到实时数据时,用磁盘缓存里最近一次
* 成功解析的结果顶上;缓存也没有就先空着,不编造。
*/
// ---------------------------------------------------------------------------
// 关联 id + 伪装头(对应 ids.ts)
// ---------------------------------------------------------------------------
const CANONICAL_SESSION_PATTERN = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
const DESCENDING_TIME_PART_PATTERN = /^[0-9a-f]{9}f[c-f][0-9a-f]$/;
const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function base62Fixed(value, width) {
const base = 62n;
let n = value;
const out = new Array(width);
for (let i = width - 1; i >= 0; i -= 1) {
out[i] = BASE62_ALPHABET.charAt(Number(n % base));
n /= base;
}
return out.join('');
}
/** sha256("prefix\0value") 取前 12 字节,稳定且不可逆。 */
function stableID(prefix, value) {
const sum = createHash('sha256').update(`${prefix}\x00${value}`).digest();
return `${prefix}_${sum.subarray(0, 12).toString('hex')}`;
}
function randomID(prefix, size) {
return `${prefix}_${randomBytes(size).toString('hex')}`;
}
/** 会话信号 = 第一条 user 消息内容的 JSON,同一对话恒定。 */
function conversationSeed(messages) {
for (const message of messages || []) {
if (!message || message.role !== 'user') continue;
const encoded = JSON.stringify(message.content ?? null);
if (encoded !== 'null' && encoded.length > 0) return encoded;
}
return '';
}
/**
* 官方客户端 Identifier.descending() 生成的时间戳前缀:
* ~(Date.now() * 0x1000 + 1) 截断到 48 位取 12 位十六进制,由于末位 counter=1,
* 低 12 位取反后必然以 "ffe" 结尾。2026-10-13 起上游解密该段做时间戳有效性校验。
*/
function invertedTimestampHex(now = Date.now()) {
const current = BigInt(now) * 0x1000n + 1n;
const inverted = ~current & 0xffffffffffffn;
return inverted.toString(16).padStart(12, '0');
}
const SESSION_MEMO_LIMIT = 1024;
const sessionMemo = new Map();
/**
* 免费通道要求真实的倒序时钟时间戳(2026-10-13 上游收紧)。
* 为保证多轮对话与重试不会因毫秒推进丢失 prompt cache 亲和与配额状态,
* 按会话信号 signal 进行 memo 缓存(上限 1024 条先进先出)。
*/
function canonicalSessionID(signal) {
if (CANONICAL_SESSION_PATTERN.test(signal)) {
const timePart = signal.slice(4, 16);
if (DESCENDING_TIME_PART_PATTERN.test(timePart)) return signal;
}
const memoized = sessionMemo.get(signal);
if (memoized !== undefined) return memoized;
const sum = createHash('sha256').update(`ses\x00${signal}`).digest();
const timePart = invertedTimestampHex();
const randomPart = base62Fixed(BigInt(`0x${sum.subarray(6, 16).toString('hex')}`), 14);
const session = `ses_${timePart}${randomPart}`;
if (sessionMemo.size >= SESSION_MEMO_LIMIT) {
const oldest = sessionMemo.keys().next();
if (!oldest.done) sessionMemo.delete(oldest.value);
}
sessionMemo.set(signal, session);
return session;
}
/**
* 官方客户端格式的 request ID:`msg_` + 递增正序时间戳(12位hex) + 14位Base62随机串
*/
function mintCanonicalRequestID() {
const current = BigInt(Date.now()) * 0x1000n + 1n;
const timePart = (current & 0xffffffffffffn).toString(16).padStart(12, '0');
const randomPart = base62Fixed(BigInt(`0x${randomBytes(10).toString('hex')}`), 14);
return `msg_${timePart}${randomPart}`;
}
function deriveRequestIDs(messages) {
let signal = conversationSeed(messages);
if (signal === '' || signal === '{}') signal = randomID('fallback', 16);
return {
session: canonicalSessionID(signal),
request: mintCanonicalRequestID(),
project: stableID('prj', 'opencode2zcode:default-project'),
};
}
function opencodeUserAgent() {
return `opencode/${OPENCODE_CLIENT_VERSION} (${process.platform} ${process.arch}; node${process.versions.node})`;
}
/** 每次上游请求都带的完整伪装头。 */
function disguiseHeaders(ids) {
return {
'user-agent': opencodeUserAgent(),
'x-opencode-client': 'cli',
'x-opencode-session': ids.session,
'x-session-affinity': ids.session,
'X-Session-Id': ids.session,
'x-opencode-request': ids.request,
'x-opencode-project': ids.project,
};
}
/**
* chat 与 responses 两种线格式都归一到同一份关联 id:
* chat 看 messages,responses 看 input(字符串或 item 数组)。
*/
function deriveRequestIDsFromWire(body) {
if (body && Array.isArray(body.messages)) return deriveRequestIDs(body.messages);
const input = body && body.input;
if (typeof input === 'string') return deriveRequestIDs([{ role: 'user', content: input }]);
if (Array.isArray(input)) {
const messages = input
.map((item) => {
if (typeof item === 'string') return { role: 'user', content: item };
if (!item || typeof item !== 'object') return null;
const role = item.role === 'assistant' ? 'assistant' : 'user';
const content =
typeof item.content === 'string'
? item.content
: Array.isArray(item.content)
? item.content.map((part) => (part && typeof part.text === 'string' ? part.text : '')).join('')
: typeof item.text === 'string'
? item.text
: '';
return { role, content };
})
.filter(Boolean);
return deriveRequestIDs(messages);
}
return deriveRequestIDs([]);
}
// ---------------------------------------------------------------------------
// 免费通道体门禁(对应 messages.ts)
// ---------------------------------------------------------------------------
/** 实测:匿名通道对不流式、或缺 bash/read 函数工具的 chat 请求直接 403。 */
const FREE_LANE_GATE_TOOL_NAMES = ['bash', 'read'];
/** 门禁工具的描述与参数:描述和参数上游不检查,两种线格式统一用同一份文案。 */
const GATE_DESCRIPTION = 'Reserved for the host runtime; do not call it.';
/** chat/completions 的线格式:工具定义嵌在 `function` 里。 */
function freeLaneGateToolChat(name) {
return {
type: 'function',
function: { name, description: GATE_DESCRIPTION, parameters: { type: 'object', properties: {} } },
};
}
/** responses 的线格式:**扁平**,`name` 直接挂在工具上。
* 套错格式会被上游判 400(tools[0] missing required field name)。 */
function freeLaneGateToolResponses(name) {
return { type: 'function', name, description: GATE_DESCRIPTION, parameters: { type: 'object', properties: {} } };
}
/**
* 补齐缺失的门禁工具。
*
* chat 道:原本一个工具都没有时补 tool_choice:"none",免得模型真去调这两个
* 占位桩。
* responses 道:**绝对不能碰 tool_choice**。那个端点只接受 "auto" /
* "required" / 具体工具名,给 "none" 会直接 400。踩过的坑——最早判定
* 「responses 道没救」就是因为照搬了 chat 道的 tool_choice:"none",
* 而它恰好是致命的那个。
*/
function ensureFreeLaneShape(body, lane) {
const tools = Array.isArray(body.tools) ? body.tools : [];
const nameOf = (tool) => {
if (!tool || typeof tool !== 'object') return undefined;
if (lane === 'responses') return tool.name ?? (tool.function && tool.function.name);
return (tool.function && tool.function.name) ?? tool.name;
};
const names = new Set(tools.map(nameOf));
const missing = FREE_LANE_GATE_TOOL_NAMES.filter((name) => !names.has(name));
const next = { ...body };
if (missing.length > 0) {
const build = lane === 'responses' ? freeLaneGateToolResponses : freeLaneGateToolChat;
next.tools = [...tools, ...missing.map(build)];
if (tools.length === 0 && lane === 'chat') next.tool_choice = 'none';
}
if (lane === 'responses' && next.tool_choice === 'none') delete next.tool_choice;
return next;
}
// ---------------------------------------------------------------------------
// 思考档位归一
// ---------------------------------------------------------------------------
/** 上游确实会接受的「主动思考」档位。off/none 不在其中,见下。 */
const ACCEPTED_EFFORT_LADDER = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
const DEFAULT_EFFORT_LADDER = ['minimal', 'low', 'medium', 'high'];
/**
* 实测(2026-10,逐模型探测)上游的档位并不统一:
* big-pickle off/none=400,minimal…max=200
* space-bunny off/none=400,minimal/high/max=200
* nemotron-3.5 off=400,none/minimal/high/max=200
* mimo-v2.5 off=500,none/high=200(minimal/max 时好时坏,上游抖动)
* 原项目当时把 off/缺失/未知一律写成 "none",今天这样会直接把请求打成 400,
* 所以这里改成:只透传上游确实认的主动档位;off / none / 缺失 / 未知一律
* **省略该字段**,退回厂商默认(这些免费模型基本都是 always-think,
* 本来也没有可靠的办法真正关掉思考)。handleUpstream 里还有一次摘字段重试兜底。
*/
function upstreamEffort(hostValue) {
if (hostValue === undefined || hostValue === null || hostValue === '') return undefined;
return ACCEPTED_EFFORT_LADDER.includes(hostValue) ? hostValue : undefined;
}
/** 从 models.dev 的 reasoning_options 推出 UI 档位阶梯(去掉上游不收的 off/none)。 */
function effortsFor(capability) {
if (!capability || !capability.reasoning) return [];
const declared = [];
for (const value of capability.effortValues || []) {
const level = value === 'none' ? 'off' : value;
if (ACCEPTED_EFFORT_LADDER.includes(level) && !declared.includes(level)) declared.push(level);
}
if (declared.length === 0) return DEFAULT_EFFORT_LADDER.slice();
return declared.sort(
(a, b) => ACCEPTED_EFFORT_LADDER.indexOf(a) - ACCEPTED_EFFORT_LADDER.indexOf(b),
);
}
/**
* 把宿主发来的 chat 体整理成免费通道能收的形状:强制流式、透传受支持的思考
* 档位(否则不带这个字段)、再套门禁。其余原样转发。
*/
function buildUpstreamBody(hostBody, lane) {
const source = hostBody && typeof hostBody === 'object' ? hostBody : {};
const body = { ...source, stream: true };
const effort = upstreamEffort(source.reasoning_effort);
if (effort === undefined) {
delete body.reasoning_effort;
} else {
body.reasoning_effort = effort;
}
return ensureFreeLaneShape(body, lane);
}
// ---------------------------------------------------------------------------
// 模型目录(对应 catalog.ts)
// ---------------------------------------------------------------------------
function isFreeModel(model) {
return String(model).toLowerCase().includes('free');
}
function metadataDeprecated(model) {
if (model.deprecated === true) return true;
const status = String(model.status ?? model.lifecycle ?? '').toLowerCase();
if (status === 'deprecated' || status === 'retired' || status === 'disabled') return true;
return model.deprecated_at != null || model.retirement_date != null;
}
/**
* 官方目录里的 SDK 包名 → 线格式。
* `@ai-sdk/openai` 是 responses,`openai-compatible` 是 chat,两者必须先判
* 精确的那个——后者也含 `openai` 子串。anthropic 等本工具没有对应的道。
*/
function decodeWire(npm) {
const value = String(npm ?? '').trim().toLowerCase();
if (value.length === 0) return undefined;
if (value === '@ai-sdk/openai' || value.endsWith('/openai')) return 'responses';
if (value.includes('openai-compatible')) return 'chat';
return 'unsupported';
}
function decodeEffortValues(raw) {
if (!Array.isArray(raw)) return { effortValues: [] };
const values = [];
for (const option of raw) {
if (!option || typeof option !== 'object') continue;
if (option.type !== 'effort' || !Array.isArray(option.values)) continue;
for (const value of option.values) {
if (typeof value === 'string' && value.length > 0 && !values.includes(value)) values.push(value);
}
}
return { effortValues: values };
}
/** models.dev 的 OpenCode 段 → Map(modelId → 元数据)。 */
function decodeModelsDev(data) {
const result = new Map();
if (!data || typeof data !== 'object') return result;
const rank = (key) => {
const lower = key.toLowerCase();
if (lower === 'opencode' || lower === 'opencode-zen' || lower === 'opencode_zen') return 0;
if (lower.includes('opencode')) return 1;
return 2;
};
const keys = Object.keys(data).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
for (const key of keys) {
if (rank(key) > 1) continue;
const provider = data[key];
if (!provider || typeof provider !== 'object') continue;
if (rank(key) === 1) {
const identity = `${provider.id ?? ''} ${provider.name ?? ''}`.toLowerCase().trim();
if (!identity.includes('opencode')) continue;
}
const models = provider.models;
if (!models || typeof models !== 'object') continue;
for (const [modelKey, raw] of Object.entries(models)) {
if (!raw || typeof raw !== 'object') continue;
const modelId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : modelKey;
const cost = raw.cost ?? {};
const limit = raw.limit ?? {};
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
result.set(modelId, {
input: num(cost.input),
output: num(cost.output),
deprecated: metadataDeprecated(raw),
reasoning: raw.reasoning === true,
contextWindow: num(limit && limit.context) ?? 262144,
maxTokens: num(limit && limit.output) ?? 32768,
modalitiesInput: Array.isArray(raw.modalities && raw.modalities.input)
? raw.modalities.input.map(String)
: [],
// 逐模型的 npm 覆盖段级默认;没有逐模型声明就走段级(chat)。
wire: decodeWire(raw.provider?.npm ?? provider.npm),
...decodeEffortValues(raw.reasoning_options),
});
}
if (result.size > 0) return result;
}
return result;
}
/**
* 是否免费——与 OpenCode 桌面版逐位对齐。桌面版的判定就一行
* (packages/app/src/components/dialog-select-model.tsx):
*
* isFree = provider === "opencode" && (!cost || cost.input === 0)
*
* 两处容易踩的细节:
* 1. 只看 input,**不看 output**;并且「没有 cost 数据」也算免费。
* 早先这里要求 input 和 output 都为 0,比桌面版严,会漏掉模型。
* 2. 桌面版还会排除 status 为 deprecated 的条目。Zen 把下架模型留在公开
* /models 和目录里(标价仍是 0),不排除的话模型选择器里会出现点下去
* 必报错的选项——实测 mimo-v2.5-free、muse-spark-1.2-contributor-free
* 就是这样被误放进来的。
*/
function decide(model, prices, ready) {
const nameFree = isFreeModel(model);
const fallback = (source) =>
nameFree ? { allowed: true, source: 'name_free', known: false } : { allowed: false, source, known: false };
if (!ready || prices.size === 0) return fallback('metadata_pending');
const price = prices.get(model);
// 目录里查无此模型:不按名字放行。实时清单里混着大量名字带 free 的
// 付费或未核实模型,名字兜底只在「整份元数据都没拉到」时才有意义。
if (!price) return { allowed: false, source: 'metadata_model_missing', known: false };
// 废弃是硬否决,后面的兜底也不许把它捞回来。
if (price.deprecated) return { allowed: false, source: 'metadata_deprecated', known: true };
if (price.input === undefined || price.input === 0) {
return { allowed: true, source: nameFree ? 'name_and_metadata_free' : 'metadata_free', known: true };
}
return { allowed: false, source: 'metadata_paid', known: true };
}
async function withTimeout(promise, ms, label) {
let timer = null;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
/** S1:带伪装头拉 Zen 的实时目录。 */
async function fetchZenModels(fetchImpl) {
const response = await withTimeout(
fetchImpl(`${ZEN_BASE_URL}/v1/models`, {
headers: {
authorization: `Bearer ${ANONYMOUS_KEY}`,
'user-agent': opencodeUserAgent(),
'x-opencode-client': 'cli',
accept: 'application/json',
},
}),
CATALOG_TIMEOUT_MS,
'models catalog fetch',
);
if (!response.ok) throw new Error(`models endpoint returned HTTP ${response.status}`);
const payload = await response.json();
const models = [];
for (const item of payload && payload.data ? payload.data : []) {
if (item && typeof item.id === 'string' && item.id.length > 0) models.push(item.id);
}
if (models.length === 0) throw new Error('models endpoint returned an empty list');
return models;
}
class ModelCatalog {
#fetchImpl;
#onChange;
#cachePath;
#cacheState = null;
#cacheSeeded = false;
#liveAt = 0;
#changeKey = '';
#zen = new Set();
#updatedAt = 0;
#prices = new Map();
#pricesReady = false;
/** 最近一次成功解析出来的清单与价格(内存快照)。编译期名单已移除。 */
#lastGoodZen = new Set();
#lastGoodPrices = new Map();
/** 上次拉到的目录 ETag,下次带 If-None-Match,没变就回 304。 */
#metadataEtag = null;
#lastError = '';
#timer = null;
#stopped = false;
#refreshing = false;
constructor(fetchImpl, onChange, options = {}) {
this.#fetchImpl = fetchImpl;
this.#onChange = typeof onChange === 'function' ? onChange : null;
this.#cachePath = typeof options.cachePath === 'string' && options.cachePath.length > 0 ? options.cachePath : null;
}
/** 先读盘播种,离线启动也能保住上次的真实清单,不退化成静态兜底。 */
#seedFromCache() {
// 种子失败不判死刑:prices 始终为空的进程会一辈子写出没有思考档位的
// 裸配置。没拿到元数据就撤掉标记,让下一次刷新重试(与 Rust 版一致)。
if (this.#cacheSeeded && this.#pricesReady) return;
this.#cacheSeeded = true;
if (!this.#cachePath) return;
let raw = null;
try {
raw = JSON.parse(fs.readFileSync(this.#cachePath, 'utf8'));
} catch {
return;
}
if (!raw || raw.version !== 1) return;
if (!Number.isFinite(raw.updatedAt) || Date.now() - raw.updatedAt > CATALOG_CACHE_TTL_MS) return;
const ids = Array.isArray(raw.zen) ? raw.zen.filter((id) => typeof id === 'string' && id.length > 0) : [];
const prices = Array.isArray(raw.prices) ? new Map(raw.prices) : new Map();
if (ids.length === 0 && prices.size === 0) return;
if (ids.length > 0) {
this.#zen = new Set(ids);
this.#updatedAt = raw.updatedAt;
}
if (prices.size > 0) {
this.#prices = prices;
this.#lastGoodPrices = new Map(prices);
this.#pricesReady = true;
// 缓存里清单和元数据是同一次写出来的,两者都在才构成可信快照。
if (ids.length > 0) this.#lastGoodZen = new Set(ids);
}
this.#cacheState = { fingerprint: this.#cacheFingerprint() };
}
#cachePayload() {
const sortPairs = (pairs) => [...pairs].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
return { zen: [...this.#zen].sort(), prices: sortPairs(this.#prices.entries()) };
}
#cacheFingerprint() {
const payload = this.#cachePayload();
return JSON.stringify([payload.zen, payload.prices]);
}
/** 只有数据真的变了才落盘,避免每 5 分钟白写一次。 */
#saveCache() {
if (!this.#cachePath) return;
// 空 prices 覆盖好缓存 = 自我中毒循环:种子失败→写坏缓存→以后每次重启
// 都从坏缓存种子。没有元数据就别动缓存(与 Rust 版一致)。
if (this.#prices.size === 0) return;
const fingerprint = this.#cacheFingerprint();
if (this.#cacheState && this.#cacheState.fingerprint === fingerprint) return;
try {
const payload = { version: 1, updatedAt: Date.now(), ...this.#cachePayload() };
const temporary = `${this.#cachePath}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(payload), 'utf8');
fs.renameSync(temporary, this.#cachePath);
this.#cacheState = { fingerprint };
} catch {
// 缓存写不进去不是致命问题,实时目录照常工作。
}
}
async start() {
this.#stopped = false;
await this.refreshOnce();
let attempts = 0;
while (this.#zen.size === 0 && attempts < 4 && !this.#stopped) {
attempts += 1;
await new Promise((resolve) => setTimeout(resolve, 15_000));
if (this.#stopped) return;
await this.refreshOnce();
}
if (this.#stopped) return;
this.#timer = setInterval(() => void this.refreshOnce(), CATALOG_REFRESH_MS);
if (this.#timer.unref) this.#timer.unref();
}
stop() {
this.#stopped = true;
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
async refreshOnce() {
if (this.#refreshing) return;
this.#refreshing = true;
try {
this.#seedFromCache();
await Promise.allSettled([this.#refreshZen(), this.#refreshMetadata()]);
this.#saveCache();
// 只在暴露清单真的变了时回调一次,重复刷新保持安静。
const exposed = this.list();
// 快照记「这次真的被放行的」,而不是整份实时清单。实时清单里混着
// 付费和未核实的模型,整份记下来会在元数据抖动时被原样放行。
if (exposed.length > 0) this.#lastGoodZen = new Set(exposed);
const key = exposed.join('|');
if (key !== this.#changeKey) {
const first = this.#changeKey === '';
this.#changeKey = key;
if (!first && this.#onChange) {
try {
this.#onChange();
} catch {
/* 回调绝不能弄坏刷新 */
}
}
}
} finally {
this.#refreshing = false;
}
}
async #refreshZen() {
try {
const ids = await fetchZenModels(this.#fetchImpl);
// 空清单不覆盖:上游抖动回了空 body 时,保住上一次的结果。
if (ids.length === 0) return;
this.#zen = new Set(ids);
this.#updatedAt = Date.now();
this.#liveAt = this.#updatedAt;
this.#lastError = '';
} catch (err) {
this.#lastError = err && err.message ? err.message : String(err);
}
}
/**
* 拉元数据。优先官方目录(models.opencode.ai),它带每个模型的 provider.npm,
* 线路判定靠它;拉不到再退回 models.dev 镜像。两者形状相同,同一套解析。
* 带上上次的 ETag:目录没变就回 304,不再重下那 5MB。
*/
async #refreshMetadata() {
const sources = [CAPABILITIES_URL, METADATA_URL];
let lastError = null;
for (const url of sources) {
try {
const headers = { accept: 'application/json' };
if (this.#metadataEtag) headers['if-none-match'] = this.#metadataEtag;
const response = await withTimeout(
this.#fetchImpl(url, { headers }),
CATALOG_TIMEOUT_MS,
'model metadata fetch',
);
if (response.status === 304) return;
if (!response.ok) throw new Error(`metadata endpoint returned HTTP ${response.status}`);
const prices = decodeModelsDev(await response.json());
if (prices.size === 0) throw new Error('metadata contains no OpenCode models');
this.#prices = prices;
this.#lastGoodPrices = new Map(prices);
this.#pricesReady = true;
this.#liveAt = Date.now();
this.#metadataEtag = response.headers.get('etag') || this.#metadataEtag;
return;
} catch (err) {
lastError = err && err.message ? err.message : String(err);
}
}
if (!this.#pricesReady && lastError) this.#lastError = lastError;
}
decision(model) {
const metadata = decide(model, this.#prices, this.#pricesReady);
if (metadata.allowed) return metadata;
// 目录已明确判定废弃时,S3 静态清单不再把它捞回来——那正是早先多出
// mimo-v2.5-free / muse-spark-1.2-contributor-free 的原因。S3 只兜「还说不准」。
if (metadata.source === 'metadata_deprecated') return metadata;
if (!metadata.known && this.#lastGoodZen.has(model)) {
return { allowed: true, source: 'static_verified', known: false };
}
return metadata;
}
/**
* 暴露的 id:S1 ∩ 允许;数据不可用时用**最近一次成功解析的结果**。
* 「名字带 free 就放行」会把已下架模型放进来(点下去必报错)——宁可少暴露。
* 编译期写死的名单已移除:它随上游增减而陈旧,正是要消除的那类误导。
*/
list() {
if (this.#zen.size === 0) return [...this.#lastGoodZen].sort();
if (!this.#pricesReady) {
// 元数据缺失:只保留"上次成功解析过、且此刻仍在实时清单里"的。
return [...this.#zen].filter((m) => this.#lastGoodZen.has(m)).sort();
}
const out = [];
for (const model of this.#zen) {
if (this.decision(model).allowed) out.push(model);
}
return out.sort();
}
reasoningCapability(model) {
const price = this.#prices.get(model);
if (!price) return undefined;
return { reasoning: price.reasoning === true, effortValues: price.effortValues ?? [] };
}
metadata(model) {
return this.#prices.get(model);
}
snapshot() {
const age = this.#updatedAt === 0 ? Infinity : Date.now() - this.#updatedAt;
const stale = this.#updatedAt !== 0 && age > 10 * 60 * 1000;
return {
status: this.#updatedAt === 0 ? 'pending' : stale ? 'stale' : 'ready',
source: this.#liveAt !== 0 ? 'live' : this.#updatedAt !== 0 ? 'cache' : 'static',
total: this.#zen.size,
exposed: this.list().length,
lastError: this.#lastError,
};
}
}
// ---------------------------------------------------------------------------
// 分道与元数据
// ---------------------------------------------------------------------------
/**
* 这个模型走哪条线。优先信官方目录里它自己声明的 SDK:`@ai-sdk/openai`
* 走 responses,`openai-compatible` 走 chat,anthropic 等本工具代理不了。
* 目录没说话时才退回按名字猜——muse-spark 走 responses,jev 走本工具没有的
* systemone 道,其余当 chat。
*/
function laneFor(id, catalog) {
const declared = catalog.metadata(id)?.wire;
if (declared === 'unsupported') return null;
if (declared === 'chat' || declared === 'responses') return declared;
const value = String(id ?? '').toLowerCase();
if (value.length === 0 || value.startsWith('jev')) return null;
if (value.startsWith('muse-spark')) return 'responses';
return 'chat';
}
/**
* models.dev 元数据 → 声明条目。
*
* 元数据拿不到时给中性默认。这里**不**放编译期写死的实测表——那份表会随上游增减
* 而陈旧,写进代码等于让程序永远按某个历史快照说话。真实档位来自上游的
* reasoning_options,写死一份反而会在上游改档位时变成误导。
*/
function catalogEntry(id, catalog) {
const meta = catalog.metadata(id);
if (!meta) {
return { id, contextWindow: 262144, maxTokens: 32768, supportsImages: false, thinkingLevels: [] };
}
return {
id,
contextWindow: meta.contextWindow,
maxTokens: meta.maxTokens,
supportsImages: Array.isArray(meta.modalitiesInput) ? meta.modalitiesInput.includes('image') : false,
thinkingLevels: effortsFor(catalog.reasoningCapability(id)),
};
}
function chatModelEntries(catalog) {
const out = [];
for (const id of catalog.list()) {
if (laneFor(id, catalog) !== 'chat') continue;
out.push(catalogEntry(id, catalog));
}
return out;
}
function responsesModelEntries(catalog) {
const out = [];
for (const id of catalog.list()) {
if (laneFor(id, catalog) !== 'responses') continue;
out.push(catalogEntry(id, catalog));
}
return out;
}
// ---------------------------------------------------------------------------
// ZCode 个人 Provider 配置
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 定位 ZCode 的数据目录(这个包要能在别人的电脑上跑,所以不能写死路径)
// ---------------------------------------------------------------------------
const SIDECAR_FILE_NAME = 'zcode-data-dir.txt';
/**
* 个人 Provider 配置的落点,与 ZCode 的 getAppConfigDir() 一致:
* {ZCODE_DATA_BASE_DIR}/.zcode/v2/provider_config.json
*
* 麻烦点在于 ZCODE_DATA_BASE_DIR **不是持久环境变量**——注册表里没有,
* 只是 ZCode 启动自己子进程时注入的。从资源管理器双击 .cmd 拿不到它,
* 于是会静默落到用户主目录,而 ZCode 读的是另一个目录,设置里就不出现分组。
*
* 所以这里改为主动探测:给若干个「可能是 ZCode 数据根」的候选目录打分,
* 拿得分最高的那个。判据是 {root}/v2/runtime —— ZCode 准备运行资源时会建它。
*/
function candidateZcodeRoots() {
const out = [];
const push = (value) => {
if (!value) return;
let resolved;
try {
resolved = path.resolve(value);
} catch {
return;
}
if (!out.includes(resolved)) out.push(resolved);
};
// 环境变量最权威(AI 在 ZCode 自己终端里跑时一定有)。
const envBase = process.env.ZCODE_DATA_BASE_DIR?.trim();
if (envBase) push(path.join(envBase, '.zcode'));
push(path.join(os.homedir(), '.zcode'));
// 再扫各固定盘:盘根、以及盘根下一层目录。够覆盖 ZCODE_DATA_BASE_DIR 被改到
// 别的盘/别的目录的情况,成本也就是几百次 existsSync。
const CAP = 500;
for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ') {
if (out.length >= CAP) break;
const root = `${letter}:\\`;
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue; // 盘不存在或不可读
}
push(path.join(root, '.zcode'));
for (const entry of entries) {
if (out.length >= CAP) break;
if (!entry.isDirectory()) continue;
push(path.join(root, entry.name, '.zcode'));
}
}
return out;
}
/** 给一个候选 .zcode 根打分;runtime 是最强信号。 */
function scoreZcodeRoot(root) {
const appConfigDir = path.join(root, 'v2');
let score = 0;
const evidence = [];
if (fs.existsSync(path.join(appConfigDir, 'runtime'))) {
score += 4;
evidence.push('v2/runtime');
}
if (fs.existsSync(path.join(appConfigDir, 'provider_config.json'))) {
score += 2;
evidence.push('v2/provider_config.json');
}
let newest = 0;
for (const name of ['tasks-index.sqlite', 'setting.json', 'config.json']) {
try {
newest = Math.max(newest, fs.statSync(path.join(appConfigDir, name)).mtimeMs);
} catch {
/* 缺这个文件不算问题 */
}
}
if (newest > 0) evidence.push(`v2 最近写入 ${new Date(newest).toISOString()}`);
return { root, baseDir: path.dirname(root), appConfigDir, score, evidence, newest };
}
/** 全部候选,按分数降序;给 --detect 看明细用。 */
function rankZcodeRoots() {
return candidateZcodeRoots()
.map(scoreZcodeRoot)
.sort((a, b) => b.score - a.score || b.newest - a.newest);
}
/** 拿得分最高的候选;只有带 runtime 的(score >= 4)才算认得出来。 */
function detectZcodeDataDir() {
const best = rankZcodeRoots()[0];
return best && best.score >= 4 ? best : null;
}
/** 旁路文件:把探测到的数据目录记下来,供 .cmd 双击时读取。 */
function sidecarPath() {
return path.join(SCRIPT_DIR, SIDECAR_FILE_NAME);
}
function writeSidecar(baseDir) {
try {
const target = sidecarPath();
if (fs.existsSync(target) && fs.readFileSync(target, 'utf8').trim() === baseDir) return;
fs.writeFileSync(target, `${baseDir}\n`, 'utf8');
} catch {
// 脚本目录可能只读;写不进去不影响本次运行。
}
}
function readSidecar() {
try {
const value = fs.readFileSync(sidecarPath(), 'utf8').trim();
return value.length > 0 ? value : null;
} catch {
return null;
}
}
/** 决定配置写到哪里:显式 --config > 环境变量 > 探测 > 旁路文件 > 用户主目录。 */
function resolveConfigTarget(explicitPath) {
if (explicitPath) {
return { configPath: explicitPath, source: 'explicit', detail: null };
}
const appConfigFileName = 'provider_config.json';
const envBase = process.env.ZCODE_DATA_BASE_DIR?.trim();
if (envBase) {
const root = path.join(envBase, '.zcode');
return {
configPath: path.join(root, 'v2', appConfigFileName),
source: 'env',
detail: scoreZcodeRoot(root),
};
}
const detected = detectZcodeDataDir();
if (detected) {
return {
configPath: path.join(detected.appConfigDir, appConfigFileName),
source: 'detected',
detail: detected,
};
}
// 旁路文件只在探测失败时兜底,而且同样要过 runtime 校验——否则把这个包连同
// 旁路文件一起拷到别的电脑,会照着旧的机器路径去写。
const sidecarBase = readSidecar();
if (sidecarBase) {
const root = path.join(sidecarBase, '.zcode');
const detail = scoreZcodeRoot(root);
if (detail.score >= 4) {
return {
configPath: path.join(root, 'v2', appConfigFileName),
source: 'sidecar',
detail,
};
}
}
return {
configPath: path.join(os.homedir(), '.zcode', 'v2', appConfigFileName),
source: 'homedir',
detail: null,
};
}
/** 落到可疑目录时大声报警——这个坑一旦踩中,设置里就是不出现模型分组,很难猜。 */
function warnAboutConfigTarget(resolved) {
if (resolved.source === 'explicit') return;
if (resolved.detail && resolved.detail.score >= 4) return;
console.warn('[zen] 警告:没能确认 ZCode 正在使用的数据目录。');
console.warn(`[zen] 本次会写到:${resolved.configPath}`);
console.warn('[zen] 如果 ZCode 读的不是这里,设置里就不会出现「OpenCode 免费模型」。');
console.warn('[zen] 跑 `node zen-proxy.mjs --detect` 看候选目录明细;');
console.warn('[zen] 或用 --config <完整路径> 直接指定 provider_config.json。');
}
function buildProviderRule({ id, name, apiType, port, modelIds }) {
return {
providerId: id,
providerName: name,
enabled: true,