ccpkit 0.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ccpkit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.1
2
+ Name: ccpkit
3
+ Version: 0.2.0
4
+ Summary: Switch Claude Code / Codex CLI API providers with one command
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/kanfangwang/ccpkit
7
+ Project-URL: Issues, https://github.com/kanfangwang/ccpkit/issues
8
+ Keywords: claude-code,codex,cli,api-provider,switcher,relay,gateway
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: dev
19
+
20
+ # ccpkit
21
+
22
+ **One command to switch between API providers for Claude Code and Codex CLI.**
23
+ Official API, relay/mirror, or your own self-hosted gateway — keep several profiles, switch instantly, verify with a built-in doctor.
24
+
25
+ [English](README.md) | [中文说明](#中文说明)
26
+
27
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue)]()
28
+ [![License](https://img.shields.io/badge/license-MIT-green)]()
29
+ [![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)]()
30
+ [![CI](https://github.com/kanfangwang/ccpkit/actions/workflows/ci.yml/badge.svg)](https://github.com/kanfangwang/ccpkit/actions/workflows/ci.yml)
31
+
32
+ ---
33
+
34
+ ## Why
35
+
36
+ Claude Code reads `~/.claude/settings.json`, Codex CLI reads `~/.codex/config.toml`.
37
+ Switching provider means editing those files by hand, remembering which key goes where,
38
+ and breaking things when you switch back. `ccpkit` keeps every provider as a named
39
+ profile and writes the right config for you — with automatic backups.
40
+
41
+ ## Features
42
+
43
+ - **Named profiles** for any provider: official, relay/mirror, self-hosted gateway
44
+ - **One-command switch** — `ccpkit use <name>`
45
+ - **Writes both clients** (Claude Code + Codex CLI) from a single profile
46
+ - **`doctor` self-check** — connectivity, latency, and model availability for every line
47
+ - **Automatic backups** of `settings.json` / `config.toml` before every write, `ccpkit restore` to roll back
48
+ - **`env` output** for people who prefer environment variables (bash / PowerShell / cmd)
49
+ - **Zero dependencies**, single file, Python 3.8+, Windows / macOS / Linux
50
+
51
+ ## Install
52
+
53
+ Pick one:
54
+
55
+ ```bash
56
+ # 1) pipx (recommended)
57
+ pipx install git+https://github.com/kanfangwang/ccpkit.git
58
+
59
+ # 2) uv
60
+ uvx --from git+https://github.com/kanfangwang/ccpkit.git ccpkit --help
61
+
62
+ # 3) plain python — just download the file
63
+ curl -LO https://raw.githubusercontent.com/kanfangwang/ccpkit/main/ccpkit.py
64
+ python ccpkit.py --help
65
+
66
+ # 4) Windows PowerShell one-liner
67
+ irm https://raw.githubusercontent.com/kanfangwang/ccpkit/main/ccpkit.py -OutFile ccpkit.py; python ccpkit.py --help
68
+ ```
69
+
70
+ ## Quick start
71
+
72
+ ```bash
73
+ # add a provider profile (interactive if you omit the flags)
74
+ ccpkit add myrelay \
75
+ --claude-base https://api.example.com/p --claude-key sk-xxx --claude-model claude-sonnet-4-6 \
76
+ --codex-base https://api.example.com/p/v1 --codex-key sk-yyy --codex-model gpt-5.5
77
+
78
+ # save and switch to it
79
+ ccpkit add myrelay ... --use
80
+
81
+ # start from a preset instead (presets only pre-fill URLs; the key is always yours)
82
+ ccpkit presets
83
+ ccpkit add mdd --preset mdd --claude-key sk-xxx --codex-key sk-yyy --use
84
+ ccpkit add official --preset anthropic --claude-key sk-ant-... --use
85
+
86
+ # switch later
87
+ ccpkit use myrelay
88
+
89
+ # only one client
90
+ ccpkit use myrelay --client codex
91
+
92
+ # check every line: alive? latency? is my model there?
93
+ ccpkit doctor myrelay --models
94
+
95
+ # measure first-token latency and throughput (spends a few tokens)
96
+ ccpkit bench myrelay --runs 3
97
+ ccpkit bench myrelay --json
98
+
99
+ # list / inspect / remove
100
+ ccpkit list
101
+ ccpkit show myrelay
102
+ ccpkit remove myrelay
103
+
104
+ # prefer env vars instead of writing files?
105
+ ccpkit env myrelay --shell powershell
106
+ ```
107
+
108
+ Example `doctor` output:
109
+
110
+ ```
111
+ [myrelay]
112
+ Claude OK 延迟 43 ms (接口可用,未返回模型清单)
113
+ Codex OK 延迟 50 ms 模型 1 个 模型可用
114
+ - gpt-5.5
115
+ 全部正常
116
+ ```
117
+
118
+ Example `bench` output (`--runs 2`, streaming):
119
+
120
+ ```
121
+ $ ccpkit bench myrelay --runs 2
122
+ ! 将发起 4 次真实请求(会产生少量用量),prompt: 'Reply with exactly: OK'
123
+ Claude #1 首字 1785 ms 总耗时 1.78s 输出 1 tok -
124
+ Claude #2 首字 2119 ms 总耗时 2.12s 输出 1 tok -
125
+
126
+ 线路 成功率 首字均值 首字最快 总耗时均值 输出tok
127
+ --------------------------------------------------------------------
128
+ Claude 2/2 1952 ms 1785 ms 1.95 s 1
129
+ ```
130
+
131
+ Numbers are illustrative and vary a lot by provider and time of day — the same relay
132
+ has been measured anywhere from 0.7 s to 27 s first-token in a single session, which
133
+ is exactly why the `bench` command exists.
134
+
135
+ `bench` also takes `--json` for machine-readable output, `--no-stream` for gateways
136
+ without SSE, `--runs N`, and `--prompt "..."` to test your own workload.
137
+
138
+ ## Providers & presets
139
+
140
+ Works with any Anthropic-compatible or OpenAI-compatible endpoint: official APIs,
141
+ relays/mirrors, or your own gateway. Built-in presets only pre-fill base URLs and a
142
+ default model — **your key is always your own**: [`docs/providers.md`](docs/providers.md).
143
+
144
+ | Preset | Claude base | Codex base |
145
+ |---|---|---|
146
+ | `anthropic` | `https://api.anthropic.com` | – |
147
+ | `openai` | – | `https://api.openai.com/v1` |
148
+ | `mdd` (alias `mdd-relay`) | `https://api.mddtech.cn/p` | `https://api.mddtech.cn/p/v1` |
149
+
150
+ ## What it writes
151
+
152
+ | Client | File | What changes |
153
+ |---|---|---|
154
+ | Claude Code | `~/.claude/settings.json` | merges an `env` block: `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` |
155
+ | Codex CLI | `~/.codex/config.toml` | adds a managed block (`model`, `model_provider`, `[model_providers.ccpkit]` with `base_url` / `env_key` / `wire_api`) |
156
+ | Codex CLI | `~/.codex/auth.json` | sets `OPENAI_API_KEY` |
157
+
158
+ Existing files are **never destroyed**: a timestamped `*.ccpkit-bak-<time>` copy is made first,
159
+ and everything outside the managed block is left untouched.
160
+
161
+ ```toml
162
+ # >>> ccpkit begin
163
+ model = "gpt-5.5"
164
+ model_provider = "ccpkit"
165
+
166
+ [model_providers.ccpkit]
167
+ name = "ccpkit"
168
+ base_url = "https://api.example.com/p/v1"
169
+ env_key = "OPENAI_API_KEY"
170
+ wire_api = "responses"
171
+ # <<< ccpkit end
172
+ ```
173
+
174
+ ## Environment variables
175
+
176
+ | Variable | Meaning |
177
+ |---|---|
178
+ | `CCPKIT_HOME` | where `profiles.json` lives (default `~/.ccpkit`) |
179
+ | `CCPKIT_CLAUDE_DIR` | override the Claude home dir (default `~`) |
180
+ | `CCPKIT_CODEX_DIR` | override the Codex home dir (default `~`) |
181
+ | `NO_COLOR` | disable colored output |
182
+
183
+ Handy for testing without touching your real config:
184
+
185
+ ```bash
186
+ CCPKIT_HOME=/tmp/t CCPKIT_CLAUDE_DIR=/tmp/t CCPKIT_CODEX_DIR=/tmp/t ccpkit add test --use ...
187
+ ```
188
+
189
+ ## Privacy
190
+
191
+ - No telemetry, no analytics, no network calls except the ones you trigger with `doctor` / model listing.
192
+ - Keys are stored locally in `~/.ccpkit/profiles.json` (chmod 600 on POSIX).
193
+ - Nothing is uploaded anywhere.
194
+
195
+ ## FAQ
196
+
197
+ **How do I go back to the official API?**
198
+ `ccpkit add official --claude-base https://api.anthropic.com --claude-key sk-ant-...` then `ccpkit use official`.
199
+ Prefer your previous file? `ccpkit restore --client claude`.
200
+
201
+ **Codex CLI ignores my model provider.**
202
+ Codex CLI must be restarted (open a new terminal). Also make sure `~/.codex/auth.json` or `OPENAI_API_KEY` is set.
203
+
204
+ **`doctor` says "接口可用,未返回模型清单".**
205
+ Some gateways don't expose a model list. The auth check still passed — that is normal.
206
+
207
+ **Windows shows garbled characters.**
208
+ Use Windows Terminal, or set `chcp 65001`. The tool falls back to ASCII markers on legacy code pages.
209
+
210
+ ## Roadmap
211
+
212
+ - [ ] `ccpkit usage` — daily token / cost report from local session logs
213
+ - [ ] Publish to PyPI so `pipx install ccpkit` works without cloning
214
+ - [ ] Homebrew / Scoop formula
215
+ - [ ] `ccpkit switch` — swap only the model, keep the provider
216
+
217
+ ## License
218
+
219
+ MIT
220
+
221
+ ---
222
+
223
+ ## 中文说明
224
+
225
+ `ccpkit` 用来管理 **Claude Code / Codex CLI 的多套 API 线路**(官方、中转、自建网关),一条命令切换。
226
+
227
+ ```bash
228
+ # 添加一条线路(不加参数会进入交互式问答)
229
+ ccpkit add 我的中转 \
230
+ --claude-base https://api.example.com/p --claude-key sk-xxx --claude-model claude-sonnet-4-6 \
231
+ --codex-base https://api.example.com/p/v1 --codex-key sk-yyy --codex-model gpt-5.5
232
+
233
+ ccpkit use 我的中转 # 切换(写入两个客户端配置)
234
+ ccpkit doctor 我的中转 # 自检:连通性 / 延迟 / 模型是否可用
235
+ ccpkit list # 查看所有线路,带 * 的是当前生效
236
+ ccpkit env 我的中转 --shell powershell # 只想用环境变量,不写文件
237
+ ccpkit restore --client claude # 恢复上次的配置备份
238
+ ```
239
+
240
+ **它写的文件**
241
+ - Claude Code:`~/.claude/settings.json` 的 `env` 段(`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_MODEL`)
242
+ - Codex CLI:`~/.codex/config.toml` 的托管块 + `~/.codex/auth.json` 的 `OPENAI_API_KEY`
243
+
244
+ **安全**:每次写入前自动备份为 `*.ccpkit-bak-<时间>`,只改托管块,不动你其它配置;key 只存在本地 `~/.ccpkit/profiles.json`,无任何上报。
@@ -0,0 +1,7 @@
1
+ ccpkit.py,sha256=-Xgsninu0Sa6EkQ1sq5GGCT-MGzj5NWMBjT0hhTeWLY,32724
2
+ ccpkit-0.2.0.dist-info/LICENSE,sha256=cFxqcvrpMM7ztBGk6hob_aqUrG_52sWWGRqyy70KxvI,1076
3
+ ccpkit-0.2.0.dist-info/METADATA,sha256=n8TFScfOXmXKJdrohq6tKW9EzGJo8A0RLiDSG-O4t9k,9369
4
+ ccpkit-0.2.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
+ ccpkit-0.2.0.dist-info/entry_points.txt,sha256=Mu3n3zj5_d6oNyQ8Y8XXzvahiJ5xsdqcjedWhUzpYt0,39
6
+ ccpkit-0.2.0.dist-info/top_level.txt,sha256=ifDpp7gjDy09rqt3NnNM2JAWKVHiq6TxoLCCB3U9r8I,7
7
+ ccpkit-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.45.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccpkit = ccpkit:main
@@ -0,0 +1 @@
1
+ ccpkit
ccpkit.py ADDED
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ ccpkit - Claude Code / Codex CLI provider profile switcher
5
+
6
+ Manage multiple API providers (official, relay/mirror, self-hosted) and switch
7
+ the config of Claude Code and Codex CLI with one command.
8
+
9
+ Zero dependencies. Python 3.8+.
10
+
11
+ Commands:
12
+ add add or update a profile
13
+ use apply a profile to Claude Code / Codex CLI
14
+ list list profiles
15
+ show show profile details and the config that would be written
16
+ env print environment variables (bash / powershell / cmd)
17
+ doctor check connectivity, latency and available models
18
+ remove delete a profile
19
+ restore restore the latest backup of a client config
20
+
21
+ Config: $CCPKIT_HOME/profiles.json (default ~/.ccpkit/profiles.json)
22
+ Targets: $CCPKIT_CLAUDE_DIR/.claude/settings.json
23
+ $CCPKIT_CODEX_DIR/.codex/config.toml + auth.json
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import shutil
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+
38
+ VERSION = "0.2.0"
39
+ MANAGED_BEGIN = "# >>> ccpkit begin"
40
+ MANAGED_END = "# <<< ccpkit end"
41
+
42
+ # --- console encoding: never crash on legacy Windows code pages (cp936 etc.) ---
43
+ try:
44
+ sys.stdout.reconfigure(errors="replace")
45
+ sys.stderr.reconfigure(errors="replace")
46
+ except Exception:
47
+ pass
48
+ _ENC = (getattr(sys.stdout, "encoding", "") or "").lower()
49
+ UNICODE_OK = ("utf" in _ENC)
50
+ OK_MARK, WARN_MARK, ERR_MARK = ("✓ ", "! ", "✗ ") if UNICODE_OK else ("[OK] ", "[!] ", "[x] ")
51
+
52
+ # ---------------------------------------------------------------- presets
53
+ # Presets only fill base_url / default model. The API key is always yours to provide.
54
+ PRESETS = {
55
+ "anthropic": {
56
+ "label": "Anthropic official",
57
+ "claude_base": "https://api.anthropic.com",
58
+ "homepage": "https://console.anthropic.com",
59
+ },
60
+ "openai": {
61
+ "label": "OpenAI official",
62
+ "codex_base": "https://api.openai.com/v1",
63
+ "homepage": "https://platform.openai.com",
64
+ },
65
+ "mdd": {
66
+ "label": "MDD API (relay example)",
67
+ "claude_base": "https://api.mddtech.cn/p",
68
+ "claude_model": "claude-sonnet-4-6",
69
+ "codex_base": "https://api.mddtech.cn/p/v1",
70
+ "codex_model": "gpt-5.5",
71
+ "homepage": "https://api.mddtech.cn/portal",
72
+ },
73
+ }
74
+ PRESET_ALIAS = {"mdd-relay": "mdd", "mdd-switch": "mdd", "mddswitch": "mdd"}
75
+
76
+
77
+ def resolve_preset(name: str):
78
+ if not name:
79
+ return None, None
80
+ key = PRESET_ALIAS.get(name.strip().lower(), name.strip().lower())
81
+ return key, PRESETS.get(key)
82
+
83
+ # ---------------------------------------------------------------- utils
84
+
85
+ def _home() -> Path:
86
+ override = os.environ.get("CCPKIT_HOME_HOME") # unused, kept for clarity
87
+ return Path(os.path.expanduser("~"))
88
+
89
+
90
+ def config_dir() -> Path:
91
+ d = os.environ.get("CCPKIT_HOME")
92
+ return Path(d) if d else Path.home() / ".ccpkit"
93
+
94
+
95
+ def profiles_path() -> Path:
96
+ return config_dir() / "profiles.json"
97
+
98
+
99
+ def claude_dir() -> Path:
100
+ d = os.environ.get("CCPKIT_CLAUDE_DIR")
101
+ return (Path(d) / ".claude") if d else Path.home() / ".claude"
102
+
103
+
104
+ def codex_dir() -> Path:
105
+ d = os.environ.get("CCPKIT_CODEX_DIR")
106
+ return (Path(d) / ".codex") if d else Path.home() / ".codex"
107
+
108
+
109
+ def use_color() -> bool:
110
+ if os.environ.get("NO_COLOR") or "--no-color" in sys.argv:
111
+ return False
112
+ if os.name == "nt":
113
+ return bool(os.environ.get("WT_SESSION") or os.environ.get("ANSICON")
114
+ or os.environ.get("TERM_PROGRAM") or os.environ.get("ConEmuANSI"))
115
+ return sys.stdout.isatty()
116
+
117
+
118
+ def c(text: str, code: str) -> str:
119
+ return f"\033[{code}m{text}\033[0m" if use_color() else text
120
+
121
+
122
+ def ok(msg): print(c(OK_MARK, "32") + msg)
123
+ def warn(msg): print(c(WARN_MARK, "33") + msg)
124
+ def err(msg): print(c(ERR_MARK, "31") + msg, file=sys.stderr)
125
+ def dim(msg): return c(msg, "90")
126
+ def bold(msg): return c(msg, "1")
127
+
128
+
129
+ def load_db() -> dict:
130
+ p = profiles_path()
131
+ if not p.exists():
132
+ return {"version": 1, "current": {}, "profiles": {}}
133
+ try:
134
+ db = json.loads(p.read_text(encoding="utf-8"))
135
+ except Exception as e:
136
+ err(f"配置文件损坏: {p} ({e})")
137
+ sys.exit(2)
138
+ db.setdefault("version", 1)
139
+ db.setdefault("current", {})
140
+ db.setdefault("profiles", {})
141
+ return db
142
+
143
+
144
+ def save_db(db: dict) -> None:
145
+ p = profiles_path()
146
+ p.parent.mkdir(parents=True, exist_ok=True)
147
+ p.write_text(json.dumps(db, ensure_ascii=False, indent=2), encoding="utf-8")
148
+ try:
149
+ os.chmod(p, 0o600)
150
+ except Exception:
151
+ pass
152
+
153
+
154
+ def backup(path: Path) -> Path | None:
155
+ if not path.exists():
156
+ return None
157
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
158
+ dst = path.with_name(path.name + f".ccpkit-bak-{ts}")
159
+ shutil.copy2(path, dst)
160
+ return dst
161
+
162
+
163
+ def mask(secret: str) -> str:
164
+ if not secret:
165
+ return dim("(未设置)")
166
+ if len(secret) <= 12:
167
+ return secret[:3] + "***"
168
+ return f"{secret[:8]}…{secret[-4:]}"
169
+
170
+
171
+ # ---------------------------------------------------------------- clients
172
+
173
+ def write_claude(prof: dict) -> list[str]:
174
+ """Merge env into ~/.claude/settings.json, keep other keys untouched."""
175
+ base, key, model = prof.get("claude_base"), prof.get("claude_key"), prof.get("claude_model")
176
+ if not (base and key):
177
+ return []
178
+ d = claude_dir()
179
+ d.mkdir(parents=True, exist_ok=True)
180
+ f = d / "settings.json"
181
+ data = {}
182
+ if f.exists():
183
+ b = backup(f)
184
+ try:
185
+ data = json.loads(f.read_text(encoding="utf-8") or "{}")
186
+ except Exception:
187
+ warn(f"原 settings.json 解析失败,已备份为 {b.name if b else '(无)'},按新文件写入")
188
+ data = {}
189
+ env = data.get("env") or {}
190
+ env["ANTHROPIC_BASE_URL"] = base
191
+ env["ANTHROPIC_AUTH_TOKEN"] = key
192
+ if model:
193
+ env["ANTHROPIC_MODEL"] = model
194
+ data["env"] = env
195
+ f.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
196
+ out = [str(f)]
197
+ return out
198
+
199
+
200
+ def _strip_managed(text: str) -> str:
201
+ lines, keep, skip = text.splitlines(), [], False
202
+ for ln in lines:
203
+ if ln.strip() == MANAGED_BEGIN:
204
+ skip = True
205
+ continue
206
+ if ln.strip() == MANAGED_END:
207
+ skip = False
208
+ continue
209
+ if not skip:
210
+ keep.append(ln)
211
+ return "\n".join(keep).strip()
212
+
213
+
214
+ def write_codex(prof: dict) -> list[str]:
215
+ base, key, model = prof.get("codex_base"), prof.get("codex_key"), prof.get("codex_model")
216
+ if not (base and key):
217
+ return []
218
+ d = codex_dir()
219
+ d.mkdir(parents=True, exist_ok=True)
220
+ cfg = d / "config.toml"
221
+ existing = ""
222
+ if cfg.exists():
223
+ backup(cfg)
224
+ existing = _strip_managed(cfg.read_text(encoding="utf-8", errors="replace"))
225
+ block = [
226
+ MANAGED_BEGIN,
227
+ f'model = "{model or "gpt-5.5"}"',
228
+ 'model_provider = "ccpkit"',
229
+ "",
230
+ "[model_providers.ccpkit]",
231
+ 'name = "ccpkit"',
232
+ f'base_url = "{base.rstrip("/")}"',
233
+ 'env_key = "OPENAI_API_KEY"',
234
+ 'wire_api = "responses"',
235
+ MANAGED_END,
236
+ ]
237
+ cfg.write_text("\n".join(block) + ("\n\n" + existing if existing else "") + "\n", encoding="utf-8")
238
+ # auth.json so the CLI can pick the key up without exporting env vars
239
+ auth = d / "auth.json"
240
+ if auth.exists():
241
+ backup(auth)
242
+ try:
243
+ ad = json.loads(auth.read_text(encoding="utf-8") or "{}")
244
+ except Exception:
245
+ ad = {}
246
+ else:
247
+ ad = {}
248
+ ad["OPENAI_API_KEY"] = key
249
+ auth.write_text(json.dumps(ad, ensure_ascii=False, indent=2), encoding="utf-8")
250
+ return [str(cfg), str(auth)]
251
+
252
+
253
+ # ---------------------------------------------------------------- network
254
+
255
+ def http_get(url: str, key: str, timeout: float = 12.0):
256
+ req = urllib.request.Request(url, headers={
257
+ "Authorization": f"Bearer {key}",
258
+ "x-api-key": key,
259
+ "Accept": "application/json",
260
+ "User-Agent": f"ccpkit/{VERSION}",
261
+ })
262
+ t0 = time.time()
263
+ try:
264
+ with urllib.request.urlopen(req, timeout=timeout) as r:
265
+ body = r.read()
266
+ return r.status, body, (time.time() - t0) * 1000, None
267
+ except urllib.error.HTTPError as e:
268
+ return e.code, e.read()[:400], (time.time() - t0) * 1000, None
269
+ except Exception as e:
270
+ return None, b"", (time.time() - t0) * 1000, str(e)
271
+
272
+
273
+ def fetch_models(base: str, key: str):
274
+ """Probe the model list. Handles bases that already end with /v1."""
275
+ base = base.rstrip("/")
276
+ if base.endswith("/v1"):
277
+ urls = [f"{base}/models"]
278
+ else:
279
+ urls = [f"{base}/v1/models", f"{base}/models"]
280
+ last = None
281
+ for url in urls:
282
+ status, body, ms, exc = http_get(url, key)
283
+ if exc:
284
+ return {"url": url, "status": None, "ms": ms, "error": exc, "models": []}
285
+ models = parse_models(body)
286
+ last = {"url": url, "status": status, "ms": ms, "error": None, "models": models}
287
+ if status == 200:
288
+ return last
289
+ return last or {"url": urls[0], "status": None, "ms": 0, "error": "no endpoint", "models": []}
290
+
291
+
292
+ def parse_models(body: bytes):
293
+ """Accept several shapes: {"data":[{"id":..}]}, {"models":[...]}, plain list."""
294
+ try:
295
+ data = json.loads(body.decode("utf-8", "ignore"))
296
+ except Exception:
297
+ return []
298
+ items = None
299
+ if isinstance(data, list):
300
+ items = data
301
+ elif isinstance(data, dict):
302
+ for k in ("data", "models", "model_list"):
303
+ if isinstance(data.get(k), list):
304
+ items = data[k]
305
+ break
306
+ out = []
307
+ for it in items or []:
308
+ if isinstance(it, str) and it.strip():
309
+ out.append(it.strip())
310
+ elif isinstance(it, dict):
311
+ for k in ("id", "name", "model", "display_name"):
312
+ v = it.get(k)
313
+ if isinstance(v, str) and v.strip():
314
+ out.append(v.strip())
315
+ break
316
+ # 去重保序
317
+ seen, uniq = set(), []
318
+ for m in out:
319
+ if m not in seen:
320
+ seen.add(m)
321
+ uniq.append(m)
322
+ return uniq
323
+
324
+
325
+ # ---------------------------------------------------------------- bench
326
+
327
+ DEFAULT_BENCH_PROMPT = "Reply with exactly: OK"
328
+
329
+
330
+ def _claude_messages_url(base: str) -> str:
331
+ b = base.rstrip("/")
332
+ return f"{b}/messages" if b.endswith("/v1") else f"{b}/v1/messages"
333
+
334
+
335
+ def _codex_responses_url(base: str) -> str:
336
+ b = base.rstrip("/")
337
+ return f"{b}/responses" if b.endswith("/v1") else f"{b}/v1/responses"
338
+
339
+
340
+ def _sse_post(url: str, key: str, payload: dict, timeout: float = 120.0):
341
+ data = json.dumps(payload).encode("utf-8")
342
+ req = urllib.request.Request(url, data=data, headers={
343
+ "Content-Type": "application/json",
344
+ "Accept": "text/event-stream",
345
+ "Authorization": f"Bearer {key}",
346
+ "x-api-key": key,
347
+ "anthropic-version": "2023-06-01",
348
+ "User-Agent": f"ccpkit/{VERSION}",
349
+ })
350
+ return urllib.request.urlopen(req, timeout=timeout)
351
+
352
+
353
+ def _bench_once(kind: str, base: str, key: str, model: str, prompt: str,
354
+ stream: bool, timeout: float) -> dict:
355
+ """One measurement. Returns {ttft, total, out, ok, err, url}."""
356
+ if kind == "claude":
357
+ url = _claude_messages_url(base)
358
+ payload = {
359
+ "model": model or "claude-sonnet-4-6",
360
+ "max_tokens": 64,
361
+ "messages": [{"role": "user", "content": prompt}],
362
+ }
363
+ else:
364
+ url = _codex_responses_url(base)
365
+ payload = {
366
+ "model": model or "gpt-5.5",
367
+ "input": prompt,
368
+ "max_output_tokens": 64,
369
+ }
370
+ if stream:
371
+ payload["stream"] = True
372
+ t0 = time.time()
373
+ ttft = None
374
+ out_tokens = 0
375
+ err = None
376
+ try:
377
+ resp = _sse_post(url, key, payload, timeout)
378
+ if stream:
379
+ for raw in resp:
380
+ line = raw.decode("utf-8", "ignore").strip()
381
+ if not line.startswith("data:"):
382
+ continue
383
+ body = line[5:].strip()
384
+ if not body or body == "[DONE]":
385
+ if body == "[DONE]":
386
+ break
387
+ continue
388
+ try:
389
+ ev = json.loads(body)
390
+ except Exception:
391
+ continue
392
+ if isinstance(ev, dict):
393
+ if ev.get("error"):
394
+ err = str(ev["error"])[:200]
395
+ break
396
+ if kind == "claude":
397
+ if ttft is None and ev.get("type") == "content_block_delta":
398
+ d = ev.get("delta") or {}
399
+ if d.get("text"):
400
+ ttft = time.time() - t0
401
+ usage = ev.get("usage") or (ev.get("message") or {}).get("usage")
402
+ if isinstance(usage, dict) and usage.get("output_tokens"):
403
+ out_tokens = usage["output_tokens"]
404
+ if ev.get("type") == "message_stop":
405
+ break
406
+ else:
407
+ if ttft is None and ev.get("type") in (
408
+ "response.output_text.delta", "response.output_text.done"):
409
+ if ev.get("delta") or ev.get("type", "").endswith(".done"):
410
+ ttft = time.time() - t0
411
+ if ev.get("type") == "response.completed":
412
+ usage = ((ev.get("response") or {}).get("usage") or {})
413
+ out_tokens = usage.get("output_tokens") or out_tokens
414
+ break
415
+ if ttft is None and err is None:
416
+ ttft = time.time() - t0
417
+ else:
418
+ body = json.loads(resp.read().decode("utf-8", "ignore"))
419
+ ttft = time.time() - t0
420
+ usage = body.get("usage") or ((body.get("response") or {}).get("usage")) or {}
421
+ out_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or 0
422
+ except urllib.error.HTTPError as e:
423
+ try:
424
+ detail = e.read()[:200].decode("utf-8", "ignore")
425
+ except Exception:
426
+ detail = ""
427
+ err = f"HTTP {e.code} {detail}"
428
+ except Exception as e:
429
+ err = str(e)[:200]
430
+ total = time.time() - t0
431
+ return {"ttft": ttft, "total": total, "out": out_tokens, "ok": err is None,
432
+ "err": err, "url": url}
433
+
434
+
435
+ def cmd_bench(a):
436
+ db = load_db()
437
+ p = db["profiles"].get(a.name)
438
+ if not p:
439
+ err(f"没有名为 {a.name} 的配置")
440
+ sys.exit(2)
441
+ lines = []
442
+ if p.get("claude_key") and a.client in ("both", "claude"):
443
+ lines.append(("Claude", p.get("claude_base"), p.get("claude_key"), p.get("claude_model")))
444
+ if p.get("codex_key") and a.client in ("both", "codex"):
445
+ lines.append(("Codex", p.get("codex_base"), p.get("codex_key"), p.get("codex_model")))
446
+ if not lines:
447
+ err("该配置没有可测的线路")
448
+ sys.exit(2)
449
+ if not a.quiet:
450
+ warn(f"将发起 {len(lines) * a.runs} 次真实请求(会产生少量用量),prompt: {a.prompt!r}")
451
+ report = {}
452
+ for kind, base, key, model in lines:
453
+ rows = []
454
+ for i in range(a.runs):
455
+ r = _bench_once(kind.lower(), base, key, model, a.prompt,
456
+ not a.no_stream, a.timeout)
457
+ rows.append(r)
458
+ if not a.json:
459
+ if r["ok"]:
460
+ ttft = f"{r['ttft'] * 1000:.0f} ms" if r["ttft"] is not None else "-"
461
+ gen = r["total"] - (r["ttft"] or 0)
462
+ tps = (r["out"] / gen) if (r["out"] >= 2 and gen > 0.2) else None
463
+ tps_s = f"{tps:.1f} tok/s" if tps else "-"
464
+ print(f" {kind} #{i + 1} 首字 {ttft:<10} 总耗时 {r['total']:.2f}s "
465
+ f"输出 {r['out']} tok {tps_s}")
466
+ else:
467
+ print(f" {kind} #{i + 1} {c('失败', '31')} {r['err']}")
468
+ ok_rows = [r for r in rows if r["ok"]]
469
+ report[kind] = {
470
+ "url": rows[0]["url"] if rows else "",
471
+ "runs": len(rows),
472
+ "ok": len(ok_rows),
473
+ "ttft_ms_avg": round(sum(r["ttft"] for r in ok_rows) / len(ok_rows) * 1000, 1) if ok_rows else None,
474
+ "ttft_ms_min": round(min(r["ttft"] for r in ok_rows) * 1000, 1) if ok_rows else None,
475
+ "total_s_avg": round(sum(r["total"] for r in ok_rows) / len(ok_rows), 2) if ok_rows else None,
476
+ "out_tokens_avg": round(sum(r["out"] for r in ok_rows) / len(ok_rows), 1) if ok_rows else None,
477
+ "errors": [r["err"] for r in rows if not r["ok"]],
478
+ }
479
+ if a.json:
480
+ print(json.dumps(report, ensure_ascii=False, indent=2))
481
+ else:
482
+ print()
483
+ print(bold(f"{'线路':<8}{'成功率':<10}{'首字均值':<12}{'首字最快':<12}{'总耗时均值':<12}输出tok"))
484
+ print(dim("-" * 68))
485
+ for kind, r in report.items():
486
+ rate = f"{r['ok']}/{r['runs']}"
487
+ t_avg = f"{r['ttft_ms_avg']:.0f} ms" if r["ttft_ms_avg"] is not None else "-"
488
+ t_min = f"{r['ttft_ms_min']:.0f} ms" if r["ttft_ms_min"] is not None else "-"
489
+ tot = f"{r['total_s_avg']:.2f} s" if r["total_s_avg"] is not None else "-"
490
+ print(f"{kind:<8}{rate:<10}{t_avg:<12}{t_min:<12}{tot:<12}{r['out_tokens_avg'] or 0:g}")
491
+ return 0 if all(r["ok"] == r["runs"] for r in report.values()) else 1
492
+
493
+
494
+ # ---------------------------------------------------------------- commands
495
+
496
+ def cmd_add(a):
497
+ db = load_db()
498
+ pname, preset = resolve_preset(getattr(a, "preset", None))
499
+ if getattr(a, "preset", None) and not preset:
500
+ err(f"未知的 preset: {a.preset}(可用: {', '.join(PRESETS)})")
501
+ sys.exit(2)
502
+ name = a.name or pname or "default"
503
+ p = db["profiles"].get(name, {})
504
+ fields = {
505
+ "claude_base": a.claude_base, "claude_key": a.claude_key, "claude_model": a.claude_model,
506
+ "codex_base": a.codex_base, "codex_key": a.codex_key, "codex_model": a.codex_model,
507
+ }
508
+ if preset:
509
+ for k, v in preset.items():
510
+ if k in fields and not fields.get(k):
511
+ fields[k] = v
512
+ print(f"使用预设 {bold(pname)} — {preset.get('label','')}")
513
+ if preset.get("homepage"):
514
+ print(dim(f" 该线路的 API key 请到 {preset['homepage']} 获取"))
515
+ if not a.yes and not any(fields.values()):
516
+ print(bold(f"添加配置 [{name}]") + dim(" (直接回车跳过某一项)"))
517
+ if confirm("配置 Claude Code 线路?"):
518
+ fields["claude_base"] = ask(" Claude base_url", "https://api.anthropic.com")
519
+ fields["claude_key"] = ask(" Claude api key", "")
520
+ fields["claude_model"] = ask(" 默认模型(可空)", "")
521
+ if confirm("配置 Codex CLI 线路?"):
522
+ fields["codex_base"] = ask(" Codex base_url(含 /v1)", "")
523
+ fields["codex_key"] = ask(" Codex api key", "")
524
+ fields["codex_model"] = ask(" 默认模型(可空)", "")
525
+ for k, v in fields.items():
526
+ if v is not None and v != "":
527
+ p[k] = v.strip() if isinstance(v, str) else v
528
+ p["name"] = name
529
+ if not (p.get("claude_key") or p.get("codex_key")):
530
+ err("至少要配置一条线路(key 不能为空)")
531
+ sys.exit(2)
532
+ db["profiles"][name] = p
533
+ save_db(db)
534
+ ok(f"已保存配置 {bold(name)} → {profiles_path()}")
535
+ if a.use:
536
+ apply_profile(db, name, a.client)
537
+ save_db(db)
538
+
539
+
540
+ def confirm(prompt: str) -> bool:
541
+ try:
542
+ return input(f"{prompt} [Y/n] ").strip().lower() in ("", "y", "yes")
543
+ except EOFError:
544
+ return False
545
+
546
+
547
+ def ask(prompt: str, default: str) -> str:
548
+ try:
549
+ v = input(f"{prompt}" + (f" [{default}]" if default else "") + ": ").strip()
550
+ except EOFError:
551
+ return default
552
+ return v or default
553
+
554
+
555
+ def apply_profile(db: dict, name: str, client: str = "both") -> None:
556
+ prof = db["profiles"].get(name)
557
+ if not prof:
558
+ err(f"没有名为 {name} 的配置")
559
+ sys.exit(2)
560
+ written = []
561
+ if client in ("both", "claude"):
562
+ w = write_claude(prof)
563
+ if w:
564
+ written += [("Claude Code", x) for x in w]
565
+ else:
566
+ warn(_missing_hint(prof, "claude", client))
567
+ if client in ("both", "codex"):
568
+ w = write_codex(prof)
569
+ if w:
570
+ written += [("Codex CLI", x) for x in w]
571
+ else:
572
+ warn(_missing_hint(prof, "codex", client))
573
+ db["current"] = db.get("current") or {}
574
+ if prof.get("claude_key"):
575
+ db["current"]["claude"] = name
576
+ if prof.get("codex_key"):
577
+ db["current"]["codex"] = name
578
+ if written:
579
+ ok(f"已切换到 {bold(name)}")
580
+ for who, path in written:
581
+ print(f" {dim(who)} → {path}")
582
+ else:
583
+ warn("没有写入任何配置")
584
+
585
+
586
+ def _missing_hint(prof: dict, kind: str, client: str) -> str:
587
+ base, key = prof.get(f"{kind}_base"), prof.get(f"{kind}_key")
588
+ who = "Claude Code" if kind == "claude" else "Codex CLI"
589
+ if not key and not base:
590
+ return f"该配置没有 {kind} 线路,跳过 {who}"
591
+ if not base:
592
+ return (f"{who} 缺少 {kind}_base,未写入。请补上:"
593
+ f"ccpkit add {prof.get('name', '<名字>')} --{kind}-base <地址>")
594
+ return f"{who} 缺少 {kind}_key,未写入,跳过"
595
+
596
+
597
+ def cmd_use(a):
598
+ db = load_db()
599
+ apply_profile(db, a.name, a.client)
600
+ save_db(db)
601
+ if a.client in ("both", "claude") and db["profiles"][a.name].get("claude_key"):
602
+ print(dim(" 提示:Claude Code 重启后生效;Codex CLI 若已在运行,请重开终端"))
603
+ if a.client in ("both", "codex") and db["profiles"][a.name].get("codex_key"):
604
+ print(dim(" 提示:Codex CLI 也支持环境变量 OPENAI_API_KEY(见 ccpkit env)"))
605
+
606
+
607
+ def cmd_list(a):
608
+ db = load_db()
609
+ profs = db["profiles"]
610
+ if not profs:
611
+ print("还没有配置。用 " + bold("ccpkit add <名字>") + " 添加,例如:")
612
+ print(dim(' ccpkit add myrelay --claude-base https://example.com/p --claude-key sk-xxx '
613
+ '--codex-base https://example.com/p/v1 --codex-key sk-yyy'))
614
+ return
615
+ cur = db.get("current", {})
616
+ tick = "✓" if UNICODE_OK else "Y"
617
+ dash = " -"
618
+ print(bold(f"{'名称':<16}{'Claude':<10}{'Codex':<10}模型"))
619
+ print(dim("-" * 64))
620
+ for name, p in profs.items():
621
+ cm = "* " if cur.get("claude") == name else " "
622
+ xm = "* " if cur.get("codex") == name else " "
623
+ cl = (cm + tick) if p.get("claude_key") else " " + dash
624
+ cx = (xm + tick) if p.get("codex_key") else " " + dash
625
+ model = p.get("claude_model") or p.get("codex_model") or ""
626
+ print(f"{name:<16}{cl:<10}{cx:<10}{model}")
627
+ print(dim("\n* = 当前生效 Y = 已配置该线路"))
628
+
629
+
630
+ def cmd_show(a):
631
+ db = load_db()
632
+ p = db["profiles"].get(a.name)
633
+ if not p:
634
+ err(f"没有名为 {a.name} 的配置")
635
+ sys.exit(2)
636
+ print(bold(a.name))
637
+ for k in ("claude_base", "claude_key", "claude_model", "codex_base", "codex_key", "codex_model", "notes"):
638
+ if p.get(k):
639
+ v = mask(p[k]) if k.endswith("_key") else p[k]
640
+ print(f" {k:<14} {v}")
641
+ print(dim("\n会被写入的文件:"))
642
+ if p.get("claude_key"):
643
+ print(f" {claude_dir() / 'settings.json'}")
644
+ if p.get("codex_key"):
645
+ print(f" {codex_dir() / 'config.toml'}")
646
+ print(f" {codex_dir() / 'auth.json'}")
647
+
648
+
649
+ def cmd_env(a):
650
+ db = load_db()
651
+ p = db["profiles"].get(a.name)
652
+ if not p:
653
+ err(f"没有名为 {a.name} 的配置")
654
+ sys.exit(2)
655
+ shell = a.shell
656
+ if shell == "auto":
657
+ shell = "powershell" if os.name == "nt" else "bash"
658
+ lines = []
659
+ if p.get("claude_key"):
660
+ if shell == "powershell":
661
+ lines += [f'$env:ANTHROPIC_BASE_URL="{p["claude_base"]}"',
662
+ f'$env:ANTHROPIC_AUTH_TOKEN="{p["claude_key"]}"']
663
+ if p.get("claude_model"):
664
+ lines.append(f'$env:ANTHROPIC_MODEL="{p["claude_model"]}"')
665
+ elif shell == "cmd":
666
+ lines += [f'set ANTHROPIC_BASE_URL={p["claude_base"]}',
667
+ f'set ANTHROPIC_AUTH_TOKEN={p["claude_key"]}']
668
+ if p.get("claude_model"):
669
+ lines.append(f'set ANTHROPIC_MODEL={p["claude_model"]}')
670
+ else:
671
+ lines += [f'export ANTHROPIC_BASE_URL={p["claude_base"]}',
672
+ f'export ANTHROPIC_AUTH_TOKEN={p["claude_key"]}']
673
+ if p.get("claude_model"):
674
+ lines.append(f'export ANTHROPIC_MODEL={p["claude_model"]}')
675
+ if p.get("codex_key"):
676
+ if shell == "powershell":
677
+ lines += [f'$env:OPENAI_BASE_URL="{p["codex_base"]}"',
678
+ f'$env:OPENAI_API_KEY="{p["codex_key"]}"']
679
+ elif shell == "cmd":
680
+ lines += [f'set OPENAI_BASE_URL={p["codex_base"]}',
681
+ f'set OPENAI_API_KEY={p["codex_key"]}']
682
+ else:
683
+ lines += [f'export OPENAI_BASE_URL={p["codex_base"]}',
684
+ f'export OPENAI_API_KEY={p["codex_key"]}']
685
+ print("\n".join(lines))
686
+
687
+
688
+ def cmd_presets(a):
689
+ print(bold("内置预设(只预填地址和默认模型,key 需要你自己提供)\n"))
690
+ for key, p in PRESETS.items():
691
+ print(f" {bold(key):<20}{p.get('label','')}")
692
+ if p.get("claude_base"):
693
+ print(f" Claude: {p['claude_base']}")
694
+ if p.get("codex_base"):
695
+ print(f" Codex : {p['codex_base']}")
696
+ if p.get("homepage"):
697
+ print(dim(f" key : {p['homepage']}"))
698
+ if PRESET_ALIAS:
699
+ print(dim("\n别名: " + ", ".join(f"{k}→{v}" for k, v in PRESET_ALIAS.items())))
700
+ print(dim("\n用法: ccpkit add <名字> --preset <预设> --claude-key sk-xxx --codex-key sk-yyy --use"))
701
+
702
+
703
+ def cmd_doctor(a):
704
+ db = load_db()
705
+ names = [a.name] if a.name else list(db["profiles"].keys())
706
+ if not names:
707
+ err("没有可检查的配置")
708
+ sys.exit(2)
709
+ all_ok = True
710
+ for name in names:
711
+ p = db["profiles"].get(name)
712
+ if not p:
713
+ err(f"没有名为 {name} 的配置")
714
+ all_ok = False
715
+ continue
716
+ print(bold(f"\n[{name}]"))
717
+ for kind, base, key, model in (
718
+ ("Claude", p.get("claude_base"), p.get("claude_key"), p.get("claude_model")),
719
+ ("Codex", p.get("codex_base"), p.get("codex_key"), p.get("codex_model")),
720
+ ):
721
+ if not (base and key):
722
+ print(f" {kind:<7}{dim('未配置,跳过')}")
723
+ continue
724
+ r = fetch_models(base, key)
725
+ if r["status"] == 200:
726
+ ms = f"{r['ms']:.0f} ms"
727
+ n = len(r["models"])
728
+ if n == 0:
729
+ print(f" {kind:<7}{c('OK', '32')} 延迟 {ms:<9} {dim('(接口可用,未返回模型清单)')}")
730
+ else:
731
+ extra = ""
732
+ if model:
733
+ extra = (c(" 模型可用", "32") if model in r["models"]
734
+ else c(f" 但不含 {model}", "33"))
735
+ print(f" {kind:<7}{c('OK', '32')} 延迟 {ms:<9} 模型 {n} 个{extra}")
736
+ if a.models and r["models"]:
737
+ for m in r["models"][:40]:
738
+ print(f" - {m}")
739
+ elif r["status"] is None:
740
+ print(f" {kind:<7}{c('连接失败', '31')} {r['error']}")
741
+ all_ok = False
742
+ else:
743
+ print(f" {kind:<7}{c('HTTP ' + str(r['status']), '31')} {r['url']}")
744
+ all_ok = False
745
+ print()
746
+ print(c("全部正常", "32") if all_ok else c("存在异常,见上面输出", "31"))
747
+ sys.exit(0 if all_ok else 1)
748
+
749
+
750
+ def cmd_remove(a):
751
+ db = load_db()
752
+ if a.name not in db["profiles"]:
753
+ err(f"没有名为 {a.name} 的配置")
754
+ sys.exit(2)
755
+ if not a.yes and not confirm(f"确认删除配置 {a.name}?"):
756
+ return
757
+ db["profiles"].pop(a.name, None)
758
+ for k, v in list(db.get("current", {}).items()):
759
+ if v == a.name:
760
+ db["current"].pop(k, None)
761
+ save_db(db)
762
+ ok(f"已删除 {a.name}")
763
+
764
+
765
+ def cmd_restore(a):
766
+ target = claude_dir() / "settings.json" if a.client == "claude" else codex_dir() / "config.toml"
767
+ cands = sorted(target.parent.glob(target.name + ".ccpkit-bak-*"))
768
+ if a.list:
769
+ if not cands:
770
+ print("没有备份")
771
+ for f in cands:
772
+ print(f)
773
+ return
774
+ if not cands:
775
+ err("没有找到备份")
776
+ sys.exit(2)
777
+ latest = cands[-1]
778
+ shutil.copy2(latest, target)
779
+ ok(f"已从 {latest.name} 恢复 {target}")
780
+
781
+
782
+ # ---------------------------------------------------------------- main
783
+
784
+ def build_parser():
785
+ p = argparse.ArgumentParser(
786
+ prog="ccpkit",
787
+ description="Claude Code / Codex CLI 多线路配置管理(切换官方 / 中转 / 自建)",
788
+ epilog="示例: ccpkit add relay --claude-base https://api.example.com/p --claude-key sk-xxx "
789
+ "--codex-base https://api.example.com/p/v1 --codex-key sk-yyy --use",
790
+ )
791
+ p.add_argument("--version", action="version", version=f"ccpkit {VERSION}")
792
+ sub = p.add_subparsers(dest="cmd")
793
+
794
+ def add_profile_args(sp):
795
+ sp.add_argument("--claude-base"), sp.add_argument("--claude-key"), sp.add_argument("--claude-model")
796
+ sp.add_argument("--codex-base"), sp.add_argument("--codex-key"), sp.add_argument("--codex-model")
797
+ sp.add_argument("--notes")
798
+
799
+ sp = sub.add_parser("add", help="新增/修改一个配置")
800
+ sp.add_argument("name", nargs="?", help="配置名(配合 --preset 可省略)")
801
+ add_profile_args(sp)
802
+ sp.add_argument("--preset", help=f"内置预设: {', '.join(PRESETS)}")
803
+ sp.add_argument("-y", "--yes", action="store_true", help="全部用命令行参数,不交互")
804
+ sp.add_argument("--use", action="store_true", help="保存后立即切换")
805
+ sp.add_argument("--client", choices=["both", "claude", "codex"], default="both")
806
+ sp.set_defaults(func=cmd_add)
807
+
808
+ sp = sub.add_parser("presets", help="列出内置预设线路")
809
+ sp.set_defaults(func=cmd_presets)
810
+
811
+ sp = sub.add_parser("use", help="切换到某个配置")
812
+ sp.add_argument("name")
813
+ sp.add_argument("--client", choices=["both", "claude", "codex"], default="both")
814
+ sp.set_defaults(func=cmd_use)
815
+
816
+ sp = sub.add_parser("list", help="列出所有配置")
817
+ sp.set_defaults(func=cmd_list)
818
+
819
+ sp = sub.add_parser("show", help="查看配置详情")
820
+ sp.add_argument("name")
821
+ sp.set_defaults(func=cmd_show)
822
+
823
+ sp = sub.add_parser("env", help="打印环境变量(不写文件)")
824
+ sp.add_argument("name")
825
+ sp.add_argument("--shell", choices=["auto", "bash", "powershell", "cmd"], default="auto")
826
+ sp.set_defaults(func=cmd_env)
827
+
828
+ sp = sub.add_parser("doctor", help="连通性 / 延迟 / 模型自检")
829
+ sp.add_argument("name", nargs="?")
830
+ sp.add_argument("--models", action="store_true", help="列出模型清单")
831
+ sp.set_defaults(func=cmd_doctor)
832
+
833
+ sp = sub.add_parser("bench", help="首字延迟 / 吞吐实测(会产生少量用量)")
834
+ sp.add_argument("name")
835
+ sp.add_argument("--client", choices=["both", "claude", "codex"], default="both")
836
+ sp.add_argument("--runs", type=int, default=2, help="每条线路测几次(默认 2)")
837
+ sp.add_argument("--prompt", default=DEFAULT_BENCH_PROMPT, help="测试用的提示词")
838
+ sp.add_argument("--no-stream", action="store_true", help="非流式(只测总耗时)")
839
+ sp.add_argument("--timeout", type=float, default=120.0)
840
+ sp.add_argument("--json", action="store_true", help="输出 JSON")
841
+ sp.add_argument("-q", "--quiet", action="store_true", help="不打印费用提醒")
842
+ sp.set_defaults(func=cmd_bench)
843
+
844
+ sp = sub.add_parser("remove", help="删除配置")
845
+ sp.add_argument("name")
846
+ sp.add_argument("-y", "--yes", action="store_true")
847
+ sp.set_defaults(func=cmd_remove)
848
+
849
+ sp = sub.add_parser("restore", help="恢复客户端配置备份")
850
+ sp.add_argument("--client", choices=["claude", "codex"], default="claude")
851
+ sp.add_argument("--list", action="store_true", help="只列出备份")
852
+ sp.set_defaults(func=cmd_restore)
853
+ return p
854
+
855
+
856
+ def main(argv=None):
857
+ parser = build_parser()
858
+ args = parser.parse_args(argv)
859
+ if not getattr(args, "cmd", None):
860
+ parser.print_help()
861
+ return 0
862
+ return args.func(args) or 0
863
+
864
+
865
+ if __name__ == "__main__":
866
+ sys.exit(main())