cli-anything-cliproxyapi 1.0.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,1238 @@
1
+ """CLIProxyAPI CLI 入口 - 基于 Click 的命令行工具。
2
+
3
+ 支持 REPL 模式和一次性命令,所有命令支持 --json 输出。
4
+ """
5
+
6
+ import json
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import click
11
+
12
+ from .core.client import ConnectionConfig, ManagementClient
13
+ from .core.config import ConfigManager
14
+ from .core.auth import AuthManager
15
+ from .core.oauth import OAuthManager
16
+ from .core.models import ModelManager
17
+ from .core.usage import UsageManager
18
+ from .core.logs import LogManager
19
+ from .core.api_keys import APIKeyManager
20
+ from .core.proxy import ProxyManager
21
+ from .utils.output import output_result, output_json, output_error, output_table, console
22
+
23
+
24
+ # ---- 全局上下文 ----
25
+
26
+ class Context:
27
+ """CLI 运行时上下文。"""
28
+
29
+ def __init__(self):
30
+ self.json_mode = False
31
+ self._client: Optional[ManagementClient] = None
32
+ self._conn: Optional[ConnectionConfig] = None
33
+
34
+ def setup(self, url: Optional[str], key: Optional[str]):
35
+ self._conn = ConnectionConfig(url=url, key=key)
36
+ self._client = ManagementClient(self._conn)
37
+
38
+ @property
39
+ def client(self) -> ManagementClient:
40
+ if self._client is None:
41
+ self.setup(None, None)
42
+ return self._client
43
+
44
+ @property
45
+ def conn(self) -> ConnectionConfig:
46
+ if self._conn is None:
47
+ self.setup(None, None)
48
+ return self._conn
49
+
50
+ @property
51
+ def config_mgr(self) -> ConfigManager:
52
+ return ConfigManager(self.client)
53
+
54
+ @property
55
+ def auth_mgr(self) -> AuthManager:
56
+ return AuthManager(self.client)
57
+
58
+ @property
59
+ def oauth_mgr(self) -> OAuthManager:
60
+ return OAuthManager(self.client)
61
+
62
+ @property
63
+ def model_mgr(self) -> ModelManager:
64
+ return ModelManager(self.client)
65
+
66
+ @property
67
+ def usage_mgr(self) -> UsageManager:
68
+ return UsageManager(self.client)
69
+
70
+ @property
71
+ def log_mgr(self) -> LogManager:
72
+ return LogManager(self.client)
73
+
74
+ @property
75
+ def key_mgr(self) -> APIKeyManager:
76
+ return APIKeyManager(self.client)
77
+
78
+ @property
79
+ def proxy_mgr(self) -> ProxyManager:
80
+ return ProxyManager(self.client)
81
+
82
+
83
+ pass_ctx = click.make_pass_decorator(Context, ensure=True)
84
+
85
+
86
+ def handle_error(ctx: Context, exc: Exception):
87
+ if ctx.json_mode:
88
+ output_json({"error": str(exc)})
89
+ else:
90
+ output_error(str(exc))
91
+ sys.exit(1)
92
+
93
+
94
+ # ---- 根命令组 ----
95
+
96
+ @click.group(invoke_without_command=True)
97
+ @click.option("--url", "-u", envvar="CPA_URL", help="CLIProxyAPI 服务器地址")
98
+ @click.option("--key", "-k", envvar="CPA_KEY", help="管理 API 密钥")
99
+ @click.option("--json", "json_mode", is_flag=True, help="JSON 格式输出")
100
+ @click.option("--repl", "repl_mode", is_flag=True, help="进入交互式 REPL")
101
+ @click.version_option(version="1.0.0", prog_name="cli-anything-cliproxyapi")
102
+ @click.pass_context
103
+ def main(click_ctx, url, key, json_mode, repl_mode):
104
+ """CLIProxyAPI 命令行管理工具。
105
+
106
+ 通过 Management API 管理 CLIProxyAPI 代理服务器的配置、认证、模型等。
107
+
108
+ 连接参数优先级: --url/--key > 环境变量 CPA_URL/CPA_KEY > ~/.cliproxyapi-cli.yaml
109
+ """
110
+ ctx: Context = click_ctx.ensure_object(Context)
111
+ ctx.json_mode = json_mode
112
+ ctx.setup(url, key)
113
+
114
+ if repl_mode:
115
+ _enter_repl(click_ctx, ctx)
116
+ elif click_ctx.invoked_subcommand is None:
117
+ click.echo(click_ctx.get_help())
118
+
119
+
120
+ def _enter_repl(click_ctx, ctx: Context):
121
+ """交互式 REPL 模式。"""
122
+ import prompt_toolkit
123
+ from prompt_toolkit.history import FileHistory
124
+
125
+ history = FileHistory(str(ConnectionConfig._load_from_config.__func__.__self__ or ""))
126
+ session = prompt_toolkit.PromptSession(
127
+ history=FileHistory(str(ConnectionConfig.DEFAULT_CONFIG_PATH.parent / ".cliproxyapi_history")),
128
+ message="cliproxyapi> ",
129
+ )
130
+
131
+ console.print("[bold]CLIProxyAPI REPL[/bold] - 输入 help 查看命令, exit 退出")
132
+ while True:
133
+ try:
134
+ line = session.prompt()
135
+ except (EOFError, KeyboardInterrupt):
136
+ break
137
+ line = line.strip()
138
+ if not line or line in ("exit", "quit"):
139
+ break
140
+ if line == "help":
141
+ click.echo(click_ctx.get_help())
142
+ continue
143
+ try:
144
+ args = line.split()
145
+ main(args, standalone_mode=False, parent=click_ctx.parent)
146
+ except SystemExit:
147
+ pass
148
+ except Exception as e:
149
+ output_error(str(e))
150
+
151
+
152
+ # ============================================================
153
+ # server 命令组
154
+ # ============================================================
155
+
156
+ @main.group()
157
+ @pass_ctx
158
+ def server(ctx: Context):
159
+ """服务器状态管理。"""
160
+
161
+
162
+ @server.command("status")
163
+ @pass_ctx
164
+ def server_status(ctx: Context):
165
+ """检查服务器健康状态。"""
166
+ try:
167
+ result = ctx.proxy_mgr.health_check()
168
+ output_result(result, ctx.json_mode)
169
+ except Exception as e:
170
+ handle_error(ctx, e)
171
+
172
+
173
+ @server.command("version")
174
+ @pass_ctx
175
+ def server_version(ctx: Context):
176
+ """获取最新版本信息。"""
177
+ try:
178
+ result = ctx.config_mgr.get_latest_version()
179
+ output_result(result, ctx.json_mode)
180
+ except Exception as e:
181
+ handle_error(ctx, e)
182
+
183
+
184
+ # ============================================================
185
+ # config 命令组
186
+ # ============================================================
187
+
188
+ @main.group()
189
+ @pass_ctx
190
+ def config(ctx: Context):
191
+ """配置管理。"""
192
+
193
+
194
+ @config.command("get")
195
+ @pass_ctx
196
+ def config_get(ctx: Context):
197
+ """获取当前配置。"""
198
+ try:
199
+ result = ctx.config_mgr.get_config()
200
+ output_result(result, ctx.json_mode)
201
+ except Exception as e:
202
+ handle_error(ctx, e)
203
+
204
+
205
+ @config.command("get-yaml")
206
+ @pass_ctx
207
+ def config_get_yaml(ctx: Context):
208
+ """获取 YAML 配置。"""
209
+ try:
210
+ result = ctx.config_mgr.get_config_yaml()
211
+ output_result(result, ctx.json_mode)
212
+ except Exception as e:
213
+ handle_error(ctx, e)
214
+
215
+
216
+ @config.command("set-yaml")
217
+ @click.argument("yaml_content", required=False)
218
+ @click.option("--file", "-f", type=click.Path(exists=True), help="从文件读取 YAML")
219
+ @pass_ctx
220
+ def config_set_yaml(ctx: Context, yaml_content, file):
221
+ """更新 YAML 配置。"""
222
+ try:
223
+ if file:
224
+ with open(file) as f:
225
+ yaml_content = f.read()
226
+ if not yaml_content:
227
+ click.echo("请提供 YAML 内容或使用 --file 指定文件")
228
+ sys.exit(1)
229
+ result = ctx.config_mgr.put_config_yaml(yaml_content)
230
+ output_result(result, ctx.json_mode)
231
+ except Exception as e:
232
+ handle_error(ctx, e)
233
+
234
+
235
+ @config.command("debug")
236
+ @click.argument("value", type=bool, required=False)
237
+ @pass_ctx
238
+ def config_debug(ctx: Context, value):
239
+ """获取或设置调试模式。"""
240
+ try:
241
+ if value is None:
242
+ result = ctx.config_mgr.get_debug()
243
+ else:
244
+ result = ctx.config_mgr.set_debug(value)
245
+ output_result(result, ctx.json_mode)
246
+ except Exception as e:
247
+ handle_error(ctx, e)
248
+
249
+
250
+ @config.command("proxy-url")
251
+ @click.argument("value", required=False)
252
+ @click.option("--delete", is_flag=True, help="删除代理 URL")
253
+ @pass_ctx
254
+ def config_proxy_url(ctx: Context, value, delete):
255
+ """获取、设置或删除代理 URL。"""
256
+ try:
257
+ if delete:
258
+ result = ctx.proxy_mgr.delete_proxy_url()
259
+ elif value:
260
+ result = ctx.config_mgr.set_proxy_url(value)
261
+ else:
262
+ result = ctx.config_mgr.get_proxy_url()
263
+ output_result(result, ctx.json_mode)
264
+ except Exception as e:
265
+ handle_error(ctx, e)
266
+
267
+
268
+ @config.command("routing")
269
+ @click.argument("value", required=False)
270
+ @pass_ctx
271
+ def config_routing(ctx: Context, value):
272
+ """获取或设置路由策略 (round-robin / fill-first)。"""
273
+ try:
274
+ if value:
275
+ result = ctx.config_mgr.set_routing_strategy(value)
276
+ else:
277
+ result = ctx.config_mgr.get_routing_strategy()
278
+ output_result(result, ctx.json_mode)
279
+ except Exception as e:
280
+ handle_error(ctx, e)
281
+
282
+
283
+ @config.command("retry")
284
+ @click.argument("value", type=int, required=False)
285
+ @pass_ctx
286
+ def config_retry(ctx: Context, value):
287
+ """获取或设置请求重试次数。"""
288
+ try:
289
+ if value is not None:
290
+ result = ctx.config_mgr.set_request_retry(value)
291
+ else:
292
+ result = ctx.config_mgr.get_request_retry()
293
+ output_result(result, ctx.json_mode)
294
+ except Exception as e:
295
+ handle_error(ctx, e)
296
+
297
+
298
+ @config.command("max-retry-interval")
299
+ @click.argument("value", type=int, required=False)
300
+ @pass_ctx
301
+ def config_max_retry_interval(ctx: Context, value):
302
+ """获取或设置最大重试等待时间(秒)。"""
303
+ try:
304
+ if value is not None:
305
+ result = ctx.config_mgr.set_max_retry_interval(value)
306
+ else:
307
+ result = ctx.config_mgr.get_max_retry_interval()
308
+ output_result(result, ctx.json_mode)
309
+ except Exception as e:
310
+ handle_error(ctx, e)
311
+
312
+
313
+ @config.command("force-model-prefix")
314
+ @click.argument("value", type=bool, required=False)
315
+ @pass_ctx
316
+ def config_force_model_prefix(ctx: Context, value):
317
+ """获取或设置强制模型前缀。"""
318
+ try:
319
+ if value is None:
320
+ result = ctx.config_mgr.get_force_model_prefix()
321
+ else:
322
+ result = ctx.config_mgr.set_force_model_prefix(value)
323
+ output_result(result, ctx.json_mode)
324
+ except Exception as e:
325
+ handle_error(ctx, e)
326
+
327
+
328
+ @config.command("ws-auth")
329
+ @click.argument("value", type=bool, required=False)
330
+ @pass_ctx
331
+ def config_ws_auth(ctx: Context, value):
332
+ """获取或设置 WebSocket 认证。"""
333
+ try:
334
+ if value is None:
335
+ result = ctx.config_mgr.get_ws_auth()
336
+ else:
337
+ result = ctx.config_mgr.set_ws_auth(value)
338
+ output_result(result, ctx.json_mode)
339
+ except Exception as e:
340
+ handle_error(ctx, e)
341
+
342
+
343
+ @config.command("quota-switch-project")
344
+ @click.argument("value", type=bool, required=False)
345
+ @pass_ctx
346
+ def config_quota_switch_project(ctx: Context, value):
347
+ """获取或设置配额超限时自动切换项目。"""
348
+ try:
349
+ if value is None:
350
+ result = ctx.config_mgr.get_switch_project()
351
+ else:
352
+ result = ctx.config_mgr.set_switch_project(value)
353
+ output_result(result, ctx.json_mode)
354
+ except Exception as e:
355
+ handle_error(ctx, e)
356
+
357
+
358
+ @config.command("quota-switch-preview")
359
+ @click.argument("value", type=bool, required=False)
360
+ @pass_ctx
361
+ def config_quota_switch_preview(ctx: Context, value):
362
+ """获取或设置配额超限时自动切换预览模型。"""
363
+ try:
364
+ if value is None:
365
+ result = ctx.config_mgr.get_switch_preview_model()
366
+ else:
367
+ result = ctx.config_mgr.set_switch_preview_model(value)
368
+ output_result(result, ctx.json_mode)
369
+ except Exception as e:
370
+ handle_error(ctx, e)
371
+
372
+
373
+ @config.command("save-connection")
374
+ @click.option("--url", "-u", required=True, help="服务器地址")
375
+ @click.option("--key", "-k", required=True, help="管理密钥")
376
+ @pass_ctx
377
+ def config_save_connection(ctx: Context, url, key):
378
+ """保存连接参数到配置文件。"""
379
+ try:
380
+ ctx.conn.save(url, key)
381
+ output_result({"status": "ok", "saved_to": str(ConnectionConfig.DEFAULT_CONFIG_PATH)}, ctx.json_mode)
382
+ except Exception as e:
383
+ handle_error(ctx, e)
384
+
385
+
386
+ # ============================================================
387
+ # auth 命令组
388
+ # ============================================================
389
+
390
+ @main.group()
391
+ @pass_ctx
392
+ def auth(ctx: Context):
393
+ """认证文件管理。"""
394
+
395
+
396
+ @auth.command("list")
397
+ @click.option("--enabled", is_flag=True, help="仅显示启用的认证文件")
398
+ @click.option("--disabled", "disabled_only", is_flag=True, help="仅显示禁用的认证文件")
399
+ @pass_ctx
400
+ def auth_list(ctx: Context, enabled, disabled_only):
401
+ """列出认证文件。"""
402
+ try:
403
+ if enabled and disabled_only:
404
+ raise click.UsageError("--enabled 和 --disabled 不能同时使用")
405
+ disabled = None
406
+ if enabled:
407
+ disabled = False
408
+ elif disabled_only:
409
+ disabled = True
410
+ result = ctx.auth_mgr.list_auth_files(disabled=disabled)
411
+ output_result(result, ctx.json_mode)
412
+ except Exception as e:
413
+ handle_error(ctx, e)
414
+
415
+
416
+ @auth.command("codex-quota")
417
+ @pass_ctx
418
+ def auth_codex_quota(ctx: Context):
419
+ """获取已启用 Codex 凭证额度。"""
420
+ try:
421
+ result = ctx.auth_mgr.get_codex_quotas()
422
+ if ctx.json_mode:
423
+ output_result(result, True)
424
+ return
425
+ quotas = result.get("quotas", [])
426
+ rows = []
427
+ for item in quotas:
428
+ if item.get("error"):
429
+ rows.append([
430
+ item.get("name", ""),
431
+ item.get("plan_type", "-"),
432
+ "失败",
433
+ item.get("error", ""),
434
+ "失败",
435
+ "",
436
+ ])
437
+ continue
438
+ primary = item.get("primary_window") or {}
439
+ secondary = item.get("secondary_window") or {}
440
+ rows.append([
441
+ item.get("name", ""),
442
+ item.get("plan_type", "-"),
443
+ f"{primary.get('remaining_percent', 0)}%",
444
+ primary.get("reset_at_local", ""),
445
+ f"{secondary.get('remaining_percent', 0)}%",
446
+ secondary.get("reset_at_local", ""),
447
+ ])
448
+ output_table(["文件", "套餐", "5小时额度", "5小时重置", "周额度", "周重置"], rows, title="Codex 额度")
449
+ except Exception as e:
450
+ handle_error(ctx, e)
451
+
452
+
453
+ @auth.command("models")
454
+ @pass_ctx
455
+ def auth_models(ctx: Context):
456
+ """查看认证文件关联的模型。"""
457
+ try:
458
+ result = ctx.auth_mgr.get_auth_file_models()
459
+ output_result(result, ctx.json_mode)
460
+ except Exception as e:
461
+ handle_error(ctx, e)
462
+
463
+
464
+ @auth.command("upload")
465
+ @click.argument("filename")
466
+ @click.option("--content", "-c", help="认证文件内容")
467
+ @click.option("--file", "-f", type=click.Path(exists=True), help="从文件上传")
468
+ @pass_ctx
469
+ def auth_upload(ctx: Context, filename, content, file):
470
+ """上传认证文件。"""
471
+ try:
472
+ if file:
473
+ with open(file) as f:
474
+ content = f.read()
475
+ if not content:
476
+ click.echo("请提供 --content 或 --file")
477
+ sys.exit(1)
478
+ result = ctx.auth_mgr.upload_auth_file(filename, content)
479
+ output_result(result, ctx.json_mode)
480
+ except Exception as e:
481
+ handle_error(ctx, e)
482
+
483
+
484
+ @auth.command("download")
485
+ @click.argument("filename")
486
+ @pass_ctx
487
+ def auth_download(ctx: Context, filename):
488
+ """下载认证文件。"""
489
+ try:
490
+ result = ctx.auth_mgr.download_auth_file(filename)
491
+ output_result(result, ctx.json_mode)
492
+ except Exception as e:
493
+ handle_error(ctx, e)
494
+
495
+
496
+ @auth.command("delete")
497
+ @click.argument("filename")
498
+ @pass_ctx
499
+ def auth_delete(ctx: Context, filename):
500
+ """删除认证文件。"""
501
+ try:
502
+ result = ctx.auth_mgr.delete_auth_file(filename)
503
+ output_result(result, ctx.json_mode)
504
+ except Exception as e:
505
+ handle_error(ctx, e)
506
+
507
+
508
+ @auth.command("status")
509
+ @click.argument("filename")
510
+ @click.argument("disabled", type=bool)
511
+ @pass_ctx
512
+ def auth_status(ctx: Context, filename, disabled):
513
+ """设置认证文件启用/禁用状态。"""
514
+ try:
515
+ result = ctx.auth_mgr.patch_auth_file_status(filename, disabled)
516
+ output_result(result, ctx.json_mode)
517
+ except Exception as e:
518
+ handle_error(ctx, e)
519
+
520
+
521
+ @auth.command("fields")
522
+ @click.argument("filename")
523
+ @click.option("--set", "field_sets", multiple=True, nargs=2, help="设置字段: --set key value")
524
+ @pass_ctx
525
+ def auth_fields(ctx: Context, filename, field_sets):
526
+ """更新认证文件字段。"""
527
+ try:
528
+ fields = {k: v for k, v in field_sets}
529
+ result = ctx.auth_mgr.patch_auth_file_fields(filename, fields)
530
+ output_result(result, ctx.json_mode)
531
+ except Exception as e:
532
+ handle_error(ctx, e)
533
+
534
+
535
+ @auth.command("vertex-import")
536
+ @click.argument("key_json")
537
+ @click.option("--prefix", default="", help="Vertex 模型命名前缀")
538
+ @pass_ctx
539
+ def auth_vertex_import(ctx: Context, key_json, prefix):
540
+ """导入 Vertex 服务账号密钥。"""
541
+ try:
542
+ result = ctx.auth_mgr.import_vertex(key_json, prefix)
543
+ output_result(result, ctx.json_mode)
544
+ except Exception as e:
545
+ handle_error(ctx, e)
546
+
547
+
548
+ @auth.command("definitions")
549
+ @click.argument("channel")
550
+ @pass_ctx
551
+ def auth_definitions(ctx: Context, channel):
552
+ """查看指定渠道的模型定义。"""
553
+ try:
554
+ result = ctx.auth_mgr.get_model_definitions(channel)
555
+ output_result(result, ctx.json_mode)
556
+ except Exception as e:
557
+ handle_error(ctx, e)
558
+
559
+
560
+ # ============================================================
561
+ # oauth 命令组
562
+ # ============================================================
563
+
564
+ @main.group()
565
+ @pass_ctx
566
+ def oauth(ctx: Context):
567
+ """OAuth 登录管理。"""
568
+
569
+
570
+ @oauth.command("login")
571
+ @click.argument("provider", type=click.Choice(list(OAuthManager.PROVIDERS.keys())))
572
+ @click.option("--no-browser", is_flag=True, help="不自动打开浏览器")
573
+ @pass_ctx
574
+ def oauth_login(ctx: Context, provider, no_browser):
575
+ """发起 OAuth 登录。支持: anthropic, codex, gemini, antigravity, qwen, kimi, iflow"""
576
+ try:
577
+ result = ctx.oauth_mgr.request_auth_url(provider, no_browser=no_browser)
578
+ output_result(result, ctx.json_mode)
579
+ except Exception as e:
580
+ handle_error(ctx, e)
581
+
582
+
583
+ @oauth.command("iflow-cookie")
584
+ @click.argument("cookie")
585
+ @pass_ctx
586
+ def oauth_iflow_cookie(ctx: Context, cookie):
587
+ """使用 Cookie 登录 iFlow。"""
588
+ try:
589
+ result = ctx.oauth_mgr.request_iflow_cookie(cookie)
590
+ output_result(result, ctx.json_mode)
591
+ except Exception as e:
592
+ handle_error(ctx, e)
593
+
594
+
595
+ @oauth.command("callback")
596
+ @click.option("--provider", required=True, help="提供商名称")
597
+ @click.option("--code", required=True, help="授权码")
598
+ @click.option("--state", required=True, help="状态参数")
599
+ @pass_ctx
600
+ def oauth_callback(ctx: Context, provider, code, state):
601
+ """处理 OAuth 回调。"""
602
+ try:
603
+ result = ctx.oauth_mgr.post_oauth_callback(provider, code, state)
604
+ output_result(result, ctx.json_mode)
605
+ except Exception as e:
606
+ handle_error(ctx, e)
607
+
608
+
609
+ @oauth.command("status")
610
+ @click.argument("session_id")
611
+ @pass_ctx
612
+ def oauth_status(ctx: Context, session_id):
613
+ """查看认证会话状态。"""
614
+ try:
615
+ result = ctx.oauth_mgr.get_auth_status(session_id)
616
+ output_result(result, ctx.json_mode)
617
+ except Exception as e:
618
+ handle_error(ctx, e)
619
+
620
+
621
+ # ============================================================
622
+ # keys 命令组
623
+ # ============================================================
624
+
625
+ @main.group()
626
+ @pass_ctx
627
+ def keys(ctx: Context):
628
+ """API 密钥管理。"""
629
+
630
+
631
+ # ---- 全局 API 密钥 ----
632
+
633
+ @keys.command("list")
634
+ @pass_ctx
635
+ def keys_list(ctx: Context):
636
+ """列出 API 密钥。"""
637
+ try:
638
+ result = ctx.key_mgr.list_api_keys()
639
+ output_result(result, ctx.json_mode)
640
+ except Exception as e:
641
+ handle_error(ctx, e)
642
+
643
+
644
+ @keys.command("set")
645
+ @click.argument("keys_list", nargs=-1, required=True)
646
+ @pass_ctx
647
+ def keys_set(ctx: Context, keys_list):
648
+ """设置 API 密钥(覆盖)。"""
649
+ try:
650
+ result = ctx.key_mgr.set_api_keys(list(keys_list))
651
+ output_result(result, ctx.json_mode)
652
+ except Exception as e:
653
+ handle_error(ctx, e)
654
+
655
+
656
+ @keys.command("add")
657
+ @click.argument("key")
658
+ @pass_ctx
659
+ def keys_add(ctx: Context, key):
660
+ """添加一个 API 密钥。"""
661
+ try:
662
+ result = ctx.key_mgr.add_api_key(key)
663
+ output_result(result, ctx.json_mode)
664
+ except Exception as e:
665
+ handle_error(ctx, e)
666
+
667
+
668
+ @keys.command("delete")
669
+ @click.argument("key")
670
+ @pass_ctx
671
+ def keys_delete(ctx: Context, key):
672
+ """删除一个 API 密钥。"""
673
+ try:
674
+ result = ctx.key_mgr.delete_api_key(key)
675
+ output_result(result, ctx.json_mode)
676
+ except Exception as e:
677
+ handle_error(ctx, e)
678
+
679
+
680
+ # ---- Gemini 密钥子命令 ----
681
+
682
+ @keys.group("gemini")
683
+ @pass_ctx
684
+ def keys_gemini(ctx: Context):
685
+ """Gemini API 密钥管理。"""
686
+
687
+
688
+ @keys_gemini.command("list")
689
+ @pass_ctx
690
+ def keys_gemini_list(ctx: Context):
691
+ try:
692
+ result = ctx.key_mgr.list_gemini_keys()
693
+ output_result(result, ctx.json_mode)
694
+ except Exception as e:
695
+ handle_error(ctx, e)
696
+
697
+
698
+ @keys_gemini.command("add")
699
+ @click.option("--api-key", required=True, help="API 密钥")
700
+ @click.option("--prefix", default="", help="前缀")
701
+ @click.option("--base-url", default="", help="基础 URL")
702
+ @click.option("--proxy-url", default="", help="代理 URL")
703
+ @pass_ctx
704
+ def keys_gemini_add(ctx: Context, api_key, prefix, base_url, proxy_url):
705
+ try:
706
+ data = {"api_key": api_key}
707
+ if prefix:
708
+ data["prefix"] = prefix
709
+ if base_url:
710
+ data["base_url"] = base_url
711
+ if proxy_url:
712
+ data["proxy_url"] = proxy_url
713
+ result = ctx.key_mgr.add_gemini_key(data)
714
+ output_result(result, ctx.json_mode)
715
+ except Exception as e:
716
+ handle_error(ctx, e)
717
+
718
+
719
+ @keys_gemini.command("delete")
720
+ @click.argument("api_key")
721
+ @pass_ctx
722
+ def keys_gemini_delete(ctx: Context, api_key):
723
+ try:
724
+ result = ctx.key_mgr.delete_gemini_key(api_key)
725
+ output_result(result, ctx.json_mode)
726
+ except Exception as e:
727
+ handle_error(ctx, e)
728
+
729
+
730
+ # ---- Claude 密钥子命令 ----
731
+
732
+ @keys.group("claude")
733
+ @pass_ctx
734
+ def keys_claude(ctx: Context):
735
+ """Claude API 密钥管理。"""
736
+
737
+
738
+ @keys_claude.command("list")
739
+ @pass_ctx
740
+ def keys_claude_list(ctx: Context):
741
+ try:
742
+ result = ctx.key_mgr.list_claude_keys()
743
+ output_result(result, ctx.json_mode)
744
+ except Exception as e:
745
+ handle_error(ctx, e)
746
+
747
+
748
+ @keys_claude.command("add")
749
+ @click.option("--api-key", required=True, help="API 密钥")
750
+ @click.option("--prefix", default="", help="前缀")
751
+ @click.option("--base-url", default="", help="基础 URL")
752
+ @click.option("--proxy-url", default="", help="代理 URL")
753
+ @pass_ctx
754
+ def keys_claude_add(ctx: Context, api_key, prefix, base_url, proxy_url):
755
+ try:
756
+ data = {"api_key": api_key}
757
+ if prefix:
758
+ data["prefix"] = prefix
759
+ if base_url:
760
+ data["base_url"] = base_url
761
+ if proxy_url:
762
+ data["proxy_url"] = proxy_url
763
+ result = ctx.key_mgr.add_claude_key(data)
764
+ output_result(result, ctx.json_mode)
765
+ except Exception as e:
766
+ handle_error(ctx, e)
767
+
768
+
769
+ @keys_claude.command("delete")
770
+ @click.argument("api_key")
771
+ @pass_ctx
772
+ def keys_claude_delete(ctx: Context, api_key):
773
+ try:
774
+ result = ctx.key_mgr.delete_claude_key(api_key)
775
+ output_result(result, ctx.json_mode)
776
+ except Exception as e:
777
+ handle_error(ctx, e)
778
+
779
+
780
+ # ---- Codex 密钥子命令 ----
781
+
782
+ @keys.group("codex")
783
+ @pass_ctx
784
+ def keys_codex(ctx: Context):
785
+ """Codex API 密钥管理。"""
786
+
787
+
788
+ @keys_codex.command("list")
789
+ @pass_ctx
790
+ def keys_codex_list(ctx: Context):
791
+ try:
792
+ result = ctx.key_mgr.list_codex_keys()
793
+ output_result(result, ctx.json_mode)
794
+ except Exception as e:
795
+ handle_error(ctx, e)
796
+
797
+
798
+ @keys_codex.command("add")
799
+ @click.option("--api-key", required=True, help="API 密钥")
800
+ @click.option("--prefix", default="", help="前缀")
801
+ @click.option("--base-url", default="", help="基础 URL")
802
+ @click.option("--proxy-url", default="", help="代理 URL")
803
+ @pass_ctx
804
+ def keys_codex_add(ctx: Context, api_key, prefix, base_url, proxy_url):
805
+ try:
806
+ data = {"api_key": api_key}
807
+ if prefix:
808
+ data["prefix"] = prefix
809
+ if base_url:
810
+ data["base_url"] = base_url
811
+ if proxy_url:
812
+ data["proxy_url"] = proxy_url
813
+ result = ctx.key_mgr.add_codex_key(data)
814
+ output_result(result, ctx.json_mode)
815
+ except Exception as e:
816
+ handle_error(ctx, e)
817
+
818
+
819
+ @keys_codex.command("delete")
820
+ @click.argument("api_key")
821
+ @pass_ctx
822
+ def keys_codex_delete(ctx: Context, api_key):
823
+ try:
824
+ result = ctx.key_mgr.delete_codex_key(api_key)
825
+ output_result(result, ctx.json_mode)
826
+ except Exception as e:
827
+ handle_error(ctx, e)
828
+
829
+
830
+ # ---- OpenAI 兼容提供商子命令 ----
831
+
832
+ @keys.group("openai-compat")
833
+ @pass_ctx
834
+ def keys_openai_compat(ctx: Context):
835
+ """OpenAI 兼容提供商管理。"""
836
+
837
+
838
+ @keys_openai_compat.command("list")
839
+ @pass_ctx
840
+ def keys_openai_compat_list(ctx: Context):
841
+ try:
842
+ result = ctx.key_mgr.list_openai_compat()
843
+ output_result(result, ctx.json_mode)
844
+ except Exception as e:
845
+ handle_error(ctx, e)
846
+
847
+
848
+ @keys_openai_compat.command("add")
849
+ @click.option("--name", required=True, help="提供商名称")
850
+ @click.option("--base-url", required=True, help="基础 URL")
851
+ @click.option("--api-key", required=True, help="API 密钥")
852
+ @click.option("--prefix", default="", help="前缀")
853
+ @pass_ctx
854
+ def keys_openai_compat_add(ctx: Context, name, base_url, api_key, prefix):
855
+ try:
856
+ provider = {"name": name, "base_url": base_url, "api_key_entries": [{"api_key": api_key}]}
857
+ if prefix:
858
+ provider["prefix"] = prefix
859
+ result = ctx.key_mgr.add_openai_compat(provider)
860
+ output_result(result, ctx.json_mode)
861
+ except Exception as e:
862
+ handle_error(ctx, e)
863
+
864
+
865
+ @keys_openai_compat.command("delete")
866
+ @click.argument("name")
867
+ @pass_ctx
868
+ def keys_openai_compat_delete(ctx: Context, name):
869
+ try:
870
+ result = ctx.key_mgr.delete_openai_compat(name)
871
+ output_result(result, ctx.json_mode)
872
+ except Exception as e:
873
+ handle_error(ctx, e)
874
+
875
+
876
+ # ---- Vertex 密钥子命令 ----
877
+
878
+ @keys.group("vertex")
879
+ @pass_ctx
880
+ def keys_vertex(ctx: Context):
881
+ """Vertex API 密钥管理。"""
882
+
883
+
884
+ @keys_vertex.command("list")
885
+ @pass_ctx
886
+ def keys_vertex_list(ctx: Context):
887
+ try:
888
+ result = ctx.key_mgr.list_vertex_keys()
889
+ output_result(result, ctx.json_mode)
890
+ except Exception as e:
891
+ handle_error(ctx, e)
892
+
893
+
894
+ @keys_vertex.command("add")
895
+ @click.option("--api-key", required=True, help="API 密钥")
896
+ @click.option("--base-url", default="", help="基础 URL")
897
+ @click.option("--prefix", default="", help="前缀")
898
+ @pass_ctx
899
+ def keys_vertex_add(ctx: Context, api_key, base_url, prefix):
900
+ try:
901
+ data = {"api_key": api_key}
902
+ if base_url:
903
+ data["base_url"] = base_url
904
+ if prefix:
905
+ data["prefix"] = prefix
906
+ result = ctx.key_mgr.add_vertex_key(data)
907
+ output_result(result, ctx.json_mode)
908
+ except Exception as e:
909
+ handle_error(ctx, e)
910
+
911
+
912
+ @keys_vertex.command("delete")
913
+ @click.argument("api_key")
914
+ @pass_ctx
915
+ def keys_vertex_delete(ctx: Context, api_key):
916
+ try:
917
+ result = ctx.key_mgr.delete_vertex_key(api_key)
918
+ output_result(result, ctx.json_mode)
919
+ except Exception as e:
920
+ handle_error(ctx, e)
921
+
922
+
923
+ # ============================================================
924
+ # models 命令组
925
+ # ============================================================
926
+
927
+ @main.group()
928
+ @pass_ctx
929
+ def models(ctx: Context):
930
+ """模型管理。"""
931
+
932
+
933
+ @models.command("list")
934
+ @click.option("--api-key", envvar="CPA_API_KEY", help="代理 API 密钥(非管理密钥)")
935
+ @pass_ctx
936
+ def models_list(ctx: Context, api_key):
937
+ """列出可用模型。"""
938
+ try:
939
+ if not api_key:
940
+ click.echo("需要 --api-key 或设置 CPA_API_KEY 环境变量")
941
+ sys.exit(1)
942
+ result = ctx.model_mgr.list_models(api_key)
943
+ output_result(result, ctx.json_mode)
944
+ except Exception as e:
945
+ handle_error(ctx, e)
946
+
947
+
948
+ @models.command("aliases")
949
+ @click.option("--set", "alias_data", default=None, help="设置别名的 JSON 数据")
950
+ @click.option("--delete", "delete_alias", nargs=2, type=str, help="删除别名: --delete channel name")
951
+ @pass_ctx
952
+ def models_aliases(ctx: Context, alias_data, delete_alias):
953
+ """管理 OAuth 模型别名。"""
954
+ try:
955
+ if delete_alias:
956
+ channel, name = delete_alias
957
+ result = ctx.model_mgr.delete_oauth_model_alias(channel, name)
958
+ elif alias_data:
959
+ import json as _json
960
+ data = _json.loads(alias_data)
961
+ result = ctx.model_mgr.put_oauth_model_alias(data)
962
+ else:
963
+ result = ctx.model_mgr.get_oauth_model_alias()
964
+ output_result(result, ctx.json_mode)
965
+ except Exception as e:
966
+ handle_error(ctx, e)
967
+
968
+
969
+ @models.command("excluded")
970
+ @click.option("--set", "excl_data", default=None, help="设置排除模型的 JSON 数据")
971
+ @click.option("--delete", "delete_excl", nargs=2, type=str, help="删除排除: --delete channel model")
972
+ @pass_ctx
973
+ def models_excluded(ctx: Context, excl_data, delete_excl):
974
+ """管理 OAuth 排除模型。"""
975
+ try:
976
+ if delete_excl:
977
+ channel, model = delete_excl
978
+ result = ctx.model_mgr.delete_oauth_excluded_models(channel, model)
979
+ elif excl_data:
980
+ import json as _json
981
+ data = _json.loads(excl_data)
982
+ result = ctx.model_mgr.put_oauth_excluded_models(data)
983
+ else:
984
+ result = ctx.model_mgr.get_oauth_excluded_models()
985
+ output_result(result, ctx.json_mode)
986
+ except Exception as e:
987
+ handle_error(ctx, e)
988
+
989
+
990
+ # ============================================================
991
+ # usage 命令组
992
+ # ============================================================
993
+
994
+ @main.group()
995
+ @pass_ctx
996
+ def usage(ctx: Context):
997
+ """使用统计。"""
998
+
999
+
1000
+ @usage.command("stats")
1001
+ @pass_ctx
1002
+ def usage_stats(ctx: Context):
1003
+ """获取使用统计。"""
1004
+ try:
1005
+ result = ctx.usage_mgr.get_stats()
1006
+ output_result(result, ctx.json_mode)
1007
+ except Exception as e:
1008
+ handle_error(ctx, e)
1009
+
1010
+
1011
+ @usage.command("export")
1012
+ @pass_ctx
1013
+ def usage_export(ctx: Context):
1014
+ """导出统计数据。"""
1015
+ try:
1016
+ result = ctx.usage_mgr.export_stats()
1017
+ output_result(result, ctx.json_mode)
1018
+ except Exception as e:
1019
+ handle_error(ctx, e)
1020
+
1021
+
1022
+ @usage.command("import")
1023
+ @click.argument("data")
1024
+ @pass_ctx
1025
+ def usage_import(ctx: Context, data):
1026
+ """导入统计数据。"""
1027
+ try:
1028
+ result = ctx.usage_mgr.import_stats(data)
1029
+ output_result(result, ctx.json_mode)
1030
+ except Exception as e:
1031
+ handle_error(ctx, e)
1032
+
1033
+
1034
+ # ============================================================
1035
+ # logs 命令组
1036
+ # ============================================================
1037
+
1038
+ @main.group()
1039
+ @pass_ctx
1040
+ def logs(ctx: Context):
1041
+ """日志管理。"""
1042
+
1043
+
1044
+ @logs.command("list")
1045
+ @click.option("--lines", "-n", default=100, help="显示行数")
1046
+ @pass_ctx
1047
+ def logs_list(ctx: Context, lines):
1048
+ """查看日志。"""
1049
+ try:
1050
+ result = ctx.log_mgr.get_logs(lines)
1051
+ output_result(result, ctx.json_mode)
1052
+ except Exception as e:
1053
+ handle_error(ctx, e)
1054
+
1055
+
1056
+ @logs.command("clear")
1057
+ @pass_ctx
1058
+ def logs_clear(ctx: Context):
1059
+ """清除日志。"""
1060
+ try:
1061
+ result = ctx.log_mgr.delete_logs()
1062
+ output_result(result, ctx.json_mode)
1063
+ except Exception as e:
1064
+ handle_error(ctx, e)
1065
+
1066
+
1067
+ @logs.command("request")
1068
+ @click.argument("value", type=bool, required=False)
1069
+ @pass_ctx
1070
+ def logs_request(ctx: Context, value):
1071
+ """获取或设置请求日志。"""
1072
+ try:
1073
+ if value is None:
1074
+ result = ctx.log_mgr.get_request_log()
1075
+ else:
1076
+ result = ctx.log_mgr.set_request_log(value)
1077
+ output_result(result, ctx.json_mode)
1078
+ except Exception as e:
1079
+ handle_error(ctx, e)
1080
+
1081
+
1082
+ @logs.command("errors")
1083
+ @click.option("--download", "-d", "name", default=None, help="下载指定错误日志文件")
1084
+ @pass_ctx
1085
+ def logs_errors(ctx: Context, name):
1086
+ """查看或下载错误日志。"""
1087
+ try:
1088
+ if name:
1089
+ result = ctx.log_mgr.download_request_error_log(name)
1090
+ else:
1091
+ result = ctx.log_mgr.get_request_error_logs()
1092
+ output_result(result, ctx.json_mode)
1093
+ except Exception as e:
1094
+ handle_error(ctx, e)
1095
+
1096
+
1097
+ @logs.command("by-id")
1098
+ @click.argument("log_id")
1099
+ @pass_ctx
1100
+ def logs_by_id(ctx: Context, log_id):
1101
+ """按 ID 查看请求日志。"""
1102
+ try:
1103
+ result = ctx.log_mgr.get_request_log_by_id(log_id)
1104
+ output_result(result, ctx.json_mode)
1105
+ except Exception as e:
1106
+ handle_error(ctx, e)
1107
+
1108
+
1109
+ # ============================================================
1110
+ # amp 命令组
1111
+ # ============================================================
1112
+
1113
+ @main.group()
1114
+ @pass_ctx
1115
+ def amp(ctx: Context):
1116
+ """Amp CLI 集成管理。"""
1117
+
1118
+
1119
+ @amp.command("config")
1120
+ @pass_ctx
1121
+ def amp_config(ctx: Context):
1122
+ """查看 Amp 配置。"""
1123
+ try:
1124
+ result = ctx.proxy_mgr.get_amp_config()
1125
+ output_result(result, ctx.json_mode)
1126
+ except Exception as e:
1127
+ handle_error(ctx, e)
1128
+
1129
+
1130
+ @amp.command("upstream-url")
1131
+ @click.argument("value", required=False)
1132
+ @click.option("--delete", is_flag=True, help="删除上游 URL")
1133
+ @pass_ctx
1134
+ def amp_upstream_url(ctx: Context, value, delete):
1135
+ """获取、设置或删除 Amp 上游 URL。"""
1136
+ try:
1137
+ if delete:
1138
+ result = ctx.proxy_mgr.delete_amp_upstream_url()
1139
+ elif value:
1140
+ result = ctx.proxy_mgr.set_amp_upstream_url(value)
1141
+ else:
1142
+ result = ctx.proxy_mgr.get_amp_upstream_url()
1143
+ output_result(result, ctx.json_mode)
1144
+ except Exception as e:
1145
+ handle_error(ctx, e)
1146
+
1147
+
1148
+ @amp.command("upstream-api-key")
1149
+ @click.argument("value", required=False)
1150
+ @click.option("--delete", is_flag=True, help="删除上游 API 密钥")
1151
+ @pass_ctx
1152
+ def amp_upstream_api_key(ctx: Context, value, delete):
1153
+ """获取、设置或删除 Amp 上游 API 密钥。"""
1154
+ try:
1155
+ if delete:
1156
+ result = ctx.proxy_mgr.delete_amp_upstream_api_key()
1157
+ elif value:
1158
+ result = ctx.proxy_mgr.set_amp_upstream_api_key(value)
1159
+ else:
1160
+ result = ctx.proxy_mgr.get_amp_upstream_api_key()
1161
+ output_result(result, ctx.json_mode)
1162
+ except Exception as e:
1163
+ handle_error(ctx, e)
1164
+
1165
+
1166
+ @amp.command("model-mappings")
1167
+ @click.option("--set", "mappings_json", default=None, help="设置映射的 JSON")
1168
+ @click.option("--add", "add_json", default=None, help="添加一条映射的 JSON")
1169
+ @click.option("--delete", "delete_from", default=None, help="删除指定 from 模型的映射")
1170
+ @pass_ctx
1171
+ def amp_model_mappings(ctx: Context, mappings_json, add_json, delete_from):
1172
+ """管理 Amp 模型映射。"""
1173
+ try:
1174
+ if delete_from:
1175
+ result = ctx.proxy_mgr.delete_amp_model_mappings(delete_from)
1176
+ elif mappings_json:
1177
+ import json as _json
1178
+ data = _json.loads(mappings_json)
1179
+ result = ctx.proxy_mgr.set_amp_model_mappings(data)
1180
+ elif add_json:
1181
+ import json as _json
1182
+ data = _json.loads(add_json)
1183
+ result = ctx.proxy_mgr.patch_amp_model_mappings(data)
1184
+ else:
1185
+ result = ctx.proxy_mgr.get_amp_model_mappings()
1186
+ output_result(result, ctx.json_mode)
1187
+ except Exception as e:
1188
+ handle_error(ctx, e)
1189
+
1190
+
1191
+ @amp.command("force-model-mappings")
1192
+ @click.argument("value", type=bool, required=False)
1193
+ @pass_ctx
1194
+ def amp_force_model_mappings(ctx: Context, value):
1195
+ """获取或设置强制模型映射。"""
1196
+ try:
1197
+ if value is None:
1198
+ result = ctx.proxy_mgr.get_amp_force_model_mappings()
1199
+ else:
1200
+ result = ctx.proxy_mgr.set_amp_force_model_mappings(value)
1201
+ output_result(result, ctx.json_mode)
1202
+ except Exception as e:
1203
+ handle_error(ctx, e)
1204
+
1205
+
1206
+ # ============================================================
1207
+ # api-call 命令
1208
+ # ============================================================
1209
+
1210
+ @main.command("api-call")
1211
+ @click.option("--method", "-X", default="GET", help="HTTP 方法")
1212
+ @click.option("--url", required=True, help="请求 URL")
1213
+ @click.option("--header", "-H", multiple=True, help="请求头 (key:value)")
1214
+ @click.option("--data", "-d", default=None, help="请求体")
1215
+ @click.option("--auth-index", default=None, help="认证索引")
1216
+ @pass_ctx
1217
+ def api_call(ctx: Context, method, url, header, data, auth_index):
1218
+ """通过代理发起 HTTP 请求。"""
1219
+ try:
1220
+ headers = {}
1221
+ for h in header:
1222
+ if ":" in h:
1223
+ k, v = h.split(":", 1)
1224
+ headers[k.strip()] = v.strip()
1225
+ result = ctx.proxy_mgr.api_call(
1226
+ method=method,
1227
+ url=url,
1228
+ headers=headers or None,
1229
+ data=data,
1230
+ auth_index=auth_index,
1231
+ )
1232
+ output_result(result, ctx.json_mode)
1233
+ except Exception as e:
1234
+ handle_error(ctx, e)
1235
+
1236
+
1237
+ if __name__ == "__main__":
1238
+ main()