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.
- litellm_controller/__init__.py +2 -0
- litellm_controller/client.py +88 -0
- litellm_controller/config.py +488 -0
- litellm_controller/examples/deepseek.py +87 -0
- litellm_controller/examples/openrouter.py +118 -0
- litellm_controller/examples/type_static_json.py +38 -0
- litellm_controller/main.py +110 -0
- litellm_controller/modules/__init__.py +1 -0
- litellm_controller/modules/metadata.py +1284 -0
- litellm_controller/modules/models.py +1018 -0
- litellm_controller/modules/routing.py +785 -0
- litellm_controller/modules/settings.py +277 -0
- litellm_controller/ui.py +337 -0
- litellm_controller/upstreams.py +95 -0
- litellm_controller-0.1.0.dist-info/METADATA +90 -0
- litellm_controller-0.1.0.dist-info/RECORD +19 -0
- litellm_controller-0.1.0.dist-info/WHEEL +5 -0
- litellm_controller-0.1.0.dist-info/entry_points.txt +2 -0
- litellm_controller-0.1.0.dist-info/top_level.txt +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())
|