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.
- ccpkit-0.2.0.dist-info/LICENSE +21 -0
- ccpkit-0.2.0.dist-info/METADATA +244 -0
- ccpkit-0.2.0.dist-info/RECORD +7 -0
- ccpkit-0.2.0.dist-info/WHEEL +5 -0
- ccpkit-0.2.0.dist-info/entry_points.txt +2 -0
- ccpkit-0.2.0.dist-info/top_level.txt +1 -0
- ccpkit.py +866 -0
|
@@ -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
|
+
[]()
|
|
28
|
+
[]()
|
|
29
|
+
[]()
|
|
30
|
+
[](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 @@
|
|
|
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())
|