litellm-controller 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ """litellm_controller: 用于管理 LiteLLM 代理模型的命令行工具。"""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,88 @@
1
+ """LiteLLM API 客户端封装。"""
2
+ import requests
3
+
4
+
5
+ class LiteLLMError(Exception):
6
+ pass
7
+
8
+
9
+ class LiteLLMClient:
10
+ def __init__(self, endpoint: str, key: str, timeout: int = 30):
11
+ self.base_url = endpoint.rstrip("/")
12
+ self.timeout = timeout
13
+ self.session = requests.Session()
14
+ self.session.headers.update(
15
+ {
16
+ "Authorization": f"Bearer {key}",
17
+ "Content-Type": "application/json",
18
+ }
19
+ )
20
+
21
+ def _request(self, method: str, path: str, **kwargs):
22
+ url = self.base_url + path
23
+ try:
24
+ resp = self.session.request(method, url, timeout=self.timeout, **kwargs)
25
+ except requests.RequestException as e:
26
+ raise LiteLLMError(f"无法连接 LiteLLM: {e}") from e
27
+ if resp.status_code >= 400:
28
+ raise LiteLLMError(f"请求失败 (HTTP {resp.status_code}): {self._extract_detail(resp)}")
29
+ if not resp.content:
30
+ return {}
31
+ try:
32
+ return resp.json()
33
+ except ValueError as e:
34
+ raise LiteLLMError("响应不是有效的 JSON") from e
35
+
36
+ @staticmethod
37
+ def _extract_detail(resp) -> str:
38
+ try:
39
+ data = resp.json()
40
+ except ValueError:
41
+ return resp.text[:200]
42
+ if isinstance(data, dict):
43
+ for k in ("detail", "message"):
44
+ v = data.get(k)
45
+ if isinstance(v, str):
46
+ return v[:300]
47
+ error = data.get("error")
48
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
49
+ return error["message"][:300]
50
+ if isinstance(error, str):
51
+ return error[:300]
52
+ return str(data)[:300]
53
+ return str(data)[:300]
54
+
55
+ def list_models(self) -> list:
56
+ data = self._request("GET", "/model/info")
57
+ return data.get("data", [])
58
+
59
+ def create_model(self, model_name: str, litellm_params: dict, model_info: dict = None):
60
+ body = {
61
+ "model_name": model_name,
62
+ "litellm_params": litellm_params,
63
+ "model_info": model_info or {},
64
+ }
65
+ return self._request("POST", "/model/new", json=body)
66
+
67
+ def update_model(self, model_id: str, **fields):
68
+ body = {k: v for k, v in fields.items() if v is not None}
69
+ return self._request("PATCH", f"/model/{model_id}/update", json=body)
70
+
71
+ def delete_model(self, model_id: str):
72
+ return self._request("POST", "/model/delete", json={"id": model_id})
73
+
74
+ def list_credentials(self) -> list:
75
+ data = self._request("GET", "/credentials")
76
+ return data.get("credentials", [])
77
+
78
+ def get_router_settings(self) -> dict:
79
+ """GET /router/settings:返回 fields / current_values / routing_strategy_descriptions。"""
80
+ return self._request("GET", "/router/settings")
81
+
82
+ def update_router_settings(self, router_settings: dict):
83
+ """POST /config/update:写入 router_settings(浅合并,routing_groups 需整表提交)。"""
84
+ return self._request("POST", "/config/update", json={"router_settings": router_settings})
85
+
86
+ def fetch_model_cost_map(self) -> dict:
87
+ data = self._request("GET", "/public/litellm_model_cost_map")
88
+ return data.get("litellm_model_cost_map", data)
@@ -0,0 +1,488 @@
1
+ """配置层:配置文件加载/保存与交互式配置向导。"""
2
+ import os
3
+ from pathlib import Path
4
+ from urllib.parse import urlparse
5
+
6
+ import yaml
7
+ from InquirerPy import prompt
8
+ from InquirerPy.base.control import Choice
9
+
10
+ from . import ui # noqa: F401 导入即注册 DotFuzzy
11
+ from .client import LiteLLMClient
12
+ from .ui import FuzzySeparator
13
+ from .upstreams import UPSTREAM_TYPES
14
+
15
+ CONFIG_DIR_ENV = "LITELLM_CONTROLLER_CONFIG_DIR"
16
+ DEFAULT_CONFIG_DIR = "~/.config/litellm-controller"
17
+
18
+ SUPPORTED_UPSTREAM_TYPES = ["openai", "anthropic", "google"]
19
+
20
+
21
+ def get_config_dir() -> Path:
22
+ config_dir = os.environ.get(
23
+ CONFIG_DIR_ENV,
24
+ DEFAULT_CONFIG_DIR,
25
+ )
26
+ return Path(config_dir).expanduser()
27
+
28
+
29
+ def _get_config_path() -> Path:
30
+ return get_config_dir() / "config.yaml"
31
+
32
+
33
+ def get_default_model_metadata() -> dict:
34
+ return {
35
+ "output_file": None,
36
+ "amend_upstream": {
37
+ "type": "off",
38
+ "url": None,
39
+ },
40
+ }
41
+
42
+
43
+ def cost_map_providers(cost_map: dict) -> list:
44
+ """从 cost map 提取内置 provider 名称列表(过滤非常规条目)。"""
45
+ providers = set()
46
+ for value in cost_map.values():
47
+ if not isinstance(value, dict):
48
+ continue
49
+ provider = value.get("litellm_provider")
50
+ if not isinstance(provider, str):
51
+ continue
52
+ if " " in provider or "http" in provider or provider.startswith("text-completion-"):
53
+ continue
54
+ providers.add(provider)
55
+ return sorted(providers)
56
+
57
+
58
+ def fetch_known_providers(endpoint: str, key: str) -> list:
59
+ """尽力从 LiteLLM 拉取内置 provider 列表;失败返回空列表(回退手动输入)。"""
60
+ try:
61
+ client = LiteLLMClient(endpoint, key, timeout=10)
62
+ cost_map = client.fetch_model_cost_map()
63
+ except Exception:
64
+ return []
65
+ return cost_map_providers(cost_map)
66
+
67
+
68
+ def guess_provider_from_name(name: str, known_providers: list) -> str | None:
69
+ """根据 Upstream 名称猜测 Provider:归一化(小写、去非字母数字)后做子串匹配。
70
+
71
+ 匹配串至少 3 个字符(避免 "p" 之类短名误伤);多候选取最长。"""
72
+ norm_name = "".join(ch for ch in (name or "").lower() if ch.isalnum())
73
+ if not norm_name:
74
+ return None
75
+ candidates = []
76
+ for provider in known_providers or []:
77
+ norm_provider = "".join(ch for ch in provider.lower() if ch.isalnum())
78
+ if len(norm_provider) >= 3 and norm_provider in norm_name:
79
+ candidates.append(provider)
80
+ if not candidates:
81
+ return None
82
+ if len(candidates) == 1:
83
+ return candidates[0]
84
+ return max(candidates, key=len)
85
+
86
+
87
+ def validate_model_metadata(meta: dict) -> dict:
88
+ if not isinstance(meta, dict):
89
+ raise ValueError("model_metadata 必须是映射")
90
+
91
+ # 未知配置项(如历史遗留的 provider 段)静默忽略,不参与任何逻辑
92
+
93
+ # amend_upstream
94
+ amend = meta.get("amend_upstream")
95
+ if amend is None:
96
+ meta["amend_upstream"] = {"type": "off", "url": None}
97
+ elif not isinstance(amend, dict):
98
+ raise ValueError("model_metadata.amend_upstream 必须是映射")
99
+ else:
100
+ a_type = amend.get("type", "off")
101
+ if a_type not in ("off", "url", "file"):
102
+ raise ValueError(f"amend_upstream.type 不受支持: {a_type} (可选: off, url, file)")
103
+ if a_type == "url":
104
+ u = (amend.get("url") or "").strip()
105
+ if not u.startswith(("http://", "https://")):
106
+ raise ValueError("amend_upstream.url 必须是有效 http(s) URL")
107
+
108
+ return meta
109
+
110
+
111
+ def load_config() -> dict:
112
+ config_path = _get_config_path()
113
+ if not config_path.exists():
114
+ raise ValueError(f"配置文件不存在: {config_path}")
115
+ with open(config_path, "r", encoding="utf-8") as f:
116
+ data = yaml.safe_load(f)
117
+ if not isinstance(data, dict):
118
+ raise ValueError("配置文件格式错误: 顶层必须是映射")
119
+ litellm = data.get("litellm")
120
+ if not isinstance(litellm, dict):
121
+ raise ValueError("配置缺少 litellm 段")
122
+ for field in ("endpoint", "key"):
123
+ value = litellm.get(field)
124
+ if not isinstance(value, str) or not value.strip():
125
+ raise ValueError(f"litellm.{field} 缺失或为空")
126
+ upstreams = data.get("upstreams")
127
+ if upstreams is None:
128
+ data["upstreams"] = []
129
+ elif not isinstance(upstreams, list):
130
+ raise ValueError("upstreams 必须是列表")
131
+ for i, up in enumerate(data["upstreams"]):
132
+ if not isinstance(up, dict):
133
+ raise ValueError(f"upstreams[{i}] 必须是映射")
134
+ for field in ("name", "type", "endpoint", "key"):
135
+ value = up.get(field)
136
+ if not isinstance(value, str) or not value.strip():
137
+ raise ValueError(f"upstreams[{i}].{field} 缺失或为空")
138
+ if up["type"] not in SUPPORTED_UPSTREAM_TYPES:
139
+ raise ValueError(f"upstreams[{i}].type 不受支持: {up['type']}")
140
+ provider = up.get("provider")
141
+ if provider is None or (isinstance(provider, str) and not provider.strip()):
142
+ up.pop("provider", None)
143
+ else:
144
+ if not isinstance(provider, str):
145
+ raise ValueError(f"upstreams[{i}].provider 设置时必须是字符串")
146
+ up["provider"] = provider.strip()
147
+ endpoint = up["endpoint"].strip()
148
+ if not endpoint.startswith(("http://", "https://")):
149
+ raise ValueError(f"upstreams[{i}].endpoint 必须是 http(s) URL")
150
+ if not urlparse(endpoint).path.strip("/"):
151
+ example = UPSTREAM_TYPES[up["type"]]["example_url"]
152
+ raise ValueError(
153
+ f"upstreams[{i}].endpoint 应为完整的模型列表 API URL(当前缺少路径),如 {example}"
154
+ )
155
+
156
+ # model_metadata
157
+ if "model_metadata" not in data:
158
+ data["model_metadata"] = get_default_model_metadata()
159
+ else:
160
+ data["model_metadata"] = validate_model_metadata(data["model_metadata"])
161
+
162
+ return data
163
+
164
+
165
+ def save_config(data: dict) -> Path:
166
+ config_path = _get_config_path()
167
+ config_path.parent.mkdir(parents=True, exist_ok=True)
168
+ with open(config_path, "w", encoding="utf-8") as f:
169
+ yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)
170
+ return config_path
171
+
172
+
173
+ _save_config = save_config
174
+
175
+
176
+ def _ask_litellm_section():
177
+ return prompt(
178
+ [
179
+ {
180
+ "type": "input",
181
+ "message": "请输入 LiteLLM endpoint(如 https://llm.example.com):",
182
+ "name": "endpoint",
183
+ "validate": lambda val: bool(val and val.strip()),
184
+ "invalid_message": "endpoint 不能为空",
185
+ },
186
+ {
187
+ "type": "password",
188
+ "message": "请输入 LiteLLM key(master key):",
189
+ "name": "key",
190
+ "validate": lambda val: bool(val and val.strip()),
191
+ "invalid_message": "key 不能为空",
192
+ },
193
+ ]
194
+ )
195
+
196
+
197
+ def ask_provider_binding(endpoint: str = None, key: str = None, default: str = None,
198
+ known_providers: list = None, preselect: str = None):
199
+ """交互式选择 Provider 绑定。
200
+
201
+ preselect 非空且在已知列表中时,该 provider 置顶并标记(指针初始落在其上,
202
+ 搜索框保持空、全部选项可见)。
203
+ 返回 (canceled: bool, provider: str | None);provider 为 None 表示不绑定。"""
204
+ if known_providers is None:
205
+ known_providers = fetch_known_providers(endpoint, key) if endpoint and key else []
206
+ if known_providers:
207
+ if preselect and preselect not in known_providers:
208
+ preselect = None
209
+ if preselect:
210
+ ordered = [preselect] + [p for p in known_providers if p != preselect]
211
+ else:
212
+ ordered = list(known_providers)
213
+ choices = [FuzzySeparator("─" * 50)]
214
+ choices.extend(
215
+ Choice(p, name=f"{p} · 按名称推荐" if p == preselect else p)
216
+ for p in ordered
217
+ )
218
+ choices.append(FuzzySeparator("─" * 50))
219
+ choices.append(Choice("__custom__", name="[手动输入其他 Provider]"))
220
+ choices.append(Choice("__none__", name="[不绑定]"))
221
+ message = (
222
+ f"选择绑定的 LiteLLM Provider(已从 LiteLLM 获取 {len(known_providers)} 个,"
223
+ "留空不绑定):"
224
+ )
225
+ if preselect:
226
+ message = (
227
+ f"选择绑定的 LiteLLM Provider(已按 Upstream 名称推荐 {preselect}):"
228
+ )
229
+ try:
230
+ provider_answer = prompt(
231
+ [
232
+ {
233
+ "type": "fuzzy",
234
+ "message": message,
235
+ "name": "provider",
236
+ "choices": choices,
237
+ "long_instruction": "输入筛选 · ↑↓ 移动 · 回车 确认 · Ctrl+C 取消",
238
+ }
239
+ ]
240
+ )
241
+ except (KeyboardInterrupt, EOFError):
242
+ return True, None
243
+ if not provider_answer:
244
+ return True, None
245
+ provider = provider_answer.get("provider")
246
+ if provider == "__none__":
247
+ return False, None
248
+ if provider == "__custom__":
249
+ try:
250
+ custom_answer = prompt(
251
+ [
252
+ {
253
+ "type": "input",
254
+ "message": "请输入 Provider 名称 (如 my-gateway):",
255
+ "name": "provider",
256
+ "validate": lambda val: bool(val and val.strip()),
257
+ "invalid_message": "provider 不能为空",
258
+ }
259
+ ]
260
+ )
261
+ except (KeyboardInterrupt, EOFError):
262
+ return True, None
263
+ if not custom_answer:
264
+ return True, None
265
+ return False, (custom_answer.get("provider") or "").strip() or None
266
+ return False, provider
267
+ try:
268
+ provider_answer = prompt(
269
+ [
270
+ {
271
+ "type": "input",
272
+ "message": (
273
+ "请输入绑定的 LiteLLM Provider 名称(留空表示不绑定,"
274
+ "如 openrouter / openai / p):"
275
+ ),
276
+ "name": "provider",
277
+ "default": default or "",
278
+ "long_instruction": "绑定后可在添加模型流程与元数据管理中自动推荐此 Upstream",
279
+ }
280
+ ]
281
+ )
282
+ except (KeyboardInterrupt, EOFError):
283
+ return True, None
284
+ if not provider_answer:
285
+ return True, None
286
+ return False, (provider_answer.get("provider") or "").strip() or None
287
+
288
+
289
+ def ask_upstream_fields(endpoint: str = None, key: str = None, existing: dict = None,
290
+ known_providers: list = None):
291
+ """交互式采集单个 Upstream 配置;传入 existing 时预填当前值(用于编辑)。
292
+
293
+ 任一步骤取消返回 None,否则返回 upstream dict。"""
294
+ existing = existing or {}
295
+ try:
296
+ name_answer = prompt(
297
+ [
298
+ {
299
+ "type": "input",
300
+ "message": "请输入 Upstream 名称(如 anthropic-main,仅用于显示):",
301
+ "name": "name",
302
+ "default": existing.get("name", ""),
303
+ "validate": lambda val: bool(val and val.strip()),
304
+ "invalid_message": "名称不能为空",
305
+ }
306
+ ]
307
+ )
308
+ if not name_answer:
309
+ return None
310
+ type_answer = prompt(
311
+ [
312
+ {
313
+ "type": "list",
314
+ "message": "请选择 Upstream 类型:",
315
+ "name": "type",
316
+ "choices": SUPPORTED_UPSTREAM_TYPES,
317
+ "default": existing.get("type"),
318
+ }
319
+ ]
320
+ )
321
+ if not type_answer:
322
+ return None
323
+ endpoint_answer = prompt(
324
+ [
325
+ {
326
+ "type": "input",
327
+ "message": (
328
+ f"请输入 Upstream 模型列表 API 的完整 URL"
329
+ f"(如 {UPSTREAM_TYPES[type_answer['type']]['example_url']}):"
330
+ ),
331
+ "name": "endpoint",
332
+ "default": existing.get("endpoint", ""),
333
+ "validate": lambda val: bool(
334
+ val and val.strip() and val.strip().startswith(("http://", "https://"))
335
+ ),
336
+ "invalid_message": "请输入完整的 http(s) URL",
337
+ }
338
+ ]
339
+ )
340
+ if endpoint_answer is None:
341
+ return None
342
+ has_existing_key = bool(existing.get("key"))
343
+ key_answer = prompt(
344
+ [
345
+ {
346
+ "type": "password",
347
+ "message": (
348
+ "请输入 Upstream key(留空保持原 key):"
349
+ if has_existing_key
350
+ else "请输入 Upstream key:"
351
+ ),
352
+ "name": "key",
353
+ "validate": (lambda val: True)
354
+ if has_existing_key
355
+ else (lambda val: bool(val and val.strip())),
356
+ "invalid_message": "key 不能为空",
357
+ }
358
+ ]
359
+ )
360
+ if key_answer is None:
361
+ return None
362
+ key_value = (key_answer.get("key") or "").strip() or existing.get("key", "")
363
+ preselect = (
364
+ guess_provider_from_name(name_answer["name"], known_providers)
365
+ if known_providers
366
+ else None
367
+ )
368
+ canceled, provider = ask_provider_binding(
369
+ endpoint, key, default=existing.get("provider"),
370
+ known_providers=known_providers, preselect=preselect,
371
+ )
372
+ if canceled:
373
+ return None
374
+ upstream = {
375
+ "name": name_answer["name"].strip(),
376
+ "type": type_answer["type"],
377
+ "endpoint": (endpoint_answer.get("endpoint") or "").strip(),
378
+ "key": key_value,
379
+ }
380
+ if provider:
381
+ upstream["provider"] = provider
382
+ return upstream
383
+ except (KeyboardInterrupt, EOFError):
384
+ return None
385
+
386
+
387
+ def _ask_upstreams(endpoint: str = None, key: str = None):
388
+ upstreams = []
389
+ known_providers = fetch_known_providers(endpoint, key) if endpoint and key else []
390
+ if endpoint and key and not known_providers:
391
+ print("未能从 LiteLLM 获取内置 Provider 列表,绑定 Provider 将使用手动输入。")
392
+ while True:
393
+ upstream = ask_upstream_fields(endpoint, key, known_providers=known_providers)
394
+ if upstream is None:
395
+ return None
396
+ upstreams.append(upstream)
397
+ more_answer = prompt(
398
+ [
399
+ {
400
+ "type": "confirm",
401
+ "message": "继续添加另一个 Upstream?",
402
+ "name": "more",
403
+ "default": False,
404
+ }
405
+ ]
406
+ )
407
+ if not more_answer or not more_answer.get("more"):
408
+ return upstreams
409
+
410
+
411
+ def _run_setup_flow() -> bool:
412
+ try:
413
+ answers = _ask_litellm_section()
414
+ if not answers:
415
+ print("\n配置已取消。")
416
+ return False
417
+ upstreams = _ask_upstreams(
418
+ answers["endpoint"].strip(), answers["key"]
419
+ )
420
+ if upstreams is None:
421
+ print("\n配置已取消。")
422
+ return False
423
+ except (KeyboardInterrupt, EOFError):
424
+ print("\n配置已取消。")
425
+ return False
426
+ data = {
427
+ "litellm": {
428
+ "endpoint": answers["endpoint"].strip(),
429
+ "key": answers["key"],
430
+ },
431
+ "upstreams": upstreams,
432
+ }
433
+
434
+ print("\n======== 最终配置概览 ========")
435
+ print(f"LiteLLM Endpoint: {data['litellm']['endpoint']}")
436
+ print(f"LiteLLM Key : {'***' if data['litellm']['key'] else '空'}")
437
+ print(f"Upstreams : {len(data['upstreams'])} 个")
438
+ for i, u in enumerate(data["upstreams"], 1):
439
+ print(f" {i}. {u['name']} ({u['type']})")
440
+ print("=" * 30)
441
+
442
+ try:
443
+ conf = prompt(
444
+ [{"type": "confirm", "message": "是否确认以上配置并保存?", "name": "ok", "default": True}]
445
+ )
446
+ except (KeyboardInterrupt, EOFError):
447
+ print("\n配置已取消。")
448
+ return False
449
+
450
+ if not conf or not conf.get("ok"):
451
+ print("\n配置已取消,未保存。")
452
+ return False
453
+
454
+ path = _save_config(data)
455
+ print(f"配置已保存到: {path}")
456
+ return True
457
+
458
+
459
+ def ensure_config_ready() -> bool:
460
+ config_path = _get_config_path()
461
+ if not config_path.exists():
462
+ print(f"未找到配置文件: {config_path}")
463
+ print("现在进入交互式配置流程。")
464
+ return _run_setup_flow()
465
+ try:
466
+ load_config()
467
+ return True
468
+ except Exception as e:
469
+ print(f"配置文件损坏或不可读取: {e}")
470
+ try:
471
+ answers = prompt(
472
+ [
473
+ {
474
+ "type": "list",
475
+ "message": "请选择操作:",
476
+ "name": "choice",
477
+ "choices": [
478
+ {"name": "重新配置并覆盖", "value": "reset"},
479
+ {"name": "退出程序", "value": "exit"},
480
+ ],
481
+ }
482
+ ]
483
+ )
484
+ except KeyboardInterrupt:
485
+ return False
486
+ if not answers or answers.get("choice") != "reset":
487
+ return False
488
+ return _run_setup_flow()
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ # --- LITELLMCTL CONFIG ---
3
+ NAME = "DeepSeek 动态时段计费元数据"
4
+ DESCRIPTION = "根据北京时间动态输出 DeepSeek 模型的高峰/非高峰时段定价"
5
+ PRIORITY = 30
6
+ ENABLED = False
7
+ # -------------------------
8
+ """DeepSeek 动态时段计费生成脚本。
9
+
10
+ 模型:deepseek/deepseek-flash
11
+ 逻辑:北京时间周一至周五 09:00-12:00, 14:00-18:00 为高峰时段(2x 价格),其余为谷价时段。
12
+ 汇率:6.71 (CNY -> USD)
13
+ 来源:https://api-docs.deepseek.com/quick_start/pricing
14
+ """
15
+ import json
16
+ import sys
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ # 配置
20
+ MODEL_ID = "deepseek/deepseek-flash"
21
+ EXCHANGE_RATE = 6.71
22
+ # 谷价 (每 1M tokens, CNY)
23
+ OFF_PEAK_PRICING = {
24
+ "input": 1.0,
25
+ "output": 4.0,
26
+ "cache_read": 0.02,
27
+ }
28
+
29
+ def is_peak_hour():
30
+ # 获取北京时间 (UTC+8)
31
+ tz_bj = timezone(timedelta(hours=8))
32
+ now_bj = datetime.now(timezone.utc).astimezone(tz_bj)
33
+
34
+ # 周末非高峰
35
+ if now_bj.weekday() >= 5: # 5: 周六, 6: 周日
36
+ return False
37
+
38
+ hour = now_bj.hour
39
+ # 高峰时段:09:00-12:00, 14:00-18:00
40
+ return bool(9 <= hour < 12 or 14 <= hour < 18)
41
+
42
+ # 四舍五入精度(小数位)。官方 model_prices_and_context_window.json 的干净值
43
+ # 集中在 6-9 位、最大 12 位,>=15 位为浮点误差。取 12 位可兼顾精度并规避误差尾巴。
44
+ ROUND_DIGITS = 12
45
+
46
+ def main():
47
+ peak = is_peak_hour()
48
+
49
+ # 换算为 USD/token(四舍五入到 ROUND_DIGITS 位)
50
+ def to_usd_token(cny_1m):
51
+ return round(cny_1m / EXCHANGE_RATE / 1_000_000, ROUND_DIGITS)
52
+
53
+ # 先算谷价,峰价 = 谷价 * 2(保持精确 2x 关系)
54
+ off_peak = {
55
+ "input": to_usd_token(OFF_PEAK_PRICING["input"]),
56
+ "output": to_usd_token(OFF_PEAK_PRICING["output"]),
57
+ "cache_read": to_usd_token(OFF_PEAK_PRICING["cache_read"]),
58
+ }
59
+ on_peak = {k: round(v * 2, ROUND_DIGITS) for k, v in off_peak.items()}
60
+ prices = on_peak if peak else off_peak
61
+
62
+ entry = {
63
+ "input_cost_per_token": prices["input"],
64
+ "output_cost_per_token": prices["output"],
65
+ "cache_read_input_token_cost": prices["cache_read"],
66
+ "max_input_tokens": 128000,
67
+ "max_output_tokens": 8192,
68
+ "litellm_provider": "deepseek",
69
+ "mode": "chat",
70
+ "supports_function_calling": True,
71
+ "supports_vision": False,
72
+ "source": "https://api-docs.deepseek.com/quick_start/pricing"
73
+ }
74
+
75
+ result = {MODEL_ID: entry}
76
+
77
+ # 打印日志到 stderr
78
+ period_name = "高峰 (2x)" if peak else "谷价 (1x)"
79
+ print(f"DeepSeek: 当前北京时间 {datetime.now(timezone(timedelta(hours=8))).strftime('%H:%M')}, "
80
+ f"时段判定: {period_name}", file=sys.stderr)
81
+
82
+ json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
83
+ sys.stdout.write("\n")
84
+ return 0
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())