-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclaude.py
More file actions
2556 lines (2301 loc) · 95.5 KB
/
Copy pathclaude.py
File metadata and controls
2556 lines (2301 loc) · 95.5 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 python3
# -*- coding: utf-8 -*-
# ==========================================================
# @xiaozhe - Termux Claude Code OpenAI Proxy
# 版权所有 © 2026 小哲
# ==========================================================
"""
Claude Code Termux 启动器 + OpenAI 本地代理管理
- 严格清洗/校验 Base URL,禁止空格等非法字符
- 只写 ANTHROPIC_AUTH_TOKEN,避免 Auth conflict
"""
import json
import os
import signal
import subprocess
import sys
import threading
import time
import urllib.request
import hashlib
import shutil
# 代理操作互斥锁(守护线程与菜单操作竞争防护)
_proxy_op_lock = threading.Lock()
# 从公共模块导入 URL/Key 清洗函数
from url_utils import sanitize_url, sanitize_key, sanitize_model
HOME = os.environ.get("HOME", "/data/data/com.termux/files/home")
PREFIX = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
DIR = os.path.join(HOME, "Claudecode")
CC = os.path.join(DIR, "cc.py")
PROXY = os.path.join(DIR, "openai_proxy.py")
PZWJ = os.path.join(HOME, ".claude", "settings.json")
PROXY_META = os.path.join(HOME, ".claude", "openai_proxy.json")
PROXY_LOG = os.path.join(HOME, ".claude", "openai_proxy.log")
PROXY_HOST = "127.0.0.1"
PROXY_PORT = 8765
PROXY_URL = f"http://{PROXY_HOST}:{PROXY_PORT}"
PROXY_PORT_BASE = 18765 # 多代理起始端口
# 预设总数上限:存储/切换不设小限制(可用环境变量 CC_MAX_PRESETS 调大)。
# openai 预设端口 = BASE + index。存几百个、逐个切换都没问题。
MAX_PRESETS = int(os.environ.get("CC_MAX_PRESETS") or "200")
PROXY_PORT_MAX = PROXY_PORT_BASE + MAX_PRESETS - 1
# 故障转移预设文件(与 openai_proxy.py 共享)
_FAILOVER_PRESETS_FILE = os.path.join(HOME, ".claude", "failover_presets.json")
# 配置备份目录
_BACKUP_DIR = os.path.join(HOME, ".claude", "backups")
# 模型预设文件最后修改时间缓存(热重载)
_MODEL_PRESETS_MTIME = 0.0
def allowed_proxy_ports(presets=None):
"""当前合法本地代理端口集合:8765 + 预设 openai 端口 + settings 当前端口"""
ports = {int(PROXY_PORT)}
if presets is None:
try:
presets = load_model_presets()
except Exception:
presets = []
for i, p in enumerate(presets or []):
if p.get("mode") == "openai" and 0 <= i < MAX_PRESETS:
ports.add(PROXY_PORT_BASE + i)
try:
cur = parse_local_proxy_port()
if cur is not None:
ports.add(int(cur))
except Exception:
pass
return ports
def can_add_preset(presets=None, replacing=False):
"""是否还能新增预设(覆盖重名不算新增)"""
if replacing:
return True
if presets is None:
presets = load_model_presets()
return len(presets) < MAX_PRESETS
def proxy_meta_path(port=None):
"""meta 文件路径:默认端口用 openai_proxy.json,其它端口用 openai_proxy_<port>.json"""
actual = int(port) if port is not None else PROXY_PORT
if actual == PROXY_PORT:
return PROXY_META
return PROXY_META.replace(".json", f"_{actual}.json")
def proxy_log_path(port=None):
"""日志路径:默认端口用 openai_proxy.log,其它端口用 openai_proxy_<port>.log"""
actual = int(port) if port is not None else PROXY_PORT
if actual == PROXY_PORT:
return PROXY_LOG
return PROXY_LOG.replace(".log", f"_{actual}.log")
def parse_local_proxy_port(url=None):
"""从 ANTHROPIC_BASE_URL 解析本地代理端口;非本地则返回 None"""
if url is None:
url = (load_settings().get("env", {}) or {}).get("ANTHROPIC_BASE_URL") or ""
base = (url or "").strip().rstrip("/")
low = base.lower()
if "127.0.0.1" not in low and "localhost" not in low:
return None
# http://127.0.0.1:18771 或 http://localhost:18771/v1
try:
# 去掉 scheme
rest = base.split("://", 1)[-1]
hostport = rest.split("/", 1)[0]
if ":" in hostport:
port_str = hostport.rsplit(":", 1)[-1]
if port_str.isdigit():
return int(port_str)
except Exception:
pass
return PROXY_PORT
def current_proxy_port():
"""当前 settings 里本地代理端口;非代理模式返回默认 PROXY_PORT"""
p = parse_local_proxy_port()
return p if p is not None else PROXY_PORT
def is_local_proxy_url(url: str) -> bool:
low = (url or "").strip().rstrip("/").lower()
return ("127.0.0.1" in low) or ("localhost" in low)
def _listening_pids_on_port(port: int):
"""反查监听/绑定该端口的 openai_proxy PID。
Termux 非 root 下 /proc/environ 不可读,改用 meta.json + 健康检查。
"""
port = int(port)
port_s = str(port)
pids = []
# 优先从 meta 文件读取 pid
meta_file = proxy_meta_path(port)
meta = load_json(meta_file, {}) or {}
meta_pid = meta.get("pid")
if meta_pid:
try:
os.kill(int(meta_pid), 0)
pids.append(int(meta_pid))
return pids
except (ProcessLookupError, PermissionError, OSError):
pass
# meta 不可用,扫 /proc/cmdline 但不读 environ
try:
for name in os.listdir("/proc"):
if not name.isdigit():
continue
pid = int(name)
try:
cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\x00", b" ").decode("utf-8", "ignore")
except Exception:
continue
if "openai_proxy.py" not in cmd:
continue
# 通过健康检查确认端口
if proxy_running(port):
pids.append(pid)
break
except Exception:
pass
return pids
def _all_openai_proxy_pids():
"""所有 openai_proxy.py 进程 PID(用于菜单 5 全停)"""
pids = []
try:
for name in os.listdir("/proc"):
if not name.isdigit():
continue
pid = int(name)
try:
cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\x00", b" ").decode("utf-8", "ignore")
except Exception:
continue
if "openai_proxy.py" in cmd:
pids.append(pid)
except Exception:
pass
return pids
def _kill_pids(pids, wait_s=2.0):
"""SIGTERM → 等待 → SIGKILL;返回是否至少杀过一个"""
killed_any = False
for pid in pids:
try:
os.kill(int(pid), signal.SIGTERM)
killed_any = True
except (ProcessLookupError, PermissionError, OSError):
continue
deadline = time.time() + wait_s
alive = set(int(p) for p in pids)
while alive and time.time() < deadline:
time.sleep(0.1)
gone = []
for pid in list(alive):
try:
os.kill(pid, 0)
except ProcessLookupError:
gone.append(pid)
except (PermissionError, OSError):
pass
for pid in gone:
alive.discard(pid)
for pid in list(alive):
try:
os.kill(pid, signal.SIGKILL)
killed_any = True
except (ProcessLookupError, PermissionError, OSError):
pass
return killed_any
DEFAULT_LIMITS = {
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000",
}
def ensure_dirs():
os.makedirs(os.path.dirname(PZWJ), exist_ok=True)
os.makedirs(DIR, exist_ok=True)
def load_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return default
def _models_endpoint(url: str) -> str:
"""智能拼接 /v1/models 端点,避免 /v1/v1/models 双重路径。
- 已含 /v1/models → 原样返回
- 已含 /v1 → 追加 /models
- 其他 → 追加 /v1/models
"""
u = (url or "").rstrip("/")
if u.endswith("/v1/models"):
return u
if u.endswith("/v1"):
return u + "/models"
return u + "/v1/models"
def save_json(path, data):
"""保存 JSON,文件权限设为仅自己可读(保护 API Key)。原子写:写临时文件后 rename。"""
ensure_dirs()
tmp_path = path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
try:
os.chmod(tmp_path, 0o600)
except Exception:
pass
os.replace(tmp_path, path)
def load_settings():
cfg = load_json(PZWJ, None)
if not isinstance(cfg, dict):
cfg = {}
if not isinstance(cfg.get("env"), dict):
cfg["env"] = {}
return cfg
def apply_default_limits(force=False):
cfg = load_settings()
env = cfg["env"]
changed = []
for k, v in DEFAULT_LIMITS.items():
if force or not env.get(k):
if env.get(k) != str(v):
env[k] = str(v)
changed.append(f"{k}={v}")
if "ANTHROPIC_API_KEY" in env:
env.pop("ANTHROPIC_API_KEY", None)
changed.append("removed ANTHROPIC_API_KEY")
ob = env.get("OPENAI_BASE_URL")
if ob:
ok, cleaned = sanitize_url(ob, kind="openai")
if ok and cleaned != ob:
env["OPENAI_BASE_URL"] = cleaned
changed.append(f"fixed OPENAI_BASE_URL -> {cleaned}")
elif not ok:
print(f"\033[31m警告: 已保存的 OPENAI_BASE_URL 非法: {ob!r}\033[0m")
print(f"\033[31m {cleaned}\033[0m")
if changed:
save_json(PZWJ, cfg)
return changed
# ── 模型预设管理 ──
MODEL_PRESETS_FILE = os.path.join(os.path.dirname(PZWJ), "model_presets.json")
def load_model_presets():
"""读取模型预设列表"""
data = load_json(MODEL_PRESETS_FILE, [])
return data if isinstance(data, list) else []
def save_model_presets(presets):
"""保存模型预设列表(硬上限 MAX_PRESETS)"""
if not isinstance(presets, list):
presets = []
if len(presets) > MAX_PRESETS:
print(f"\033[31m预设超过上限 {MAX_PRESETS},已截断保留前 {MAX_PRESETS} 个\033[0m")
presets = presets[:MAX_PRESETS]
save_json(MODEL_PRESETS_FILE, presets)
try:
os.chmod(MODEL_PRESETS_FILE, 0o600)
except Exception:
pass
# 多Agent已废弃,不再写 agent_presets.json
def add_model_preset_interactive():
"""交互式添加模型预设。
- OpenAI 代理模式:只需地址 + API Key,自动拉取 /v1/models 并批量建预设(名字自动取,不设上限)。
- Anthropic 直连模式:保留原流程(名字 + URL + Key + 模型名)。
"""
print("\n\033[1;36m--- 添加模型预设 ---\033[0m")
print("\n模式选择:")
print("1. Anthropic 直连(DeepSeek/MiMo/智谱/豆包等)")
print("2. OpenAI 代理模式(只需地址+Key,自动拉取模型列表)")
mode = input("请选择(1/2):").strip()
if mode == "2":
# ── OpenAI 模式:自动拉模型,批量建预设 ──
u = input("OpenAI 兼容 Base URL(如 https://api.openai.com/v1):").strip()
ok, url = sanitize_url(u, kind="openai")
if not ok:
print(f"\033[31mURL 错误: {url}\033[0m")
return
key = input("API Key:").strip()
ok, key = sanitize_key(key)
if not ok:
print(f"\033[31m{key}\033[0m")
return
print("\033[33m⏳ 正在拉取模型列表...\033[0m")
models = fetch_openai_models(url, key)
if not models:
print("\n\033[31m未能获取到模型列表。请确认地址/Key 正确且支持 GET /v1/models。\033[0m")
fb = input("是否手动输入一个模型名继续?(y/n, 默认 n):").strip().lower()
if fb != "y":
return
mid = input("模型名(如 gpt-4o):").strip()
if not mid:
return
models = [{"id": mid, "owned_by": ""}]
presets = load_model_presets()
existing_names = {p.get("name") for p in presets if p.get("name")}
existing_ids = {p.get("model") for p in presets if p.get("model")}
added = 0
skipped = 0
for m in models:
mid = m["id"]
if mid in existing_ids:
skipped += 1
continue
name = mid
if name in existing_names:
suffix = 1
while f"{name}_{suffix}" in existing_names:
suffix += 1
name = f"{name}_{suffix}"
presets.append({
"name": name,
"mode": "openai",
"openai_base_url": url,
"api_key": key,
"model": mid,
})
existing_names.add(name)
existing_ids.add(mid)
added += 1
if added == 0:
print(f"\n\033[33m没有新增模型(拉到 {len(models)} 个,全部已存在)\033[0m")
return
save_model_presets(presets)
print(f"\n\033[32m✅ 成功导入 {added} 个模型预设!\033[0m")
if skipped:
print(f"\033[33m(跳过 {skipped} 个已存在的模型)\033[0m")
print(f" 当前共 {len(presets)} 个预设")
if input("\n\033[33m立即切换到第一个新模型?(y/n, 默认 n):\033[0m").strip().lower() == "y":
apply_model_preset(len(presets) - added)
return
# ── Anthropic 直连模式:保留原流程 ──
name = input("预设名称(如 豆包写代码):").strip()
if not name:
print("\033[31m名称不能为空\033[0m")
return
u = input("Anthropic 兼容 URL:").strip()
ok, url = sanitize_url(u, kind="anthropic")
if not ok:
print(f"\033[31mURL 错误: {url}\033[0m")
return
key = input("API Key:").strip()
ok, key = sanitize_key(key)
if not ok:
print(f"\033[31m{key}\033[0m")
return
model = sanitize_model(input("模型名:").strip() or "gpt-4o")
preset = {
"name": name,
"mode": "direct",
"anthropic_base_url": url,
"api_key": key,
"model": model,
}
presets = load_model_presets()
replacing = False
for p in presets:
if p.get("name") == name:
if input(f"\033[33m预设「{name}」已存在,覆盖?(y/n):\033[0m").strip().lower() != "y":
return
presets.remove(p)
replacing = True
break
if not can_add_preset(presets, replacing=replacing):
print(f"\033[31m预设已达上限 {MAX_PRESETS} 个,请先删除再用\033[0m")
return
presets.append(preset)
save_model_presets(presets)
print(f"\033[32m预设「{name}」已保存!({len(presets)}/{MAX_PRESETS})\033[0m")
def delete_model_preset(index):
"""删除指定索引的预设;若是 openai 会先停该端口,并重排后续端口前清理残留"""
presets = load_model_presets()
if not (0 <= index < len(presets)):
return False
name = presets[index].get("name", "未知")
was_openai = presets[index].get("mode") == "openai"
old_ports = []
for i, p in enumerate(presets):
if p.get("mode") == "openai" and 0 <= i < MAX_PRESETS:
old_ports.append(PROXY_PORT_BASE + i)
# 先停被删项端口
if was_openai and 0 <= index < MAX_PRESETS:
stop_proxy(port=PROXY_PORT_BASE + index, quiet=True)
presets.pop(index)
save_model_presets(presets)
# 端口按 index 重排:停掉旧 openai 端口集合中不再合法的
new_ports = allowed_proxy_ports(presets)
for port in old_ports:
if port not in new_ports and proxy_running(port):
stop_proxy(port=port, quiet=True)
print(f"\033[33m已删除预设「{name}」({len(presets)}/{MAX_PRESETS})\033[0m")
return True
def get_preset_port(index):
"""获取预设的代理端口(代理模式专用)。index 必须在 [0, MAX_PRESETS)。非法返回 None。"""
try:
idx = int(index)
except Exception:
return None
if not (0 <= idx < MAX_PRESETS):
return None
return PROXY_PORT_BASE + idx
def fetch_openai_models(base_url: str, api_key: str) -> list:
"""调上游 /v1/models 获取可用模型列表。
返回 [{'id':..., 'name':...}, ...];失败返回 [] 并在 stderr 打印原因。
"""
import urllib.request, urllib.error
ok, clean = sanitize_url(base_url, kind="openai")
if not ok:
print(f"\033[31mURL 非法: {clean}\033[0m", file=sys.stderr)
return []
api_url = f"{clean}/models"
req = urllib.request.Request(
api_url,
method="GET",
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": "claude-code-launcher/2.0",
},
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read().decode("utf-8", errors="ignore"))
except urllib.error.HTTPError as e:
body = e.fp.read().decode("utf-8", errors="ignore")[:300] if e.fp else ""
print(f"\033[31m上游 /v1/models 返回 {e.code}: {body}\033[0m", file=sys.stderr)
return []
except Exception as e:
print(f"\033[31m拉取模型列表失败: {e}\033[0m", file=sys.stderr)
return []
raw_list = data.get("data") if isinstance(data, dict) else data if isinstance(data, list) else []
if not raw_list:
print("\033[33m上游返回的模型列表为空\033[0m", file=sys.stderr)
return []
models = []
seen_ids = set()
for item in raw_list:
if not isinstance(item, dict):
continue
mid = item.get("id") or ""
if not mid or mid in seen_ids:
continue
owned = item.get("owned_by") or ""
# 过滤掉非 LLM 模型(embedding/whisper/image/text-embedding)
skip_patterns = (
"embedding", "whisper", "tts", "davinci", "ada", "babbage", "curie",
"moderation", "image", "dall-e", "transcribe", "vision",
)
if any(p in mid.lower() for p in skip_patterns):
continue
models.append({"id": mid, "owned_by": owned})
seen_ids.add(mid)
return models
def import_openai_models_interactive():
"""交互式:输入地址+API Key → 自动拉模型列表 → 批量创建预设(不设上限约束)。"""
print("\n" + "=" * 60)
print("\033[1;36m批量导入 OpenAI 兼容模型\033[0m")
print("输入地址和 API Key 后,自动拉取 /v1/models 列表,")
print("拉到几个就自动建几个预设(可跨预设切换器逐个切换使用)。")
print("=" * 60 + "\n")
u = input("OpenAI 兼容 Base URL(如 https://api.openai.com/v1):").strip()
key = input("API Key:").strip()
if not u or not key:
print("\033[31m地址和 Key 不能为空\033[0m")
return
ok_url, clean_url = sanitize_url(u, kind="openai")
if not ok_url:
print(f"\033[31mURL 错误: {clean_url}\033[0m")
return
ok_key, clean_key = sanitize_key(key)
if not ok_key:
print(f"\033[31m{clean_key}\033[0m")
return
print("\033[33m⏳ 正在拉取模型列表...\033[0m")
models = fetch_openai_models(clean_url, clean_key)
if not models:
print("\n\033[31m未能获取到可用模型。请确认:\033[0m")
print(" 1) 地址和 API Key 正确")
print(" 2) 该端点支持 GET /v1/models")
print(" 3) 网络可到达")
# 回退:让用户手动输入一个模型名
fallback = input("\n是否手动输入一个模型名继续?(y/n, 默认 n):").strip().lower()
if fallback == "y":
mid = input("模型名(如 gpt-4o):").strip()
if not mid:
return
models = [{"id": mid, "owned_by": ""}]
else:
return
# 读取现预设,准备追加
presets = load_model_presets()
existing_names = {p.get("name") for p in presets if p.get("name")}
existing_ids = {p.get("model") for p in presets if p.get("model")}
added = 0
skipped = 0
for m in models:
mid = m["id"]
if mid in existing_ids:
skipped += 1
continue
# 自动取名:就用模型 id(去掉多余前缀/版本号可做美化,但保持原始 id 最准)
name = mid
# 避免重名
if name in existing_names:
suffix = 1
while f"{name}_{suffix}" in existing_names:
suffix += 1
name = f"{name}_{suffix}"
preset = {
"name": name,
"mode": "openai",
"openai_base_url": clean_url,
"api_key": clean_key,
"model": mid,
}
presets.append(preset)
existing_names.add(name)
existing_ids.add(mid)
added += 1
if added == 0:
print(f"\n\033[33m没有新增模型(已有 {len(existing_ids)} 个,全部已存在或被跳过)\033[0m")
return
save_model_presets(presets)
print(f"\n\033[32m✅ 成功导入 {added} 个模型预设!\033[0m")
if skipped:
print(f"\033[33m(跳过 {skipped} 个已存在的模型)\033[0m")
print(f" 当前共 {len(presets)} 个预设")
print("\033[36m可到「模型配置」菜单中切换使用。\033[0m")
if input("\n\033[33m是否立即切换到第一个新模型?(y/n, 默认 n):\033[0m").strip().lower() == "y":
new_idx = len(presets) - added
apply_model_preset(new_idx)
def stop_all_proxies(quiet=False):
"""停止所有相关本地代理:扫 /proc 找全部 openai_proxy.py 进程,批量杀"""
# 先用 /proc 拿全 PID(快速,无需逐端口探活)
all_pids = _all_openai_proxy_pids()
stopped = []
if all_pids:
_kill_pids(all_pids)
time.sleep(0.3)
stopped = all_pids
# 清理 meta 文件
presets = load_model_presets()
for i in range(min(len(presets), MAX_PRESETS)):
port = PROXY_PORT_BASE + i
meta_file = proxy_meta_path(port)
if os.path.exists(meta_file):
try:
os.remove(meta_file)
except Exception:
pass
# 默认端口 meta
for p in [PROXY_PORT]:
meta_file = proxy_meta_path(p)
if os.path.exists(meta_file):
try:
os.remove(meta_file)
except Exception:
pass
# 确认清理
orphans = cleanup_orphan_proxies()
if not quiet:
if stopped:
print(f"\033[33m已停止进程 PID: {stopped}\033[0m")
if orphans:
print(f"\033[33m已清理残留进程: {orphans}\033[0m")
else:
left = _all_openai_proxy_pids()
if left:
print(f"\033[31m仍有代理进程: {left}\033[0m")
else:
print("\033[32m本地代理已全部离线\033[0m")
return stopped
def cleanup_orphan_proxies():
"""杀掉所有 openai_proxy.py 残留进程(不在合法端口也杀)"""
pids = _all_openai_proxy_pids()
if not pids:
return []
_kill_pids(pids)
time.sleep(0.3)
return pids
def proxy_status():
"""显示所有代理运行状态"""
presets = load_model_presets()
statuses = []
for i, p in enumerate(presets):
if p.get("mode") == "openai":
port = get_preset_port(i)
if port is None:
statuses.append((p.get("name", f"预设{i+1}"), None, False))
continue
alive = False
try:
import urllib.request
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as r:
alive = r.status == 200
except Exception:
pass
statuses.append((p.get("name", f"预设{i+1}"), port, alive))
return statuses
def apply_model_preset(index):
"""应用指定索引的预设。
策略(手机内存友好):
- openai:启动当前预设对应的单个本地代理
- direct:stop_all 本地代理,清 OPENAI_*,纯直连可用
"""
presets = load_model_presets()
if not (0 <= index < len(presets)):
print("\033[31m无效的预设索引\033[0m")
return False
preset = presets[index]
name = preset.get("name", "未知")
mode = preset.get("mode", "direct")
if mode == "openai":
openai_base = preset.get("openai_base_url", "")
api_key = preset.get("api_key", "")
model = preset.get("model", "gpt-4o")
port = get_preset_port(index)
if port is None:
print(f"\033[31m预设索引 {index} 超出端口上限 {MAX_PRESETS}\033[0m")
return False
if not start_proxy(api_key, openai_base, model, port=port):
print("\033[31m代理启动失败\033[0m")
return False
shezhi(
f"http://127.0.0.1:{port}", api_key, model,
extra_env={"OPENAI_API_KEY": api_key, "OPENAI_BASE_URL": openai_base},
)
print(f"\033[32m✅ 已切换到预设「{name}」\033[0m")
print(f" 模式: 代理 → 127.0.0.1:{port} → {openai_base}")
return True
# ── 直连:必须可用,不依赖本地代理 ──
stop_all_proxies(quiet=True)
url = preset.get("anthropic_base_url", "")
api_key = preset.get("api_key", "")
model = preset.get("model", "gpt-4o")
# extra_env=None → shezhi 清掉 OPENAI_*,避免误判本地代理
if not shezhi(url, api_key, model, extra_env=None):
print("\033[31m直连配置写入失败\033[0m")
return False
# 再保险:清 OPENAI 残留
cfg = load_settings()
env = cfg.get("env", {})
dirty = False
for k in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"):
if k in env:
env.pop(k, None)
dirty = True
if dirty:
save_json(PZWJ, cfg)
print(f"\033[32m✅ 已切换到预设「{name}」\033[0m")
print(f" 模式: 直连 → {url}")
print(" 本地代理已全部停止(直连不需要)")
return True
def model_config_menu():
"""
合并菜单 4+5+7:统一的模型配置管理。
分两大类:直连模型(Anthropic兼容)、代理模型(OpenAI兼容)。
每类都可添加多个,一键切换。
"""
while True:
presets = load_model_presets()
direct = [p for p in presets if p.get("mode") != "openai"]
proxy_list = [p for p in presets if p.get("mode") == "openai"]
# 当前活跃的模型
env = load_settings().get("env", {})
active_url = env.get("ANTHROPIC_BASE_URL", "")
active_model = env.get("ANTHROPIC_MODEL", "")
active_name = "无"
active_port = parse_local_proxy_port(active_url)
for i, p in enumerate(presets):
if p.get("mode") == "openai":
pp = get_preset_port(i)
if active_port is not None and pp is not None and active_port == pp:
active_name = p.get("name", "未知")
break
else:
u = (p.get("anthropic_base_url") or "").rstrip("/")
if u and u == active_url.rstrip("/"):
active_name = p.get("name", "未知")
break
print(f"\n\033[1;36m══════ 模型配置管理 ({len(presets)}/{MAX_PRESETS}) ══════\033[0m")
print(f"当前使用: \033[32m{active_name}\033[0m ({active_model})")
# 显示所有代理状态
proxy_states = proxy_status()
alive_count = sum(1 for _, _, a in proxy_states if a)
if proxy_states:
print(f"代理状态: \033[32m{alive_count}/{len(proxy_states)} 在线\033[0m")
for sname, sport, salive in proxy_states:
icon = "\033[32m●\033[0m" if salive else "\033[31m○\033[0m"
print(f" {icon} {sname} (127.0.0.1:{sport})")
# ── 直连模型 ──
print(f"\n\033[33m📡 直连模型(Anthropic兼容,无需代理)\033[0m")
if direct:
for i, p in enumerate(direct):
marker = " ◀" if p.get("name") == active_name else ""
print(f" \033[32m{i+1}\033[0m. {p.get('name')} → {p.get('model')}{marker}")
else:
print(" (暂无,选 a 或 b 添加)")
# ── 代理模型 ──
print(f"\n\033[36m🔄 代理模型(OpenAI兼容,需本地代理)\033[0m")
if proxy_list:
for i, p in enumerate(proxy_list):
marker = " ◀" if p.get("name") == active_name else ""
print(f" \033[36m{i+1}\033[0m. {p.get('name')} → {p.get('model')}{marker}")
else:
print(" (暂无,选 c 添加)")
print(f"\n\033[1m━━ 操作 ━━\033[0m")
print(" a. 添加直连模型(DeepSeek/智谱/自定义等)")
print(" b. 快速预设(一键 DeepSeek/MiMo/智谱/豆包)")
print(" c. 批量导入 OpenAI 模型(只需地址+Key,自动拉取全部模型)")
if presets:
print(" d. 切换模型(应用)")
print(" e. 删除模型")
print(" t. 测试连接(检测当前模型 API 是否可用)")
print(" 0. 返回主菜单")
c = input("\033[1m请选择:\033[0m").strip().lower()
if c == "0":
break
elif c == "a":
add_model_preset_interactive()
elif c == "b":
_quick_presets_submenu()
elif c == "c":
_add_proxy_model_interactive()
elif c == "d" and presets:
print("\n所有模型:")
for i, p in enumerate(presets):
tag = "🔄" if p.get("mode") == "openai" else "🔗"
print(f" {i+1}. {tag} {p.get('name')} → {p.get('model')}")
idx = input("输入要应用的编号:").strip()
if idx.isdigit() and 1 <= int(idx) <= len(presets):
apply_model_preset(int(idx) - 1)
elif c == "e" and presets:
print("\n所有模型:")
for i, p in enumerate(presets):
tag = "🔄" if p.get("mode") == "openai" else "🔗"
print(f" {i+1}. {tag} {p.get('name')} → {p.get('model')}")
idx = input("输入要删除的编号:").strip()
if idx.isdigit() and 1 <= int(idx) <= len(presets):
name = presets[int(idx) - 1].get("name", "未知")
if input(f"\033[31m确认删除「{name}」?(y/n):\033[0m").strip().lower() == "y":
delete_model_preset(int(idx) - 1)
elif c == "t":
_test_current_connection()
else:
print("\033[31m无效选项\033[0m")
input("\033[1m回车继续>\033[0m")
def _test_current_connection():
"""测试当前配置的模型是否可用"""
env = load_settings().get("env", {})
url = env.get("ANTHROPIC_BASE_URL", "")
key = env.get("ANTHROPIC_AUTH_TOKEN", "")
model = env.get("ANTHROPIC_MODEL", "")
if not url:
print("\033[31m未配置模型,请先配置\033[0m")
return
print(f"\033[33m正在测试连接...\033[0m")
print(f" 地址: {url}")
print(f" 模型: {model}")
if is_local_proxy_mode():
# 代理模式:先测代理,再测上游
port = current_proxy_port()
if not proxy_running(port):
print(f"\033[31m❌ 代理未运行 (127.0.0.1:{port}),请先菜单 6 重启\033[0m")
return
print(f" \033[32m✅ 代理运行中 (127.0.0.1:{port})\033[0m")
test_url = f"http://127.0.0.1:{port}/health"
try:
import urllib.request
with urllib.request.urlopen(test_url, timeout=5) as r:
import json
data = json.loads(r.read())
upstream = data.get("upstream", {})
if upstream.get("reachable"):
print(f" \033[32m✅ 上游可达: {upstream.get('status')}\033[0m")
else:
print(f" \033[33m⚠️ 上游不可达: {upstream.get('error', '未知')}\033[0m")
print(f" \033[33m 请检查 API Key 和 Base URL 是否正确\033[0m")
except Exception as e:
print(f" \033[31m❌ 代理测试失败: {e}\033[0m")
else:
# 直连模式:直接请求
try:
import urllib.request
# 用 /v1/models 或简单请求测试
test_url = _models_endpoint(url)
req = urllib.request.Request(
test_url,
headers={"Authorization": f"Bearer {key}", "User-Agent": "claude-code-test"},
method="GET",
)
with urllib.request.urlopen(req, timeout=10) as r:
print(f" \033[32m✅ 连接成功 (HTTP {r.status})\033[0m")
except urllib.error.HTTPError as e:
if e.code == 401:
print(f" \033[31m❌ API Key 无效 (401)\033[0m")
elif e.code == 404:
print(f" \033[33m⚠️ 接口路径可能不对 (404),但网络通\033[0m")
else:
print(f" \033[33m⚠️ HTTP {e.code}: {e.reason[:100]}\033[0m")
except urllib.error.URLError as e:
print(f" \033[31m❌ 网络不通: {e.reason}\033[0m")
except Exception as e:
print(f" \033[31m❌ 测试失败: {e}\033[0m")
def _quick_presets_submenu():
"""快速预设子菜单:一键填入 DeepSeek/MiMo/智谱/豆包"""
print(
"""
\033[1;36m--- 快速预设 ---\033[0m
1.DeepSeek 2.MiMo 3.智谱GLM 4.豆包AI
5.自定义 Anthropic URL
0.返回"""
)
b = input("请输入对应数字:").strip()
if b == "0":
return
api = input("API_key:").strip()
ok_key, api = sanitize_key(api)
if not ok_key:
print(f"\033[31m{api}\033[0m")
return
if b == "1":
name = input("预设名称(默认 DeepSeek 写代码):").strip() or "DeepSeek 写代码"
model = "deepseek-v4-pro[1m]"
url = "https://api.deepseek.com/anthropic"
preset = {"name": name, "mode": "direct", "anthropic_base_url": url, "api_key": api, "model": model}
elif b == "2":
name = input("预设名称(默认 MiMo 日常):").strip() or "MiMo 日常"
model = "mimo-v2.5-pro[1m]"
url = "https://api.xiaomimimo.com/anthropic"
preset = {"name": name, "mode": "direct", "anthropic_base_url": url, "api_key": api, "model": model}
elif b == "3":
name = input("预设名称(默认 智谱GLM):").strip() or "智谱GLM"
model = "glm-5.2[1m]"
url = "https://open.bigmodel.cn/api/anthropic"
preset = {"name": name, "mode": "direct", "anthropic_base_url": url, "api_key": api, "model": model}
elif b == "4":
name = input("预设名称(默认 豆包AI):").strip() or "豆包AI"
model = "doubao-seed-2.1-pro"
url = "https://ark.cn-beijing.volces.com/api/compatible"
preset = {"name": name, "mode": "direct", "anthropic_base_url": url, "api_key": api, "model": model}
elif b == "5":
u = input("base_URL:").strip()
ok, uu = sanitize_url(u, kind="anthropic")
if not ok:
print(f"\033[31m{uu}\033[0m")
return
name = input("预设名称:").strip() or "自定义"
model = input("模型名:").strip() or "gpt-4o"
preset = {"name": name, "mode": "direct", "anthropic_base_url": uu, "api_key": api, "model": model}
else:
print("\033[31m无效\033[0m")
return
# 保存预设
presets = load_model_presets()
replacing = False
for p in presets:
if p.get("name") == name:
if input(f"\033[33m「{name}」已存在,覆盖?(y/n):\033[0m").strip().lower() != "y":
return
presets.remove(p)
replacing = True
break
if not can_add_preset(presets, replacing=replacing):
print(f"\033[31m预设已达上限 {MAX_PRESETS} 个,请先删除再用\033[0m")
return
presets.append(preset)
save_model_presets(presets)
print(f"\033[32m预设「{name}」已保存!({len(presets)}/{MAX_PRESETS})\033[0m")
# 询问是否立即切换
if input("\033[33m立即切换到该模型?(y/n):\033[0m").strip().lower() == "y":
apply_model_preset(len(presets) - 1)
def _add_proxy_model_interactive():
"""添加 OpenAI 代理模型(只需地址+Key,自动拉取模型列表批量导入)。"""
import_openai_models_interactive()
# 询问是否立即切换并启动代理