codebee 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,2750 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""模型接入层:供应商注册表 + 本机多源导入 + 按 CLI 绑定 + 难度路由。
|
|
3
|
+
|
|
4
|
+
data/models.json 结构:
|
|
5
|
+
{
|
|
6
|
+
"providers": [{"id","name","protocol":"anthropic|openai|google","base_url","api_key",
|
|
7
|
+
"model","model_easy","model_hard","source","source_id"}],
|
|
8
|
+
"bindings": {"<agent-id>": {"chain", # 跨厂商有序模型链(唯一真源):
|
|
9
|
+
[{"provider_id","model"}, ...]
|
|
10
|
+
# 第 1 条主模型,其余降级备选;各条可来自不同供应商
|
|
11
|
+
"provider_id", # 主供应商(链首非空 provider 的冗余,兼容旧读方)
|
|
12
|
+
"models", # 链内模型名序列(冗余,兼容旧读方)
|
|
13
|
+
"model", # 链首模型名(冗余)
|
|
14
|
+
"difficulty_routing"}},
|
|
15
|
+
"orchestrator": {"provider_id", "model", "enabled"} # 编排设置:直连 API 的规划/管理模型
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
导入来源(只读,不改写任何工具自身的配置):CCSwitch、Claude Code、Codex CLI、
|
|
19
|
+
ZCode、Qwen Code、Gemini CLI、OpenCode、Continue、Cursor、Trae。
|
|
20
|
+
|
|
21
|
+
密钥只落本地盘(与 CCSwitch 同等信任域);API 一律脱敏返回。
|
|
22
|
+
anthropic/openai 可注入到 CLI;google 仅登记(当前无对应 CLI 可注入)。
|
|
23
|
+
启用的供应商/模型自动排在最前(启用即置顶,成为该供应商的默认模型 #1),
|
|
24
|
+
停用的退到启用块之后;排序同时落盘,UI 与运行时解析看到的是同一顺序。
|
|
25
|
+
按运行注入,不改写任何 CLI 的全局配置文件:
|
|
26
|
+
claude : env ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL
|
|
27
|
+
codex : env ORCH_API_KEY + -c model_provider/model_providers.orch.* 覆盖
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import copy
|
|
32
|
+
import ipaddress
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import socket
|
|
38
|
+
import sqlite3
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
import urllib.request
|
|
42
|
+
|
|
43
|
+
from . import paths
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
47
|
+
"""禁用重定向:SSRF 防护的一部分。"""
|
|
48
|
+
|
|
49
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
_LOCK = threading.RLock()
|
|
53
|
+
_FILE = paths.DATA_DIR / "models.json"
|
|
54
|
+
|
|
55
|
+
# 可注入 CLI 的协议;google 只登记(当前 catalog 里没有可注入的 gemini CLI)
|
|
56
|
+
_PROTOCOLS = ("anthropic", "openai", "google")
|
|
57
|
+
_BINDABLE_PROTOCOLS = ("anthropic", "openai")
|
|
58
|
+
# 聚合中转网关(new-api/one-api 系)一个密钥常同时开多条 wire,导入时不必先问
|
|
59
|
+
# 用户选哪条:protocol="auto" 表示「不指定,按实测能力集挑」。旧数据里的
|
|
60
|
+
# anthropic/openai/google 一律视为显式指定(用户可覆盖),行为完全不变。
|
|
61
|
+
_PROTOCOL_AUTO = "auto"
|
|
62
|
+
_PROTOCOL_CHOICES = _PROTOCOLS + (_PROTOCOL_AUTO,)
|
|
63
|
+
# auto 挑主协议时的偏好顺序:只挑「实测过的」wire(wire_caps),不猜。
|
|
64
|
+
_WIRE_PREFERENCE = ("anthropic", "openai")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load():
|
|
68
|
+
try:
|
|
69
|
+
data = _normalize_ids(json.loads(_FILE.read_text(encoding="utf-8")))
|
|
70
|
+
except Exception:
|
|
71
|
+
return {"providers": [], "bindings": {}}
|
|
72
|
+
# 多 KEY 供应商的 api_key 是「首个可用 KEY」的镜像:每次读取时重算,冷却
|
|
73
|
+
# 到期自动把首选 KEY 换回来(或已切到备用)。没有 keys 数组的老数据不动。
|
|
74
|
+
for p in data.get("providers") or []:
|
|
75
|
+
if isinstance(p.get("keys"), list):
|
|
76
|
+
_sync_api_key(p)
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_ids(data):
|
|
81
|
+
"""修复历史数据的重复 prov-N id(早期按 len(plist)+1 分配会撞号)。
|
|
82
|
+
|
|
83
|
+
只给「重复出现」的条目重新编号,首次出现的保留原 id;不改顺序,也不动绑定
|
|
84
|
+
引用——绑定原本就解析到首次出现的那条,修复前后语义一致。无重复时零改动。
|
|
85
|
+
重复 id 会让 UI 无法区分、按供应商的操作(删除/启停/刷新)打到错误的那条。
|
|
86
|
+
"""
|
|
87
|
+
plist = data.get("providers") or []
|
|
88
|
+
seen, has_dup = set(), False
|
|
89
|
+
for p in plist:
|
|
90
|
+
pid = p.get("id")
|
|
91
|
+
if pid and pid not in seen:
|
|
92
|
+
seen.add(pid)
|
|
93
|
+
else:
|
|
94
|
+
has_dup = True
|
|
95
|
+
break
|
|
96
|
+
if not has_dup:
|
|
97
|
+
return data
|
|
98
|
+
seen = set()
|
|
99
|
+
for p in plist:
|
|
100
|
+
pid = p.get("id")
|
|
101
|
+
if pid and pid not in seen:
|
|
102
|
+
seen.add(pid)
|
|
103
|
+
continue
|
|
104
|
+
new_id = _next_pid(plist)
|
|
105
|
+
while new_id in seen:
|
|
106
|
+
new_id = "prov-%d" % (int(new_id.split("-")[1]) + 1)
|
|
107
|
+
p["id"] = new_id
|
|
108
|
+
seen.add(new_id)
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- 模型列表拉取
|
|
113
|
+
|
|
114
|
+
_LITE = ("mini", "flash", "lite", "nano", "small", "tiny", "8b", "7b", "4b")
|
|
115
|
+
_HEAVY = ("opus", "pro", "max", "ultra", "plus", "heavy", "codex")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _auto_priority(name):
|
|
119
|
+
"""按模型名启发式估强弱:分越高越强(优先级越靠前)。仅用于新模型的初始排序。"""
|
|
120
|
+
n = (name or "").lower()
|
|
121
|
+
score = 50.0
|
|
122
|
+
if any(k in n for k in _LITE):
|
|
123
|
+
score -= 30
|
|
124
|
+
if any(k in n for k in _HEAVY):
|
|
125
|
+
score += 15
|
|
126
|
+
vers = re.findall(r"(\d+)\.(\d+)", n)
|
|
127
|
+
if vers:
|
|
128
|
+
score += min(20.0, float(vers[0][0]) * 4 + float(vers[0][1]))
|
|
129
|
+
else:
|
|
130
|
+
m = re.search(r"(\d+)", n)
|
|
131
|
+
if m:
|
|
132
|
+
score += min(12.0, float(m.group(1)) * 2)
|
|
133
|
+
for i, fam in enumerate(("gpt-5", "claude", "gemini", "glm-5", "deepseek", "qwen", "kimi", "grok")):
|
|
134
|
+
if fam in n:
|
|
135
|
+
score += 10 - i
|
|
136
|
+
break
|
|
137
|
+
return score
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _auth_header_variants(key, protocol):
|
|
141
|
+
if protocol == "google":
|
|
142
|
+
return [{"x-goog-api-key": key, "User-Agent": "codebee-orchestrator/1.0"}]
|
|
143
|
+
h1 = {"Authorization": "Bearer " + key, "User-Agent": "codebee-orchestrator/1.0"}
|
|
144
|
+
if protocol == "anthropic":
|
|
145
|
+
h2 = {"x-api-key": key, "anthropic-version": "2023-06-01",
|
|
146
|
+
"User-Agent": "codebee-orchestrator/1.0"}
|
|
147
|
+
return [h1, h2]
|
|
148
|
+
return [h1]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_host(url, allow_private):
|
|
152
|
+
"""SSRF 防护:校验协议与解析后 IP;私网/环回仅在 allow_private 时放行。"""
|
|
153
|
+
p = urllib.parse.urlsplit(url)
|
|
154
|
+
if p.scheme not in ("http", "https"):
|
|
155
|
+
return None, "协议必须是 http/https"
|
|
156
|
+
host = p.hostname
|
|
157
|
+
if not host:
|
|
158
|
+
return None, "缺少主机名"
|
|
159
|
+
try:
|
|
160
|
+
infos = socket.getaddrinfo(host, None)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return None, "域名解析失败: %r" % e
|
|
163
|
+
for i in infos:
|
|
164
|
+
a = ipaddress.ip_address(i[4][0])
|
|
165
|
+
if not allow_private and (a.is_private or a.is_loopback or a.is_link_local or a.is_reserved):
|
|
166
|
+
return None, ("拒绝访问私网/环回地址 %s。内网自建网关属预期场景:该供应商"
|
|
167
|
+
"导入/新增内网 IP 时会自动开启 allow_private。" % a)
|
|
168
|
+
return host, ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
172
|
+
"""GET {base}/models 拉取模型列表。返回 (names, err)。
|
|
173
|
+
|
|
174
|
+
仅访问用户自己配置的供应商地址;协议白名单 + 解析 IP 边界校验 + 禁用重定向。
|
|
175
|
+
google 走 /v1beta/models,返回项形如 {"name": "models/gemini-x"}。
|
|
176
|
+
protocol="auto"(导入时未指定格式)两条 URL 形状都试——anthropic 与 openai
|
|
177
|
+
的取列表路径本就相同,多试 google 那条只是让 auto 名副其实。
|
|
178
|
+
"""
|
|
179
|
+
import urllib.parse
|
|
180
|
+
base = (base_url or "").rstrip("/")
|
|
181
|
+
if not base.startswith(("http://", "https://")):
|
|
182
|
+
return None, "base_url 必须是 http/https"
|
|
183
|
+
std = [base + "/models"] if base.endswith("/v1") else [base + "/v1/models", base + "/models"]
|
|
184
|
+
goog = [base + "/models"] if base.endswith("/v1beta") else [base + "/v1beta/models"]
|
|
185
|
+
if protocol == "google":
|
|
186
|
+
urls = goog
|
|
187
|
+
elif protocol == _PROTOCOL_AUTO:
|
|
188
|
+
urls = std + [u for u in goog if u not in std]
|
|
189
|
+
else:
|
|
190
|
+
urls = std
|
|
191
|
+
last_err = ""
|
|
192
|
+
opener = urllib.request.build_opener(_NoRedirect)
|
|
193
|
+
for url in urls:
|
|
194
|
+
host_info = _validate_host(url, allow_private)
|
|
195
|
+
if host_info is None:
|
|
196
|
+
last_err = host_info[1]
|
|
197
|
+
continue
|
|
198
|
+
# auto 不知道是哪条 wire,鉴权头也按两种都试(google 那种单独补上)
|
|
199
|
+
hdrs = _auth_header_variants(api_key, protocol)
|
|
200
|
+
if protocol == _PROTOCOL_AUTO:
|
|
201
|
+
hdrs = _auth_header_variants(api_key, "anthropic") + \
|
|
202
|
+
[{"x-goog-api-key": api_key, "User-Agent": "codebee-orchestrator/1.0"}]
|
|
203
|
+
for headers in hdrs:
|
|
204
|
+
try:
|
|
205
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
206
|
+
with opener.open(req, timeout=15) as resp:
|
|
207
|
+
raw = resp.read(2 * 1024 * 1024) # 响应上限 2MB
|
|
208
|
+
data = json.loads(raw.decode("utf-8", "replace"))
|
|
209
|
+
items = (data.get("data") or data.get("models")) if isinstance(data, dict) else data
|
|
210
|
+
names = []
|
|
211
|
+
for it in items or []:
|
|
212
|
+
if isinstance(it, str):
|
|
213
|
+
names.append(it)
|
|
214
|
+
elif isinstance(it, dict):
|
|
215
|
+
n = it.get("id") or it.get("name") or it.get("model")
|
|
216
|
+
if n and protocol == "google":
|
|
217
|
+
n = n.split("/")[-1] # "models/gemini-x" → "gemini-x"
|
|
218
|
+
names.append(n)
|
|
219
|
+
names = [n for n in names if n]
|
|
220
|
+
if names:
|
|
221
|
+
return names, ""
|
|
222
|
+
last_err = url + " 返回 200 但未解析到模型"
|
|
223
|
+
except Exception as e:
|
|
224
|
+
last_err = "%s → %r" % (url, e)
|
|
225
|
+
return None, last_err
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def refresh_models(provider_id):
|
|
229
|
+
"""拉取单个供应商的可用模型列表(保留既有启停与手动优先级)。返回 (数量, 错误)。"""
|
|
230
|
+
with _LOCK:
|
|
231
|
+
data = _load()
|
|
232
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
233
|
+
if not prov:
|
|
234
|
+
return 0, "供应商不存在"
|
|
235
|
+
if not prov.get("api_key"):
|
|
236
|
+
return 0, "该供应商未配置密钥"
|
|
237
|
+
names, err = _fetch_models_http(prov.get("base_url"), prov["api_key"],
|
|
238
|
+
prov.get("protocol"), bool(prov.get("allow_private")))
|
|
239
|
+
if names is None:
|
|
240
|
+
return 0, err
|
|
241
|
+
with _LOCK:
|
|
242
|
+
data = _load()
|
|
243
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
244
|
+
if not prov:
|
|
245
|
+
return 0, "供应商不存在"
|
|
246
|
+
old = {m.get("name"): m for m in prov.get("models") or []}
|
|
247
|
+
existing, fresh, hidden = [], [], []
|
|
248
|
+
for n in names:
|
|
249
|
+
o = old.get(n)
|
|
250
|
+
if o:
|
|
251
|
+
# 保留 hidden:用户删掉的模型刷新时不能被重新带回
|
|
252
|
+
item = {"name": n, "enabled": bool(o.get("enabled", True)),
|
|
253
|
+
"priority": o.get("priority", 0),
|
|
254
|
+
"hidden": bool(o.get("hidden"))}
|
|
255
|
+
(hidden if item["hidden"] else existing).append(item)
|
|
256
|
+
else:
|
|
257
|
+
fresh.append({"name": n, "enabled": True,
|
|
258
|
+
"auto": _auto_priority(n)})
|
|
259
|
+
# 已删除但本次未返回的条目也保留,否则下次拉取会当作新模型“复活”
|
|
260
|
+
hidden += [dict(o) for n, o in old.items()
|
|
261
|
+
if o.get("hidden") and n not in names]
|
|
262
|
+
# 顺序 = 既有启用(保持用户手动排序)+ 新模型(按强弱估分)+ 既有停用 + 已删除墓碑。
|
|
263
|
+
# 启用块始终在停用块之前(顺带自愈历史数据);新模型不能插到启用块前面,
|
|
264
|
+
# 否则会顶掉手动顺序与「#1 即默认模型」语义。
|
|
265
|
+
existing.sort(key=lambda m: m.get("priority", 999))
|
|
266
|
+
fresh.sort(key=lambda m: -m.pop("auto"))
|
|
267
|
+
hidden.sort(key=lambda m: m.get("priority", 999))
|
|
268
|
+
allm = ([m for m in existing if m.get("enabled", True)] + fresh +
|
|
269
|
+
[m for m in existing if not m.get("enabled", True)] + hidden)
|
|
270
|
+
for i, m in enumerate(allm):
|
|
271
|
+
m["priority"] = i + 1
|
|
272
|
+
prov["models"] = allm
|
|
273
|
+
prov["models_fetched_at"] = time.strftime("%Y-%m-%d %H:%M")
|
|
274
|
+
_save(data)
|
|
275
|
+
# 取列表成功后自动做一次 wire 适配探测,但丢到后台线程:探测最坏要等多个
|
|
276
|
+
# 候选端点各自超时(网络不通/慢时几十秒),同步跑会把 /api/models/refresh
|
|
277
|
+
# 拖成长请求,前端 await 不到响应——用户看就是「点了获取模型列表没反应」。
|
|
278
|
+
# 探完落 wire_caps,页面下一轮轮询自然刷出「已适配」徽标。
|
|
279
|
+
def _bg_probe(pid=provider_id):
|
|
280
|
+
try:
|
|
281
|
+
probe_wire_caps(pid)
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
threading.Thread(target=_bg_probe, name="wire-probe", daemon=True).start()
|
|
285
|
+
return len(allm), ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def refresh_all_async():
|
|
289
|
+
"""后台逐个刷新全部供应商的模型列表(导入后自动触发)。返回供应商数。
|
|
290
|
+
|
|
291
|
+
已停用的供应商跳过——停用即「暂不参与编排」,也不该再发网络请求。
|
|
292
|
+
"""
|
|
293
|
+
ids = [p.get("id") for p in providers()
|
|
294
|
+
if p.get("api_key") and p.get("enabled", True)]
|
|
295
|
+
|
|
296
|
+
def _worker():
|
|
297
|
+
for pid in ids:
|
|
298
|
+
try:
|
|
299
|
+
refresh_models(pid)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
threading.Thread(target=_worker, name="model-refresh", daemon=True).start()
|
|
304
|
+
return len(ids)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _ranked(models):
|
|
308
|
+
"""参与排序的模型(已删除的除外),按优先级。"""
|
|
309
|
+
return sorted([m for m in models if not m.get("hidden")],
|
|
310
|
+
key=lambda m: m.get("priority", 999))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _renumber(models):
|
|
314
|
+
"""重排优先级:可见模型按现有顺序占 1..k,已删除的顺位排在末尾。"""
|
|
315
|
+
visible = _ranked(models)
|
|
316
|
+
hidden = sorted([m for m in models if m.get("hidden")],
|
|
317
|
+
key=lambda m: m.get("priority", 999))
|
|
318
|
+
for i, m in enumerate(visible + hidden):
|
|
319
|
+
m["priority"] = i + 1
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _promote(models, front_names=()):
|
|
323
|
+
"""启用置顶重排:front_names(本次转为启用的模型)排最前,
|
|
324
|
+
其余启用模型按原优先级跟随,停用的退到启用块之后,已删除的仍在末尾。
|
|
325
|
+
|
|
326
|
+
启用即「参与编排」,置顶后它就是该供应商的默认模型(#1);停用的
|
|
327
|
+
无论原优先级多靠前,都排在所有启用模型之后。排序稳定,各块内部
|
|
328
|
+
保持既有相对顺序(手动拖拽的结果不会被无关操作打乱)。
|
|
329
|
+
"""
|
|
330
|
+
front = {n for n in front_names if n}
|
|
331
|
+
visible = _ranked(models)
|
|
332
|
+
newly = [m for m in visible if m.get("name") in front]
|
|
333
|
+
rest = [m for m in visible if m.get("name") not in front]
|
|
334
|
+
rest.sort(key=lambda m: not m.get("enabled", True))
|
|
335
|
+
hidden = sorted([m for m in models if m.get("hidden")],
|
|
336
|
+
key=lambda m: m.get("priority", 999))
|
|
337
|
+
for i, m in enumerate(newly + rest + hidden):
|
|
338
|
+
m["priority"] = i + 1
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _drop_model_refs(data, prov, name):
|
|
342
|
+
"""删除模型后清空指向它的默认模型/难度映射与 CLI 绑定,避免留下悬空模型名。"""
|
|
343
|
+
pid = prov.get("id")
|
|
344
|
+
for field in ("model", "model_easy", "model_hard"):
|
|
345
|
+
if (prov.get(field) or "") == name:
|
|
346
|
+
prov[field] = ""
|
|
347
|
+
for b in (data.get("bindings") or {}).values():
|
|
348
|
+
if b.get("provider_id") == pid and (b.get("model") or "") == name:
|
|
349
|
+
b["model"] = ""
|
|
350
|
+
if b.get("models"):
|
|
351
|
+
b["models"] = [m for m in b["models"] if m != name]
|
|
352
|
+
if b.get("chain"):
|
|
353
|
+
b["chain"] = [c for c in b["chain"]
|
|
354
|
+
if not (c.get("provider_id") == pid and c.get("model") == name)]
|
|
355
|
+
_sync_chain_refs(b)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
_MODEL_OPS = ("enable", "disable", "delete", "restore")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _find_prov(data, provider_id):
|
|
362
|
+
return next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _apply_model_op(data, prov, models, name, op):
|
|
366
|
+
"""对单个模型套用 enable/disable/delete/restore。返回 (是否改动, 提示或错误)。
|
|
367
|
+
|
|
368
|
+
无改动且无可提示原因时返回 (False, "")——批量调用里「已是目标状态」不算失败。
|
|
369
|
+
"""
|
|
370
|
+
target = next((m for m in models if m.get("name") == name), None)
|
|
371
|
+
if not target:
|
|
372
|
+
return False, "模型不存在"
|
|
373
|
+
if op == "enable":
|
|
374
|
+
if target.get("hidden"):
|
|
375
|
+
return False, "已删除的模型不能启用(先恢复)"
|
|
376
|
+
if target.get("enabled", True):
|
|
377
|
+
return False, ""
|
|
378
|
+
target["enabled"] = True
|
|
379
|
+
elif op == "disable":
|
|
380
|
+
if target.get("hidden"):
|
|
381
|
+
return False, "已删除的模型不能停用"
|
|
382
|
+
if not target.get("enabled", True):
|
|
383
|
+
return False, ""
|
|
384
|
+
target["enabled"] = False
|
|
385
|
+
elif op == "delete":
|
|
386
|
+
if target.get("hidden"):
|
|
387
|
+
return False, ""
|
|
388
|
+
target["hidden"] = True
|
|
389
|
+
target["enabled"] = False
|
|
390
|
+
_drop_model_refs(data, prov, name)
|
|
391
|
+
elif op == "restore":
|
|
392
|
+
if not target.get("hidden"):
|
|
393
|
+
return False, "该模型未被删除"
|
|
394
|
+
target["hidden"] = False
|
|
395
|
+
target["enabled"] = True
|
|
396
|
+
return True, ""
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def model_ops(provider_id, names, op):
|
|
400
|
+
"""批量模型操作(enable | disable | delete | restore)。返回 (改动数, 错误)。
|
|
401
|
+
|
|
402
|
+
先整体校验再落盘:任一名不存在、或 enable/disable 选中了已删除的模型,
|
|
403
|
+
整个请求都不生效,避免只改一半。全部改动合并为一次写盘。
|
|
404
|
+
restore 与可见模型混选时,可见项直接跳过(不算失败),便于「恢复所选」。
|
|
405
|
+
"""
|
|
406
|
+
names = list(dict.fromkeys(n for n in (names or []) if n))
|
|
407
|
+
if op not in _MODEL_OPS:
|
|
408
|
+
return 0, "未知操作 " + op
|
|
409
|
+
if not names:
|
|
410
|
+
return 0, "未选择模型"
|
|
411
|
+
with _LOCK:
|
|
412
|
+
data = _load()
|
|
413
|
+
prov = _find_prov(data, provider_id)
|
|
414
|
+
if not prov:
|
|
415
|
+
return 0, "供应商不存在"
|
|
416
|
+
models = prov.get("models") or []
|
|
417
|
+
by_name = {m.get("name"): m for m in models}
|
|
418
|
+
missing = [n for n in names if n not in by_name]
|
|
419
|
+
if missing:
|
|
420
|
+
return 0, "模型不存在:" + "、".join(missing[:3]) + ("…" if len(missing) > 3 else "")
|
|
421
|
+
if op in ("enable", "disable"):
|
|
422
|
+
gone = [n for n in names if by_name[n].get("hidden")]
|
|
423
|
+
if gone:
|
|
424
|
+
return 0, "%s已删除的模型:%s(请先恢复)" % (
|
|
425
|
+
"不能启用" if op == "enable" else "不能停用",
|
|
426
|
+
"、".join(gone[:3]) + ("…" if len(gone) > 3 else ""))
|
|
427
|
+
changed = 0
|
|
428
|
+
newly_enabled = [] # 本次转为启用的模型(enable/restore),重排时置顶
|
|
429
|
+
for n in names:
|
|
430
|
+
ok, _ = _apply_model_op(data, prov, models, n, op)
|
|
431
|
+
if ok:
|
|
432
|
+
changed += 1
|
|
433
|
+
if op in ("enable", "restore"):
|
|
434
|
+
newly_enabled.append(n)
|
|
435
|
+
if changed:
|
|
436
|
+
_promote(models, newly_enabled) # 启用的置顶,停用的退到启用块之后
|
|
437
|
+
_save(data)
|
|
438
|
+
return changed, ""
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _restore_all(provider_id):
|
|
442
|
+
with _LOCK:
|
|
443
|
+
data = _load()
|
|
444
|
+
prov = _find_prov(data, provider_id)
|
|
445
|
+
if not prov:
|
|
446
|
+
return "供应商不存在"
|
|
447
|
+
models = prov.get("models") or []
|
|
448
|
+
hidden = [m for m in models if m.get("hidden")]
|
|
449
|
+
if not hidden:
|
|
450
|
+
return "没有已删除的模型"
|
|
451
|
+
names = [m["name"] for m in hidden]
|
|
452
|
+
for m in hidden:
|
|
453
|
+
m["hidden"] = False
|
|
454
|
+
m["enabled"] = True
|
|
455
|
+
_promote(models, names) # 恢复 = 重新启用:同样置顶
|
|
456
|
+
_save(data)
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _move_model(provider_id, name, op):
|
|
461
|
+
with _LOCK:
|
|
462
|
+
data = _load()
|
|
463
|
+
prov = _find_prov(data, provider_id)
|
|
464
|
+
if not prov:
|
|
465
|
+
return "供应商不存在"
|
|
466
|
+
models = prov.get("models") or []
|
|
467
|
+
target = next((m for m in models if m.get("name") == name), None)
|
|
468
|
+
if not target:
|
|
469
|
+
return "模型不存在"
|
|
470
|
+
ordered = _ranked(models)
|
|
471
|
+
if target not in ordered:
|
|
472
|
+
return "已删除的模型不能调序"
|
|
473
|
+
idx = ordered.index(target)
|
|
474
|
+
j = idx - 1 if op == "up" else idx + 1
|
|
475
|
+
if 0 <= j < len(ordered):
|
|
476
|
+
ordered[idx], ordered[j] = ordered[j], ordered[idx]
|
|
477
|
+
for i, m in enumerate(ordered):
|
|
478
|
+
m["priority"] = i + 1
|
|
479
|
+
_promote(models) # 越过启用/停用边界的移动会被收回:启用的始终在前
|
|
480
|
+
_save(data)
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def model_op(provider_id, name, op):
|
|
485
|
+
"""单个模型操作。
|
|
486
|
+
|
|
487
|
+
op: enable | disable | up | down | delete | restore | restore-all。
|
|
488
|
+
删除是「标记隐藏」而非物理移除——刷新/重导入模型列表时不会被带回,
|
|
489
|
+
恢复后重新启用并置顶。批量走 model_ops()。
|
|
490
|
+
"""
|
|
491
|
+
if op == "restore-all":
|
|
492
|
+
return _restore_all(provider_id)
|
|
493
|
+
if op in ("up", "down"):
|
|
494
|
+
return _move_model(provider_id, name, op)
|
|
495
|
+
if op in _MODEL_OPS:
|
|
496
|
+
return model_ops(provider_id, [name], op)[1] or None
|
|
497
|
+
return "未知操作 " + op
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _save(data):
|
|
501
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
502
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
503
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
504
|
+
tmp.replace(_FILE)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _backup_file(path):
|
|
508
|
+
"""改写用户数据前留 .bak(data/ 不在 git 里,损坏没有回滚)。"""
|
|
509
|
+
try:
|
|
510
|
+
if path.is_file():
|
|
511
|
+
shutil.copyfile(path, str(path) + ".bak")
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def providers():
|
|
517
|
+
with _LOCK:
|
|
518
|
+
return _load().get("providers", [])
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def bindings():
|
|
522
|
+
with _LOCK:
|
|
523
|
+
return _load().get("bindings", {})
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _mask(key):
|
|
527
|
+
if not key:
|
|
528
|
+
return ""
|
|
529
|
+
return key[:8] + "..." + key[-4:] if len(key) > 16 else key[:4] + "..."
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def provider_view():
|
|
533
|
+
"""脱敏后的供应商列表(给 UI/API)。enabled 缺省视为启用;启用的排在停用前。
|
|
534
|
+
|
|
535
|
+
keys 里的密钥逐条脱敏;冷却状态实时算好(cooling)给界面显示。
|
|
536
|
+
"""
|
|
537
|
+
out = []
|
|
538
|
+
now = time.time()
|
|
539
|
+
for p in providers():
|
|
540
|
+
row = {k: (_mask(v) if k == "api_key" else v) for k, v in p.items()}
|
|
541
|
+
row["enabled"] = bool(p.get("enabled", True))
|
|
542
|
+
ks = []
|
|
543
|
+
for k in _provider_keys(p):
|
|
544
|
+
ks.append({"id": k["id"], "key": _mask(k["key"]),
|
|
545
|
+
"label": k.get("label") or "", "enabled": k["enabled"],
|
|
546
|
+
"cooling": k["cooling"],
|
|
547
|
+
"cool_until": float(k.get("cool_until") or 0),
|
|
548
|
+
"last_error": (k.get("last_error") or "")[:200],
|
|
549
|
+
"last_fail_at": float(k.get("last_fail_at") or 0)})
|
|
550
|
+
row["keys"] = ks
|
|
551
|
+
row["keys_enabled"] = sum(1 for k in ks if k["enabled"])
|
|
552
|
+
out.append(row)
|
|
553
|
+
out.sort(key=lambda r: not r["enabled"]) # 兜底:导入等未走启停操作的也保持启用在前
|
|
554
|
+
return out
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ---------------------------------------------------------------- 多 KEY(同厂商多密钥)
|
|
558
|
+
# 一个厂商可配多把 KEY(不同账号,或欠费后的备用号):
|
|
559
|
+
# provider["keys"] = [{"id","key","label","enabled","cool_until","last_error"}, ...]
|
|
560
|
+
# 数组顺序即调用顺序(界面可拖拽);api_key 是「首个可用 KEY」的镜像,所有既有
|
|
561
|
+
# 读取点(凭据注入/健康探测/取模型列表)无需感知多 KEY 结构。没配 keys 的老数据
|
|
562
|
+
# 由 _provider_keys 按 api_key 现场合成一条——文件一个字节都不用改。
|
|
563
|
+
_KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
|
|
564
|
+
MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
|
|
565
|
+
MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
|
|
566
|
+
# 欠费/配额类失败:换 KEY 有意义(同厂商另一账号还能用),与瞬态网络错误分开记
|
|
567
|
+
_QUOTA_HINTS = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
568
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _quota_error(err):
|
|
572
|
+
"""疑似欠费/配额耗尽。误判的代价只是临时切到备用 KEY(冷却到期或手动恢复
|
|
573
|
+
即回到首选),比「账单断了还死磕同一把 KEY」小得多。"""
|
|
574
|
+
e = (err or "").lower()
|
|
575
|
+
return any(k in e for k in _QUOTA_HINTS)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _provider_keys(prov, available_only=False, now=None):
|
|
579
|
+
"""供应商的 KEY 列表,顺序=调用顺序。返回 [{id,key,label,enabled,cooling,...}]。
|
|
580
|
+
|
|
581
|
+
available_only 只留「启用且不在冷却期」的;没配 keys 时按 api_key 合成一条。
|
|
582
|
+
"""
|
|
583
|
+
ks = (prov or {}).get("keys")
|
|
584
|
+
if not isinstance(ks, list) or not ks:
|
|
585
|
+
legacy = ((prov or {}).get("api_key") or "").strip()
|
|
586
|
+
ks = [{"id": "k1", "key": legacy, "label": "", "enabled": True}] if legacy else []
|
|
587
|
+
now = time.time() if now is None else now
|
|
588
|
+
out = []
|
|
589
|
+
for i, k in enumerate(ks):
|
|
590
|
+
if not isinstance(k, dict):
|
|
591
|
+
continue
|
|
592
|
+
kk = (k.get("key") or "").strip()
|
|
593
|
+
if not kk:
|
|
594
|
+
continue
|
|
595
|
+
en = bool(k.get("enabled", True))
|
|
596
|
+
cooling = float(k.get("cool_until") or 0) > now
|
|
597
|
+
if available_only and (not en or cooling):
|
|
598
|
+
continue
|
|
599
|
+
out.append(dict(k, key=kk, enabled=en, cooling=cooling,
|
|
600
|
+
id=str(k.get("id") or ("k%d" % (i + 1)))))
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _chain_keys(prov):
|
|
605
|
+
"""链条目展开用的 KEY 序列:优先「启用且未冷却」;全都冷却时退回首个启用的
|
|
606
|
+
(冷却没到期也得有人顶,否则整个供应商被跳过——代价比多试一次大)。"""
|
|
607
|
+
avail = _provider_keys(prov, available_only=True)
|
|
608
|
+
if avail:
|
|
609
|
+
return avail[:MAX_PROVIDER_KEYS]
|
|
610
|
+
return [k for k in _provider_keys(prov) if k["enabled"]][:1]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _sync_api_key(prov):
|
|
614
|
+
"""把 api_key 镜像刷成「首个可用 KEY」(全冷却时取首个启用的)。
|
|
615
|
+
|
|
616
|
+
这是多 KEY 与既有单 KEY 代码之间的唯一桥:注入/健康/探测读 api_key 的地方
|
|
617
|
+
自动跟着切换,不必逐处改成读 keys。没有启用的 KEY 时置空——既有
|
|
618
|
+
「无密钥即跳过」的判定自然生效。
|
|
619
|
+
"""
|
|
620
|
+
if not isinstance(prov.get("keys"), list):
|
|
621
|
+
return # 老结构:api_key 就是真源,别动它
|
|
622
|
+
avail = _provider_keys(prov, available_only=True)
|
|
623
|
+
pick = avail[0] if avail else next((k for k in _provider_keys(prov) if k["enabled"]), None)
|
|
624
|
+
prov["api_key"] = pick["key"] if pick else ""
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _materialize_keys(prov):
|
|
628
|
+
"""把「按 api_key 合成」的隐式单 KEY 落成显式 keys 数组(首次改 KEY 时调用)。
|
|
629
|
+
返回该数组(就地写入 prov)。"""
|
|
630
|
+
if not isinstance(prov.get("keys"), list):
|
|
631
|
+
prov["keys"] = [{"id": "k1", "key": k["key"], "label": k.get("label") or "",
|
|
632
|
+
"enabled": True} for k in _provider_keys(prov)]
|
|
633
|
+
return prov["keys"]
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _next_key_id(keys):
|
|
637
|
+
used = {str(k.get("id") or "") for k in keys if isinstance(k, dict)}
|
|
638
|
+
for i in range(1, MAX_PROVIDER_KEYS + 2):
|
|
639
|
+
if ("k%d" % i) not in used:
|
|
640
|
+
return "k%d" % i
|
|
641
|
+
return "k%d" % (len(used) + 1)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def key_op(provider_id, op, key_id="", key="", label="", enabled=None, ids=None):
|
|
645
|
+
"""KEY 级操作(add | update | delete | reorder | reset)。返回错误串或 None。
|
|
646
|
+
|
|
647
|
+
reset 清掉冷却与最近错误(欠费充值后手动恢复);reorder 用 ids 给全量顺序。
|
|
648
|
+
"""
|
|
649
|
+
import time as _t
|
|
650
|
+
with _LOCK:
|
|
651
|
+
data = _load()
|
|
652
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
653
|
+
if not prov:
|
|
654
|
+
return "供应商不存在"
|
|
655
|
+
keys = _materialize_keys(prov)
|
|
656
|
+
if op == "add":
|
|
657
|
+
val = (key or "").strip()
|
|
658
|
+
if not val:
|
|
659
|
+
return "密钥不能为空"
|
|
660
|
+
if len(keys) >= MAX_PROVIDER_KEYS:
|
|
661
|
+
return "最多 %d 把密钥" % MAX_PROVIDER_KEYS
|
|
662
|
+
keys.append({"id": _next_key_id(keys), "key": val,
|
|
663
|
+
"label": (label or "").strip(), "enabled": True})
|
|
664
|
+
elif op in ("update", "delete", "enable", "disable", "reset"):
|
|
665
|
+
target = next((k for k in keys if str(k.get("id")) == str(key_id)), None)
|
|
666
|
+
if target is None:
|
|
667
|
+
return "密钥不存在"
|
|
668
|
+
if op == "delete":
|
|
669
|
+
keys.remove(target)
|
|
670
|
+
elif op == "update":
|
|
671
|
+
if (key or "").strip():
|
|
672
|
+
target["key"] = key.strip()
|
|
673
|
+
if label is not None:
|
|
674
|
+
target["label"] = (label or "").strip()
|
|
675
|
+
if enabled is not None:
|
|
676
|
+
target["enabled"] = bool(enabled)
|
|
677
|
+
if target["enabled"]:
|
|
678
|
+
target.pop("cool_until", None)
|
|
679
|
+
target.pop("last_error", None)
|
|
680
|
+
elif op in ("enable", "disable"):
|
|
681
|
+
target["enabled"] = (op == "enable")
|
|
682
|
+
if target["enabled"]: # 重新启用即视为手动恢复
|
|
683
|
+
target.pop("cool_until", None)
|
|
684
|
+
target.pop("last_error", None)
|
|
685
|
+
else: # reset:只清冷却与错误,不动启用状态(欠费充值后恢复首选位)
|
|
686
|
+
target.pop("cool_until", None)
|
|
687
|
+
target.pop("last_error", None)
|
|
688
|
+
elif op == "reorder":
|
|
689
|
+
order = [str(i) for i in (ids or [])]
|
|
690
|
+
byid = {str(k.get("id")): k for k in keys}
|
|
691
|
+
if sorted(order) != sorted(byid):
|
|
692
|
+
return "排序列表与现有密钥不一致"
|
|
693
|
+
prov["keys"] = [byid[i] for i in order]
|
|
694
|
+
else:
|
|
695
|
+
return "未知操作 " + op
|
|
696
|
+
_sync_api_key(prov)
|
|
697
|
+
# 密钥变了:旧的 wire 适配探测结果作废(换号可能换了可用协议面)
|
|
698
|
+
if op in ("add", "update", "delete"):
|
|
699
|
+
prov.pop("wire_caps", None)
|
|
700
|
+
_save(data)
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def note_key_error(provider_id, key_id, error=""):
|
|
705
|
+
"""一次 KEY 级失败回写:欠费类进冷却(后续解析自动跳过 → 切备用 KEY)。"""
|
|
706
|
+
import time as _t
|
|
707
|
+
if not provider_id or not key_id:
|
|
708
|
+
return
|
|
709
|
+
with _LOCK:
|
|
710
|
+
data = _load()
|
|
711
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
712
|
+
if not prov or not isinstance(prov.get("keys"), list):
|
|
713
|
+
return # 老结构没有 keys 数组:不值得为它落盘
|
|
714
|
+
target = next((k for k in prov["keys"] if str(k.get("id")) == str(key_id)), None)
|
|
715
|
+
if target is None:
|
|
716
|
+
return
|
|
717
|
+
target["last_error"] = (error or "")[:300]
|
|
718
|
+
target["last_fail_at"] = _t.time()
|
|
719
|
+
if _quota_error(error):
|
|
720
|
+
target["cool_until"] = _t.time() + _KEY_COOLDOWN_S
|
|
721
|
+
_sync_api_key(prov)
|
|
722
|
+
_save(data)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def note_key_ok(provider_id, key_id):
|
|
726
|
+
"""一次 KEY 级成功回写:清最近错误(冷却不动——那是欠费标记,等它自己过期)。"""
|
|
727
|
+
if not provider_id or not key_id:
|
|
728
|
+
return
|
|
729
|
+
with _LOCK:
|
|
730
|
+
data = _load()
|
|
731
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
732
|
+
if not prov or not isinstance(prov.get("keys"), list):
|
|
733
|
+
return
|
|
734
|
+
target = next((k for k in prov["keys"] if str(k.get("id")) == str(key_id)), None)
|
|
735
|
+
if target is None or not target.get("last_error"):
|
|
736
|
+
return
|
|
737
|
+
target.pop("last_error", None)
|
|
738
|
+
_save(data)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _is_private_host(url):
|
|
742
|
+
"""主机是私网/环回 IP 字面量或 localhost 时返回 True(自动放行内网网关)。"""
|
|
743
|
+
import urllib.parse
|
|
744
|
+
host = urllib.parse.urlsplit(url or "").hostname or ""
|
|
745
|
+
try:
|
|
746
|
+
a = ipaddress.ip_address(host)
|
|
747
|
+
return a.is_private or a.is_loopback
|
|
748
|
+
except ValueError:
|
|
749
|
+
return host == "localhost"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def upsert_provider(entry):
|
|
753
|
+
"""新增/更新供应商。api_key 为空串表示沿用旧值。返回错误或 None。"""
|
|
754
|
+
with _LOCK:
|
|
755
|
+
data = _load()
|
|
756
|
+
plist = data.setdefault("providers", [])
|
|
757
|
+
pid = (entry.get("id") or "").strip()
|
|
758
|
+
name = (entry.get("name") or "").strip()
|
|
759
|
+
base_url = (entry.get("base_url") or "").strip().rstrip("/")
|
|
760
|
+
if not name:
|
|
761
|
+
return "名称不能为空"
|
|
762
|
+
if not base_url.startswith(("http://", "https://")):
|
|
763
|
+
return "base_url 必须是 http/https"
|
|
764
|
+
target = None
|
|
765
|
+
if pid:
|
|
766
|
+
target = next((p for p in plist if p["id"] == pid), None)
|
|
767
|
+
if target is None:
|
|
768
|
+
target = {"id": _next_pid(plist)}
|
|
769
|
+
plist.append(target)
|
|
770
|
+
# 未指定格式:更新时沿用旧值(不因表单漏传就把已定的格式打回 auto),
|
|
771
|
+
# 新增时默认 auto——聚合网关一个密钥常同时开多条 wire,不必先问用户。
|
|
772
|
+
proto = entry.get("protocol") or target.get("protocol") or _PROTOCOL_AUTO
|
|
773
|
+
if proto not in _PROTOCOL_CHOICES:
|
|
774
|
+
return "protocol 只能是 %s" % " / ".join(_PROTOCOL_CHOICES)
|
|
775
|
+
# 地址或密钥变了,旧的 wire 适配探测结果作废(下次刷新/手动测试重测)
|
|
776
|
+
if target.get("base_url") != base_url or (entry.get("api_key") or "").strip():
|
|
777
|
+
target.pop("wire_caps", None)
|
|
778
|
+
target.update({
|
|
779
|
+
"name": name, "protocol": proto, "base_url": base_url,
|
|
780
|
+
"model": (entry.get("model") or "").strip(),
|
|
781
|
+
"model_easy": (entry.get("model_easy") or "").strip(),
|
|
782
|
+
"model_hard": (entry.get("model_hard") or "").strip(),
|
|
783
|
+
"source": entry.get("source") or target.get("source") or "manual",
|
|
784
|
+
})
|
|
785
|
+
key = (entry.get("api_key") or "").strip()
|
|
786
|
+
if isinstance(target.get("keys"), list):
|
|
787
|
+
# 多 KEY 供应商:表单里新填的密钥替换首选 KEY 的值(不另开一把),
|
|
788
|
+
# 并清掉它的冷却/错误——用户手填密钥就是「这把是好的」的意思。
|
|
789
|
+
if key:
|
|
790
|
+
ks = _materialize_keys(target)
|
|
791
|
+
if ks:
|
|
792
|
+
ks[0]["key"] = key
|
|
793
|
+
ks[0].pop("cool_until", None)
|
|
794
|
+
ks[0].pop("last_error", None)
|
|
795
|
+
else:
|
|
796
|
+
target["keys"] = [{"id": "k1", "key": key, "label": "", "enabled": True}]
|
|
797
|
+
_sync_api_key(target)
|
|
798
|
+
elif key or "api_key" not in target:
|
|
799
|
+
target["api_key"] = key
|
|
800
|
+
if _is_private_host(target["base_url"]):
|
|
801
|
+
target["allow_private"] = True
|
|
802
|
+
_save(data)
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def providers_op(ids, op):
|
|
807
|
+
"""批量供应商操作(enable | disable | delete | duplicate)。返回 (改动数, 错误)。
|
|
808
|
+
|
|
809
|
+
停用只影响 Tutti 编排时的运行时解析(resolve_binding 返回空 → 回落 CLI 默认),
|
|
810
|
+
不清除配置,也不影响绑定引用,随时可再启用。
|
|
811
|
+
duplicate 复制一条(同地址同密钥的第二个账号/新网关),副本不带绑定引用。
|
|
812
|
+
"""
|
|
813
|
+
ids = list(dict.fromkeys(i for i in (ids or []) if i))
|
|
814
|
+
if op not in ("enable", "disable", "delete", "duplicate"):
|
|
815
|
+
return 0, "未知操作 " + op
|
|
816
|
+
if not ids:
|
|
817
|
+
return 0, "未选择供应商"
|
|
818
|
+
with _LOCK:
|
|
819
|
+
data = _load()
|
|
820
|
+
plist = data.get("providers", [])
|
|
821
|
+
known = {p.get("id") for p in plist}
|
|
822
|
+
if op == "duplicate":
|
|
823
|
+
missing = [i for i in ids if i not in known]
|
|
824
|
+
if missing:
|
|
825
|
+
return 0, "供应商不存在"
|
|
826
|
+
fresh = []
|
|
827
|
+
for i in ids:
|
|
828
|
+
src = next(p for p in plist if p.get("id") == i)
|
|
829
|
+
dup = copy.deepcopy(src)
|
|
830
|
+
dup["id"] = _next_pid(plist + fresh)
|
|
831
|
+
dup["name"] = (src.get("name") or "供应商") + " 副本"
|
|
832
|
+
dup["enabled"] = True
|
|
833
|
+
# 副本的 KEY 重新编号,避免与源共用一个 id(界面按 id 定位)
|
|
834
|
+
if isinstance(dup.get("keys"), list):
|
|
835
|
+
for n, k in enumerate(dup["keys"], 1):
|
|
836
|
+
if isinstance(k, dict):
|
|
837
|
+
k["id"] = "k%d" % n
|
|
838
|
+
k.pop("cool_until", None)
|
|
839
|
+
k.pop("last_error", None)
|
|
840
|
+
dup.pop("orchestrator", None)
|
|
841
|
+
fresh.append(dup)
|
|
842
|
+
plist.extend(fresh)
|
|
843
|
+
_save(data)
|
|
844
|
+
return len(fresh), ""
|
|
845
|
+
missing = [i for i in ids if i not in known]
|
|
846
|
+
if missing:
|
|
847
|
+
return 0, "供应商不存在:%d 个" % len(missing)
|
|
848
|
+
changed = 0
|
|
849
|
+
if op == "delete":
|
|
850
|
+
data["providers"] = [p for p in plist if p.get("id") not in ids]
|
|
851
|
+
for b in (data.get("bindings") or {}).values():
|
|
852
|
+
if b.get("provider_id") in ids:
|
|
853
|
+
b["provider_id"] = ""
|
|
854
|
+
if b.get("chain"):
|
|
855
|
+
b["chain"] = [c for c in b["chain"] if c.get("provider_id") not in ids]
|
|
856
|
+
_sync_chain_refs(b)
|
|
857
|
+
orch = data.get("orchestrator")
|
|
858
|
+
if orch and orch.get("provider_id") in ids:
|
|
859
|
+
orch["provider_id"] = ""
|
|
860
|
+
orch["enabled"] = False
|
|
861
|
+
changed = len(ids)
|
|
862
|
+
else:
|
|
863
|
+
want = (op == "enable")
|
|
864
|
+
for p in plist:
|
|
865
|
+
if p.get("id") in ids and bool(p.get("enabled", True)) != want:
|
|
866
|
+
p["enabled"] = want
|
|
867
|
+
changed += 1
|
|
868
|
+
if changed:
|
|
869
|
+
# 启用置顶:本次选中的启用者排最前,其余启用跟随,停用的殿后;
|
|
870
|
+
# 排序稳定,各块内部保持原有顺序。落盘重排,列表页看到的就是它。
|
|
871
|
+
sel = set(ids)
|
|
872
|
+
plist.sort(key=lambda p: (
|
|
873
|
+
not (p.get("id") in sel and p.get("enabled", True)),
|
|
874
|
+
not bool(p.get("enabled", True))))
|
|
875
|
+
_save(data)
|
|
876
|
+
return changed, ("" if changed else "所选供应商已是目标状态")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def delete_provider(pid):
|
|
880
|
+
"""删除单个供应商(兼容旧调用)。"""
|
|
881
|
+
providers_op([pid], "delete")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# 绑定模型链最多几个:1 个主模型 + 2 个降级备选(与 runner 降级链上限一致)
|
|
885
|
+
MAX_BIND_MODELS = 3
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _clean_models(models):
|
|
889
|
+
"""去空、去重、限长,保持勾选顺序(第 1 个即主模型)。"""
|
|
890
|
+
out = []
|
|
891
|
+
for m in models or []:
|
|
892
|
+
m = str(m or "").strip()
|
|
893
|
+
if m and m not in out:
|
|
894
|
+
out.append(m)
|
|
895
|
+
return out[:MAX_BIND_MODELS]
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _clean_chain(chain):
|
|
899
|
+
"""规范化跨厂商模型链:[{provider_id, model}],去空去重限长,保持顺序。"""
|
|
900
|
+
out, seen = [], set()
|
|
901
|
+
for c in chain or []:
|
|
902
|
+
if not isinstance(c, dict):
|
|
903
|
+
continue
|
|
904
|
+
pid = str(c.get("provider_id") or "").strip()
|
|
905
|
+
m = str(c.get("model") or "").strip()
|
|
906
|
+
if not m or (pid, m) in seen:
|
|
907
|
+
continue
|
|
908
|
+
seen.add((pid, m))
|
|
909
|
+
out.append({"provider_id": pid, "model": m})
|
|
910
|
+
return out[:MAX_BIND_MODELS]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _sync_chain_refs(b):
|
|
914
|
+
"""chain 是唯一真源:重建 models / model 兼容冗余与主供应商字段。
|
|
915
|
+
|
|
916
|
+
链空时保留既有 provider_id——主供应商是用户显式绑定的,不因模型链
|
|
917
|
+
清空而丢失(此后按该供应商默认/难度模型解析)。
|
|
918
|
+
"""
|
|
919
|
+
chain = b.get("chain") or []
|
|
920
|
+
b["models"] = [c["model"] for c in chain]
|
|
921
|
+
b["model"] = b["models"][0] if b["models"] else ""
|
|
922
|
+
pid = next((c["provider_id"] for c in chain if c.get("provider_id")), None)
|
|
923
|
+
if pid is not None:
|
|
924
|
+
b["provider_id"] = pid
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _binding_chain(b):
|
|
928
|
+
"""取绑定的跨厂商链:优先 chain;旧数据从 provider_id + models 推导。"""
|
|
929
|
+
chain = b.get("chain") or []
|
|
930
|
+
if chain:
|
|
931
|
+
return chain
|
|
932
|
+
pid = b.get("provider_id") or ""
|
|
933
|
+
names = _clean_models(b.get("models") or ([b["model"]] if b.get("model") else []))
|
|
934
|
+
return [{"provider_id": pid, "model": n} for n in names]
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def set_binding(agent_id, provider_id=None, model=None, models=None,
|
|
938
|
+
difficulty_routing=None, chain=None):
|
|
939
|
+
"""写一条 CLI 绑定。chain=[{provider_id, model}] 是跨厂商模型链(唯一真源),
|
|
940
|
+
models/model 是它的兼容冗余;任一形式写入都会同步重建其余字段。"""
|
|
941
|
+
with _LOCK:
|
|
942
|
+
data = _load()
|
|
943
|
+
b = data.setdefault("bindings", {}).setdefault(agent_id, {})
|
|
944
|
+
eff_pid = b.get("provider_id") or ""
|
|
945
|
+
if provider_id is not None:
|
|
946
|
+
eff_pid = provider_id if provider_id in [p["id"] for p in data.get("providers", [])] else ""
|
|
947
|
+
if chain is not None:
|
|
948
|
+
b["chain"] = _clean_chain(chain)
|
|
949
|
+
_sync_chain_refs(b)
|
|
950
|
+
if provider_id is not None:
|
|
951
|
+
b["provider_id"] = eff_pid # 显式指定主供应商时覆盖链首推导
|
|
952
|
+
elif models is not None:
|
|
953
|
+
# 有序模型链:第 1 个主模型,其余按序降级
|
|
954
|
+
clean = _clean_models(models)
|
|
955
|
+
b["chain"] = [{"provider_id": eff_pid, "model": m} for m in clean]
|
|
956
|
+
_sync_chain_refs(b)
|
|
957
|
+
elif model is not None:
|
|
958
|
+
b["model"] = (model or "").strip()
|
|
959
|
+
clean = [b["model"]] if b["model"] else []
|
|
960
|
+
b["chain"] = [{"provider_id": eff_pid, "model": m} for m in clean]
|
|
961
|
+
b["models"] = clean
|
|
962
|
+
# model 为空 = 仅注入供应商凭据(resolve 文档承诺的状态):
|
|
963
|
+
# 显式指定 provider_id 时必须落盘,否则全新绑定静默丢主供应商
|
|
964
|
+
if provider_id is not None:
|
|
965
|
+
b["provider_id"] = eff_pid
|
|
966
|
+
elif provider_id is not None:
|
|
967
|
+
b["provider_id"] = eff_pid
|
|
968
|
+
if difficulty_routing is not None:
|
|
969
|
+
b["difficulty_routing"] = bool(difficulty_routing)
|
|
970
|
+
_save(data)
|
|
971
|
+
return b
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ---------------------------------------------------------------- 多源导入
|
|
975
|
+
#
|
|
976
|
+
# 每个来源一个采集器:只读本机已有 AI 工具 / CLI 的配置,产出统一的供应商条目。
|
|
977
|
+
# 采集器不改写任何工具的配置;密钥仅落到 data/models.json(与 CCSwitch 同等信任域)。
|
|
978
|
+
# 采集器返回 {"providers": [...], "pricing": {...}, "note": str, "error": str}。
|
|
979
|
+
|
|
980
|
+
_HOME = os.path.expanduser("~")
|
|
981
|
+
_APPDATA = os.environ.get("APPDATA") or os.path.join(_HOME, "AppData", "Roaming")
|
|
982
|
+
|
|
983
|
+
CCSWITCH_DB = os.path.join(_HOME, ".cc-switch", "cc-switch.db")
|
|
984
|
+
CLAUDE_SETTINGS = os.path.join(_HOME, ".claude", "settings.json")
|
|
985
|
+
CODEX_DIR = os.path.join(_HOME, ".codex")
|
|
986
|
+
DSH_DIR = os.environ.get("DSH_HOME") or os.path.join(_HOME, ".dsh")
|
|
987
|
+
DSH_SETTINGS = os.path.join(DSH_DIR, "settings.yaml")
|
|
988
|
+
ZCODE_CONFIG = os.path.join(_HOME, ".zcode", "v2", "config.json")
|
|
989
|
+
QWEN_SETTINGS = os.path.join(_HOME, ".qwen", "settings.json")
|
|
990
|
+
GEMINI_DIR = os.path.join(_HOME, ".gemini")
|
|
991
|
+
OPENCODE_CONFIG = os.path.join(_HOME, ".config", "opencode", "opencode.json")
|
|
992
|
+
OPENCODE_AUTH = os.path.join(_HOME, ".local", "share", "opencode", "auth.json")
|
|
993
|
+
CONTINUE_DIR = os.path.join(_HOME, ".continue")
|
|
994
|
+
CURSOR_DB = os.path.join(_APPDATA, "Cursor", "User", "globalStorage", "state.vscdb")
|
|
995
|
+
TRAE_DBS = [os.path.join(_APPDATA, "Trae CN", "User", "globalStorage", "state.vscdb"),
|
|
996
|
+
os.path.join(_APPDATA, "TRAE SOLO CN", "User", "globalStorage", "state.vscdb")]
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _next_pid(plist):
|
|
1000
|
+
"""分配下一个 prov-N id(取现有最大序号 +1,避免删除后再新增撞号)。"""
|
|
1001
|
+
n = 0
|
|
1002
|
+
for p in plist:
|
|
1003
|
+
m = re.match(r"^prov-(\d+)$", p.get("id") or "")
|
|
1004
|
+
if m:
|
|
1005
|
+
n = max(n, int(m.group(1)))
|
|
1006
|
+
return "prov-%d" % (n + 1)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _read_json(path):
|
|
1010
|
+
try:
|
|
1011
|
+
with open(path, "r", encoding="utf-8-sig") as f:
|
|
1012
|
+
return json.load(f)
|
|
1013
|
+
except Exception:
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _read_env_file(path):
|
|
1018
|
+
"""解析 KEY=VALUE 形式的 .env(忽略注释与空行)。"""
|
|
1019
|
+
out = {}
|
|
1020
|
+
try:
|
|
1021
|
+
with open(path, "r", encoding="utf-8-sig", errors="replace") as f:
|
|
1022
|
+
for line in f:
|
|
1023
|
+
line = line.strip()
|
|
1024
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1025
|
+
continue
|
|
1026
|
+
k, v = line.split("=", 1)
|
|
1027
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|
|
1030
|
+
return out
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _sqlite_ro(path):
|
|
1034
|
+
return sqlite3.connect("file:%s?mode=ro" % path.replace("\\", "/"), uri=True)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _vsc_rows(db):
|
|
1038
|
+
"""读取 VS Code 系(Cursor / Trae)state.vscdb 的 ItemTable:{key: str}。"""
|
|
1039
|
+
out = {}
|
|
1040
|
+
try:
|
|
1041
|
+
con = _sqlite_ro(db)
|
|
1042
|
+
try:
|
|
1043
|
+
cur = con.cursor()
|
|
1044
|
+
cur.execute("SELECT key, value FROM ItemTable")
|
|
1045
|
+
for k, v in cur.fetchall():
|
|
1046
|
+
out[k] = v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v)
|
|
1047
|
+
finally:
|
|
1048
|
+
con.close()
|
|
1049
|
+
except Exception:
|
|
1050
|
+
return {}
|
|
1051
|
+
return out
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _toml_parse(text):
|
|
1055
|
+
"""极简 TOML:只取顶层标量与 [section] 标量(够解析 codex config.toml)。
|
|
1056
|
+
|
|
1057
|
+
仅识别带引号的字符串、true/false 与裸值;不做类型推断,够用即止。
|
|
1058
|
+
"""
|
|
1059
|
+
top, sections, cur = {}, {}, None
|
|
1060
|
+
for raw in (text or "").splitlines():
|
|
1061
|
+
line = raw.strip()
|
|
1062
|
+
if not line or line.startswith("#"):
|
|
1063
|
+
continue
|
|
1064
|
+
m = re.match(r"^\[([^\]]+)\]$", line)
|
|
1065
|
+
if m:
|
|
1066
|
+
sec = m.group(1).strip()
|
|
1067
|
+
cur = sections.setdefault(sec, {})
|
|
1068
|
+
continue
|
|
1069
|
+
m = re.match(r"^([A-Za-z_][\w.-]*)\s*=\s*(.+?)\s*$", line)
|
|
1070
|
+
if not m:
|
|
1071
|
+
continue
|
|
1072
|
+
k, v = m.group(1), m.group(2)
|
|
1073
|
+
mq = re.match(r'^"([^"]*)"$', v)
|
|
1074
|
+
if mq:
|
|
1075
|
+
val = mq.group(1)
|
|
1076
|
+
elif v in ("true", "false"):
|
|
1077
|
+
val = (v == "true")
|
|
1078
|
+
else:
|
|
1079
|
+
val = v
|
|
1080
|
+
(cur if cur is not None else top)[k] = val
|
|
1081
|
+
return top, sections
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _toml_quoted(text, key):
|
|
1085
|
+
m = re.search(r'(?m)^\s*' + re.escape(key) + r'\s*=\s*"([^"]*)"', text or "")
|
|
1086
|
+
return m.group(1) if m else None
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _yaml_models(path):
|
|
1090
|
+
"""解析 Continue 的 config.yaml(优先 PyYAML,缺失时用极简回退解析)。"""
|
|
1091
|
+
try:
|
|
1092
|
+
import yaml
|
|
1093
|
+
with open(path, "r", encoding="utf-8-sig") as f:
|
|
1094
|
+
d = yaml.safe_load(f)
|
|
1095
|
+
if isinstance(d, dict) and isinstance(d.get("models"), list):
|
|
1096
|
+
return d["models"]
|
|
1097
|
+
except Exception:
|
|
1098
|
+
pass
|
|
1099
|
+
# 回退:只解析顶层 models: 下的扁平映射列表(Continue 模型条目即此形状)
|
|
1100
|
+
try:
|
|
1101
|
+
lines = open(path, "r", encoding="utf-8-sig", errors="replace").read().splitlines()
|
|
1102
|
+
except Exception:
|
|
1103
|
+
return []
|
|
1104
|
+
items, cur, in_models = [], None, False
|
|
1105
|
+
for raw in lines:
|
|
1106
|
+
line = raw.rstrip()
|
|
1107
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
1108
|
+
continue
|
|
1109
|
+
indent = len(line) - len(line.lstrip())
|
|
1110
|
+
s = line.strip()
|
|
1111
|
+
if indent == 0:
|
|
1112
|
+
if s.startswith("models:"):
|
|
1113
|
+
in_models = True
|
|
1114
|
+
continue
|
|
1115
|
+
in_models = False
|
|
1116
|
+
continue
|
|
1117
|
+
if not in_models:
|
|
1118
|
+
continue
|
|
1119
|
+
if s.startswith("- "):
|
|
1120
|
+
if cur is not None:
|
|
1121
|
+
items.append(cur)
|
|
1122
|
+
cur, s = {}, s[2:].strip()
|
|
1123
|
+
if cur is not None and ":" in s:
|
|
1124
|
+
k, v = s.split(":", 1)
|
|
1125
|
+
v = v.strip().strip('"').strip("'")
|
|
1126
|
+
if v:
|
|
1127
|
+
cur[k.strip()] = v
|
|
1128
|
+
if cur is not None:
|
|
1129
|
+
items.append(cur)
|
|
1130
|
+
return items
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
_ENDPOINT_SUFFIX = ("/chat/completions", "/completions", "/responses", "/messages",
|
|
1134
|
+
"/v1beta/models", "/models")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _strip_endpoint(url):
|
|
1138
|
+
"""把误填成完整接口地址的 base_url 收敛回基址。
|
|
1139
|
+
|
|
1140
|
+
部分工具(如 Trae 自定义模型)保存的是 https://host/v1/chat/completions,
|
|
1141
|
+
这里剥掉接口段,只保留 https://host/v1。
|
|
1142
|
+
"""
|
|
1143
|
+
u = (url or "").strip().rstrip("/")
|
|
1144
|
+
for suf in _ENDPOINT_SUFFIX:
|
|
1145
|
+
if u.endswith(suf):
|
|
1146
|
+
u = u[: -len(suf)].rstrip("/")
|
|
1147
|
+
break
|
|
1148
|
+
return u
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _prov(name, protocol, base_url, api_key, source, source_id, model="",
|
|
1152
|
+
model_easy="", model_hard="", wire_api=None, models=None):
|
|
1153
|
+
"""归一化一个导入条目;地址非法时返回 None。"""
|
|
1154
|
+
base_url = _strip_endpoint(base_url)
|
|
1155
|
+
if not base_url.startswith(("http://", "https://")):
|
|
1156
|
+
return None
|
|
1157
|
+
proto = protocol if protocol in _PROTOCOLS else "openai"
|
|
1158
|
+
out = {
|
|
1159
|
+
"name": (name or "").strip() or source_id,
|
|
1160
|
+
"protocol": proto,
|
|
1161
|
+
"base_url": base_url,
|
|
1162
|
+
"api_key": (api_key or "").strip(),
|
|
1163
|
+
"model": (model or "").strip(),
|
|
1164
|
+
"model_easy": (model_easy or "").strip(),
|
|
1165
|
+
"model_hard": (model_hard or "").strip(),
|
|
1166
|
+
"source": source,
|
|
1167
|
+
"source_id": source_id,
|
|
1168
|
+
}
|
|
1169
|
+
if wire_api:
|
|
1170
|
+
out["wire_api"] = wire_api
|
|
1171
|
+
if _is_private_host(base_url):
|
|
1172
|
+
out["allow_private"] = True
|
|
1173
|
+
names = [n for n in (models or []) if n]
|
|
1174
|
+
if names:
|
|
1175
|
+
out["models"] = [{"name": n, "enabled": True, "priority": i + 1}
|
|
1176
|
+
for i, n in enumerate(dict.fromkeys(names))]
|
|
1177
|
+
out["models_fetched_at"] = "配置导入"
|
|
1178
|
+
return out
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _merge_provider(data, prov):
|
|
1182
|
+
"""按 (source, source_id) 幂等合并:重导入继承 id 并保留用户改动。
|
|
1183
|
+
|
|
1184
|
+
保留项:用户填过的默认/难度模型、启停状态、已拉取的模型列表(含删除墓碑)。
|
|
1185
|
+
返回 added | updated | duplicate。
|
|
1186
|
+
"""
|
|
1187
|
+
plist = data.setdefault("providers", [])
|
|
1188
|
+
sid = prov.get("source_id") or ""
|
|
1189
|
+
old = None
|
|
1190
|
+
if sid:
|
|
1191
|
+
old = next((p for p in plist if p.get("source") == prov.get("source")
|
|
1192
|
+
and p.get("source_id") == sid), None)
|
|
1193
|
+
if old is None:
|
|
1194
|
+
# 跨来源去重:同一个网关常被多个工具分别记录(如 CCSwitch / Claude Code / ZCode),
|
|
1195
|
+
# 导入全部时按「地址 + 密钥」收敛成一条,避免同一供应商出现多份。
|
|
1196
|
+
same = next((p for p in plist if p.get("base_url") == prov.get("base_url")), None)
|
|
1197
|
+
if same is not None:
|
|
1198
|
+
newkey = prov.get("api_key") or ""
|
|
1199
|
+
have = same.get("api_key") or ""
|
|
1200
|
+
if newkey and not have:
|
|
1201
|
+
same["api_key"] = newkey # 从别的工具补齐缺失的密钥
|
|
1202
|
+
return "updated"
|
|
1203
|
+
if not (newkey and have and newkey != have):
|
|
1204
|
+
return "duplicate" # 密钥相同,或新条目未带密钥
|
|
1205
|
+
# 同地址但密钥不同 = 同一网关的两个账号,继续按新增处理
|
|
1206
|
+
if old is not None:
|
|
1207
|
+
new = dict(old)
|
|
1208
|
+
new.update(prov)
|
|
1209
|
+
for f in ("model", "model_easy", "model_hard"):
|
|
1210
|
+
if (old.get(f) or "").strip():
|
|
1211
|
+
new[f] = old[f] # 用户填过的字段不被配置覆盖
|
|
1212
|
+
if old.get("models") is not None:
|
|
1213
|
+
new["models"] = old["models"] # 已有列表优先(含启停 / 排序 / 墓碑)
|
|
1214
|
+
new["models_fetched_at"] = old.get("models_fetched_at") or new.get("models_fetched_at")
|
|
1215
|
+
for f in ("allow_private", "wire_api"):
|
|
1216
|
+
if f in old:
|
|
1217
|
+
new[f] = old[f]
|
|
1218
|
+
if not old.get("enabled", True):
|
|
1219
|
+
new["enabled"] = False
|
|
1220
|
+
for i, p in enumerate(plist):
|
|
1221
|
+
if p is old:
|
|
1222
|
+
plist[i] = new
|
|
1223
|
+
break
|
|
1224
|
+
return "updated"
|
|
1225
|
+
prov = dict(prov)
|
|
1226
|
+
prov["id"] = _next_pid(plist)
|
|
1227
|
+
plist.append(prov)
|
|
1228
|
+
return "added"
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# ---------------------------------------------------------------- 各来源采集器
|
|
1232
|
+
|
|
1233
|
+
def _cc_providers(app, pid, name, sc):
|
|
1234
|
+
"""CCSwitch 单条 provider 记录 → 归一化条目(按 app_type 解析各自配置形状)。
|
|
1235
|
+
|
|
1236
|
+
source_id 沿用历史格式 `<app>:<pid>`:老用户已有的供应商重导入是「更新」而非重复,
|
|
1237
|
+
也因此不会丢掉已拉取的模型列表与难度映射。
|
|
1238
|
+
"""
|
|
1239
|
+
sid = "%s:%s" % (app, pid)
|
|
1240
|
+
if app in ("claude", "claude-desktop"):
|
|
1241
|
+
env = sc.get("env") or {}
|
|
1242
|
+
p = _prov(name, "anthropic", env.get("ANTHROPIC_BASE_URL"),
|
|
1243
|
+
env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"),
|
|
1244
|
+
"ccswitch", sid,
|
|
1245
|
+
model=env.get("ANTHROPIC_MODEL") or env.get("ANTHROPIC_DEFAULT_SONNET_MODEL") or "",
|
|
1246
|
+
model_easy=env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL") or "",
|
|
1247
|
+
model_hard=env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or "")
|
|
1248
|
+
return [p] if p else []
|
|
1249
|
+
if app == "codex":
|
|
1250
|
+
key = ((sc.get("auth") or {}).get("OPENAI_API_KEY")) or ""
|
|
1251
|
+
text = sc.get("config") or ""
|
|
1252
|
+
model = _toml_quoted(text, "model") or ""
|
|
1253
|
+
secs = [(s, kv) for s, kv in _toml_parse(text)[1].items()
|
|
1254
|
+
if s.startswith("model_providers.")]
|
|
1255
|
+
out = []
|
|
1256
|
+
for i, (sec, kv) in enumerate(secs):
|
|
1257
|
+
ident = sec.split(".", 1)[1]
|
|
1258
|
+
# 首个区块用历史 id(更新老数据);多区块时其余追加标识区分
|
|
1259
|
+
extra = (kv.get("name") or ident) if i else ""
|
|
1260
|
+
p = _prov(("%s · %s" % (name, extra)) if extra else name,
|
|
1261
|
+
"openai", kv.get("base_url"),
|
|
1262
|
+
kv.get("experimental_bearer_token") or key,
|
|
1263
|
+
"ccswitch", sid if i == 0 else sid + ":" + ident, model=model,
|
|
1264
|
+
wire_api=kv.get("wire_api") or "responses")
|
|
1265
|
+
if p:
|
|
1266
|
+
out.append(p)
|
|
1267
|
+
if not out: # 兼容旧格式:base_url 直接写在顶层
|
|
1268
|
+
p = _prov(name, "openai", _toml_quoted(text, "base_url"), key, "ccswitch", sid,
|
|
1269
|
+
model=model, wire_api=_toml_quoted(text, "wire_api") or "responses")
|
|
1270
|
+
if p:
|
|
1271
|
+
out.append(p)
|
|
1272
|
+
return out
|
|
1273
|
+
if app == "gemini":
|
|
1274
|
+
env = sc.get("env") or {}
|
|
1275
|
+
p = _prov(name, "google", env.get("GOOGLE_GEMINI_BASE_URL") or env.get("GEMINI_BASE_URL"),
|
|
1276
|
+
env.get("GEMINI_API_KEY") or env.get("GOOGLE_API_KEY"), "ccswitch", sid,
|
|
1277
|
+
model=env.get("GEMINI_MODEL") or "")
|
|
1278
|
+
return [p] if p else []
|
|
1279
|
+
if app == "openclaw":
|
|
1280
|
+
api = str(sc.get("api") or "").lower()
|
|
1281
|
+
proto = "anthropic" if "anthropic" in api else "openai"
|
|
1282
|
+
names = [m.get("id") or m.get("name")
|
|
1283
|
+
for m in (sc.get("models") or []) if isinstance(m, dict)]
|
|
1284
|
+
p = _prov(name, proto, sc.get("baseUrl"), sc.get("apiKey"), "ccswitch", sid, models=names)
|
|
1285
|
+
return [p] if p else []
|
|
1286
|
+
return []
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _src_ccswitch():
|
|
1290
|
+
out = {"providers": [], "pricing": {}, "note": "", "found": os.path.isfile(CCSWITCH_DB)}
|
|
1291
|
+
if not out["found"]:
|
|
1292
|
+
return out
|
|
1293
|
+
try:
|
|
1294
|
+
con = _sqlite_ro(CCSWITCH_DB)
|
|
1295
|
+
try:
|
|
1296
|
+
cur = con.cursor()
|
|
1297
|
+
cur.execute("SELECT id, app_type, name, settings_config FROM providers")
|
|
1298
|
+
rows = cur.fetchall()
|
|
1299
|
+
finally:
|
|
1300
|
+
con.close()
|
|
1301
|
+
except Exception as e:
|
|
1302
|
+
out["error"] = "读取数据库失败:%r" % (e,)
|
|
1303
|
+
return out
|
|
1304
|
+
skipped = []
|
|
1305
|
+
for pid, app, name, cfg in rows:
|
|
1306
|
+
try:
|
|
1307
|
+
sc = json.loads(cfg)
|
|
1308
|
+
except Exception:
|
|
1309
|
+
skipped.append("%s/%s" % (app, name))
|
|
1310
|
+
continue
|
|
1311
|
+
got = [p for p in _cc_providers(app, pid, name, sc or {}) if p]
|
|
1312
|
+
if got:
|
|
1313
|
+
out["providers"].extend(got)
|
|
1314
|
+
else:
|
|
1315
|
+
skipped.append("%s/%s" % (app, name))
|
|
1316
|
+
try:
|
|
1317
|
+
con = _sqlite_ro(CCSWITCH_DB)
|
|
1318
|
+
try:
|
|
1319
|
+
cur = con.cursor()
|
|
1320
|
+
cur.execute("SELECT model_id, input_cost_per_million, output_cost_per_million "
|
|
1321
|
+
"FROM model_pricing")
|
|
1322
|
+
for mid, cin, cout in cur.fetchall():
|
|
1323
|
+
try:
|
|
1324
|
+
out["pricing"][mid] = {"in": float(cin), "out": float(cout)}
|
|
1325
|
+
except Exception:
|
|
1326
|
+
pass
|
|
1327
|
+
finally:
|
|
1328
|
+
con.close()
|
|
1329
|
+
except Exception:
|
|
1330
|
+
pass
|
|
1331
|
+
if skipped:
|
|
1332
|
+
out["note"] = "跳过 %d 条(缺地址或格式不识别):%s" % (
|
|
1333
|
+
len(skipped), "、".join(sorted(set(skipped))[:4]))
|
|
1334
|
+
return out
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _src_claude():
|
|
1338
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(CLAUDE_SETTINGS)}
|
|
1339
|
+
if not out["found"]:
|
|
1340
|
+
return out
|
|
1341
|
+
env = (_read_json(CLAUDE_SETTINGS) or {}).get("env") or {}
|
|
1342
|
+
p = _prov("Claude Code", "anthropic", env.get("ANTHROPIC_BASE_URL"),
|
|
1343
|
+
env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"),
|
|
1344
|
+
"claude", "claude:settings",
|
|
1345
|
+
model=env.get("ANTHROPIC_MODEL") or env.get("ANTHROPIC_DEFAULT_SONNET_MODEL") or "",
|
|
1346
|
+
model_easy=env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL") or "",
|
|
1347
|
+
model_hard=env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or "")
|
|
1348
|
+
if p:
|
|
1349
|
+
out["providers"].append(p)
|
|
1350
|
+
elif env.get("ANTHROPIC_BASE_URL"):
|
|
1351
|
+
out["note"] = "settings.json 里的 ANTHROPIC_BASE_URL 不是 http/https"
|
|
1352
|
+
else:
|
|
1353
|
+
out["note"] = "settings.json 的 env 未配置 ANTHROPIC_BASE_URL"
|
|
1354
|
+
return out
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _src_codex():
|
|
1358
|
+
cfg = os.path.join(CODEX_DIR, "config.toml")
|
|
1359
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(cfg)}
|
|
1360
|
+
if not out["found"]:
|
|
1361
|
+
return out
|
|
1362
|
+
try:
|
|
1363
|
+
with open(cfg, "r", encoding="utf-8-sig", errors="replace") as f:
|
|
1364
|
+
text = f.read()
|
|
1365
|
+
except Exception as e:
|
|
1366
|
+
out["error"] = "读取 config.toml 失败:%r" % (e,)
|
|
1367
|
+
return out
|
|
1368
|
+
key = (_read_json(os.path.join(CODEX_DIR, "auth.json")) or {}).get("OPENAI_API_KEY") or ""
|
|
1369
|
+
model = _toml_quoted(text, "model") or ""
|
|
1370
|
+
active = _toml_quoted(text, "model_provider") or ""
|
|
1371
|
+
top, sections = _toml_parse(text)
|
|
1372
|
+
for sec, kv in sections.items():
|
|
1373
|
+
if not sec.startswith("model_providers."):
|
|
1374
|
+
continue
|
|
1375
|
+
ident = sec.split(".", 1)[1]
|
|
1376
|
+
p = _prov(kv.get("name") or ("Codex " + ident), "openai", kv.get("base_url"),
|
|
1377
|
+
kv.get("experimental_bearer_token") or key, "codex", "codex:" + ident,
|
|
1378
|
+
model=(model if ident == active else ""),
|
|
1379
|
+
wire_api=kv.get("wire_api") or "responses")
|
|
1380
|
+
if p:
|
|
1381
|
+
out["providers"].append(p)
|
|
1382
|
+
if not out["providers"]:
|
|
1383
|
+
p = _prov("Codex CLI", "openai", top.get("base_url") or _toml_quoted(text, "base_url"),
|
|
1384
|
+
key, "codex", "codex:default", model=model,
|
|
1385
|
+
wire_api=_toml_quoted(text, "wire_api") or "responses")
|
|
1386
|
+
if p:
|
|
1387
|
+
out["providers"].append(p)
|
|
1388
|
+
if not out["providers"]:
|
|
1389
|
+
out["note"] = ("config.toml 未配置第三方 model_providers"
|
|
1390
|
+
+ ("" if key else "(且 auth.json 无 OPENAI_API_KEY)"))
|
|
1391
|
+
return out
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _src_zcode():
|
|
1395
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(ZCODE_CONFIG)}
|
|
1396
|
+
if not out["found"]:
|
|
1397
|
+
return out
|
|
1398
|
+
d = _read_json(ZCODE_CONFIG) or {}
|
|
1399
|
+
default_model = str(d.get("model") or "")
|
|
1400
|
+
tail = default_model.split("/", 1)[1] if "/" in default_model else default_model
|
|
1401
|
+
bad = []
|
|
1402
|
+
for pid, p in (d.get("provider") or {}).items():
|
|
1403
|
+
if not isinstance(p, dict):
|
|
1404
|
+
continue
|
|
1405
|
+
o = p.get("options") or {}
|
|
1406
|
+
kind = str(p.get("kind") or "").lower()
|
|
1407
|
+
proto = "anthropic" if "anthropic" in kind else "openai"
|
|
1408
|
+
names = list((p.get("models") or {}).keys())
|
|
1409
|
+
pr = _prov(p.get("name") or pid, proto, o.get("baseURL"), o.get("apiKey"),
|
|
1410
|
+
"zcode", "zcode:%s" % pid,
|
|
1411
|
+
model=(tail if tail in names else ""), models=names)
|
|
1412
|
+
if pr:
|
|
1413
|
+
out["providers"].append(pr)
|
|
1414
|
+
else:
|
|
1415
|
+
bad.append(str(p.get("name") or pid))
|
|
1416
|
+
if bad:
|
|
1417
|
+
out["note"] = "跳过 %d 个(缺合法 baseURL):%s" % (len(bad), "、".join(bad[:4]))
|
|
1418
|
+
return out
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _src_qwen():
|
|
1422
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(QWEN_SETTINGS)}
|
|
1423
|
+
if not out["found"]:
|
|
1424
|
+
return out
|
|
1425
|
+
d = _read_json(QWEN_SETTINGS) or {}
|
|
1426
|
+
env = d.get("env") or {}
|
|
1427
|
+
cur = (d.get("model") or {}).get("name") if isinstance(d.get("model"), dict) else ""
|
|
1428
|
+
for key, items in (d.get("modelProviders") or {}).items():
|
|
1429
|
+
kl = str(key).lower()
|
|
1430
|
+
proto = "openai" if "openai" in kl else ("anthropic" if "anthropic" in kl else "openai")
|
|
1431
|
+
for it in (items or []):
|
|
1432
|
+
if not isinstance(it, dict):
|
|
1433
|
+
continue
|
|
1434
|
+
base = it.get("baseUrl") or it.get("base_url")
|
|
1435
|
+
apikey = (it.get("apiKey") or env.get(it.get("envKey") or it.get("apiKeyEnv") or "")
|
|
1436
|
+
or "")
|
|
1437
|
+
mid = it.get("id") or it.get("name") or key
|
|
1438
|
+
pr = _prov(it.get("name") or mid, proto, base, apikey, "qwen",
|
|
1439
|
+
"qwen:%s:%s" % (key, mid),
|
|
1440
|
+
model=(mid if mid and mid == cur else ""), models=[mid])
|
|
1441
|
+
if pr:
|
|
1442
|
+
out["providers"].append(pr)
|
|
1443
|
+
if not out["providers"]:
|
|
1444
|
+
out["note"] = "settings.json 的 modelProviders 为空"
|
|
1445
|
+
return out
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _src_gemini():
|
|
1449
|
+
envp = os.path.join(GEMINI_DIR, ".env")
|
|
1450
|
+
setp = os.path.join(GEMINI_DIR, "settings.json")
|
|
1451
|
+
out = {"providers": [], "note": "",
|
|
1452
|
+
"found": os.path.isfile(envp) or os.path.isfile(setp)}
|
|
1453
|
+
if not out["found"]:
|
|
1454
|
+
return out
|
|
1455
|
+
env = _read_env_file(envp)
|
|
1456
|
+
key = env.get("GEMINI_API_KEY") or env.get("GOOGLE_API_KEY") or ""
|
|
1457
|
+
base = env.get("GOOGLE_GEMINI_BASE_URL") or env.get("GEMINI_BASE_URL") or ""
|
|
1458
|
+
if not base and key:
|
|
1459
|
+
base = "https://generativelanguage.googleapis.com"
|
|
1460
|
+
p = _prov("Gemini CLI", "google", base, key, "gemini", "gemini:env",
|
|
1461
|
+
model=env.get("GEMINI_MODEL") or "")
|
|
1462
|
+
if p:
|
|
1463
|
+
out["providers"].append(p)
|
|
1464
|
+
else:
|
|
1465
|
+
out["note"] = "未在 .env 找到 GEMINI_API_KEY / GOOGLE_GEMINI_BASE_URL"
|
|
1466
|
+
return out
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _src_opencode():
|
|
1470
|
+
out = {"providers": [], "note": "",
|
|
1471
|
+
"found": os.path.isfile(OPENCODE_CONFIG) or os.path.isfile(OPENCODE_AUTH)}
|
|
1472
|
+
if not out["found"]:
|
|
1473
|
+
return out
|
|
1474
|
+
d = _read_json(OPENCODE_CONFIG) or {}
|
|
1475
|
+
auth = _read_json(OPENCODE_AUTH) or {}
|
|
1476
|
+
for pid, p in (d.get("provider") or {}).items():
|
|
1477
|
+
if not isinstance(p, dict):
|
|
1478
|
+
continue
|
|
1479
|
+
o = p.get("options") or {}
|
|
1480
|
+
key = o.get("apiKey") or ""
|
|
1481
|
+
if not key:
|
|
1482
|
+
a = auth.get(pid)
|
|
1483
|
+
key = (a.get("key") or a.get("apiKey") or "") if isinstance(a, dict) else (a or "")
|
|
1484
|
+
proto = "anthropic" if "anthropic" in str(p.get("npm") or "").lower() else "openai"
|
|
1485
|
+
names = list((p.get("models") or {}).keys())
|
|
1486
|
+
pr = _prov(p.get("name") or pid, proto, o.get("baseURL"), key,
|
|
1487
|
+
"opencode", "opencode:%s" % pid, models=names)
|
|
1488
|
+
if pr:
|
|
1489
|
+
out["providers"].append(pr)
|
|
1490
|
+
if not out["providers"]:
|
|
1491
|
+
out["note"] = "opencode.json 的 provider 为空"
|
|
1492
|
+
return out
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _src_continue():
|
|
1496
|
+
yp = os.path.join(CONTINUE_DIR, "config.yaml")
|
|
1497
|
+
jp = os.path.join(CONTINUE_DIR, "config.json")
|
|
1498
|
+
out = {"providers": [], "note": "",
|
|
1499
|
+
"found": os.path.isfile(yp) or os.path.isfile(jp)}
|
|
1500
|
+
if not out["found"]:
|
|
1501
|
+
return out
|
|
1502
|
+
models = []
|
|
1503
|
+
d = _read_json(jp)
|
|
1504
|
+
if isinstance(d, dict) and isinstance(d.get("models"), list):
|
|
1505
|
+
models = d["models"]
|
|
1506
|
+
elif os.path.isfile(yp):
|
|
1507
|
+
models = _yaml_models(yp)
|
|
1508
|
+
for m in models:
|
|
1509
|
+
if not isinstance(m, dict):
|
|
1510
|
+
continue
|
|
1511
|
+
base = m.get("apiBase") or m.get("api_base") or m.get("baseUrl")
|
|
1512
|
+
proto = "anthropic" if "anthropic" in str(m.get("provider") or "").lower() else "openai"
|
|
1513
|
+
name = m.get("name") or m.get("model") or "Continue"
|
|
1514
|
+
mid = m.get("model") or ""
|
|
1515
|
+
pr = _prov(name, proto, base, m.get("apiKey") or m.get("api_key"), "continue",
|
|
1516
|
+
"continue:%s" % name, model=mid, models=[mid] if mid else None)
|
|
1517
|
+
if pr:
|
|
1518
|
+
out["providers"].append(pr)
|
|
1519
|
+
if not out["providers"]:
|
|
1520
|
+
out["note"] = "配置里没有带 apiBase 的模型条目"
|
|
1521
|
+
return out
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _src_cursor():
|
|
1525
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(CURSOR_DB)}
|
|
1526
|
+
if not out["found"]:
|
|
1527
|
+
return out
|
|
1528
|
+
rows = _vsc_rows(CURSOR_DB)
|
|
1529
|
+
blob = next((v for k, v in rows.items() if "persistentStorage.applicationUser" in k), "")
|
|
1530
|
+
|
|
1531
|
+
def field(name):
|
|
1532
|
+
m = re.search(r'"%s"\s*:\s*"([^"]*)"' % re.escape(name), blob)
|
|
1533
|
+
return m.group(1) if m else ""
|
|
1534
|
+
|
|
1535
|
+
openai_key = rows.get("cursorAuth/openAIKey") or ""
|
|
1536
|
+
claude_key = rows.get("cursorAuth/claudeKey") or ""
|
|
1537
|
+
if openai_key and field("openAIBaseUrl"):
|
|
1538
|
+
p = _prov("Cursor · OpenAI", "openai", field("openAIBaseUrl"), openai_key,
|
|
1539
|
+
"cursor", "cursor:openai")
|
|
1540
|
+
if p:
|
|
1541
|
+
out["providers"].append(p)
|
|
1542
|
+
if claude_key and field("claudeBaseUrl"):
|
|
1543
|
+
p = _prov("Cursor · Claude", "anthropic", field("claudeBaseUrl"), claude_key,
|
|
1544
|
+
"cursor", "cursor:claude")
|
|
1545
|
+
if p:
|
|
1546
|
+
out["providers"].append(p)
|
|
1547
|
+
if not out["providers"]:
|
|
1548
|
+
out["note"] = "Cursor 未自定义 API Key / Base URL(官方订阅无本地凭证可导入)"
|
|
1549
|
+
return out
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _trae_models(d):
|
|
1553
|
+
"""Trae 的 model_list 可能是 [{...}] 或 {agent: [{...}]}。"""
|
|
1554
|
+
if isinstance(d, list):
|
|
1555
|
+
for it in d:
|
|
1556
|
+
if isinstance(it, dict):
|
|
1557
|
+
yield it
|
|
1558
|
+
elif isinstance(d, dict):
|
|
1559
|
+
for v in d.values():
|
|
1560
|
+
if isinstance(v, list):
|
|
1561
|
+
for it in v:
|
|
1562
|
+
if isinstance(it, dict):
|
|
1563
|
+
yield it
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _src_trae():
|
|
1567
|
+
out = {"providers": [], "note": "", "found": False}
|
|
1568
|
+
seen = set() # 同一模型会在多个 key / 多个库中重复出现,按归一化后的条目去重
|
|
1569
|
+
for db in TRAE_DBS:
|
|
1570
|
+
if not os.path.isfile(db):
|
|
1571
|
+
continue
|
|
1572
|
+
out["found"] = True
|
|
1573
|
+
for k, s in _vsc_rows(db).items():
|
|
1574
|
+
if ("model_list" not in k and "modelList" not in k) or "base_url" not in s:
|
|
1575
|
+
continue
|
|
1576
|
+
try:
|
|
1577
|
+
d = json.loads(s)
|
|
1578
|
+
except Exception:
|
|
1579
|
+
continue
|
|
1580
|
+
for it in _trae_models(d):
|
|
1581
|
+
base, ak = it.get("base_url"), it.get("ak")
|
|
1582
|
+
if not base or not ak:
|
|
1583
|
+
continue
|
|
1584
|
+
name = it.get("display_name") or it.get("name") or "Trae"
|
|
1585
|
+
proto = "anthropic" if "anthropic" in str(base).lower() else "openai"
|
|
1586
|
+
p = _prov(name, proto, base, ak, "trae",
|
|
1587
|
+
"trae:%s:%s" % (name, _strip_endpoint(base)))
|
|
1588
|
+
if p and p["source_id"] not in seen:
|
|
1589
|
+
seen.add(p["source_id"])
|
|
1590
|
+
out["providers"].append(p)
|
|
1591
|
+
if not out["providers"]:
|
|
1592
|
+
out["note"] = ("Trae 未添加自定义模型(预置模型的 AK / BaseURL 为空,无凭证可导入)"
|
|
1593
|
+
if out["found"] else "")
|
|
1594
|
+
return out
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _dotenv_get(path, key):
|
|
1598
|
+
"""极简 dotenv:取 KEY=VALUE 行的值(dsh 的 ~/.dsh/.env 是 credentials-local 存储)。"""
|
|
1599
|
+
try:
|
|
1600
|
+
with open(path, "r", encoding="utf-8-sig", errors="replace") as f:
|
|
1601
|
+
for raw in f:
|
|
1602
|
+
line = raw.strip()
|
|
1603
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1604
|
+
continue
|
|
1605
|
+
if line.startswith("export "):
|
|
1606
|
+
line = line[7:].lstrip()
|
|
1607
|
+
k, v = line.split("=", 1)
|
|
1608
|
+
if k.strip() == key:
|
|
1609
|
+
return v.strip().strip('"').strip("'")
|
|
1610
|
+
except Exception:
|
|
1611
|
+
pass
|
|
1612
|
+
return ""
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _src_dsh():
|
|
1616
|
+
"""DeepSeek Harness(dsh):~/.dsh/settings.yaml 的 llm-deepseek 端点与模型。
|
|
1617
|
+
|
|
1618
|
+
dsh 的约定是密钥不进 settings.yaml——走 ~/.dsh/.env(credentials-local 存储)
|
|
1619
|
+
或进程 env 的 DEEPSEEK_API_KEY;都没有时仅登记供应商,导入后补填密钥。
|
|
1620
|
+
"""
|
|
1621
|
+
out = {"providers": [], "note": "", "found": os.path.isfile(DSH_SETTINGS)}
|
|
1622
|
+
if not out["found"]:
|
|
1623
|
+
return out
|
|
1624
|
+
d = {}
|
|
1625
|
+
try:
|
|
1626
|
+
import yaml
|
|
1627
|
+
with open(DSH_SETTINGS, "r", encoding="utf-8-sig") as f:
|
|
1628
|
+
d = yaml.safe_load(f)
|
|
1629
|
+
except Exception:
|
|
1630
|
+
d = {}
|
|
1631
|
+
if not isinstance(d, dict):
|
|
1632
|
+
d = {}
|
|
1633
|
+
llm = d.get("llm-deepseek") if isinstance(d.get("llm-deepseek"), dict) else {}
|
|
1634
|
+
adm = d.get("agent-default-model") if isinstance(d.get("agent-default-model"), dict) else {}
|
|
1635
|
+
base = str(llm.get("baseURL") or "")
|
|
1636
|
+
names = [str(m["id"]) for m in (llm.get("models") or [])
|
|
1637
|
+
if isinstance(m, dict) and m.get("id")]
|
|
1638
|
+
if not base:
|
|
1639
|
+
out["note"] = "settings.yaml 未配置 llm-deepseek.baseURL"
|
|
1640
|
+
return out
|
|
1641
|
+
key = (_dotenv_get(os.path.join(DSH_DIR, ".env"), "DEEPSEEK_API_KEY")
|
|
1642
|
+
or os.environ.get("DEEPSEEK_API_KEY") or "")
|
|
1643
|
+
p = _prov("DeepSeek Harness", "openai", base, key, "dsh", "dsh:llm-deepseek",
|
|
1644
|
+
model=str(adm.get("model") or "") or (names[0] if names else ""))
|
|
1645
|
+
if p:
|
|
1646
|
+
p["models"] = [{"name": n, "enabled": True, "priority": i + 1}
|
|
1647
|
+
for i, n in enumerate(dict.fromkeys(names))]
|
|
1648
|
+
out["providers"].append(p)
|
|
1649
|
+
if not key:
|
|
1650
|
+
out["note"] = ("未找到 DEEPSEEK_API_KEY(dsh 把密钥放在 ~/.dsh/.env 或环境变量),"
|
|
1651
|
+
"导入后请在编辑里补填")
|
|
1652
|
+
return out
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
# (来源 id, 显示名, 说明, 采集器, 配置文件路径)
|
|
1656
|
+
_SOURCES = [
|
|
1657
|
+
("ccswitch", "CCSwitch", "本地库(Claude / Claude Desktop / Codex / Gemini / OpenClaw)",
|
|
1658
|
+
_src_ccswitch, lambda: [CCSWITCH_DB]),
|
|
1659
|
+
("claude", "Claude Code", "~/.claude/settings.json 的 env 供应商",
|
|
1660
|
+
_src_claude, lambda: [CLAUDE_SETTINGS]),
|
|
1661
|
+
("codex", "Codex CLI", "~/.codex/config.toml + auth.json(含多 model_providers)",
|
|
1662
|
+
_src_codex, lambda: [os.path.join(CODEX_DIR, "config.toml")]),
|
|
1663
|
+
("zcode", "ZCode", "~/.zcode/v2/config.json 的 provider 表",
|
|
1664
|
+
_src_zcode, lambda: [ZCODE_CONFIG]),
|
|
1665
|
+
("qwen", "Qwen Code", "~/.qwen/settings.json 的 modelProviders",
|
|
1666
|
+
_src_qwen, lambda: [QWEN_SETTINGS]),
|
|
1667
|
+
("gemini", "Gemini CLI", "~/.gemini 的 .env / settings.json",
|
|
1668
|
+
_src_gemini, lambda: [os.path.join(GEMINI_DIR, ".env"),
|
|
1669
|
+
os.path.join(GEMINI_DIR, "settings.json")]),
|
|
1670
|
+
("opencode", "OpenCode", "~/.config/opencode/opencode.json 的 provider 表",
|
|
1671
|
+
_src_opencode, lambda: [OPENCODE_CONFIG, OPENCODE_AUTH]),
|
|
1672
|
+
("continue", "Continue", "~/.continue/config.yaml 的 models",
|
|
1673
|
+
_src_continue, lambda: [os.path.join(CONTINUE_DIR, "config.yaml"),
|
|
1674
|
+
os.path.join(CONTINUE_DIR, "config.json")]),
|
|
1675
|
+
("cursor", "Cursor", "Cursor 的 cursorAuth/openAIKey + 自定义 Base URL",
|
|
1676
|
+
_src_cursor, lambda: [CURSOR_DB]),
|
|
1677
|
+
("trae", "Trae", "Trae / Trae SOLO 中自定义模型的 Base URL + AK",
|
|
1678
|
+
_src_trae, lambda: list(TRAE_DBS)),
|
|
1679
|
+
("dsh", "DeepSeek Harness", "~/.dsh/settings.yaml 的 llm-deepseek(密钥走 ~/.dsh/.env)",
|
|
1680
|
+
_src_dsh, lambda: [DSH_SETTINGS]),
|
|
1681
|
+
]
|
|
1682
|
+
|
|
1683
|
+
_SOURCE_NAMES = {sid: name for sid, name, _d, _f, _p in _SOURCES}
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def source_names():
|
|
1687
|
+
"""来源 id → 显示名(UI 打标签用)。"""
|
|
1688
|
+
return dict(_SOURCE_NAMES)
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _collect(fn):
|
|
1692
|
+
try:
|
|
1693
|
+
res = fn() or {}
|
|
1694
|
+
except Exception as e:
|
|
1695
|
+
return {"providers": [], "pricing": {}, "note": "", "found": False,
|
|
1696
|
+
"error": "%s: %s" % (type(e).__name__, e)}
|
|
1697
|
+
res.setdefault("providers", [])
|
|
1698
|
+
res.setdefault("pricing", {})
|
|
1699
|
+
res.setdefault("note", "")
|
|
1700
|
+
res.setdefault("error", "")
|
|
1701
|
+
res.setdefault("found", False)
|
|
1702
|
+
return res
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def sources():
|
|
1706
|
+
"""本机所有支持的导入来源及探测结果(配置文件是否存在、可导入几条)。"""
|
|
1707
|
+
rows = []
|
|
1708
|
+
for sid, name, desc, fn, paths in _SOURCES:
|
|
1709
|
+
ps = [p for p in paths() if p]
|
|
1710
|
+
found = any(os.path.isfile(p) for p in ps)
|
|
1711
|
+
row = {"id": sid, "name": name, "desc": desc, "paths": ps,
|
|
1712
|
+
"found": found, "count": 0, "note": "", "error": ""}
|
|
1713
|
+
if found:
|
|
1714
|
+
res = _collect(fn)
|
|
1715
|
+
row["count"] = len(res["providers"])
|
|
1716
|
+
row["note"] = res["note"]
|
|
1717
|
+
row["error"] = res["error"]
|
|
1718
|
+
rows.append(row)
|
|
1719
|
+
return rows
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def import_sources(ids=None):
|
|
1723
|
+
"""从选定的本机工具配置幂等导入供应商。
|
|
1724
|
+
|
|
1725
|
+
ids=None 表示全部来源;显式传入空列表表示什么都不导入(UI 未勾选时不误触)。
|
|
1726
|
+
返回 {imported, added, updated, duplicate, sources: [...], message}。
|
|
1727
|
+
"""
|
|
1728
|
+
want = None if ids is None else set(ids)
|
|
1729
|
+
chosen = [(sid, name, fn) for sid, name, _d, fn, _p in _SOURCES
|
|
1730
|
+
if want is None or sid in want]
|
|
1731
|
+
out = {"imported": 0, "added": 0, "updated": 0, "duplicate": 0, "pricing": 0,
|
|
1732
|
+
"sources": [], "message": ""}
|
|
1733
|
+
with _LOCK:
|
|
1734
|
+
data = _load()
|
|
1735
|
+
for sid, name, fn in chosen:
|
|
1736
|
+
res = _collect(fn)
|
|
1737
|
+
added = updated = dup = 0
|
|
1738
|
+
for prov in res["providers"]:
|
|
1739
|
+
if not prov:
|
|
1740
|
+
continue
|
|
1741
|
+
state = _merge_provider(data, prov)
|
|
1742
|
+
if state == "added":
|
|
1743
|
+
added += 1
|
|
1744
|
+
elif state == "duplicate":
|
|
1745
|
+
dup += 1
|
|
1746
|
+
else:
|
|
1747
|
+
updated += 1
|
|
1748
|
+
if res["pricing"]:
|
|
1749
|
+
data.setdefault("pricing", {}).update(res["pricing"])
|
|
1750
|
+
out["pricing"] = len(data["pricing"])
|
|
1751
|
+
out["added"] += added
|
|
1752
|
+
out["updated"] += updated
|
|
1753
|
+
out["duplicate"] += dup
|
|
1754
|
+
out["sources"].append({
|
|
1755
|
+
"id": sid, "name": name, "found": res["found"],
|
|
1756
|
+
"added": added, "updated": updated, "duplicate": dup,
|
|
1757
|
+
"count": added + updated, "note": res["note"], "error": res["error"]})
|
|
1758
|
+
if out["added"] or out["updated"] or out["pricing"]:
|
|
1759
|
+
_save(data)
|
|
1760
|
+
out["imported"] = out["added"] + out["updated"]
|
|
1761
|
+
parts = []
|
|
1762
|
+
if out["added"]:
|
|
1763
|
+
parts.append("新增 %d" % out["added"])
|
|
1764
|
+
if out["updated"]:
|
|
1765
|
+
parts.append("更新 %d" % out["updated"])
|
|
1766
|
+
if out["duplicate"]:
|
|
1767
|
+
parts.append("跳过重复 %d" % out["duplicate"])
|
|
1768
|
+
if out["pricing"]:
|
|
1769
|
+
parts.append("价格 %d 条" % out["pricing"])
|
|
1770
|
+
out["message"] = ("导入完成:" + ",".join(parts)) if parts else "未发现可导入的供应商"
|
|
1771
|
+
return out
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def import_ccswitch():
|
|
1775
|
+
"""兼容旧接口:仅导入 CCSwitch。返回 (导入数, 说明)。"""
|
|
1776
|
+
r = import_sources(["ccswitch"])
|
|
1777
|
+
s = r["sources"][0] if r["sources"] else {}
|
|
1778
|
+
msg = r["message"]
|
|
1779
|
+
if s.get("note"):
|
|
1780
|
+
msg += ";" + s["note"]
|
|
1781
|
+
if s.get("error"):
|
|
1782
|
+
msg += ";" + s["error"]
|
|
1783
|
+
return r["imported"], msg
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
# ---------------------------------------------------------------- 运行时解析
|
|
1787
|
+
|
|
1788
|
+
def _enabled_models(prov):
|
|
1789
|
+
ms = [m for m in (prov.get("models") or [])
|
|
1790
|
+
if m.get("enabled", True) and not m.get("hidden")]
|
|
1791
|
+
return sorted(ms, key=lambda m: m.get("priority", 999))
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _model_bindable(prov, model):
|
|
1795
|
+
"""模型是否可用于链降级:models[] 里显式停用/隐藏的不可用;
|
|
1796
|
+
名单里查不到(如纯字符串 models 或自由模型名)视为可用,不拦。"""
|
|
1797
|
+
for m in (prov.get("models") or []):
|
|
1798
|
+
if isinstance(m, dict) and m.get("name") == model:
|
|
1799
|
+
return bool(m.get("enabled", True)) and not m.get("hidden")
|
|
1800
|
+
return True
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def bind_agent(agent, difficulty="default"):
|
|
1804
|
+
"""按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。"""
|
|
1805
|
+
r = resolve_binding(agent.get("id"), difficulty) or resolve_binding(agent.get("kind"), difficulty)
|
|
1806
|
+
if not r:
|
|
1807
|
+
return agent
|
|
1808
|
+
a = dict(agent)
|
|
1809
|
+
merged = dict(agent.get("env") or {})
|
|
1810
|
+
merged.update(r.get("env") or {})
|
|
1811
|
+
a["env"] = merged
|
|
1812
|
+
if r.get("model"):
|
|
1813
|
+
a["model"] = r["model"]
|
|
1814
|
+
if r.get("model_fallbacks"):
|
|
1815
|
+
a["model_fallbacks"] = r["model_fallbacks"]
|
|
1816
|
+
if r.get("codex_provider"):
|
|
1817
|
+
a["codex_provider"] = r["codex_provider"]
|
|
1818
|
+
if r.get("call_chain"):
|
|
1819
|
+
a["call_chain"] = r["call_chain"]
|
|
1820
|
+
return a
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
# 用「自家 env 约定」而非 codex -c 覆盖来接收供应商的 CLI。
|
|
1824
|
+
# dsh(DeepSeek Harness)的 llm-deepseek 适配器只认 DEEPSEEK_API_KEY /
|
|
1825
|
+
# DEEPSEEK_BASE_URL,且端点必须是 OpenAI 兼容的 /chat/completions。
|
|
1826
|
+
_DEEPSEEK_ENV_TARGETS = ("deepseek-harness", "dsh")
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _deepseek_env_target(target):
|
|
1830
|
+
return (target or "").strip().lower() in _DEEPSEEK_ENV_TARGETS
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
def _chain_entry_env(prov, model, target="", endpoint=None, key="", key_id="",
|
|
1834
|
+
provider_id=""):
|
|
1835
|
+
"""一条链的运行时注入:env(claude=ANTHROPIC_*,codex=一次性 provider 覆盖,
|
|
1836
|
+
dsh=DEEPSEEK_*)。endpoint=(proto, base_url, wire_api) 是 wire_caps 适配出的
|
|
1837
|
+
生效端点;None 时按供应商原生协议与地址注入。key 指定用哪把密钥(多 KEY
|
|
1838
|
+
展开时逐条注入),空则用供应商当前的首选密钥。
|
|
1839
|
+
|
|
1840
|
+
key_id/provider_id 原样带进条目,供 runner 把「哪把 KEY 失败了」回写冷却。
|
|
1841
|
+
"""
|
|
1842
|
+
proto, base = ((endpoint[0], endpoint[1]) if endpoint
|
|
1843
|
+
else (prov["protocol"], prov["base_url"]))
|
|
1844
|
+
use_key = key or prov.get("api_key") or ""
|
|
1845
|
+
out = {"model": model, "env": {}, "provider": prov,
|
|
1846
|
+
"provider_id": provider_id or prov.get("id") or "", "key_id": key_id}
|
|
1847
|
+
if _deepseek_env_target(target):
|
|
1848
|
+
out["env"] = {"DEEPSEEK_API_KEY": use_key,
|
|
1849
|
+
"DEEPSEEK_BASE_URL": base}
|
|
1850
|
+
return out
|
|
1851
|
+
if proto == "anthropic":
|
|
1852
|
+
out["env"] = {"ANTHROPIC_BASE_URL": base,
|
|
1853
|
+
"ANTHROPIC_AUTH_TOKEN": use_key}
|
|
1854
|
+
if model:
|
|
1855
|
+
out["env"]["ANTHROPIC_MODEL"] = model
|
|
1856
|
+
else:
|
|
1857
|
+
out["env"] = {"ORCH_API_KEY": use_key}
|
|
1858
|
+
wire_api = endpoint[2] if endpoint else prov.get("wire_api", "responses")
|
|
1859
|
+
out["codex_provider"] = {
|
|
1860
|
+
"name": "orch", "base_url": base,
|
|
1861
|
+
"env_key": "ORCH_API_KEY", "wire_api": wire_api}
|
|
1862
|
+
return out
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _entry_endpoint(prov, allowed):
|
|
1866
|
+
"""链条目的生效端点:显式协议命中直接用;否则查 wire_caps——实测通过的
|
|
1867
|
+
wire(可能是同密钥的另一条协议面,也可能是 auto 供应商的分类结果)也可
|
|
1868
|
+
注入。返回 (proto, base_url, wire_api) 或 None(真不匹配,维持跳过语义)。
|
|
1869
|
+
|
|
1870
|
+
protocol="auto"(导入时未指定格式)只认实测结果:没有 wire_caps 就返回
|
|
1871
|
+
None——不猜。分类由「获取模型列表」后的后台探测补齐,是瞬态状态。
|
|
1872
|
+
"""
|
|
1873
|
+
proto = prov.get("protocol")
|
|
1874
|
+
if proto in allowed:
|
|
1875
|
+
return proto, prov.get("base_url"), prov.get("wire_api", "responses")
|
|
1876
|
+
caps = prov.get("wire_caps") or {}
|
|
1877
|
+
for p in allowed:
|
|
1878
|
+
cap = caps.get(p) or {}
|
|
1879
|
+
if cap.get("base"):
|
|
1880
|
+
return p, cap["base"], cap.get("wire_api") or "responses"
|
|
1881
|
+
return None
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def _protocol_candidates(prov):
|
|
1885
|
+
"""需要「唯一协议」时按序尝试的候选 [(proto, base)]。
|
|
1886
|
+
|
|
1887
|
+
显式协议只有一条(就是它自己);auto 列出实测过的 wire(偏好序),
|
|
1888
|
+
调用方逐个试、全失败才报错——不猜一条去发请求。"""
|
|
1889
|
+
proto = (prov or {}).get("protocol")
|
|
1890
|
+
if proto in _PROTOCOLS:
|
|
1891
|
+
return [(proto, (prov or {}).get("base_url") or "")]
|
|
1892
|
+
caps = (prov or {}).get("wire_caps") or {}
|
|
1893
|
+
return [(p, caps[p]["base"]) for p in _WIRE_PREFERENCE
|
|
1894
|
+
if (caps.get(p) or {}).get("base")]
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
1898
|
+
"""返回 {env:{}, model:..., model_fallbacks:[...], codex_provider:..., call_chain:[...]} 或 None。
|
|
1899
|
+
|
|
1900
|
+
call_chain 是跨厂商降级链(唯一真源):每条 {model, env, provider, [codex_provider]},
|
|
1901
|
+
按序尝试;供应商失效(停用/删除/无密钥/协议不可注入)的条目被跳过,全链失效
|
|
1902
|
+
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m。
|
|
1903
|
+
难度路由只在无显式链时生效。
|
|
1904
|
+
"""
|
|
1905
|
+
b = bindings().get(agent_kind_or_id) or bindings().get(
|
|
1906
|
+
"codex-cli" if agent_kind_or_id == "codex" else "claude-code") or {}
|
|
1907
|
+
chain = _binding_chain(b)
|
|
1908
|
+
provs = {p.get("id"): p for p in providers()}
|
|
1909
|
+
routing = bool(b.get("difficulty_routing"))
|
|
1910
|
+
tier = difficulty if difficulty in ("easy", "hard") else None
|
|
1911
|
+
# dsh 走 DEEPSEEK_* env,端点必须是 OpenAI 兼容的 /chat/completions,
|
|
1912
|
+
# anthropic 协议的网关注进去也调不通,直接判为不可绑定。
|
|
1913
|
+
allowed = ("openai",) if _deepseek_env_target(agent_kind_or_id) else _BINDABLE_PROTOCOLS
|
|
1914
|
+
# 2026-09-15 连载验收实测:CLI 与供应商协议必须匹配——codex 只吃 openai wire
|
|
1915
|
+
# (codex_provider 机制),claude 只吃 anthropic wire。混着注入会产生
|
|
1916
|
+
# 「codex 拿到 ANTHROPIC_* env 却缺 ORCH_API_KEY」这类必然失败的组合
|
|
1917
|
+
# (症状:Missing environment variable: ORCH_API_KEY)。
|
|
1918
|
+
if agent_kind_or_id in ("codex-cli", "codex"):
|
|
1919
|
+
allowed = ("openai",)
|
|
1920
|
+
elif agent_kind_or_id in ("claude-code", "claude"):
|
|
1921
|
+
allowed = ("anthropic",)
|
|
1922
|
+
|
|
1923
|
+
if chain:
|
|
1924
|
+
entries = []
|
|
1925
|
+
# 告警模块联动:已被健康监测判定 down 的供应商直接跳过——
|
|
1926
|
+
# 链降级的语义就是「别把时间浪费在已知挂掉的网关上」
|
|
1927
|
+
# (2026-09-15 实测:qwencode 对宕机网关内部重试 40 分钟才轮到补位)。
|
|
1928
|
+
try:
|
|
1929
|
+
from . import health
|
|
1930
|
+
down_set = health.down_names()
|
|
1931
|
+
except Exception:
|
|
1932
|
+
down_set = set()
|
|
1933
|
+
for item in chain:
|
|
1934
|
+
model = (item.get("model") or "").strip()
|
|
1935
|
+
pid = (item.get("provider_id") or "").strip()
|
|
1936
|
+
if not pid:
|
|
1937
|
+
if model:
|
|
1938
|
+
entries.append({"model": model, "env": {}, "provider": None})
|
|
1939
|
+
continue
|
|
1940
|
+
prov = provs.get(pid)
|
|
1941
|
+
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
1942
|
+
continue # 该条失效:跳过(降级链的语义就是逐条顶上)
|
|
1943
|
+
ep = _entry_endpoint(prov, allowed)
|
|
1944
|
+
if not ep:
|
|
1945
|
+
continue # 原生协议与适配过的 wire 都不匹配:跳过
|
|
1946
|
+
if prov.get("name") in down_set:
|
|
1947
|
+
continue # 健康监测判定 down:跳过,省掉无效等待
|
|
1948
|
+
if model and not _model_bindable(prov, model):
|
|
1949
|
+
continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
|
|
1950
|
+
# 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
|
|
1951
|
+
# 冷却跳过(切备用),全冷却时仍留一条顶上——降级复用既有尝试循环。
|
|
1952
|
+
for kk in _chain_keys(prov):
|
|
1953
|
+
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
1954
|
+
break
|
|
1955
|
+
entries.append(_chain_entry_env(
|
|
1956
|
+
prov, model or prov.get("model") or "",
|
|
1957
|
+
target=agent_kind_or_id, endpoint=ep, key=kk["key"],
|
|
1958
|
+
key_id=kk.get("id") or "", provider_id=pid))
|
|
1959
|
+
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
1960
|
+
break
|
|
1961
|
+
if not entries:
|
|
1962
|
+
return None
|
|
1963
|
+
head = entries[0]
|
|
1964
|
+
out = {"model": head["model"], "env": head["env"], "provider": head.get("provider"),
|
|
1965
|
+
# model_fallbacks 是「换模型」的列表(runner 无链时的回退用):
|
|
1966
|
+
# 同模型的其它 KEY 条目不算换模型,必须排除,否则主模型会被当成
|
|
1967
|
+
# 自己的降级备选。多 KEY 的切换由 call_chain 逐条尝试负责。
|
|
1968
|
+
"model_fallbacks": list(dict.fromkeys(
|
|
1969
|
+
e["model"] for e in entries[1:]
|
|
1970
|
+
if e.get("model") and e["model"] != head["model"])),
|
|
1971
|
+
"call_chain": [dict(e) for e in entries]}
|
|
1972
|
+
if head.get("codex_provider"):
|
|
1973
|
+
out["codex_provider"] = head["codex_provider"]
|
|
1974
|
+
return out
|
|
1975
|
+
|
|
1976
|
+
# 无显式链:难度映射 / 供应商默认 / 启用模型优先级(原语义)
|
|
1977
|
+
pid = b.get("provider_id")
|
|
1978
|
+
if not pid:
|
|
1979
|
+
return None
|
|
1980
|
+
prov = provs.get(pid)
|
|
1981
|
+
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
1982
|
+
return None
|
|
1983
|
+
ep = _entry_endpoint(prov, allowed)
|
|
1984
|
+
if not ep:
|
|
1985
|
+
return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
|
|
1986
|
+
names = [m["name"] for m in _enabled_models(prov)]
|
|
1987
|
+
model = prov.get("model_" + tier) or "" if (routing and tier) else ""
|
|
1988
|
+
if not model:
|
|
1989
|
+
model = prov.get("model") or ""
|
|
1990
|
+
if not model and names:
|
|
1991
|
+
model = names[0] if (not routing or difficulty != "easy") else names[-1]
|
|
1992
|
+
# model 可为空:仅注入供应商凭据,不指定模型(用网关默认)
|
|
1993
|
+
fallbacks = [n for n in names if n != model][:MAX_BIND_MODELS - 1]
|
|
1994
|
+
# 多 KEY:主模型先按 KEY 逐把试(欠费自动切备用),再降级到别的模型
|
|
1995
|
+
chain_keys = _chain_keys(prov)
|
|
1996
|
+
entries = []
|
|
1997
|
+
for kk in chain_keys:
|
|
1998
|
+
entries.append(_chain_entry_env(prov, model, target=agent_kind_or_id,
|
|
1999
|
+
endpoint=ep, key=kk["key"],
|
|
2000
|
+
key_id=kk.get("id") or "", provider_id=pid))
|
|
2001
|
+
for n in fallbacks:
|
|
2002
|
+
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2003
|
+
break
|
|
2004
|
+
entries.append(_chain_entry_env(prov, n, target=agent_kind_or_id,
|
|
2005
|
+
endpoint=ep, provider_id=pid))
|
|
2006
|
+
head = entries[0]
|
|
2007
|
+
out = {"model": model, "env": head["env"], "provider": prov,
|
|
2008
|
+
"model_fallbacks": fallbacks,
|
|
2009
|
+
"call_chain": [dict(e) for e in entries]}
|
|
2010
|
+
if head.get("codex_provider"):
|
|
2011
|
+
out["codex_provider"] = head["codex_provider"]
|
|
2012
|
+
return out
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def launch_pick(agent_id, protocols):
|
|
2016
|
+
"""「一键打开」专属选链:绑定链里第一个「已启用+有密钥+协议匹配」的供应商。
|
|
2017
|
+
|
|
2018
|
+
与 resolve_binding 的差别:协议不匹配不降级——交互 TUI 只认自家协议的凭据
|
|
2019
|
+
(claude 只吃 ANTHROPIC_*,塞 ORCH_API_KEY 等于没 key),宁可明确提示也不
|
|
2020
|
+
静默错注入。返回 (pick|None, note):pick={model, provider};note 面向用户的
|
|
2021
|
+
不可用原因(链上同协议供应商停用 / 协议不匹配 / 未绑定),可直达 toast。
|
|
2022
|
+
"""
|
|
2023
|
+
protocols = tuple(p for p in (protocols or ()) if p)
|
|
2024
|
+
b = bindings().get(agent_id) or {}
|
|
2025
|
+
chain = _binding_chain(b)
|
|
2026
|
+
provs = {p.get("id"): p for p in providers()}
|
|
2027
|
+
disabled, mismatch = [], False
|
|
2028
|
+
for item in chain:
|
|
2029
|
+
pid = str(item.get("provider_id") or "").strip()
|
|
2030
|
+
if not pid:
|
|
2031
|
+
continue # 纯链条目(只传 -m):对打开场景无凭据可用
|
|
2032
|
+
prov = provs.get(pid)
|
|
2033
|
+
if not prov:
|
|
2034
|
+
continue
|
|
2035
|
+
if not prov.get("enabled", True) or not prov.get("api_key"):
|
|
2036
|
+
if prov.get("protocol") in protocols:
|
|
2037
|
+
disabled.append(prov.get("name") or pid)
|
|
2038
|
+
continue
|
|
2039
|
+
if prov.get("protocol") in protocols:
|
|
2040
|
+
return {"model": str(item.get("model") or "").strip() or prov.get("model") or "",
|
|
2041
|
+
"provider": prov}, None
|
|
2042
|
+
# 原生协议不匹配但适配测试过(wire_caps):按适配出的端点给一份
|
|
2043
|
+
# 协议/地址已覆写的供应商副本,下游凭据注入逻辑无需感知差异。
|
|
2044
|
+
caps = prov.get("wire_caps") or {}
|
|
2045
|
+
for proto in protocols:
|
|
2046
|
+
cap = caps.get(proto) or {}
|
|
2047
|
+
if cap.get("base"):
|
|
2048
|
+
adapted = dict(prov, protocol=proto, base_url=cap["base"])
|
|
2049
|
+
if cap.get("wire_api"):
|
|
2050
|
+
adapted["wire_api"] = cap["wire_api"]
|
|
2051
|
+
return {"model": str(item.get("model") or "").strip() or prov.get("model") or "",
|
|
2052
|
+
"provider": adapted}, None
|
|
2053
|
+
mismatch = True
|
|
2054
|
+
if disabled:
|
|
2055
|
+
return None, ("绑定链里的 %s 已停用或无密钥:打开后需在其自带界面登录;"
|
|
2056
|
+
"要打开即用请在「CLI 绑定」页启用"
|
|
2057
|
+
% "、".join(dict.fromkeys(disabled)))
|
|
2058
|
+
if mismatch:
|
|
2059
|
+
return None, ("当前绑定的供应商协议与该 CLI 不匹配:打开后需在其自带界面登录;"
|
|
2060
|
+
"要打开即用请在「CLI 绑定」页换绑可注入协议的供应商")
|
|
2061
|
+
return None, ("未绑定供应商:打开后需在其自带界面登录;"
|
|
2062
|
+
"要打开即用请到「CLI 绑定」页绑定")
|
|
2063
|
+
|
|
2064
|
+
|
|
2065
|
+
def migrate_orch_models():
|
|
2066
|
+
"""一次性迁移:把 orchestration.json 里的编排模型链搬进 bindings(幂等)。
|
|
2067
|
+
|
|
2068
|
+
旧结构里「参与编排」与「编排模型」都存 orchestration.json;合并后模型链
|
|
2069
|
+
归 models.json 的 bindings 管(provider 可空 = 纯链,只传 -m),orchestration.json
|
|
2070
|
+
只留 enabled。bindings 里已有链的跳过(不覆盖用户后续改动);改动前两个
|
|
2071
|
+
文件各留 .bak。返回本次迁移的条数。
|
|
2072
|
+
"""
|
|
2073
|
+
try:
|
|
2074
|
+
state = json.loads(paths.ENABLED_FILE.read_text(encoding="utf-8"))
|
|
2075
|
+
except Exception:
|
|
2076
|
+
return 0
|
|
2077
|
+
if not isinstance(state, dict):
|
|
2078
|
+
return 0
|
|
2079
|
+
chains = {}
|
|
2080
|
+
for aid, pref in state.items():
|
|
2081
|
+
if not isinstance(pref, dict):
|
|
2082
|
+
continue
|
|
2083
|
+
chain = _clean_models(
|
|
2084
|
+
pref.get("models") or ([pref["model"]] if pref.get("model") else []))
|
|
2085
|
+
if chain:
|
|
2086
|
+
chains[aid] = chain
|
|
2087
|
+
if not chains:
|
|
2088
|
+
return 0
|
|
2089
|
+
with _LOCK:
|
|
2090
|
+
data = _load()
|
|
2091
|
+
changed = False
|
|
2092
|
+
for aid, chain in chains.items():
|
|
2093
|
+
b = data.setdefault("bindings", {}).setdefault(aid, {})
|
|
2094
|
+
if b.get("models"):
|
|
2095
|
+
continue
|
|
2096
|
+
b["models"] = chain
|
|
2097
|
+
b["model"] = chain[0]
|
|
2098
|
+
changed = True
|
|
2099
|
+
if not changed:
|
|
2100
|
+
return 0
|
|
2101
|
+
_backup_file(_FILE)
|
|
2102
|
+
_save(data)
|
|
2103
|
+
_backup_file(paths.ENABLED_FILE)
|
|
2104
|
+
for aid in chains:
|
|
2105
|
+
pref = state.get(aid) or {}
|
|
2106
|
+
pref.pop("models", None)
|
|
2107
|
+
pref.pop("model", None)
|
|
2108
|
+
paths.ENABLED_FILE.write_text(
|
|
2109
|
+
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
2110
|
+
return len(chains)
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
def models_view():
|
|
2114
|
+
"""扁平化模型目录(画廊展示用):跨供应商的模型卡片列表 + 价格。"""
|
|
2115
|
+
pricing = _load().get("pricing") or {}
|
|
2116
|
+
rows = []
|
|
2117
|
+
for p in sorted(providers(), key=lambda x: not x.get("enabled", True)):
|
|
2118
|
+
base = {"provider_id": p.get("id"), "provider_name": p.get("name"),
|
|
2119
|
+
"protocol": p.get("protocol"),
|
|
2120
|
+
"fetched_at": p.get("models_fetched_at") or "",
|
|
2121
|
+
"fetch_status": "ok" if p.get("models") is not None else "未获取"}
|
|
2122
|
+
ms = p.get("models")
|
|
2123
|
+
if ms is None:
|
|
2124
|
+
rows.append(dict(base, name="", enabled=False, priority=0))
|
|
2125
|
+
continue
|
|
2126
|
+
for m in sorted(ms, key=lambda x: x.get("priority", 999)):
|
|
2127
|
+
if m.get("hidden"):
|
|
2128
|
+
continue
|
|
2129
|
+
pr = pricing.get(m["name"]) or {}
|
|
2130
|
+
rows.append(dict(base, name=m["name"], enabled=bool(m.get("enabled", True)),
|
|
2131
|
+
priority=m.get("priority", 0),
|
|
2132
|
+
price_in=pr.get("in"), price_out=pr.get("out")))
|
|
2133
|
+
return rows
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def reorder_models(provider_id, ordered_names):
|
|
2137
|
+
"""按给定名称顺序重设优先级(列表中未出现的模型排在最后,保持相对顺序)。"""
|
|
2138
|
+
with _LOCK:
|
|
2139
|
+
data = _load()
|
|
2140
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
2141
|
+
if not prov:
|
|
2142
|
+
return "供应商不存在"
|
|
2143
|
+
models = prov.get("models") or []
|
|
2144
|
+
by_name = {m.get("name"): m for m in models}
|
|
2145
|
+
if not by_name:
|
|
2146
|
+
return "该供应商还没有模型列表,请先获取"
|
|
2147
|
+
prio, i = {}, 1
|
|
2148
|
+
for n in ordered_names or []:
|
|
2149
|
+
if n in by_name and n not in prio:
|
|
2150
|
+
prio[n] = i
|
|
2151
|
+
i += 1
|
|
2152
|
+
for m in models:
|
|
2153
|
+
if m.get("name") not in prio:
|
|
2154
|
+
prio[m["name"]] = i
|
|
2155
|
+
i += 1
|
|
2156
|
+
for m in models:
|
|
2157
|
+
m["priority"] = prio[m["name"]]
|
|
2158
|
+
_promote(models) # 拖拽不能把停用模型排到启用模型前面
|
|
2159
|
+
_save(data)
|
|
2160
|
+
return None
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
def _post_json_http(url, headers, body, allow_private, timeout=20):
|
|
2164
|
+
"""带 SSRF 防护的 POST。返回 (status, json_obj|None, err)。"""
|
|
2165
|
+
import urllib.parse
|
|
2166
|
+
p = urllib.parse.urlsplit(url)
|
|
2167
|
+
if p.scheme not in ("http", "https"):
|
|
2168
|
+
return 0, None, "协议必须是 http/https"
|
|
2169
|
+
host_info = _validate_host(url, allow_private)
|
|
2170
|
+
if host_info is None:
|
|
2171
|
+
return 0, None, host_info[1]
|
|
2172
|
+
try:
|
|
2173
|
+
req = urllib.request.Request(url, method="POST",
|
|
2174
|
+
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
2175
|
+
data=json.dumps(body).encode("utf-8"))
|
|
2176
|
+
with urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
|
|
2177
|
+
raw = resp.read(1024 * 1024)
|
|
2178
|
+
return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
|
|
2179
|
+
except Exception as e:
|
|
2180
|
+
return 0, None, repr(e)[:300]
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def _sse_parse(proto, obj):
|
|
2184
|
+
"""解析一条 SSE 事件 JSON → (增量文本, usage增量或None)。三协议字段各异。
|
|
2185
|
+
|
|
2186
|
+
只认文本增量与用量字段;tool_call / reasoning 等事件返回空串跳过。"""
|
|
2187
|
+
if not isinstance(obj, dict):
|
|
2188
|
+
return "", None
|
|
2189
|
+
if proto == "anthropic":
|
|
2190
|
+
t = obj.get("type")
|
|
2191
|
+
if t == "content_block_delta":
|
|
2192
|
+
d = obj.get("delta") or {}
|
|
2193
|
+
return (d.get("text") or ""), None
|
|
2194
|
+
if t == "message_start":
|
|
2195
|
+
u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
|
|
2196
|
+
return "", {"input": int(u.get("input_tokens") or 0),
|
|
2197
|
+
"cached": (int(u.get("cache_read_input_tokens") or 0)
|
|
2198
|
+
+ int(u.get("cache_creation_input_tokens") or 0))}
|
|
2199
|
+
if t == "message_delta":
|
|
2200
|
+
u = obj.get("usage") or {}
|
|
2201
|
+
return "", {"output": int(u.get("output_tokens") or 0)}
|
|
2202
|
+
return "", None
|
|
2203
|
+
if proto == "google":
|
|
2204
|
+
text = ""
|
|
2205
|
+
for cand in obj.get("candidates") or []:
|
|
2206
|
+
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
2207
|
+
if isinstance(p, dict):
|
|
2208
|
+
text += p.get("text") or ""
|
|
2209
|
+
um = obj.get("usageMetadata")
|
|
2210
|
+
usage = None
|
|
2211
|
+
if isinstance(um, dict):
|
|
2212
|
+
usage = {"input": int(um.get("promptTokenCount") or 0),
|
|
2213
|
+
"output": int(um.get("candidatesTokenCount") or 0),
|
|
2214
|
+
"total": int(um.get("totalTokenCount") or 0)}
|
|
2215
|
+
return text, usage
|
|
2216
|
+
# openai 兼容(chat/completions 流)
|
|
2217
|
+
text = ""
|
|
2218
|
+
for ch in obj.get("choices") or []:
|
|
2219
|
+
d = ch.get("delta") or {}
|
|
2220
|
+
text += d.get("content") or ""
|
|
2221
|
+
usage = None
|
|
2222
|
+
u = obj.get("usage")
|
|
2223
|
+
if isinstance(u, dict):
|
|
2224
|
+
usage = {"input": int(u.get("prompt_tokens") or 0),
|
|
2225
|
+
"output": int(u.get("completion_tokens") or 0),
|
|
2226
|
+
"total": int(u.get("total_tokens") or 0),
|
|
2227
|
+
"cached": int(((u.get("prompt_tokens_details") or {}) or {}).get("cached_tokens") or 0)}
|
|
2228
|
+
return text, usage
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
|
|
2232
|
+
"""带 SSRF 防护的流式 POST(SSE)。返回 (status, text, usage, err)。
|
|
2233
|
+
|
|
2234
|
+
编排者直连调用不经 run_process,此前生成全程日志只有一行标题(黑箱);
|
|
2235
|
+
这里逐行读 data: 事件、边收边回调 on_delta(增量文本),直连调用也能像
|
|
2236
|
+
CLI 步骤一样看到「正在吐字」。单条事件解析失败静默跳过,不中断整流。"""
|
|
2237
|
+
import urllib.parse
|
|
2238
|
+
p = urllib.parse.urlsplit(url)
|
|
2239
|
+
if p.scheme not in ("http", "https"):
|
|
2240
|
+
return 0, "", None, "协议必须是 http/https"
|
|
2241
|
+
if _validate_host(url, allow_private) is None:
|
|
2242
|
+
return 0, "", None, "目标地址校验未通过"
|
|
2243
|
+
parts, usage = [], {}
|
|
2244
|
+
resp_status = 0
|
|
2245
|
+
try:
|
|
2246
|
+
req = urllib.request.Request(url, method="POST",
|
|
2247
|
+
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
2248
|
+
data=json.dumps(body).encode("utf-8"))
|
|
2249
|
+
with urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
|
|
2250
|
+
resp_status = resp.status
|
|
2251
|
+
if not 200 <= resp.status < 300:
|
|
2252
|
+
raw = resp.read(65536).decode("utf-8", "replace")
|
|
2253
|
+
return resp.status, "", None, "HTTP %s %s" % (resp.status, raw[:200])
|
|
2254
|
+
while True:
|
|
2255
|
+
line = resp.readline()
|
|
2256
|
+
if not line:
|
|
2257
|
+
break
|
|
2258
|
+
line = line.decode("utf-8", "replace").strip()
|
|
2259
|
+
if not line.startswith("data:"):
|
|
2260
|
+
continue
|
|
2261
|
+
payload = line[5:].strip()
|
|
2262
|
+
if not payload or payload == "[DONE]":
|
|
2263
|
+
continue
|
|
2264
|
+
try:
|
|
2265
|
+
obj = json.loads(payload)
|
|
2266
|
+
except Exception:
|
|
2267
|
+
continue
|
|
2268
|
+
delta, u = _sse_parse(proto, obj)
|
|
2269
|
+
if delta:
|
|
2270
|
+
parts.append(delta)
|
|
2271
|
+
try:
|
|
2272
|
+
on_delta(delta)
|
|
2273
|
+
except Exception:
|
|
2274
|
+
pass
|
|
2275
|
+
if isinstance(u, dict):
|
|
2276
|
+
usage.update({k: v for k, v in u.items() if v})
|
|
2277
|
+
if sum(len(s) for s in parts) > 2 * 1024 * 1024: # 防失控
|
|
2278
|
+
break
|
|
2279
|
+
except Exception as e:
|
|
2280
|
+
return 0, "", None, repr(e)[:300]
|
|
2281
|
+
text = "".join(parts)
|
|
2282
|
+
if not usage.get("total"):
|
|
2283
|
+
usage["total"] = usage.get("input", 0) + usage.get("output", 0) + usage.get("cached", 0)
|
|
2284
|
+
return resp_status, text, usage, ""
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def test_provider(provider_id):
|
|
2288
|
+
"""供应商连通性测试:GET /models 并测延迟。返回 {ok, latency_ms, count, error}。
|
|
2289
|
+
|
|
2290
|
+
多 KEY:按顺序试,记录哪把通(key_id 回给界面),失败的 KEY 记账。
|
|
2291
|
+
"""
|
|
2292
|
+
import time as _t
|
|
2293
|
+
with _LOCK:
|
|
2294
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
2295
|
+
if not prov:
|
|
2296
|
+
return {"ok": False, "error": "供应商不存在"}
|
|
2297
|
+
keys = _chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
|
|
2298
|
+
t0 = _t.time()
|
|
2299
|
+
last = ""
|
|
2300
|
+
for kk in keys:
|
|
2301
|
+
names, err = _fetch_models_http(prov.get("base_url"), kk["key"],
|
|
2302
|
+
prov.get("protocol"), bool(prov.get("allow_private")))
|
|
2303
|
+
if names is not None:
|
|
2304
|
+
note_key_ok(provider_id, kk.get("id") or "")
|
|
2305
|
+
return {"ok": True, "latency_ms": int((_t.time() - t0) * 1000),
|
|
2306
|
+
"count": len(names), "error": "", "key_id": kk.get("id") or ""}
|
|
2307
|
+
last = err
|
|
2308
|
+
note_key_error(provider_id, kk.get("id") or "", err)
|
|
2309
|
+
return {"ok": False, "latency_ms": int((_t.time() - t0) * 1000), "error": last}
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def test_model(provider_id, model_name, key_id=""):
|
|
2313
|
+
"""单模型连通性测试:发一条 1 token 的最小对话。返回 {ok, latency_ms, error}。
|
|
2314
|
+
|
|
2315
|
+
auto 供应商逐条试实测过的 wire(显式协议只有一条);多 KEY 供应商逐把试
|
|
2316
|
+
(指定 key_id 则只测那把)。返回里带 protocol/key_id 说明这次是谁通的。
|
|
2317
|
+
"""
|
|
2318
|
+
import time as _t
|
|
2319
|
+
import urllib.parse
|
|
2320
|
+
with _LOCK:
|
|
2321
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
2322
|
+
if not prov or not prov.get("api_key"):
|
|
2323
|
+
return {"ok": False, "error": "供应商不存在或未配置密钥"}
|
|
2324
|
+
protos = _protocol_candidates(prov)
|
|
2325
|
+
if not protos:
|
|
2326
|
+
return {"ok": False, "error": "该供应商还没有可用 wire——先「获取模型列表」或手动指定格式"}
|
|
2327
|
+
if key_id:
|
|
2328
|
+
keys = [k for k in _provider_keys(prov) if k["id"] == key_id]
|
|
2329
|
+
if not keys:
|
|
2330
|
+
return {"ok": False, "error": "密钥不存在"}
|
|
2331
|
+
else:
|
|
2332
|
+
keys = _chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
|
|
2333
|
+
t0 = _t.time()
|
|
2334
|
+
last = {"ok": False, "error": "无可用 wire"}
|
|
2335
|
+
for kk in keys:
|
|
2336
|
+
for proto, pbase in protos:
|
|
2337
|
+
base = (pbase or "").rstrip("/")
|
|
2338
|
+
if proto == "google":
|
|
2339
|
+
url = base + "/v1beta/models/%s:generateContent" % model_name
|
|
2340
|
+
if base.endswith("/v1beta"):
|
|
2341
|
+
url = base + "/models/%s:generateContent" % model_name
|
|
2342
|
+
headers = {"x-goog-api-key": kk["key"]}
|
|
2343
|
+
body = {"contents": [{"parts": [{"text": "ping"}]}]}
|
|
2344
|
+
else:
|
|
2345
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
2346
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
2347
|
+
if proto == "anthropic":
|
|
2348
|
+
headers = {"x-api-key": kk["key"], "anthropic-version": "2023-06-01"}
|
|
2349
|
+
else:
|
|
2350
|
+
headers = {"Authorization": "Bearer " + kk["key"]}
|
|
2351
|
+
body = {"model": model_name, "max_tokens": 1,
|
|
2352
|
+
"messages": [{"role": "user", "content": "ping"}]}
|
|
2353
|
+
status, data, err = _post_json_http(url, headers, body, bool(prov.get("allow_private")))
|
|
2354
|
+
if status == 0:
|
|
2355
|
+
last = {"ok": False, "error": err}
|
|
2356
|
+
note_key_error(provider_id, kk.get("id") or "", err)
|
|
2357
|
+
continue
|
|
2358
|
+
if 200 <= status < 300:
|
|
2359
|
+
note_key_ok(provider_id, kk.get("id") or "")
|
|
2360
|
+
return {"ok": True, "latency_ms": int((_t.time() - t0) * 1000),
|
|
2361
|
+
"error": "", "protocol": proto, "key_id": kk.get("id") or ""}
|
|
2362
|
+
msg = ""
|
|
2363
|
+
if isinstance(data, dict):
|
|
2364
|
+
e = data.get("error")
|
|
2365
|
+
msg = e.get("message", "") if isinstance(e, dict) else str(e)
|
|
2366
|
+
last = {"ok": False, "error": "HTTP %s %s" % (status, str(msg)[:160])}
|
|
2367
|
+
note_key_error(provider_id, kk.get("id") or "", last["error"])
|
|
2368
|
+
last["latency_ms"] = int((_t.time() - t0) * 1000)
|
|
2369
|
+
return last
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
# ---------------------------------------------------------------- wire 协议适配
|
|
2373
|
+
# 聚合中转网关(new-api/one-api 系)通常同一密钥同时开 openai(/chat/completions)
|
|
2374
|
+
# 与 anthropic(/v1/messages) 两面 wire。在「模型接入」页用 1 token 最小对话实测,
|
|
2375
|
+
# 通过的记入 provider["wire_caps"][proto];绑定解析(_entry_endpoint)据此放宽
|
|
2376
|
+
# 「协议必须原生匹配」——claude 链上的 openai 供应商、codex 链上的 anthropic
|
|
2377
|
+
# 供应商不再被跳过。探不过就保持原样跳过:宁可 ⚠ 也不错注入(2026-09-15 实测
|
|
2378
|
+
# 混注入会产生 Missing ORCH_API_KEY 这类必然失败组合)。
|
|
2379
|
+
|
|
2380
|
+
# 已知第一方双端点映射:(主机名集合, 原生路径前缀, 另一协议的端点 base)。
|
|
2381
|
+
# 这类网关两种 wire 挂在不同路径,同 base 探测必 404,只能按已知映射补候选。
|
|
2382
|
+
_KNOWN_WIRE_BASES = (
|
|
2383
|
+
(("api.z.ai",), "/api/anthropic", "https://api.z.ai/api/paas/v4"),
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def _wire_base_candidates(base_url, target_proto):
|
|
2388
|
+
"""探测候选 base 列表:常规 /v1 变体 + 已知双端点映射(映射优先)。"""
|
|
2389
|
+
import urllib.parse
|
|
2390
|
+
base = (base_url or "").rstrip("/")
|
|
2391
|
+
if target_proto == "openai":
|
|
2392
|
+
# openai 习惯:base 含 /v1 直接用;否则补 /v1,再兜一个不带 /v1 的
|
|
2393
|
+
cands = [base if base.endswith("/v1") else base + "/v1"]
|
|
2394
|
+
if not base.endswith("/v1"):
|
|
2395
|
+
cands.append(base)
|
|
2396
|
+
else:
|
|
2397
|
+
# anthropic 习惯:CLI 在 base 后拼 /v1/messages,base 本身不含 /v1
|
|
2398
|
+
cands = [base[:-3] if base.endswith("/v1") else base]
|
|
2399
|
+
u = urllib.parse.urlsplit(base)
|
|
2400
|
+
host, path = u.hostname or "", u.path or ""
|
|
2401
|
+
for hosts, suffix, alt in _KNOWN_WIRE_BASES:
|
|
2402
|
+
if host in hosts and path.startswith(suffix) and alt not in cands:
|
|
2403
|
+
cands.insert(0, alt)
|
|
2404
|
+
return cands
|
|
2405
|
+
|
|
2406
|
+
|
|
2407
|
+
def _probe_wire_once(base, api_key, target_proto, model, allow_private, timeout=12):
|
|
2408
|
+
"""对一个候选 base 实测目标 wire(1 token 最小对话)。返回 (ok, wire_api, err)。
|
|
2409
|
+
|
|
2410
|
+
openai wire 先试 responses(codex 默认)再退 chat/completions;anthropic
|
|
2411
|
+
只试 /v1/messages(x-api-key 与 Bearer 两种鉴权头都试)。"""
|
|
2412
|
+
if target_proto == "anthropic":
|
|
2413
|
+
url = base.rstrip("/") + "/v1/messages"
|
|
2414
|
+
body = {"model": model, "max_tokens": 1,
|
|
2415
|
+
"messages": [{"role": "user", "content": "ping"}]}
|
|
2416
|
+
variants = [("messages", url, body, {"x-api-key": api_key,
|
|
2417
|
+
"anthropic-version": "2023-06-01"}),
|
|
2418
|
+
("messages", url, body, {"Authorization": "Bearer " + api_key,
|
|
2419
|
+
"anthropic-version": "2023-06-01"})]
|
|
2420
|
+
else:
|
|
2421
|
+
b = base.rstrip("/")
|
|
2422
|
+
variants = [
|
|
2423
|
+
("responses", b + "/responses",
|
|
2424
|
+
{"model": model, "input": "ping", "max_output_tokens": 16},
|
|
2425
|
+
{"Authorization": "Bearer " + api_key}),
|
|
2426
|
+
("chat", b + "/chat/completions",
|
|
2427
|
+
{"model": model, "max_tokens": 1,
|
|
2428
|
+
"messages": [{"role": "user", "content": "ping"}]},
|
|
2429
|
+
{"Authorization": "Bearer " + api_key}),
|
|
2430
|
+
]
|
|
2431
|
+
if target_proto != "openai": # pragma: no cover — 调用方保证
|
|
2432
|
+
variants = variants[-1:]
|
|
2433
|
+
last = ""
|
|
2434
|
+
for item in variants:
|
|
2435
|
+
wire_api, url, body, headers = item
|
|
2436
|
+
status, _data, err = _post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
2437
|
+
if 200 <= status < 300:
|
|
2438
|
+
return True, wire_api, ""
|
|
2439
|
+
last = err if status == 0 else "HTTP %s" % status
|
|
2440
|
+
return False, "", last
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def probe_wire_caps(provider_id):
|
|
2444
|
+
"""适配测试:实测该供应商的可用 wire,存进 wire_caps。返回 (caps, note)。
|
|
2445
|
+
|
|
2446
|
+
显式协议的供应商只测「除原生外」的 wire(原生天然可用,不必花请求);
|
|
2447
|
+
protocol="auto"(导入未指定格式)则把可注入 wire 全测一遍,caps 就是分类
|
|
2448
|
+
结果。之前通过、本次失败的条目移除(网关两面变动以实测为准)。google 不参与。
|
|
2449
|
+
note 是给 UI 的补充说明(失败原因 / 未测原因),全通过时为空串。"""
|
|
2450
|
+
import time as _t
|
|
2451
|
+
with _LOCK:
|
|
2452
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
2453
|
+
if not prov:
|
|
2454
|
+
return {}, "供应商不存在"
|
|
2455
|
+
if not prov.get("api_key"):
|
|
2456
|
+
return {}, "该供应商未配置密钥"
|
|
2457
|
+
model = prov.get("model") or next(
|
|
2458
|
+
(m.get("name") for m in _ranked(prov.get("models") or [])
|
|
2459
|
+
if m.get("name") and m.get("enabled", True)), "")
|
|
2460
|
+
if not model:
|
|
2461
|
+
return {}, "没有可用模型名——先「获取模型列表」再测"
|
|
2462
|
+
native = prov.get("protocol")
|
|
2463
|
+
auto = native == _PROTOCOL_AUTO
|
|
2464
|
+
caps = dict(prov.get("wire_caps") or {})
|
|
2465
|
+
notes = []
|
|
2466
|
+
for target in _BINDABLE_PROTOCOLS:
|
|
2467
|
+
if not auto and target == native:
|
|
2468
|
+
continue # 显式协议:原生那条不用测
|
|
2469
|
+
found, err = None, ""
|
|
2470
|
+
for base in _wire_base_candidates(prov.get("base_url"), target):
|
|
2471
|
+
ok, wire_api, err = _probe_wire_once(base, prov["api_key"], target,
|
|
2472
|
+
model, bool(prov.get("allow_private")))
|
|
2473
|
+
if ok:
|
|
2474
|
+
found = {"base": base.rstrip("/"), "wire_api": wire_api,
|
|
2475
|
+
"checked_at": _t.strftime("%Y-%m-%d %H:%M")}
|
|
2476
|
+
break
|
|
2477
|
+
if found:
|
|
2478
|
+
caps[target] = found
|
|
2479
|
+
else:
|
|
2480
|
+
caps.pop(target, None)
|
|
2481
|
+
notes.append("%s wire 不通(%s)" % (target, (err or "无响应")[:80]))
|
|
2482
|
+
if auto and not caps:
|
|
2483
|
+
notes.append("没有探到可用的 wire——检查地址/密钥,或手动指定格式")
|
|
2484
|
+
if caps != (prov.get("wire_caps") or {}):
|
|
2485
|
+
with _LOCK:
|
|
2486
|
+
data = _load()
|
|
2487
|
+
p = next((q for q in data.get("providers", []) if q.get("id") == provider_id), None)
|
|
2488
|
+
if p is not None:
|
|
2489
|
+
if caps:
|
|
2490
|
+
p["wire_caps"] = caps
|
|
2491
|
+
else:
|
|
2492
|
+
p.pop("wire_caps", None)
|
|
2493
|
+
_save(data)
|
|
2494
|
+
return caps, ";".join(notes)
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def classify_difficulty(goal, verify_command):
|
|
2498
|
+
"""难度启发式(规划器 LLM 判定优先,这里只做退化)。"""
|
|
2499
|
+
text = goal or ""
|
|
2500
|
+
hard_words = ("重构", "架构", "迁移", "安全", "性能", "并发", "分布式", "设计")
|
|
2501
|
+
if verify_command and (len(text) > 120 or any(w in text for w in hard_words)):
|
|
2502
|
+
return "hard"
|
|
2503
|
+
if len(text) <= 60 and not any(w in text for w in hard_words):
|
|
2504
|
+
return "easy"
|
|
2505
|
+
return "hard"
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
# ---------------------------------------------------------------- 跨厂商链迁移
|
|
2509
|
+
|
|
2510
|
+
def migrate_chains():
|
|
2511
|
+
"""把旧 bindings {provider_id, models} 升级为跨厂商 chain(幂等)。
|
|
2512
|
+
|
|
2513
|
+
chain 是唯一真源,models/model 是它的兼容冗余。改动前留 .bak。
|
|
2514
|
+
返回是否发生了迁移。
|
|
2515
|
+
"""
|
|
2516
|
+
with _LOCK:
|
|
2517
|
+
data = _load()
|
|
2518
|
+
changed = False
|
|
2519
|
+
for b in (data.get("bindings") or {}).values():
|
|
2520
|
+
if b.get("chain"):
|
|
2521
|
+
continue
|
|
2522
|
+
names = _clean_models(b.get("models") or ([b["model"]] if b.get("model") else []))
|
|
2523
|
+
if not names:
|
|
2524
|
+
continue
|
|
2525
|
+
pid = b.get("provider_id") or ""
|
|
2526
|
+
b["chain"] = [{"provider_id": pid, "model": n} for n in names]
|
|
2527
|
+
changed = True
|
|
2528
|
+
if not changed:
|
|
2529
|
+
return False
|
|
2530
|
+
_backup_file(_FILE)
|
|
2531
|
+
_save(data)
|
|
2532
|
+
return True
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
# ---------------------------------------------------------------- 编排设置(编排者模型)
|
|
2536
|
+
|
|
2537
|
+
def orchestrator_view():
|
|
2538
|
+
"""编排者配置(脱敏展示 + 可用性判定)。"""
|
|
2539
|
+
with _LOCK:
|
|
2540
|
+
cfg = dict(_load().get("orchestrator") or {})
|
|
2541
|
+
prov = next((p for p in providers() if p.get("id") == cfg.get("provider_id")), None)
|
|
2542
|
+
cfg["provider_name"] = (prov or {}).get("name", "")
|
|
2543
|
+
cfg["protocol"] = (prov or {}).get("protocol", "")
|
|
2544
|
+
cfg["enabled"] = bool(cfg.get("enabled", False))
|
|
2545
|
+
cfg["ready"] = bool(
|
|
2546
|
+
cfg.get("enabled") and prov and prov.get("api_key")
|
|
2547
|
+
and prov.get("enabled", True) and (cfg.get("model") or prov.get("model")))
|
|
2548
|
+
return cfg
|
|
2549
|
+
|
|
2550
|
+
|
|
2551
|
+
def set_orchestrator(provider_id, model=None, enabled=None):
|
|
2552
|
+
"""保存编排者配置。provider_id 空串 = 不使用编排者。返回错误或 None。"""
|
|
2553
|
+
with _LOCK:
|
|
2554
|
+
data = _load()
|
|
2555
|
+
pid = (provider_id or "").strip()
|
|
2556
|
+
if pid and not _find_prov(data, pid):
|
|
2557
|
+
return "供应商不存在"
|
|
2558
|
+
cfg = data.get("orchestrator") or {}
|
|
2559
|
+
cfg["provider_id"] = pid
|
|
2560
|
+
if model is not None:
|
|
2561
|
+
cfg["model"] = (model or "").strip()
|
|
2562
|
+
if enabled is not None:
|
|
2563
|
+
cfg["enabled"] = bool(enabled)
|
|
2564
|
+
if not pid:
|
|
2565
|
+
cfg["enabled"] = False
|
|
2566
|
+
data["orchestrator"] = cfg
|
|
2567
|
+
_save(data)
|
|
2568
|
+
return None
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
def resolve_orchestrator():
|
|
2572
|
+
"""返回编排者实际可用的 (provider, model_name),未启用/失效返回 None。"""
|
|
2573
|
+
with _LOCK:
|
|
2574
|
+
cfg = _load().get("orchestrator") or {}
|
|
2575
|
+
if not cfg.get("enabled", False):
|
|
2576
|
+
return None
|
|
2577
|
+
pid = cfg.get("provider_id") or ""
|
|
2578
|
+
prov = next((p for p in providers() if p.get("id") == pid), None)
|
|
2579
|
+
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
2580
|
+
return None
|
|
2581
|
+
model = (cfg.get("model") or "").strip() or prov.get("model") or ""
|
|
2582
|
+
if not model:
|
|
2583
|
+
names = _enabled_models(prov)
|
|
2584
|
+
model = names[0]["name"] if names else ""
|
|
2585
|
+
if not model:
|
|
2586
|
+
return None
|
|
2587
|
+
return prov, model
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
def _chat_cache_path(provider_id, model_name, prompt, max_tokens):
|
|
2591
|
+
"""§07 T2.2:精确匹配响应缓存的落盘路径(只缓存 ok 的幂等调用)。"""
|
|
2592
|
+
import hashlib as _h
|
|
2593
|
+
key = "|".join([str(provider_id), str(model_name), str(max_tokens), str(prompt)])
|
|
2594
|
+
name = _h.sha256(key.encode("utf-8")).hexdigest()[:24]
|
|
2595
|
+
return paths.DATA_DIR / "chat_cache" / (name + ".json")
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_ttl=0,
|
|
2599
|
+
on_delta=None):
|
|
2600
|
+
"""直连供应商 API 做一次对话(编排者规划 / 连通性测试)。
|
|
2601
|
+
|
|
2602
|
+
支持 anthropic / openai / google 三种协议;复用 SSRF 防护。
|
|
2603
|
+
返回 {ok, text, tokens, usage, error};usage 为细分 {input, output, cached, reasoning, total}。
|
|
2604
|
+
cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens,只缓存
|
|
2605
|
+
ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
|
|
2606
|
+
的二次请求会屏蔽模型的新输出。
|
|
2607
|
+
on_delta 给定时走 SSE 流式:每收到一段增量文本回调一次。编排者直连调用
|
|
2608
|
+
不经 run_process、原本生成全程日志只有一行标题,靠它把「正在吐字」实时
|
|
2609
|
+
写进步骤日志(planner._log_streamer 节流落盘)。
|
|
2610
|
+
"""
|
|
2611
|
+
if cache_ttl > 0:
|
|
2612
|
+
try:
|
|
2613
|
+
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens)
|
|
2614
|
+
if cache_path.is_file():
|
|
2615
|
+
age = time.time() - cache_path.stat().st_mtime
|
|
2616
|
+
if age <= cache_ttl:
|
|
2617
|
+
import json as _json
|
|
2618
|
+
data = _json.loads(cache_path.read_text(encoding="utf-8"))
|
|
2619
|
+
if isinstance(data, dict) and data.get("ok"):
|
|
2620
|
+
return data
|
|
2621
|
+
except Exception:
|
|
2622
|
+
pass
|
|
2623
|
+
with _LOCK:
|
|
2624
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
2625
|
+
if not prov or not prov.get("api_key"):
|
|
2626
|
+
return {"ok": False, "text": "", "tokens": 0, "usage": None,
|
|
2627
|
+
"error": "供应商不存在或未配置密钥"}
|
|
2628
|
+
protos = _protocol_candidates(prov)
|
|
2629
|
+
if not protos:
|
|
2630
|
+
return {"ok": False, "text": "", "tokens": 0, "usage": None,
|
|
2631
|
+
"error": "该供应商还没有可用 wire——先「获取模型列表」或手动指定格式"}
|
|
2632
|
+
# 多 KEY:按「KEY 序 × 协议」展开逐条试。欠费的 KEY 先被跳过(切备用),
|
|
2633
|
+
# 冷却中的 KEY 一条都不剩时仍按原顺序试——比整家供应商不可用强。
|
|
2634
|
+
keys = _chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
|
|
2635
|
+
cands = [(proto, base, k["key"], k.get("id") or "")
|
|
2636
|
+
for k in keys for (proto, base) in protos]
|
|
2637
|
+
if on_delta is not None:
|
|
2638
|
+
cands = cands[:1] # 流式不重试:回调会重复吐字,宁可按首选 wire 失败
|
|
2639
|
+
|
|
2640
|
+
def _build(proto, base, use_key):
|
|
2641
|
+
"""按协议构造 (url, headers, body)。base 已 strip。"""
|
|
2642
|
+
if proto == "google":
|
|
2643
|
+
if base.endswith("/v1beta"):
|
|
2644
|
+
url = base + "/models/%s:generateContent" % model_name
|
|
2645
|
+
else:
|
|
2646
|
+
url = base + "/v1beta/models/%s:generateContent" % model_name
|
|
2647
|
+
headers = {"x-goog-api-key": use_key}
|
|
2648
|
+
body = {"contents": [{"parts": [{"text": prompt}]}],
|
|
2649
|
+
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
2650
|
+
else:
|
|
2651
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
2652
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
2653
|
+
if proto == "anthropic":
|
|
2654
|
+
headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
|
|
2655
|
+
else:
|
|
2656
|
+
headers = {"Authorization": "Bearer " + use_key}
|
|
2657
|
+
body = {"model": model_name, "max_tokens": max_tokens,
|
|
2658
|
+
"messages": [{"role": "user", "content": prompt}]}
|
|
2659
|
+
return url, headers, body
|
|
2660
|
+
|
|
2661
|
+
last_err = ""
|
|
2662
|
+
for proto, pbase, use_key, key_id in cands:
|
|
2663
|
+
base = (pbase or "").rstrip("/")
|
|
2664
|
+
url, headers, body = _build(proto, base, use_key)
|
|
2665
|
+
|
|
2666
|
+
if on_delta is not None:
|
|
2667
|
+
sbody = dict(body)
|
|
2668
|
+
sbody["stream"] = True
|
|
2669
|
+
if proto not in ("anthropic", "google"):
|
|
2670
|
+
sbody["stream_options"] = {"include_usage": True} # openai 系最后一个 chunk 带 usage
|
|
2671
|
+
status, text, usage_d, err = _post_sse_http(
|
|
2672
|
+
url, headers, sbody, bool(prov.get("allow_private")), timeout, proto, on_delta)
|
|
2673
|
+
if status == 0 or err:
|
|
2674
|
+
return {"ok": False, "text": "", "tokens": 0, "usage": None, "error": err}
|
|
2675
|
+
if not (text or "").strip():
|
|
2676
|
+
# 网关对 stream 请求回了 200 但没吐任何 SSE 事件(空流/普通 JSON 体,
|
|
2677
|
+
# 实测 vsllm 大请求会这样):绝不能当成功返回空文本,退回非流式重发
|
|
2678
|
+
pass
|
|
2679
|
+
else:
|
|
2680
|
+
if not usage_d.get("total"):
|
|
2681
|
+
usage_d["total"] = (usage_d.get("input", 0) + usage_d.get("output", 0)
|
|
2682
|
+
+ usage_d.get("cached", 0))
|
|
2683
|
+
return {"ok": True, "text": (text or "").strip(), "tokens": usage_d.get("total") or 0,
|
|
2684
|
+
"usage": usage_d, "error": ""}
|
|
2685
|
+
|
|
2686
|
+
status, data, err = _post_json_http(url, headers, body, bool(prov.get("allow_private")),
|
|
2687
|
+
timeout=timeout)
|
|
2688
|
+
if status == 0:
|
|
2689
|
+
last_err = err
|
|
2690
|
+
note_key_error(provider_id, key_id, err) # 记账:欠费类进冷却,切备用
|
|
2691
|
+
continue # 这条 wire/KEY 连不上:换下一条
|
|
2692
|
+
if not 200 <= status < 300:
|
|
2693
|
+
msg = ""
|
|
2694
|
+
if isinstance(data, dict):
|
|
2695
|
+
e = data.get("error")
|
|
2696
|
+
msg = e.get("message", "") if isinstance(e, dict) else str(e)
|
|
2697
|
+
last_err = "HTTP %s %s" % (status, str(msg)[:200])
|
|
2698
|
+
note_key_error(provider_id, key_id, last_err)
|
|
2699
|
+
continue
|
|
2700
|
+
note_key_ok(provider_id, key_id)
|
|
2701
|
+
break
|
|
2702
|
+
else:
|
|
2703
|
+
return {"ok": False, "text": "", "tokens": 0, "usage": None,
|
|
2704
|
+
"error": last_err or "所有可用 wire 均失败"}
|
|
2705
|
+
text = ""
|
|
2706
|
+
usage = {"input": 0, "output": 0, "cached": 0, "reasoning": 0, "total": 0}
|
|
2707
|
+
try:
|
|
2708
|
+
if proto == "anthropic":
|
|
2709
|
+
text = "\n".join(b.get("text", "") for b in (data.get("content") or [])
|
|
2710
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
2711
|
+
u = data.get("usage") or {}
|
|
2712
|
+
usage["input"] = int(u.get("input_tokens") or 0)
|
|
2713
|
+
usage["output"] = int(u.get("output_tokens") or 0)
|
|
2714
|
+
usage["cached"] = (int(u.get("cache_read_input_tokens") or 0)
|
|
2715
|
+
+ int(u.get("cache_creation_input_tokens") or 0))
|
|
2716
|
+
elif proto == "google":
|
|
2717
|
+
cand = ((data.get("candidates") or [{}])[0].get("content") or {})
|
|
2718
|
+
text = "\n".join(p.get("text", "") for p in (cand.get("parts") or [])
|
|
2719
|
+
if isinstance(p, dict))
|
|
2720
|
+
um = data.get("usageMetadata") or {}
|
|
2721
|
+
usage["input"] = int(um.get("promptTokenCount") or 0)
|
|
2722
|
+
usage["output"] = int(um.get("candidatesTokenCount") or 0)
|
|
2723
|
+
usage["total"] = int(um.get("totalTokenCount") or 0)
|
|
2724
|
+
else:
|
|
2725
|
+
choice = (data.get("choices") or [{}])[0]
|
|
2726
|
+
msg = choice.get("message") or {}
|
|
2727
|
+
text = msg.get("content") or ""
|
|
2728
|
+
u = data.get("usage") or {}
|
|
2729
|
+
usage["input"] = int(u.get("prompt_tokens") or 0)
|
|
2730
|
+
usage["output"] = int(u.get("completion_tokens") or 0)
|
|
2731
|
+
usage["total"] = int(u.get("total_tokens") or 0)
|
|
2732
|
+
except Exception as e:
|
|
2733
|
+
return {"ok": False, "text": "", "tokens": 0, "usage": None,
|
|
2734
|
+
"error": "响应解析失败: %r" % e}
|
|
2735
|
+
if not usage["total"]:
|
|
2736
|
+
usage["total"] = usage["input"] + usage["output"] + usage["cached"]
|
|
2737
|
+
result = {"ok": True, "text": (text or "").strip(), "tokens": usage["total"],
|
|
2738
|
+
"usage": usage, "error": ""}
|
|
2739
|
+
# §07 T2.2:cache_ttl>0 时落盘缓存(仅 ok 结果,原子写)
|
|
2740
|
+
if cache_ttl > 0:
|
|
2741
|
+
try:
|
|
2742
|
+
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens)
|
|
2743
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2744
|
+
tmp = cache_path.with_suffix(".tmp")
|
|
2745
|
+
import json as _json
|
|
2746
|
+
tmp.write_text(_json.dumps(result, ensure_ascii=False), encoding="utf-8")
|
|
2747
|
+
tmp.replace(cache_path)
|
|
2748
|
+
except Exception:
|
|
2749
|
+
pass
|
|
2750
|
+
return result
|