cc-switch-ui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """CC Switch Web UI —— Claude Code 多供应商 / 多账号管理面板"""
2
+
3
+ __version__ = "0.1.0"
cc_switch_ui/app.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ CC Switch Web UI —— CLI 入口点。
3
+ """
4
+
5
+ import argparse
6
+ import atexit
7
+ import os
8
+ import signal
9
+
10
+ from .config import CONFIG_PATH
11
+ from .server import create_app
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(
16
+ description="CC Switch Web UI —— Claude Code 多供应商 / 多账号管理面板",
17
+ )
18
+ parser.add_argument("--host", default="127.0.0.1", help="监听地址 (默认 127.0.0.1)")
19
+ parser.add_argument("--port", type=int, default=8765, help="监听端口 (默认 8765)")
20
+ args = parser.parse_args()
21
+
22
+ app = create_app()
23
+ claude_proc = app._claude_proc
24
+
25
+ def _cleanup_children(*_):
26
+ """app 退出时连带停掉 claude 子进程,避免留下「失联孤儿」。"""
27
+ claude_proc.keepalive = False
28
+ claude_proc.stop()
29
+
30
+ # 进程退出 / 被 systemd 或守护脚本 SIGTERM 时,清理 claude 子进程
31
+ atexit.register(_cleanup_children)
32
+ signal.signal(signal.SIGTERM, lambda *a: (_cleanup_children(), os._exit(0)))
33
+
34
+ print(f"配置文件: {CONFIG_PATH}")
35
+ print(f"CC Switch Web UI 已启动 → http://{args.host}:{args.port}")
36
+ # threaded=True 保证 SSE 长连接不阻塞其它请求
37
+ app.run(host=args.host, port=args.port, threaded=True, debug=False)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
cc_switch_ui/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """
2
+ 配置管理 —— 读写 ~/.ccm_config,管理供应商与账号。
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import threading
9
+ from pathlib import Path
10
+
11
+ # --------------------------------------------------------------------------- #
12
+ # 常量
13
+ # --------------------------------------------------------------------------- #
14
+
15
+ CONFIG_PATH = Path(os.path.expanduser("~/.ccm_config"))
16
+
17
+ # 预置供应商。所有都走 Anthropic 兼容协议,claude 通过环境变量切换后端。
18
+ DEFAULT_PROVIDERS = {
19
+ "claude": {
20
+ "label": "Claude 官方",
21
+ "base_url": "https://api.anthropic.com",
22
+ "auth_var": "ANTHROPIC_API_KEY",
23
+ "model": "",
24
+ "accounts": [],
25
+ "active_account": None,
26
+ },
27
+ "deepseek": {
28
+ "label": "DeepSeek",
29
+ "base_url": "https://api.deepseek.com/anthropic",
30
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
31
+ "model": "deepseek-chat",
32
+ "accounts": [],
33
+ "active_account": None,
34
+ },
35
+ "kimi": {
36
+ "label": "Kimi (Moonshot)",
37
+ "base_url": "https://api.moonshot.cn/anthropic",
38
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
39
+ "model": "kimi-k2-0905-preview",
40
+ "accounts": [],
41
+ "active_account": None,
42
+ },
43
+ "glm": {
44
+ "label": "GLM (智谱)",
45
+ "base_url": "https://open.bigmodel.cn/api/anthropic",
46
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
47
+ "model": "glm-4.6",
48
+ "accounts": [],
49
+ "active_account": None,
50
+ },
51
+ "qwen": {
52
+ "label": "Qwen (通义千问)",
53
+ "base_url": "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
54
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
55
+ "model": "qwen3-coder-plus",
56
+ "accounts": [],
57
+ "active_account": None,
58
+ },
59
+ "openrouter": {
60
+ "label": "OpenRouter",
61
+ "base_url": "https://openrouter.ai/api/v1",
62
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
63
+ "model": "anthropic/claude-3.5-sonnet",
64
+ "accounts": [],
65
+ "active_account": None,
66
+ },
67
+ "custom": {
68
+ "label": "自定义",
69
+ "base_url": "",
70
+ "auth_var": "ANTHROPIC_AUTH_TOKEN",
71
+ "model": "",
72
+ "accounts": [],
73
+ "active_account": None,
74
+ },
75
+ }
76
+
77
+ AUTH_VARS = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
78
+
79
+ _config_lock = threading.Lock()
80
+
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # 配置读写
84
+ # --------------------------------------------------------------------------- #
85
+
86
+ def _default_config():
87
+ """深拷贝预置项,避免运行期被修改污染默认值。"""
88
+ return {
89
+ "current_provider": "claude",
90
+ "providers": json.loads(json.dumps(DEFAULT_PROVIDERS)),
91
+ }
92
+
93
+
94
+ def load_config():
95
+ if not CONFIG_PATH.exists():
96
+ cfg = _default_config()
97
+ save_config(cfg)
98
+ return cfg
99
+ try:
100
+ cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
101
+ except (json.JSONDecodeError, OSError):
102
+ cfg = _default_config()
103
+ save_config(cfg)
104
+ return cfg
105
+
106
+ # 合并:确保所有预置供应商存在,且字段完整(向前兼容)
107
+ cfg.setdefault("current_provider", "claude")
108
+ providers = cfg.setdefault("providers", {})
109
+ for key, preset in DEFAULT_PROVIDERS.items():
110
+ p = providers.setdefault(key, json.loads(json.dumps(preset)))
111
+ for field, val in preset.items():
112
+ if field not in ("accounts", "active_account"):
113
+ p.setdefault(field, val)
114
+ p.setdefault("accounts", [])
115
+ p.setdefault("active_account", None)
116
+ return cfg
117
+
118
+
119
+ def save_config(cfg):
120
+ CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
121
+ try:
122
+ os.chmod(CONFIG_PATH, 0o600) # 含密钥,限制权限
123
+ except OSError:
124
+ pass
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # 工具函数
129
+ # --------------------------------------------------------------------------- #
130
+
131
+ def mask_key(key: str) -> str:
132
+ if not key:
133
+ return ""
134
+ if len(key) <= 8:
135
+ return "•" * len(key)
136
+ return f"{key[:4]}{'•' * 6}{key[-4:]}"
137
+
138
+
139
+ def active_account_of(provider: dict):
140
+ aid = provider.get("active_account")
141
+ for acc in provider.get("accounts", []):
142
+ if acc["id"] == aid:
143
+ return acc
144
+ return None
145
+
146
+
147
+ def public_state():
148
+ """返回给前端的状态(密钥脱敏)。"""
149
+ cfg = load_config()
150
+ out_providers = {}
151
+ for key, p in cfg["providers"].items():
152
+ accounts = [
153
+ {
154
+ "id": a["id"],
155
+ "name": a.get("name", ""),
156
+ "key_masked": mask_key(a.get("api_key", "")),
157
+ "has_key": bool(a.get("api_key")),
158
+ }
159
+ for a in p.get("accounts", [])
160
+ ]
161
+ out_providers[key] = {
162
+ "id": key,
163
+ "label": p.get("label", key),
164
+ "base_url": p.get("base_url", ""),
165
+ "model": p.get("model", ""),
166
+ "auth_var": p.get("auth_var", "ANTHROPIC_API_KEY"),
167
+ "accounts": accounts,
168
+ "active_account": p.get("active_account"),
169
+ }
170
+ return {
171
+ "current_provider": cfg["current_provider"],
172
+ "providers": out_providers,
173
+ "ccm_available": shutil.which("ccm") is not None,
174
+ "claude_available": shutil.which("claude") is not None,
175
+ }
176
+
177
+
178
+ def build_env_for_active():
179
+ """根据当前供应商 + 激活账号构造注入环境变量。"""
180
+ cfg = load_config()
181
+ pid = cfg["current_provider"]
182
+ provider = cfg["providers"].get(pid, {})
183
+ acc = active_account_of(provider)
184
+ label = provider.get("label", pid)
185
+
186
+ env = {}
187
+ base_url = provider.get("base_url", "")
188
+ model = provider.get("model", "")
189
+ auth_var = provider.get("auth_var", "ANTHROPIC_API_KEY")
190
+ api_key = acc.get("api_key", "") if acc else ""
191
+
192
+ if pid != "claude" and base_url:
193
+ env["ANTHROPIC_BASE_URL"] = base_url
194
+ if api_key:
195
+ env[auth_var] = api_key
196
+ if model:
197
+ env["ANTHROPIC_MODEL"] = model
198
+ return env, label, bool(api_key)
199
+
200
+
201
+ def get_lock():
202
+ return _config_lock