codebee 0.1.65 → 0.1.67
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/CHANGELOG.md +532 -513
- package/LICENSE +21 -21
- package/README.md +494 -487
- package/app/core/acceptance.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -0
- package/app/core/attachments.py +623 -532
- package/app/core/automation.py +758 -758
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +930 -759
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1234 -1191
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +23 -24
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -186
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -0
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +173 -173
- package/app/core/dispatch_log.py +113 -113
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +220 -65
- package/app/core/errorlog.py +137 -137
- package/app/core/evaluation.py +174 -0
- package/app/core/failure_notify.py +290 -0
- package/app/core/flows.py +401 -355
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -342
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -156
- package/app/core/jobs.py +1050 -1022
- package/app/core/knowledge.py +56 -5
- package/app/core/manager.py +2122 -1996
- package/app/core/market.py +362 -362
- package/app/core/market_remote.py +190 -92
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +3546 -3332
- package/app/core/notify.py +106 -106
- package/app/core/operations.py +228 -0
- package/app/core/paihang.py +138 -109
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +4808 -4494
- package/app/core/planner.py +37 -32
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +539 -533
- package/app/core/publish/fanqie.py +159 -128
- package/app/core/publish/flow.py +440 -406
- package/app/core/publish/flows-fanqie-calibrated.json +17 -9
- package/app/core/publish/flows-qimao-calibrated.json +6 -2
- package/app/core/publish/ledger.py +203 -198
- package/app/core/publish/manager.py +692 -590
- package/app/core/publish/qimao.py +141 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -124
- package/app/core/revisions.py +51 -0
- package/app/core/router.py +119 -81
- package/app/core/runner.py +2065 -2042
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +149 -149
- package/app/core/settings_schema.py +262 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +114 -106
- package/app/core/skillpacks/fanqie-novel.md +80 -80
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skills.py +315 -73
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +230 -11
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/upstream.py +41 -0
- package/app/core/usage.py +114 -3
- package/app/core/volumes.py +389 -355
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2465 -2447
- package/app/main.py +2957 -2832
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +14384 -13977
- package/app/ui/artdiff.js +74 -0
- package/app/ui/board.css +20 -41
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +2766 -2711
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +7 -1
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +6179 -6125
- package/bin/tutti.js +147 -147
- package/package.json +46 -41
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
|
@@ -1,1191 +1,1234 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""内置执行智能体:不经外部 CLI,直连供应商 API 完成直连(direct)任务。
|
|
3
|
-
|
|
4
|
-
为什么:direct 是快档位,典型诉求是问答/轻量改写。拉起 codex/claude CLI 要付
|
|
5
|
-
进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
|
|
6
|
-
对话视图。内置智能体直接打 chat API + 工具循环:
|
|
7
|
-
- 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
|
|
8
|
-
- 工具:list_files / read_file / write_file(锁死工作目录内)+ run_command
|
|
9
|
-
(本机全权执行命令,产品定位全信任,与 codex/claude 全权沙箱同档);
|
|
10
|
-
- 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
|
|
11
|
-
密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import base64
|
|
16
|
-
import io
|
|
17
|
-
import json
|
|
18
|
-
import mimetypes
|
|
19
|
-
import os
|
|
20
|
-
import threading
|
|
21
|
-
import time
|
|
22
|
-
import urllib.parse
|
|
23
|
-
import urllib.request
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
from . import modelhub, runner, tlsctx
|
|
27
|
-
|
|
28
|
-
MAX_TOOL_ITERS = 16 # 单步工具循环上限(防模型打转)
|
|
29
|
-
READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
|
|
30
|
-
READ_HEAD_BYTES = 44 * 1024 # TokenJuice 借鉴(openhuman):超限文件头尾保留、
|
|
31
|
-
READ_TAIL_BYTES = 16 * 1024 # 中段省略——日志/代码的报错常在尾部,纯截头会丢关键信息
|
|
32
|
-
LIST_MAX_ENTRIES = 200
|
|
33
|
-
MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体防爆)
|
|
34
|
-
IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
|
|
35
|
-
IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
|
|
36
|
-
IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
|
|
37
|
-
THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
|
|
38
|
-
STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
|
|
39
|
-
BASE_MAX_TOKENS = 8000 # 单次生成输出预算(推理模型的思考 token 也计入此额度)
|
|
40
|
-
MAX_TOKENS_CAP = 16000 # 思考撑爆预算后的提额上限(每轮 run 只提一次)
|
|
41
|
-
THINK_EXHAUST_MIN_CHARS = 1500 # 零正文时思考达到此长度 ≈ 思考占满输出预算
|
|
42
|
-
EMPTY_TEXT_MAX_STREAK = 3 # 连续零正文轮数上限(防同参数盲重试烧穿预算)
|
|
43
|
-
|
|
44
|
-
CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
|
|
45
|
-
CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
|
|
46
|
-
CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
|
|
47
|
-
CMD_OUT_TAIL = 8 * 1024
|
|
48
|
-
|
|
49
|
-
_SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
|
|
50
|
-
|
|
51
|
-
## 工作方式
|
|
52
|
-
- 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
|
|
53
|
-
- read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
|
|
54
|
-
- 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
|
|
55
|
-
- run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
|
|
56
|
-
- Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
|
|
57
|
-
- 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
|
|
58
|
-
- 产出文件一律 UTF-8 编码。
|
|
59
|
-
- 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。"""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def resolve(provider_id="", model="", difficulty="default"):
|
|
63
|
-
"""选内置智能体可用的 (provider, model):编排者配置优先,否则首个可用供应商。
|
|
64
|
-
|
|
65
|
-
返回 {prov, model, provider_id, provider_name} 或 None(无可用供应商 → 调用方
|
|
66
|
-
回退 CLI 路径)。"""
|
|
67
|
-
provider_id = str(provider_id or "").strip()
|
|
68
|
-
model = str(model or "").strip()
|
|
69
|
-
if provider_id:
|
|
70
|
-
for p in modelhub.providers():
|
|
71
|
-
if p.get("id") != provider_id or not p.get("enabled", True) or not p.get("api_key"):
|
|
72
|
-
continue
|
|
73
|
-
selected = model
|
|
74
|
-
if selected:
|
|
75
|
-
try:
|
|
76
|
-
enabled_names = [m.get("name") for m in modelhub._enabled_models(p)]
|
|
77
|
-
except Exception:
|
|
78
|
-
enabled_names = []
|
|
79
|
-
if selected not in enabled_names:
|
|
80
|
-
return None
|
|
81
|
-
if not selected:
|
|
82
|
-
selected = (p.get("model_hard") if difficulty == "hard" else
|
|
83
|
-
p.get("model_easy") if difficulty == "easy" else
|
|
84
|
-
p.get("model") or "").strip()
|
|
85
|
-
if not selected:
|
|
86
|
-
try:
|
|
87
|
-
names = modelhub._enabled_models(p)
|
|
88
|
-
except Exception:
|
|
89
|
-
names = []
|
|
90
|
-
selected = names[0]["name"] if names else ""
|
|
91
|
-
if selected:
|
|
92
|
-
return {"prov": p, "model": selected,
|
|
93
|
-
"provider_id": p.get("id") or "",
|
|
94
|
-
"provider_name": p.get("name") or p.get("id") or ""}
|
|
95
|
-
return None
|
|
96
|
-
orch = None
|
|
97
|
-
try:
|
|
98
|
-
orch = modelhub.resolve_orchestrator()
|
|
99
|
-
except Exception:
|
|
100
|
-
orch = None
|
|
101
|
-
if orch:
|
|
102
|
-
prov, model = orch
|
|
103
|
-
return {"prov": prov, "model": model,
|
|
104
|
-
"provider_id": prov.get("id") or "",
|
|
105
|
-
"provider_name": prov.get("name") or prov.get("id") or ""}
|
|
106
|
-
for p in modelhub.providers():
|
|
107
|
-
if not p.get("enabled", True) or not p.get("api_key"):
|
|
108
|
-
continue
|
|
109
|
-
m = ((p.get("model_hard") if difficulty == "hard" else
|
|
110
|
-
p.get("model_easy") if difficulty == "easy" else
|
|
111
|
-
p.get("model")) or "").strip()
|
|
112
|
-
if not m:
|
|
113
|
-
try:
|
|
114
|
-
names = modelhub._enabled_models(p)
|
|
115
|
-
except Exception:
|
|
116
|
-
names = []
|
|
117
|
-
m = names[0]["name"] if names else ""
|
|
118
|
-
if m:
|
|
119
|
-
return {"prov": p, "model": m, "provider_id": p.get("id") or "",
|
|
120
|
-
"provider_name": p.get("name") or p.get("id") or ""}
|
|
121
|
-
return None
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# ---------------------------------------------------------------- 工作目录工具
|
|
125
|
-
# 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
|
|
126
|
-
# 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
|
|
127
|
-
|
|
128
|
-
def _tool_list_files(workdir, args):
|
|
129
|
-
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
130
|
-
if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
131
|
-
return "(非法路径: %s)" % rel
|
|
132
|
-
base = Path(workdir or ".").resolve()
|
|
133
|
-
p = (base / rel).resolve() if rel else base
|
|
134
|
-
if base not in p.parents and p != base:
|
|
135
|
-
return "(路径越界: %s)" % rel
|
|
136
|
-
if not p.is_dir():
|
|
137
|
-
return "(目录不存在: %s)" % (rel or ".")
|
|
138
|
-
out = []
|
|
139
|
-
for root, dirs, files in os.walk(str(p)):
|
|
140
|
-
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")]
|
|
141
|
-
for f in files:
|
|
142
|
-
fp = os.path.join(root, f)
|
|
143
|
-
relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
|
|
144
|
-
try:
|
|
145
|
-
size = os.path.getsize(fp)
|
|
146
|
-
except OSError:
|
|
147
|
-
size = -1
|
|
148
|
-
out.append("%s (%d B)" % (relp, size))
|
|
149
|
-
if len(out) >= LIST_MAX_ENTRIES:
|
|
150
|
-
return "\n".join(out) + "\n…(截断,共 200+ 项)"
|
|
151
|
-
return "\n".join(out) or "(空目录)"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
_BIN_MAGIC = (
|
|
155
|
-
(b"\x89PNG\r\n\x1a\n", "PNG 图片"),
|
|
156
|
-
(b"\xff\xd8\xff", "JPEG 图片"),
|
|
157
|
-
(b"GIF8", "GIF 图片"),
|
|
158
|
-
(b"%PDF-", "PDF 文档"),
|
|
159
|
-
(b"PK\x03\x04", "ZIP 压缩包"),
|
|
160
|
-
(b"\x1f\x8b", "GZIP 压缩包"),
|
|
161
|
-
(b"7z\xbc\xaf\x27\x1c", "7z 压缩包"),
|
|
162
|
-
(b"Rar!\x1a\x07", "RAR 压缩包"),
|
|
163
|
-
(b"\x00\x00\x01\x00", "ICO 图标"),
|
|
164
|
-
(b"OggS", "OGG 音频"),
|
|
165
|
-
(b"ID3", "MP3 音频"),
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _bin_format(head):
|
|
170
|
-
"""常见二进制魔数 → 人类可读格式名(写进给模型的提示,让它立刻止损)。"""
|
|
171
|
-
if len(head) >= 12 and head[:4] == b"RIFF":
|
|
172
|
-
return {"WEBP": "WEBP 图片", "WAVE": "WAV 音频",
|
|
173
|
-
"AVI ": "AVI 视频"}.get(head[8:12].decode("ascii", "replace"),
|
|
174
|
-
"RIFF 媒体")
|
|
175
|
-
if len(head) >= 8 and head[4:8] == b"ftyp":
|
|
176
|
-
return "MP4/MOV 视频"
|
|
177
|
-
for magic, name in _BIN_MAGIC:
|
|
178
|
-
if head.startswith(magic):
|
|
179
|
-
return name
|
|
180
|
-
return "二进制文件"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _looks_binary(head):
|
|
184
|
-
"""文本判定从宽:NUL 字节即判二进制(git 同款启发式),控制字符占比兜底
|
|
185
|
-
(部分二进制头 8KB 内无 NUL)。"""
|
|
186
|
-
if not head:
|
|
187
|
-
return False
|
|
188
|
-
if b"\x00" in head:
|
|
189
|
-
return True
|
|
190
|
-
sample = head[:8192]
|
|
191
|
-
ctrl = sum(1 for b in sample if b < 32 and b not in (9, 10, 12, 13))
|
|
192
|
-
return ctrl * 20 > len(sample) # >5% 视为二进制
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _trim_partial_utf8(chunk):
|
|
196
|
-
"""头部截断可能把多字节字符切成两半:剥掉尾部不完整序列,否则 UTF-8
|
|
197
|
-
strict 失败会误落 GBK 解出错字(runner.tail_decoded 的头截断对偶)。"""
|
|
198
|
-
i = len(chunk) - 1
|
|
199
|
-
while i >= 0 and i >= len(chunk) - 3 and (chunk[i] & 0xC0) == 0x80:
|
|
200
|
-
i -= 1
|
|
201
|
-
if 0 <= i < len(chunk):
|
|
202
|
-
b = chunk[i]
|
|
203
|
-
need = 4 if (b & 0xF8) == 0xF0 else 3 if (b & 0xF0) == 0xE0 \
|
|
204
|
-
else 2 if (b & 0xC0) == 0xC0 else 1
|
|
205
|
-
if len(chunk) - i < need:
|
|
206
|
-
return chunk[:i]
|
|
207
|
-
return chunk
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _guess_image_mime(path, raw=b""):
|
|
211
|
-
m = mimetypes.guess_type(str(path))[0]
|
|
212
|
-
if m:
|
|
213
|
-
return m
|
|
214
|
-
if raw.startswith(b"\x89PNG"):
|
|
215
|
-
return "image/png"
|
|
216
|
-
if raw.startswith(b"\xff\xd8"):
|
|
217
|
-
return "image/jpeg"
|
|
218
|
-
if raw.startswith(b"GIF8"):
|
|
219
|
-
return "image/gif"
|
|
220
|
-
if raw.startswith(b"RIFF"):
|
|
221
|
-
return "image/webp"
|
|
222
|
-
return "image/png"
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _prep_images(paths, log=None):
|
|
226
|
-
"""图片路径 → [(mime, b64)]。Pillow 可用时:长边超 1568px 等比缩(万级像素
|
|
227
|
-
原图既烧 token 又常被网关拒),仍超 3.5MB 转 JPEG q85;Pillow 缺失或解码
|
|
228
|
-
失败时原图直传,单张超 5MB 剔除并日志。最多 MAX_IMAGES 张。"""
|
|
229
|
-
try:
|
|
230
|
-
from PIL import Image
|
|
231
|
-
except Exception:
|
|
232
|
-
Image = None
|
|
233
|
-
out = []
|
|
234
|
-
for p in (paths or [])[:MAX_IMAGES]:
|
|
235
|
-
try:
|
|
236
|
-
raw = Path(p).read_bytes()
|
|
237
|
-
except OSError:
|
|
238
|
-
if log:
|
|
239
|
-
log("[图片] 跳过(读取失败): %s" % os.path.basename(str(p)))
|
|
240
|
-
continue
|
|
241
|
-
mime = _guess_image_mime(p, raw)
|
|
242
|
-
data = raw
|
|
243
|
-
if Image is not None:
|
|
244
|
-
try:
|
|
245
|
-
im = Image.open(io.BytesIO(raw))
|
|
246
|
-
im.load()
|
|
247
|
-
w, h = im.size
|
|
248
|
-
edge = max(w, h, 1)
|
|
249
|
-
if edge > IMAGE_MAX_EDGE:
|
|
250
|
-
r = IMAGE_MAX_EDGE / float(edge)
|
|
251
|
-
im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
|
|
252
|
-
Image.LANCZOS)
|
|
253
|
-
fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
|
|
254
|
-
"image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
|
|
255
|
-
buf = io.BytesIO()
|
|
256
|
-
im.save(buf, format=fmt) # GIF 多帧仅存首帧,可接受
|
|
257
|
-
data = buf.getvalue()
|
|
258
|
-
except Exception:
|
|
259
|
-
data = raw # 解码/编码失败退回原始字节
|
|
260
|
-
if (Image is not None and mime != "image/jpeg"
|
|
261
|
-
and len(data) > IMAGE_JPEG_BYTES):
|
|
262
|
-
try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
|
|
263
|
-
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
264
|
-
buf = io.BytesIO()
|
|
265
|
-
im.save(buf, format="JPEG", quality=85)
|
|
266
|
-
data = buf.getvalue()
|
|
267
|
-
mime = "image/jpeg"
|
|
268
|
-
except Exception:
|
|
269
|
-
pass
|
|
270
|
-
if len(data) > IMAGE_MAX_BYTES:
|
|
271
|
-
if log:
|
|
272
|
-
log("[图片] 剔除(过大 %d MB): %s"
|
|
273
|
-
% (len(data) // (1024 * 1024), os.path.basename(str(p))))
|
|
274
|
-
continue
|
|
275
|
-
out.append((mime, base64.b64encode(data).decode("ascii")))
|
|
276
|
-
return out
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _tool_read_file(workdir, args):
|
|
280
|
-
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
281
|
-
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
282
|
-
return "(非法路径: %s)" % rel
|
|
283
|
-
base = Path(workdir or ".").resolve()
|
|
284
|
-
p = (base / rel).resolve()
|
|
285
|
-
if base not in p.parents:
|
|
286
|
-
return "(路径越界: %s)" % rel
|
|
287
|
-
if not p.is_file():
|
|
288
|
-
return "(文件不存在: %s)" % rel
|
|
289
|
-
data = p.read_bytes()[:READ_MAX_BYTES + 1]
|
|
290
|
-
truncated = len(data) > READ_MAX_BYTES
|
|
291
|
-
if truncated:
|
|
292
|
-
# 头尾保留、中段省略(TokenJuice 借鉴):头 44k 给结构/开头上下文,
|
|
293
|
-
# 尾 16k 给报错/结论(日志与代码的关键信息常在末尾),中段标注省略量
|
|
294
|
-
try:
|
|
295
|
-
size = p.stat().st_size
|
|
296
|
-
except OSError:
|
|
297
|
-
size = len(data)
|
|
298
|
-
with open(p, "rb") as fh:
|
|
299
|
-
head = fh.read(READ_HEAD_BYTES)
|
|
300
|
-
fh.seek(max(0, size - READ_TAIL_BYTES))
|
|
301
|
-
tail = fh.read(READ_TAIL_BYTES)
|
|
302
|
-
head = _trim_partial_utf8(head)
|
|
303
|
-
# 尾段开头的残缺多字节序列剥头(对偶于 _trim_partial_utf8 剥尾)
|
|
304
|
-
while tail:
|
|
305
|
-
try:
|
|
306
|
-
tail.decode("utf-8")
|
|
307
|
-
break
|
|
308
|
-
except UnicodeDecodeError:
|
|
309
|
-
tail = tail[1:]
|
|
310
|
-
text = runner.decode_output(head) \
|
|
311
|
-
+ "\n…(中段省略 %d 字节;头 %dKB + 尾 %dKB 保留)\n" % (
|
|
312
|
-
max(0, size - READ_HEAD_BYTES - READ_TAIL_BYTES),
|
|
313
|
-
READ_HEAD_BYTES // 1024, READ_TAIL_BYTES // 1024) \
|
|
314
|
-
+ runner.decode_output(tail)
|
|
315
|
-
return "…(文件超 64KB,已头尾保留读取)\n" + text
|
|
316
|
-
head = data
|
|
317
|
-
if not head:
|
|
318
|
-
return "(空文件)"
|
|
319
|
-
if _looks_binary(head):
|
|
320
|
-
try:
|
|
321
|
-
size = p.stat().st_size
|
|
322
|
-
except OSError:
|
|
323
|
-
size = len(head)
|
|
324
|
-
fmt = _bin_format(head)
|
|
325
|
-
if "图片" in fmt:
|
|
326
|
-
return ("(图片文件: %s — %s,%d B,无法按文本读取。用户以附件发来的图片会"
|
|
327
|
-
"直接出现在对话中,无需用工具读取;若需要目录里这张图的内容,"
|
|
328
|
-
"请提示用户把它作为附件发送。)" % (rel, fmt, size))
|
|
329
|
-
return ("(二进制文件,无法按文本读取: %s — %s,%d B。read_file 只支持文本文件;"
|
|
330
|
-
"请如实告知用户该文件内容无法以文本方式查看,不要反复重读。)"
|
|
331
|
-
% (rel, fmt, size))
|
|
332
|
-
text = runner.decode_output(head)
|
|
333
|
-
return text
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _tool_write_file(workdir, args):
|
|
337
|
-
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
338
|
-
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
339
|
-
return "(非法路径: %s)" % rel
|
|
340
|
-
content = str(args.get("content") if args.get("content") is not None else "")
|
|
341
|
-
base = Path(workdir or ".").resolve()
|
|
342
|
-
dest = (base / rel).resolve()
|
|
343
|
-
if base not in dest.parents:
|
|
344
|
-
return "(路径越界: %s)" % rel
|
|
345
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
-
dest.write_text(content, encoding="utf-8")
|
|
347
|
-
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
351
|
-
"""本机执行一条 shell 命令,返回 (退出码, 合并输出)。
|
|
352
|
-
|
|
353
|
-
薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
|
|
354
|
-
→ cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
|
|
355
|
-
(用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
|
|
356
|
-
提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
|
|
357
|
-
输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
|
|
358
|
-
cmdline = str(args.get("command") or "").strip()
|
|
359
|
-
if not cmdline:
|
|
360
|
-
return "(命令为空,未执行)"
|
|
361
|
-
try:
|
|
362
|
-
timeout = int(float(args.get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
|
|
363
|
-
except (TypeError, ValueError):
|
|
364
|
-
timeout = CMD_DEFAULT_TIMEOUT
|
|
365
|
-
r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
|
|
366
|
-
timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
|
|
367
|
-
deadline=deadline, cancel_event=cancel_event)
|
|
368
|
-
out = runner.clean_cli_text(
|
|
369
|
-
(r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
|
|
370
|
-
limit = CMD_OUT_HEAD + CMD_OUT_TAIL
|
|
371
|
-
if not out.strip():
|
|
372
|
-
body = "(无输出)"
|
|
373
|
-
elif len(out) <= limit:
|
|
374
|
-
body = out
|
|
375
|
-
else:
|
|
376
|
-
body = (out[:CMD_OUT_HEAD]
|
|
377
|
-
+ "\n…(中段省略 %d 字符;头 %dK + 尾 %dK 保留)\n" % (
|
|
378
|
-
len(out) - limit, CMD_OUT_HEAD // 1024, CMD_OUT_TAIL // 1024)
|
|
379
|
-
+ out[-CMD_OUT_TAIL:])
|
|
380
|
-
if r.get("cancelled"):
|
|
381
|
-
note = "已取消,进程树已终止"
|
|
382
|
-
elif r.get("timed_out"):
|
|
383
|
-
note = "超时 %d 秒,进程树已强制终止" % timeout
|
|
384
|
-
else:
|
|
385
|
-
note = ""
|
|
386
|
-
prefix = ("(%s)\n" % note) if note else ""
|
|
387
|
-
code = r.get("exit_code")
|
|
388
|
-
return "%s退出码: %s\n%s" % (prefix, "未知" if code is None else code, body)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
TOOLS_SPEC = [
|
|
392
|
-
{"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
|
|
393
|
-
"args": {"path": "子目录相对路径,留空=根目录"}},
|
|
394
|
-
{"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别,超 64KB 时保留开头与结尾、中段省略;图片等二进制文件无法读取)",
|
|
395
|
-
"args": {"path": "文件相对路径"}},
|
|
396
|
-
{"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,父目录自动创建)",
|
|
397
|
-
"args": {"path": "文件相对路径", "content": "完整文本内容"}},
|
|
398
|
-
{"name": "run_command",
|
|
399
|
-
"description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
|
|
400
|
-
"args": {"command": "完整命令行(shell 语法,本机直接执行)",
|
|
401
|
-
"timeout_sec": "可选,超时秒数"}},
|
|
402
|
-
]
|
|
403
|
-
|
|
404
|
-
_TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
|
|
405
|
-
"write_file": _tool_write_file, "run_command": _tool_run_command}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
|
|
409
|
-
fn = _TOOL_IMPL.get(name or "")
|
|
410
|
-
if fn is None:
|
|
411
|
-
return "(未知工具: %s)" % name
|
|
412
|
-
try:
|
|
413
|
-
if name == "run_command":
|
|
414
|
-
return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
|
|
415
|
-
return fn(workdir, args or {})
|
|
416
|
-
except Exception as e:
|
|
417
|
-
return "工具执行失败: %s" % (e)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
# ---------------------------------------------------------------- 协议适配
|
|
421
|
-
|
|
422
|
-
def _openai_tools():
|
|
423
|
-
return [{"type": "function", "function": {
|
|
424
|
-
"name": t["name"], "description": t["description"],
|
|
425
|
-
"parameters": {"type": "object",
|
|
426
|
-
"properties": {k: {"type": "string", "description": v}
|
|
427
|
-
for k, v in t["args"].items()},
|
|
428
|
-
"required": list(t["args"].keys())}}}
|
|
429
|
-
for t in TOOLS_SPEC]
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def _anthropic_tools():
|
|
433
|
-
return [{"name": t["name"], "description": t["description"],
|
|
434
|
-
"input_schema": {"type": "object",
|
|
435
|
-
"properties": {k: {"type": "string", "description": v}
|
|
436
|
-
for k, v in t["args"].items()},
|
|
437
|
-
"required": list(t["args"].keys())}}
|
|
438
|
-
for t in TOOLS_SPEC]
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
def _post_json(url, headers, body, allow_private, timeout):
|
|
442
|
-
"""传输层单点(测试在这里打桩)。"""
|
|
443
|
-
return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def _call_with_optional_deadline(fn, args, kwargs, deadline):
|
|
447
|
-
"""Keep older test/integration transport shims source-compatible."""
|
|
448
|
-
if deadline is None:
|
|
449
|
-
return fn(*args, **kwargs)
|
|
450
|
-
try:
|
|
451
|
-
return fn(*args, **kwargs, deadline=deadline)
|
|
452
|
-
except TypeError as exc:
|
|
453
|
-
if "unexpected keyword argument 'deadline'" not in str(exc):
|
|
454
|
-
raise
|
|
455
|
-
return fn(*args, **kwargs)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
|
|
459
|
-
deadline=None):
|
|
460
|
-
"""可打断的模型调用:cancel_event 置位即刻放弃等待返回。
|
|
461
|
-
|
|
462
|
-
单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
|
|
463
|
-
线程自行收尾(响应被丢弃,socket 随线程结束释放),主流程立即返回。
|
|
464
|
-
服务端仍会把这次生成跑完(token 已花),但任务本身即刻终止。"""
|
|
465
|
-
box = {}
|
|
466
|
-
|
|
467
|
-
def _go():
|
|
468
|
-
try:
|
|
469
|
-
box["r"] = _post_json(url, headers, body, allow_private, timeout)
|
|
470
|
-
except Exception as e:
|
|
471
|
-
box["r"] = (0, None, repr(e))
|
|
472
|
-
|
|
473
|
-
th = threading.Thread(target=_go, daemon=True)
|
|
474
|
-
th.start()
|
|
475
|
-
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
476
|
-
while th.is_alive():
|
|
477
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
478
|
-
break
|
|
479
|
-
if time.monotonic() >= wait_until:
|
|
480
|
-
return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
|
|
481
|
-
th.join(0.5)
|
|
482
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
483
|
-
return 0, None, "已取消"
|
|
484
|
-
return box.get("r") or (0, None, "无响应")
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
# ---------------------------------------------------------------- 思考过程(流式)
|
|
488
|
-
# 用户诉求(2026-09-22):「把思考过程打印出来」——直连对话此前整轮只有三点
|
|
489
|
-
# 打字动画,模型的思维链(DeepSeek/vLLM 系 reasoning_content、Anthropic
|
|
490
|
-
# thinking 块)被 _sse_parse 明确丢弃。这里自带一份「认思考」的增量解析:
|
|
491
|
-
# 边收边把思考与正文分别攒起来,交给调用方实时落盘 + 结束后存进步骤记录。
|
|
492
|
-
|
|
493
|
-
def _reason_from_delta(proto, obj):
|
|
494
|
-
"""从一条流式事件里取「思考增量」。三协议字段各异,取不到返回空串。
|
|
495
|
-
|
|
496
|
-
openai 兼容面最杂:reasoning_content(DeepSeek/vLLM/云知声实测)、
|
|
497
|
-
reasoning(部分网关)、reasoning_details[].text(OpenRouter 系)。
|
|
498
|
-
anthropic 是 thinking_delta(另有 redacted_thinking 不文本化,跳过)。"""
|
|
499
|
-
if proto == "openai":
|
|
500
|
-
total = ""
|
|
501
|
-
for ch in obj.get("choices") or []:
|
|
502
|
-
d = ch.get("delta") or {}
|
|
503
|
-
for k in ("reasoning_content", "reasoning"):
|
|
504
|
-
v = d.get(k)
|
|
505
|
-
if isinstance(v, str):
|
|
506
|
-
total += v
|
|
507
|
-
for det in (d.get("reasoning_details") or []):
|
|
508
|
-
if isinstance(det, dict) and isinstance(det.get("text"), str):
|
|
509
|
-
total += det["text"]
|
|
510
|
-
return total
|
|
511
|
-
if proto == "anthropic" and obj.get("type") == "content_block_delta":
|
|
512
|
-
d = obj.get("delta") or {}
|
|
513
|
-
if d.get("type") == "thinking_delta":
|
|
514
|
-
return d.get("thinking") or ""
|
|
515
|
-
return ""
|
|
516
|
-
if proto == "google":
|
|
517
|
-
total = ""
|
|
518
|
-
for cand in obj.get("candidates") or []:
|
|
519
|
-
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
520
|
-
if isinstance(p, dict) and p.get("thought"):
|
|
521
|
-
total += p.get("text") or ""
|
|
522
|
-
return total
|
|
523
|
-
return ""
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
def _reason_from_message(proto, data):
|
|
527
|
-
"""非流式响应里的思考字段(网关不支持流式时的兜底)。"""
|
|
528
|
-
if not isinstance(data, dict):
|
|
529
|
-
return ""
|
|
530
|
-
if proto == "anthropic":
|
|
531
|
-
return "\n".join(b.get("thinking", "") for b in (data.get("content") or [])
|
|
532
|
-
if isinstance(b, dict) and b.get("type") == "thinking")
|
|
533
|
-
if proto == "google":
|
|
534
|
-
total = ""
|
|
535
|
-
for cand in data.get("candidates") or []:
|
|
536
|
-
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
537
|
-
if isinstance(p, dict) and p.get("thought"):
|
|
538
|
-
total += p.get("text") or ""
|
|
539
|
-
return total
|
|
540
|
-
msg = ((data.get("choices") or [{}])[0].get("message") or {})
|
|
541
|
-
total = ""
|
|
542
|
-
for k in ("reasoning_content", "reasoning"):
|
|
543
|
-
v = msg.get(k)
|
|
544
|
-
if isinstance(v, str):
|
|
545
|
-
total += v
|
|
546
|
-
for det in (msg.get("reasoning_details") or []):
|
|
547
|
-
if isinstance(det, dict) and isinstance(det.get("text"), str):
|
|
548
|
-
total += det["text"]
|
|
549
|
-
return total
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def _stream_accumulate(proto, obj, acc):
|
|
553
|
-
"""把一条 SSE 事件并入累加器(就地改写 acc)。返回是否有「有意义的进展」。
|
|
554
|
-
|
|
555
|
-
acc = {text, reasoning, tools, usage};工具参数按 index 归并——openai 的
|
|
556
|
-
function.arguments 与 anthropic 的 input_json_delta 都是分片到达的。"""
|
|
557
|
-
progress = False
|
|
558
|
-
if proto == "anthropic":
|
|
559
|
-
t = obj.get("type")
|
|
560
|
-
if t == "content_block_start":
|
|
561
|
-
blk = obj.get("content_block") or {}
|
|
562
|
-
if blk.get("type") == "tool_use":
|
|
563
|
-
idx = int(obj.get("index") or 0)
|
|
564
|
-
acc["tools"][idx] = {"id": blk.get("id") or "",
|
|
565
|
-
"name": blk.get("name") or "", "raw": ""}
|
|
566
|
-
progress = True
|
|
567
|
-
elif t == "content_block_delta":
|
|
568
|
-
d = obj.get("delta") or {}
|
|
569
|
-
if d.get("type") == "text_delta" and d.get("text"):
|
|
570
|
-
acc["text"] += d["text"]
|
|
571
|
-
progress = True
|
|
572
|
-
elif d.get("type") == "input_json_delta":
|
|
573
|
-
idx = int(obj.get("index") or 0)
|
|
574
|
-
slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
|
|
575
|
-
slot["raw"] += d.get("partial_json") or ""
|
|
576
|
-
progress = True
|
|
577
|
-
elif t == "message_start":
|
|
578
|
-
u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
|
|
579
|
-
acc["usage"].update({"input": int(u.get("input_tokens") or 0),
|
|
580
|
-
"output": int(u.get("output_tokens") or 0),
|
|
581
|
-
"cached": (int(u.get("cache_read_input_tokens") or 0)
|
|
582
|
-
+ int(u.get("cache_creation_input_tokens") or 0))})
|
|
583
|
-
elif t == "message_delta":
|
|
584
|
-
u = obj.get("usage") or {}
|
|
585
|
-
if u.get("output_tokens"):
|
|
586
|
-
acc["usage"]["output"] = int(u["output_tokens"])
|
|
587
|
-
elif proto == "google":
|
|
588
|
-
for cand in obj.get("candidates") or []:
|
|
589
|
-
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
590
|
-
if not isinstance(p, dict):
|
|
591
|
-
continue
|
|
592
|
-
if p.get("thought"):
|
|
593
|
-
acc["reasoning"] += p.get("text") or ""
|
|
594
|
-
progress = True
|
|
595
|
-
elif p.get("functionCall"):
|
|
596
|
-
fc = p["functionCall"]
|
|
597
|
-
acc["tools"][len(acc["tools"])] = {
|
|
598
|
-
"id": "call_%d" % (len(acc["tools"]) + 1),
|
|
599
|
-
"name": fc.get("name") or "",
|
|
600
|
-
"raw": json.dumps(fc.get("args") or {}, ensure_ascii=False)}
|
|
601
|
-
progress = True
|
|
602
|
-
elif p.get("text"):
|
|
603
|
-
acc["text"] += p["text"]
|
|
604
|
-
progress = True
|
|
605
|
-
u = obj.get("usageMetadata")
|
|
606
|
-
if isinstance(u, dict):
|
|
607
|
-
acc["usage"].update({"input": int(u.get("promptTokenCount") or 0),
|
|
608
|
-
"output": int(u.get("candidatesTokenCount") or 0),
|
|
609
|
-
"total": int(u.get("totalTokenCount") or 0)})
|
|
610
|
-
else:
|
|
611
|
-
for ch in obj.get("choices") or []:
|
|
612
|
-
d = ch.get("delta") or {}
|
|
613
|
-
if isinstance(d.get("content"), str) and d["content"]:
|
|
614
|
-
acc["text"] += d["content"]
|
|
615
|
-
progress = True
|
|
616
|
-
for i, tc in enumerate(d.get("tool_calls") or []):
|
|
617
|
-
if not isinstance(tc, dict):
|
|
618
|
-
continue
|
|
619
|
-
idx = int(tc.get("index") if tc.get("index") is not None else i)
|
|
620
|
-
slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
|
|
621
|
-
if tc.get("id"):
|
|
622
|
-
slot["id"] = tc["id"]
|
|
623
|
-
fn = tc.get("function") or {}
|
|
624
|
-
if fn.get("name"):
|
|
625
|
-
slot["name"] = fn["name"]
|
|
626
|
-
if fn.get("arguments"):
|
|
627
|
-
slot["raw"] += fn["arguments"]
|
|
628
|
-
progress = True
|
|
629
|
-
u = obj.get("usage")
|
|
630
|
-
if isinstance(u, dict):
|
|
631
|
-
acc["usage"].update({"input": int(u.get("prompt_tokens") or 0),
|
|
632
|
-
"output": int(u.get("completion_tokens") or 0),
|
|
633
|
-
"total": int(u.get("total_tokens") or 0),
|
|
634
|
-
"cached": int(((u.get("prompt_tokens_details") or {})
|
|
635
|
-
.get("cached_tokens")) or 0)})
|
|
636
|
-
return progress
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
def _calls_from_acc(acc):
|
|
640
|
-
"""累加器里的工具分片 → [{id, name, args}](参数 JSON 解析失败给空对象)。"""
|
|
641
|
-
out = []
|
|
642
|
-
for idx in sorted(acc["tools"].keys()):
|
|
643
|
-
slot = acc["tools"][idx]
|
|
644
|
-
name = slot.get("name") or ""
|
|
645
|
-
if not name:
|
|
646
|
-
continue
|
|
647
|
-
try:
|
|
648
|
-
args = json.loads(slot.get("raw") or "{}")
|
|
649
|
-
except Exception:
|
|
650
|
-
args = {}
|
|
651
|
-
if not isinstance(args, dict):
|
|
652
|
-
args = {}
|
|
653
|
-
out.append({"id": slot.get("id") or ("call_%d" % (idx + 1)),
|
|
654
|
-
"name": name, "args": args})
|
|
655
|
-
return out
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
659
|
-
on_reason=None, on_text=None, on_tick=None, cancel_event=None,
|
|
660
|
-
deadline=None):
|
|
661
|
-
"""流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
|
|
662
|
-
|
|
663
|
-
与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
|
|
664
|
-
即刻放弃等待(socket 随线程结束释放)。三个回调:on_reason 每收到一段思考
|
|
665
|
-
增量(调用方据此实时打印「思考过程」),on_text 收到**累计**正文(只在真的
|
|
666
|
-
变长时触发,运行中直接当正文预览),on_tick 是无文本进展时的心跳。
|
|
667
|
-
events=0 表示网关根本没按流回(多半忽略了 stream 字段)——调用方据此回落
|
|
668
|
-
非流式;events>0 而正文为空则是模型真没说话,不该重发(白烧一次生成)。
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return {"role": "
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
if
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"""
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
"""
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
if
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置执行智能体:不经外部 CLI,直连供应商 API 完成直连(direct)任务。
|
|
3
|
+
|
|
4
|
+
为什么:direct 是快档位,典型诉求是问答/轻量改写。拉起 codex/claude CLI 要付
|
|
5
|
+
进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
|
|
6
|
+
对话视图。内置智能体直接打 chat API + 工具循环:
|
|
7
|
+
- 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
|
|
8
|
+
- 工具:list_files / read_file / write_file(锁死工作目录内)+ run_command
|
|
9
|
+
(本机全权执行命令,产品定位全信任,与 codex/claude 全权沙箱同档);
|
|
10
|
+
- 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
|
|
11
|
+
密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import mimetypes
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import modelhub, runner, tlsctx
|
|
27
|
+
|
|
28
|
+
MAX_TOOL_ITERS = 16 # 单步工具循环上限(防模型打转)
|
|
29
|
+
READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
|
|
30
|
+
READ_HEAD_BYTES = 44 * 1024 # TokenJuice 借鉴(openhuman):超限文件头尾保留、
|
|
31
|
+
READ_TAIL_BYTES = 16 * 1024 # 中段省略——日志/代码的报错常在尾部,纯截头会丢关键信息
|
|
32
|
+
LIST_MAX_ENTRIES = 200
|
|
33
|
+
MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体防爆)
|
|
34
|
+
IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
|
|
35
|
+
IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
|
|
36
|
+
IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
|
|
37
|
+
THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
|
|
38
|
+
STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
|
|
39
|
+
BASE_MAX_TOKENS = 8000 # 单次生成输出预算(推理模型的思考 token 也计入此额度)
|
|
40
|
+
MAX_TOKENS_CAP = 16000 # 思考撑爆预算后的提额上限(每轮 run 只提一次)
|
|
41
|
+
THINK_EXHAUST_MIN_CHARS = 1500 # 零正文时思考达到此长度 ≈ 思考占满输出预算
|
|
42
|
+
EMPTY_TEXT_MAX_STREAK = 3 # 连续零正文轮数上限(防同参数盲重试烧穿预算)
|
|
43
|
+
|
|
44
|
+
CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
|
|
45
|
+
CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
|
|
46
|
+
CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
|
|
47
|
+
CMD_OUT_TAIL = 8 * 1024
|
|
48
|
+
|
|
49
|
+
_SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
|
|
50
|
+
|
|
51
|
+
## 工作方式
|
|
52
|
+
- 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
|
|
53
|
+
- read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
|
|
54
|
+
- 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
|
|
55
|
+
- run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
|
|
56
|
+
- Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
|
|
57
|
+
- 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
|
|
58
|
+
- 产出文件一律 UTF-8 编码。
|
|
59
|
+
- 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve(provider_id="", model="", difficulty="default"):
|
|
63
|
+
"""选内置智能体可用的 (provider, model):编排者配置优先,否则首个可用供应商。
|
|
64
|
+
|
|
65
|
+
返回 {prov, model, provider_id, provider_name} 或 None(无可用供应商 → 调用方
|
|
66
|
+
回退 CLI 路径)。"""
|
|
67
|
+
provider_id = str(provider_id or "").strip()
|
|
68
|
+
model = str(model or "").strip()
|
|
69
|
+
if provider_id:
|
|
70
|
+
for p in modelhub.providers():
|
|
71
|
+
if p.get("id") != provider_id or not p.get("enabled", True) or not p.get("api_key"):
|
|
72
|
+
continue
|
|
73
|
+
selected = model
|
|
74
|
+
if selected:
|
|
75
|
+
try:
|
|
76
|
+
enabled_names = [m.get("name") for m in modelhub._enabled_models(p)]
|
|
77
|
+
except Exception:
|
|
78
|
+
enabled_names = []
|
|
79
|
+
if selected not in enabled_names:
|
|
80
|
+
return None
|
|
81
|
+
if not selected:
|
|
82
|
+
selected = (p.get("model_hard") if difficulty == "hard" else
|
|
83
|
+
p.get("model_easy") if difficulty == "easy" else
|
|
84
|
+
p.get("model") or "").strip()
|
|
85
|
+
if not selected:
|
|
86
|
+
try:
|
|
87
|
+
names = modelhub._enabled_models(p)
|
|
88
|
+
except Exception:
|
|
89
|
+
names = []
|
|
90
|
+
selected = names[0]["name"] if names else ""
|
|
91
|
+
if selected:
|
|
92
|
+
return {"prov": p, "model": selected,
|
|
93
|
+
"provider_id": p.get("id") or "",
|
|
94
|
+
"provider_name": p.get("name") or p.get("id") or ""}
|
|
95
|
+
return None
|
|
96
|
+
orch = None
|
|
97
|
+
try:
|
|
98
|
+
orch = modelhub.resolve_orchestrator()
|
|
99
|
+
except Exception:
|
|
100
|
+
orch = None
|
|
101
|
+
if orch:
|
|
102
|
+
prov, model = orch
|
|
103
|
+
return {"prov": prov, "model": model,
|
|
104
|
+
"provider_id": prov.get("id") or "",
|
|
105
|
+
"provider_name": prov.get("name") or prov.get("id") or ""}
|
|
106
|
+
for p in modelhub.providers():
|
|
107
|
+
if not p.get("enabled", True) or not p.get("api_key"):
|
|
108
|
+
continue
|
|
109
|
+
m = ((p.get("model_hard") if difficulty == "hard" else
|
|
110
|
+
p.get("model_easy") if difficulty == "easy" else
|
|
111
|
+
p.get("model")) or "").strip()
|
|
112
|
+
if not m:
|
|
113
|
+
try:
|
|
114
|
+
names = modelhub._enabled_models(p)
|
|
115
|
+
except Exception:
|
|
116
|
+
names = []
|
|
117
|
+
m = names[0]["name"] if names else ""
|
|
118
|
+
if m:
|
|
119
|
+
return {"prov": p, "model": m, "provider_id": p.get("id") or "",
|
|
120
|
+
"provider_name": p.get("name") or p.get("id") or ""}
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- 工作目录工具
|
|
125
|
+
# 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
|
|
126
|
+
# 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
|
|
127
|
+
|
|
128
|
+
def _tool_list_files(workdir, args):
|
|
129
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
130
|
+
if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
131
|
+
return "(非法路径: %s)" % rel
|
|
132
|
+
base = Path(workdir or ".").resolve()
|
|
133
|
+
p = (base / rel).resolve() if rel else base
|
|
134
|
+
if base not in p.parents and p != base:
|
|
135
|
+
return "(路径越界: %s)" % rel
|
|
136
|
+
if not p.is_dir():
|
|
137
|
+
return "(目录不存在: %s)" % (rel or ".")
|
|
138
|
+
out = []
|
|
139
|
+
for root, dirs, files in os.walk(str(p)):
|
|
140
|
+
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")]
|
|
141
|
+
for f in files:
|
|
142
|
+
fp = os.path.join(root, f)
|
|
143
|
+
relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
|
|
144
|
+
try:
|
|
145
|
+
size = os.path.getsize(fp)
|
|
146
|
+
except OSError:
|
|
147
|
+
size = -1
|
|
148
|
+
out.append("%s (%d B)" % (relp, size))
|
|
149
|
+
if len(out) >= LIST_MAX_ENTRIES:
|
|
150
|
+
return "\n".join(out) + "\n…(截断,共 200+ 项)"
|
|
151
|
+
return "\n".join(out) or "(空目录)"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_BIN_MAGIC = (
|
|
155
|
+
(b"\x89PNG\r\n\x1a\n", "PNG 图片"),
|
|
156
|
+
(b"\xff\xd8\xff", "JPEG 图片"),
|
|
157
|
+
(b"GIF8", "GIF 图片"),
|
|
158
|
+
(b"%PDF-", "PDF 文档"),
|
|
159
|
+
(b"PK\x03\x04", "ZIP 压缩包"),
|
|
160
|
+
(b"\x1f\x8b", "GZIP 压缩包"),
|
|
161
|
+
(b"7z\xbc\xaf\x27\x1c", "7z 压缩包"),
|
|
162
|
+
(b"Rar!\x1a\x07", "RAR 压缩包"),
|
|
163
|
+
(b"\x00\x00\x01\x00", "ICO 图标"),
|
|
164
|
+
(b"OggS", "OGG 音频"),
|
|
165
|
+
(b"ID3", "MP3 音频"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _bin_format(head):
|
|
170
|
+
"""常见二进制魔数 → 人类可读格式名(写进给模型的提示,让它立刻止损)。"""
|
|
171
|
+
if len(head) >= 12 and head[:4] == b"RIFF":
|
|
172
|
+
return {"WEBP": "WEBP 图片", "WAVE": "WAV 音频",
|
|
173
|
+
"AVI ": "AVI 视频"}.get(head[8:12].decode("ascii", "replace"),
|
|
174
|
+
"RIFF 媒体")
|
|
175
|
+
if len(head) >= 8 and head[4:8] == b"ftyp":
|
|
176
|
+
return "MP4/MOV 视频"
|
|
177
|
+
for magic, name in _BIN_MAGIC:
|
|
178
|
+
if head.startswith(magic):
|
|
179
|
+
return name
|
|
180
|
+
return "二进制文件"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _looks_binary(head):
|
|
184
|
+
"""文本判定从宽:NUL 字节即判二进制(git 同款启发式),控制字符占比兜底
|
|
185
|
+
(部分二进制头 8KB 内无 NUL)。"""
|
|
186
|
+
if not head:
|
|
187
|
+
return False
|
|
188
|
+
if b"\x00" in head:
|
|
189
|
+
return True
|
|
190
|
+
sample = head[:8192]
|
|
191
|
+
ctrl = sum(1 for b in sample if b < 32 and b not in (9, 10, 12, 13))
|
|
192
|
+
return ctrl * 20 > len(sample) # >5% 视为二进制
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _trim_partial_utf8(chunk):
|
|
196
|
+
"""头部截断可能把多字节字符切成两半:剥掉尾部不完整序列,否则 UTF-8
|
|
197
|
+
strict 失败会误落 GBK 解出错字(runner.tail_decoded 的头截断对偶)。"""
|
|
198
|
+
i = len(chunk) - 1
|
|
199
|
+
while i >= 0 and i >= len(chunk) - 3 and (chunk[i] & 0xC0) == 0x80:
|
|
200
|
+
i -= 1
|
|
201
|
+
if 0 <= i < len(chunk):
|
|
202
|
+
b = chunk[i]
|
|
203
|
+
need = 4 if (b & 0xF8) == 0xF0 else 3 if (b & 0xF0) == 0xE0 \
|
|
204
|
+
else 2 if (b & 0xC0) == 0xC0 else 1
|
|
205
|
+
if len(chunk) - i < need:
|
|
206
|
+
return chunk[:i]
|
|
207
|
+
return chunk
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _guess_image_mime(path, raw=b""):
|
|
211
|
+
m = mimetypes.guess_type(str(path))[0]
|
|
212
|
+
if m:
|
|
213
|
+
return m
|
|
214
|
+
if raw.startswith(b"\x89PNG"):
|
|
215
|
+
return "image/png"
|
|
216
|
+
if raw.startswith(b"\xff\xd8"):
|
|
217
|
+
return "image/jpeg"
|
|
218
|
+
if raw.startswith(b"GIF8"):
|
|
219
|
+
return "image/gif"
|
|
220
|
+
if raw.startswith(b"RIFF"):
|
|
221
|
+
return "image/webp"
|
|
222
|
+
return "image/png"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _prep_images(paths, log=None):
|
|
226
|
+
"""图片路径 → [(mime, b64)]。Pillow 可用时:长边超 1568px 等比缩(万级像素
|
|
227
|
+
原图既烧 token 又常被网关拒),仍超 3.5MB 转 JPEG q85;Pillow 缺失或解码
|
|
228
|
+
失败时原图直传,单张超 5MB 剔除并日志。最多 MAX_IMAGES 张。"""
|
|
229
|
+
try:
|
|
230
|
+
from PIL import Image
|
|
231
|
+
except Exception:
|
|
232
|
+
Image = None
|
|
233
|
+
out = []
|
|
234
|
+
for p in (paths or [])[:MAX_IMAGES]:
|
|
235
|
+
try:
|
|
236
|
+
raw = Path(p).read_bytes()
|
|
237
|
+
except OSError:
|
|
238
|
+
if log:
|
|
239
|
+
log("[图片] 跳过(读取失败): %s" % os.path.basename(str(p)))
|
|
240
|
+
continue
|
|
241
|
+
mime = _guess_image_mime(p, raw)
|
|
242
|
+
data = raw
|
|
243
|
+
if Image is not None:
|
|
244
|
+
try:
|
|
245
|
+
im = Image.open(io.BytesIO(raw))
|
|
246
|
+
im.load()
|
|
247
|
+
w, h = im.size
|
|
248
|
+
edge = max(w, h, 1)
|
|
249
|
+
if edge > IMAGE_MAX_EDGE:
|
|
250
|
+
r = IMAGE_MAX_EDGE / float(edge)
|
|
251
|
+
im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
|
|
252
|
+
Image.LANCZOS)
|
|
253
|
+
fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
|
|
254
|
+
"image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
|
|
255
|
+
buf = io.BytesIO()
|
|
256
|
+
im.save(buf, format=fmt) # GIF 多帧仅存首帧,可接受
|
|
257
|
+
data = buf.getvalue()
|
|
258
|
+
except Exception:
|
|
259
|
+
data = raw # 解码/编码失败退回原始字节
|
|
260
|
+
if (Image is not None and mime != "image/jpeg"
|
|
261
|
+
and len(data) > IMAGE_JPEG_BYTES):
|
|
262
|
+
try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
|
|
263
|
+
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
264
|
+
buf = io.BytesIO()
|
|
265
|
+
im.save(buf, format="JPEG", quality=85)
|
|
266
|
+
data = buf.getvalue()
|
|
267
|
+
mime = "image/jpeg"
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
if len(data) > IMAGE_MAX_BYTES:
|
|
271
|
+
if log:
|
|
272
|
+
log("[图片] 剔除(过大 %d MB): %s"
|
|
273
|
+
% (len(data) // (1024 * 1024), os.path.basename(str(p))))
|
|
274
|
+
continue
|
|
275
|
+
out.append((mime, base64.b64encode(data).decode("ascii")))
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tool_read_file(workdir, args):
|
|
280
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
281
|
+
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
282
|
+
return "(非法路径: %s)" % rel
|
|
283
|
+
base = Path(workdir or ".").resolve()
|
|
284
|
+
p = (base / rel).resolve()
|
|
285
|
+
if base not in p.parents:
|
|
286
|
+
return "(路径越界: %s)" % rel
|
|
287
|
+
if not p.is_file():
|
|
288
|
+
return "(文件不存在: %s)" % rel
|
|
289
|
+
data = p.read_bytes()[:READ_MAX_BYTES + 1]
|
|
290
|
+
truncated = len(data) > READ_MAX_BYTES
|
|
291
|
+
if truncated:
|
|
292
|
+
# 头尾保留、中段省略(TokenJuice 借鉴):头 44k 给结构/开头上下文,
|
|
293
|
+
# 尾 16k 给报错/结论(日志与代码的关键信息常在末尾),中段标注省略量
|
|
294
|
+
try:
|
|
295
|
+
size = p.stat().st_size
|
|
296
|
+
except OSError:
|
|
297
|
+
size = len(data)
|
|
298
|
+
with open(p, "rb") as fh:
|
|
299
|
+
head = fh.read(READ_HEAD_BYTES)
|
|
300
|
+
fh.seek(max(0, size - READ_TAIL_BYTES))
|
|
301
|
+
tail = fh.read(READ_TAIL_BYTES)
|
|
302
|
+
head = _trim_partial_utf8(head)
|
|
303
|
+
# 尾段开头的残缺多字节序列剥头(对偶于 _trim_partial_utf8 剥尾)
|
|
304
|
+
while tail:
|
|
305
|
+
try:
|
|
306
|
+
tail.decode("utf-8")
|
|
307
|
+
break
|
|
308
|
+
except UnicodeDecodeError:
|
|
309
|
+
tail = tail[1:]
|
|
310
|
+
text = runner.decode_output(head) \
|
|
311
|
+
+ "\n…(中段省略 %d 字节;头 %dKB + 尾 %dKB 保留)\n" % (
|
|
312
|
+
max(0, size - READ_HEAD_BYTES - READ_TAIL_BYTES),
|
|
313
|
+
READ_HEAD_BYTES // 1024, READ_TAIL_BYTES // 1024) \
|
|
314
|
+
+ runner.decode_output(tail)
|
|
315
|
+
return "…(文件超 64KB,已头尾保留读取)\n" + text
|
|
316
|
+
head = data
|
|
317
|
+
if not head:
|
|
318
|
+
return "(空文件)"
|
|
319
|
+
if _looks_binary(head):
|
|
320
|
+
try:
|
|
321
|
+
size = p.stat().st_size
|
|
322
|
+
except OSError:
|
|
323
|
+
size = len(head)
|
|
324
|
+
fmt = _bin_format(head)
|
|
325
|
+
if "图片" in fmt:
|
|
326
|
+
return ("(图片文件: %s — %s,%d B,无法按文本读取。用户以附件发来的图片会"
|
|
327
|
+
"直接出现在对话中,无需用工具读取;若需要目录里这张图的内容,"
|
|
328
|
+
"请提示用户把它作为附件发送。)" % (rel, fmt, size))
|
|
329
|
+
return ("(二进制文件,无法按文本读取: %s — %s,%d B。read_file 只支持文本文件;"
|
|
330
|
+
"请如实告知用户该文件内容无法以文本方式查看,不要反复重读。)"
|
|
331
|
+
% (rel, fmt, size))
|
|
332
|
+
text = runner.decode_output(head)
|
|
333
|
+
return text
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _tool_write_file(workdir, args):
|
|
337
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
338
|
+
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
339
|
+
return "(非法路径: %s)" % rel
|
|
340
|
+
content = str(args.get("content") if args.get("content") is not None else "")
|
|
341
|
+
base = Path(workdir or ".").resolve()
|
|
342
|
+
dest = (base / rel).resolve()
|
|
343
|
+
if base not in dest.parents:
|
|
344
|
+
return "(路径越界: %s)" % rel
|
|
345
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
dest.write_text(content, encoding="utf-8")
|
|
347
|
+
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
351
|
+
"""本机执行一条 shell 命令,返回 (退出码, 合并输出)。
|
|
352
|
+
|
|
353
|
+
薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
|
|
354
|
+
→ cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
|
|
355
|
+
(用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
|
|
356
|
+
提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
|
|
357
|
+
输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
|
|
358
|
+
cmdline = str(args.get("command") or "").strip()
|
|
359
|
+
if not cmdline:
|
|
360
|
+
return "(命令为空,未执行)"
|
|
361
|
+
try:
|
|
362
|
+
timeout = int(float(args.get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
timeout = CMD_DEFAULT_TIMEOUT
|
|
365
|
+
r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
|
|
366
|
+
timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
|
|
367
|
+
deadline=deadline, cancel_event=cancel_event)
|
|
368
|
+
out = runner.clean_cli_text(
|
|
369
|
+
(r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
|
|
370
|
+
limit = CMD_OUT_HEAD + CMD_OUT_TAIL
|
|
371
|
+
if not out.strip():
|
|
372
|
+
body = "(无输出)"
|
|
373
|
+
elif len(out) <= limit:
|
|
374
|
+
body = out
|
|
375
|
+
else:
|
|
376
|
+
body = (out[:CMD_OUT_HEAD]
|
|
377
|
+
+ "\n…(中段省略 %d 字符;头 %dK + 尾 %dK 保留)\n" % (
|
|
378
|
+
len(out) - limit, CMD_OUT_HEAD // 1024, CMD_OUT_TAIL // 1024)
|
|
379
|
+
+ out[-CMD_OUT_TAIL:])
|
|
380
|
+
if r.get("cancelled"):
|
|
381
|
+
note = "已取消,进程树已终止"
|
|
382
|
+
elif r.get("timed_out"):
|
|
383
|
+
note = "超时 %d 秒,进程树已强制终止" % timeout
|
|
384
|
+
else:
|
|
385
|
+
note = ""
|
|
386
|
+
prefix = ("(%s)\n" % note) if note else ""
|
|
387
|
+
code = r.get("exit_code")
|
|
388
|
+
return "%s退出码: %s\n%s" % (prefix, "未知" if code is None else code, body)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
TOOLS_SPEC = [
|
|
392
|
+
{"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
|
|
393
|
+
"args": {"path": "子目录相对路径,留空=根目录"}},
|
|
394
|
+
{"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别,超 64KB 时保留开头与结尾、中段省略;图片等二进制文件无法读取)",
|
|
395
|
+
"args": {"path": "文件相对路径"}},
|
|
396
|
+
{"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,父目录自动创建)",
|
|
397
|
+
"args": {"path": "文件相对路径", "content": "完整文本内容"}},
|
|
398
|
+
{"name": "run_command",
|
|
399
|
+
"description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
|
|
400
|
+
"args": {"command": "完整命令行(shell 语法,本机直接执行)",
|
|
401
|
+
"timeout_sec": "可选,超时秒数"}},
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
_TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
|
|
405
|
+
"write_file": _tool_write_file, "run_command": _tool_run_command}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
|
|
409
|
+
fn = _TOOL_IMPL.get(name or "")
|
|
410
|
+
if fn is None:
|
|
411
|
+
return "(未知工具: %s)" % name
|
|
412
|
+
try:
|
|
413
|
+
if name == "run_command":
|
|
414
|
+
return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
|
|
415
|
+
return fn(workdir, args or {})
|
|
416
|
+
except Exception as e:
|
|
417
|
+
return "工具执行失败: %s" % (e)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------- 协议适配
|
|
421
|
+
|
|
422
|
+
def _openai_tools():
|
|
423
|
+
return [{"type": "function", "function": {
|
|
424
|
+
"name": t["name"], "description": t["description"],
|
|
425
|
+
"parameters": {"type": "object",
|
|
426
|
+
"properties": {k: {"type": "string", "description": v}
|
|
427
|
+
for k, v in t["args"].items()},
|
|
428
|
+
"required": list(t["args"].keys())}}}
|
|
429
|
+
for t in TOOLS_SPEC]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _anthropic_tools():
|
|
433
|
+
return [{"name": t["name"], "description": t["description"],
|
|
434
|
+
"input_schema": {"type": "object",
|
|
435
|
+
"properties": {k: {"type": "string", "description": v}
|
|
436
|
+
for k, v in t["args"].items()},
|
|
437
|
+
"required": list(t["args"].keys())}}
|
|
438
|
+
for t in TOOLS_SPEC]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _post_json(url, headers, body, allow_private, timeout):
|
|
442
|
+
"""传输层单点(测试在这里打桩)。"""
|
|
443
|
+
return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _call_with_optional_deadline(fn, args, kwargs, deadline):
|
|
447
|
+
"""Keep older test/integration transport shims source-compatible."""
|
|
448
|
+
if deadline is None:
|
|
449
|
+
return fn(*args, **kwargs)
|
|
450
|
+
try:
|
|
451
|
+
return fn(*args, **kwargs, deadline=deadline)
|
|
452
|
+
except TypeError as exc:
|
|
453
|
+
if "unexpected keyword argument 'deadline'" not in str(exc):
|
|
454
|
+
raise
|
|
455
|
+
return fn(*args, **kwargs)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
|
|
459
|
+
deadline=None):
|
|
460
|
+
"""可打断的模型调用:cancel_event 置位即刻放弃等待返回。
|
|
461
|
+
|
|
462
|
+
单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
|
|
463
|
+
线程自行收尾(响应被丢弃,socket 随线程结束释放),主流程立即返回。
|
|
464
|
+
服务端仍会把这次生成跑完(token 已花),但任务本身即刻终止。"""
|
|
465
|
+
box = {}
|
|
466
|
+
|
|
467
|
+
def _go():
|
|
468
|
+
try:
|
|
469
|
+
box["r"] = _post_json(url, headers, body, allow_private, timeout)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
box["r"] = (0, None, repr(e))
|
|
472
|
+
|
|
473
|
+
th = threading.Thread(target=_go, daemon=True)
|
|
474
|
+
th.start()
|
|
475
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
476
|
+
while th.is_alive():
|
|
477
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
478
|
+
break
|
|
479
|
+
if time.monotonic() >= wait_until:
|
|
480
|
+
return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
|
|
481
|
+
th.join(0.5)
|
|
482
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
483
|
+
return 0, None, "已取消"
|
|
484
|
+
return box.get("r") or (0, None, "无响应")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------- 思考过程(流式)
|
|
488
|
+
# 用户诉求(2026-09-22):「把思考过程打印出来」——直连对话此前整轮只有三点
|
|
489
|
+
# 打字动画,模型的思维链(DeepSeek/vLLM 系 reasoning_content、Anthropic
|
|
490
|
+
# thinking 块)被 _sse_parse 明确丢弃。这里自带一份「认思考」的增量解析:
|
|
491
|
+
# 边收边把思考与正文分别攒起来,交给调用方实时落盘 + 结束后存进步骤记录。
|
|
492
|
+
|
|
493
|
+
def _reason_from_delta(proto, obj):
|
|
494
|
+
"""从一条流式事件里取「思考增量」。三协议字段各异,取不到返回空串。
|
|
495
|
+
|
|
496
|
+
openai 兼容面最杂:reasoning_content(DeepSeek/vLLM/云知声实测)、
|
|
497
|
+
reasoning(部分网关)、reasoning_details[].text(OpenRouter 系)。
|
|
498
|
+
anthropic 是 thinking_delta(另有 redacted_thinking 不文本化,跳过)。"""
|
|
499
|
+
if proto == "openai":
|
|
500
|
+
total = ""
|
|
501
|
+
for ch in obj.get("choices") or []:
|
|
502
|
+
d = ch.get("delta") or {}
|
|
503
|
+
for k in ("reasoning_content", "reasoning"):
|
|
504
|
+
v = d.get(k)
|
|
505
|
+
if isinstance(v, str):
|
|
506
|
+
total += v
|
|
507
|
+
for det in (d.get("reasoning_details") or []):
|
|
508
|
+
if isinstance(det, dict) and isinstance(det.get("text"), str):
|
|
509
|
+
total += det["text"]
|
|
510
|
+
return total
|
|
511
|
+
if proto == "anthropic" and obj.get("type") == "content_block_delta":
|
|
512
|
+
d = obj.get("delta") or {}
|
|
513
|
+
if d.get("type") == "thinking_delta":
|
|
514
|
+
return d.get("thinking") or ""
|
|
515
|
+
return ""
|
|
516
|
+
if proto == "google":
|
|
517
|
+
total = ""
|
|
518
|
+
for cand in obj.get("candidates") or []:
|
|
519
|
+
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
520
|
+
if isinstance(p, dict) and p.get("thought"):
|
|
521
|
+
total += p.get("text") or ""
|
|
522
|
+
return total
|
|
523
|
+
return ""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _reason_from_message(proto, data):
|
|
527
|
+
"""非流式响应里的思考字段(网关不支持流式时的兜底)。"""
|
|
528
|
+
if not isinstance(data, dict):
|
|
529
|
+
return ""
|
|
530
|
+
if proto == "anthropic":
|
|
531
|
+
return "\n".join(b.get("thinking", "") for b in (data.get("content") or [])
|
|
532
|
+
if isinstance(b, dict) and b.get("type") == "thinking")
|
|
533
|
+
if proto == "google":
|
|
534
|
+
total = ""
|
|
535
|
+
for cand in data.get("candidates") or []:
|
|
536
|
+
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
537
|
+
if isinstance(p, dict) and p.get("thought"):
|
|
538
|
+
total += p.get("text") or ""
|
|
539
|
+
return total
|
|
540
|
+
msg = ((data.get("choices") or [{}])[0].get("message") or {})
|
|
541
|
+
total = ""
|
|
542
|
+
for k in ("reasoning_content", "reasoning"):
|
|
543
|
+
v = msg.get(k)
|
|
544
|
+
if isinstance(v, str):
|
|
545
|
+
total += v
|
|
546
|
+
for det in (msg.get("reasoning_details") or []):
|
|
547
|
+
if isinstance(det, dict) and isinstance(det.get("text"), str):
|
|
548
|
+
total += det["text"]
|
|
549
|
+
return total
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _stream_accumulate(proto, obj, acc):
|
|
553
|
+
"""把一条 SSE 事件并入累加器(就地改写 acc)。返回是否有「有意义的进展」。
|
|
554
|
+
|
|
555
|
+
acc = {text, reasoning, tools, usage};工具参数按 index 归并——openai 的
|
|
556
|
+
function.arguments 与 anthropic 的 input_json_delta 都是分片到达的。"""
|
|
557
|
+
progress = False
|
|
558
|
+
if proto == "anthropic":
|
|
559
|
+
t = obj.get("type")
|
|
560
|
+
if t == "content_block_start":
|
|
561
|
+
blk = obj.get("content_block") or {}
|
|
562
|
+
if blk.get("type") == "tool_use":
|
|
563
|
+
idx = int(obj.get("index") or 0)
|
|
564
|
+
acc["tools"][idx] = {"id": blk.get("id") or "",
|
|
565
|
+
"name": blk.get("name") or "", "raw": ""}
|
|
566
|
+
progress = True
|
|
567
|
+
elif t == "content_block_delta":
|
|
568
|
+
d = obj.get("delta") or {}
|
|
569
|
+
if d.get("type") == "text_delta" and d.get("text"):
|
|
570
|
+
acc["text"] += d["text"]
|
|
571
|
+
progress = True
|
|
572
|
+
elif d.get("type") == "input_json_delta":
|
|
573
|
+
idx = int(obj.get("index") or 0)
|
|
574
|
+
slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
|
|
575
|
+
slot["raw"] += d.get("partial_json") or ""
|
|
576
|
+
progress = True
|
|
577
|
+
elif t == "message_start":
|
|
578
|
+
u = (obj.get("message") or {}).get("usage") or obj.get("usage") or {}
|
|
579
|
+
acc["usage"].update({"input": int(u.get("input_tokens") or 0),
|
|
580
|
+
"output": int(u.get("output_tokens") or 0),
|
|
581
|
+
"cached": (int(u.get("cache_read_input_tokens") or 0)
|
|
582
|
+
+ int(u.get("cache_creation_input_tokens") or 0))})
|
|
583
|
+
elif t == "message_delta":
|
|
584
|
+
u = obj.get("usage") or {}
|
|
585
|
+
if u.get("output_tokens"):
|
|
586
|
+
acc["usage"]["output"] = int(u["output_tokens"])
|
|
587
|
+
elif proto == "google":
|
|
588
|
+
for cand in obj.get("candidates") or []:
|
|
589
|
+
for p in ((cand.get("content") or {}).get("parts") or []):
|
|
590
|
+
if not isinstance(p, dict):
|
|
591
|
+
continue
|
|
592
|
+
if p.get("thought"):
|
|
593
|
+
acc["reasoning"] += p.get("text") or ""
|
|
594
|
+
progress = True
|
|
595
|
+
elif p.get("functionCall"):
|
|
596
|
+
fc = p["functionCall"]
|
|
597
|
+
acc["tools"][len(acc["tools"])] = {
|
|
598
|
+
"id": "call_%d" % (len(acc["tools"]) + 1),
|
|
599
|
+
"name": fc.get("name") or "",
|
|
600
|
+
"raw": json.dumps(fc.get("args") or {}, ensure_ascii=False)}
|
|
601
|
+
progress = True
|
|
602
|
+
elif p.get("text"):
|
|
603
|
+
acc["text"] += p["text"]
|
|
604
|
+
progress = True
|
|
605
|
+
u = obj.get("usageMetadata")
|
|
606
|
+
if isinstance(u, dict):
|
|
607
|
+
acc["usage"].update({"input": int(u.get("promptTokenCount") or 0),
|
|
608
|
+
"output": int(u.get("candidatesTokenCount") or 0),
|
|
609
|
+
"total": int(u.get("totalTokenCount") or 0)})
|
|
610
|
+
else:
|
|
611
|
+
for ch in obj.get("choices") or []:
|
|
612
|
+
d = ch.get("delta") or {}
|
|
613
|
+
if isinstance(d.get("content"), str) and d["content"]:
|
|
614
|
+
acc["text"] += d["content"]
|
|
615
|
+
progress = True
|
|
616
|
+
for i, tc in enumerate(d.get("tool_calls") or []):
|
|
617
|
+
if not isinstance(tc, dict):
|
|
618
|
+
continue
|
|
619
|
+
idx = int(tc.get("index") if tc.get("index") is not None else i)
|
|
620
|
+
slot = acc["tools"].setdefault(idx, {"id": "", "name": "", "raw": ""})
|
|
621
|
+
if tc.get("id"):
|
|
622
|
+
slot["id"] = tc["id"]
|
|
623
|
+
fn = tc.get("function") or {}
|
|
624
|
+
if fn.get("name"):
|
|
625
|
+
slot["name"] = fn["name"]
|
|
626
|
+
if fn.get("arguments"):
|
|
627
|
+
slot["raw"] += fn["arguments"]
|
|
628
|
+
progress = True
|
|
629
|
+
u = obj.get("usage")
|
|
630
|
+
if isinstance(u, dict):
|
|
631
|
+
acc["usage"].update({"input": int(u.get("prompt_tokens") or 0),
|
|
632
|
+
"output": int(u.get("completion_tokens") or 0),
|
|
633
|
+
"total": int(u.get("total_tokens") or 0),
|
|
634
|
+
"cached": int(((u.get("prompt_tokens_details") or {})
|
|
635
|
+
.get("cached_tokens")) or 0)})
|
|
636
|
+
return progress
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _calls_from_acc(acc):
|
|
640
|
+
"""累加器里的工具分片 → [{id, name, args}](参数 JSON 解析失败给空对象)。"""
|
|
641
|
+
out = []
|
|
642
|
+
for idx in sorted(acc["tools"].keys()):
|
|
643
|
+
slot = acc["tools"][idx]
|
|
644
|
+
name = slot.get("name") or ""
|
|
645
|
+
if not name:
|
|
646
|
+
continue
|
|
647
|
+
try:
|
|
648
|
+
args = json.loads(slot.get("raw") or "{}")
|
|
649
|
+
except Exception:
|
|
650
|
+
args = {}
|
|
651
|
+
if not isinstance(args, dict):
|
|
652
|
+
args = {}
|
|
653
|
+
out.append({"id": slot.get("id") or ("call_%d" % (idx + 1)),
|
|
654
|
+
"name": name, "args": args})
|
|
655
|
+
return out
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
659
|
+
on_reason=None, on_text=None, on_tick=None, cancel_event=None,
|
|
660
|
+
deadline=None):
|
|
661
|
+
"""流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
|
|
662
|
+
|
|
663
|
+
与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
|
|
664
|
+
即刻放弃等待(socket 随线程结束释放)。三个回调:on_reason 每收到一段思考
|
|
665
|
+
增量(调用方据此实时打印「思考过程」),on_text 收到**累计**正文(只在真的
|
|
666
|
+
变长时触发,运行中直接当正文预览),on_tick 是无文本进展时的心跳。
|
|
667
|
+
events=0 表示网关根本没按流回(多半忽略了 stream 字段)——调用方据此回落
|
|
668
|
+
非流式;events>0 而正文为空则是模型真没说话,不该重发(白烧一次生成)。
|
|
669
|
+
|
|
670
|
+
首字延迟与吞吐(§07 体验面):`first_token_ms` = 发出请求到第一个**带内容**
|
|
671
|
+
的增量(思考 / 正文 / 工具参数)之间的毫秒数;`tokens_per_sec` 用 output
|
|
672
|
+
token 除以「首个到末个内容增量」的窗口,窗口过短或拿不到 output 时记 0
|
|
673
|
+
(0 = 不可测,不是"一秒 0 个 token")。两者只有走流式才有值,非流式回落
|
|
674
|
+
路径与 CLI 子进程路径都给 0。"""
|
|
675
|
+
acc = {"text": "", "reasoning": "", "tools": {}, "usage": {}}
|
|
676
|
+
box = {"status": 0, "err": "", "events": 0, "sent_text": None,
|
|
677
|
+
"first_ms": None, "last_ms": None}
|
|
678
|
+
lock = threading.Lock()
|
|
679
|
+
|
|
680
|
+
def _emit(cb, payload):
|
|
681
|
+
if cb is None:
|
|
682
|
+
return
|
|
683
|
+
try:
|
|
684
|
+
cb(payload)
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
def _go():
|
|
689
|
+
try:
|
|
690
|
+
parsed = urllib.parse.urlsplit(url)
|
|
691
|
+
if parsed.scheme not in ("http", "https"):
|
|
692
|
+
box["err"] = "协议必须是 http/https"
|
|
693
|
+
return
|
|
694
|
+
host, herr = modelhub._validate_host(url, allow_private)
|
|
695
|
+
if host is None:
|
|
696
|
+
box["err"] = herr
|
|
697
|
+
return
|
|
698
|
+
t_send = time.monotonic() # 首字延迟基线:请求发出,不含 DNS/连接前准备
|
|
699
|
+
req = urllib.request.Request(
|
|
700
|
+
url, method="POST",
|
|
701
|
+
headers=dict(headers, **{"Content-Type": "application/json",
|
|
702
|
+
"Accept": "text/event-stream"}),
|
|
703
|
+
data=json.dumps(body).encode("utf-8"))
|
|
704
|
+
with modelhub._opener().open(req, timeout=timeout) as resp:
|
|
705
|
+
box["status"] = resp.status
|
|
706
|
+
if not 200 <= resp.status < 300:
|
|
707
|
+
raw = resp.read(65536).decode("utf-8", "replace")
|
|
708
|
+
box["err"] = "HTTP %s %s" % (resp.status, raw[:200])
|
|
709
|
+
return
|
|
710
|
+
for raw in resp:
|
|
711
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
712
|
+
if not line.startswith("data:"):
|
|
713
|
+
continue
|
|
714
|
+
payload = line[5:].strip()
|
|
715
|
+
if not payload or payload == "[DONE]":
|
|
716
|
+
continue
|
|
717
|
+
try:
|
|
718
|
+
obj = json.loads(payload)
|
|
719
|
+
except Exception:
|
|
720
|
+
continue # 半行/坏行静默跳过,不中断整流
|
|
721
|
+
if not isinstance(obj, dict):
|
|
722
|
+
continue
|
|
723
|
+
box["events"] += 1
|
|
724
|
+
reason = _reason_from_delta(proto, obj)
|
|
725
|
+
with lock:
|
|
726
|
+
if reason:
|
|
727
|
+
acc["reasoning"] += reason
|
|
728
|
+
moved = _stream_accumulate(proto, obj, acc)
|
|
729
|
+
text_now = acc["text"]
|
|
730
|
+
if reason or moved:
|
|
731
|
+
now_ms = (time.monotonic() - t_send) * 1000.0
|
|
732
|
+
if box["first_ms"] is None:
|
|
733
|
+
box["first_ms"] = now_ms
|
|
734
|
+
box["last_ms"] = now_ms
|
|
735
|
+
if reason:
|
|
736
|
+
_emit(on_reason, reason)
|
|
737
|
+
if text_now != box["sent_text"]:
|
|
738
|
+
box["sent_text"] = text_now
|
|
739
|
+
_emit(on_text, text_now) # 累计正文(运行中实时打印)
|
|
740
|
+
elif moved and not reason:
|
|
741
|
+
_emit(on_tick, None) # 只有工具/用量在推进的心跳
|
|
742
|
+
if sum(len(x) for x in (acc["text"], acc["reasoning"])) > 4 * 1024 * 1024:
|
|
743
|
+
break # 防失控
|
|
744
|
+
except Exception as e:
|
|
745
|
+
box["err"] = box["err"] or tlsctx.humanize(repr(e)[:300])
|
|
746
|
+
|
|
747
|
+
th = threading.Thread(target=_go, daemon=True)
|
|
748
|
+
th.start()
|
|
749
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
750
|
+
while th.is_alive():
|
|
751
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
752
|
+
break
|
|
753
|
+
if time.monotonic() >= wait_until:
|
|
754
|
+
break
|
|
755
|
+
th.join(0.5)
|
|
756
|
+
with lock:
|
|
757
|
+
out = {"status": box["status"], "text": acc["text"],
|
|
758
|
+
"reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
|
|
759
|
+
"calls": _calls_from_acc(acc), "events": box["events"],
|
|
760
|
+
"first_token_ms": round(box["first_ms"], 1) if box["first_ms"] is not None else 0.0,
|
|
761
|
+
"tokens_per_sec": 0.0, "error": box["err"]}
|
|
762
|
+
gen_s = 0.0
|
|
763
|
+
if box["first_ms"] is not None and box["last_ms"] is not None:
|
|
764
|
+
gen_s = (box["last_ms"] - box["first_ms"]) / 1000.0
|
|
765
|
+
out_tok = int(acc["usage"].get("output") or 0)
|
|
766
|
+
if gen_s > 0.2 and out_tok > 0:
|
|
767
|
+
out["tokens_per_sec"] = round(out_tok / gen_s, 2)
|
|
768
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
769
|
+
out["error"], out["status"] = "已取消", 0
|
|
770
|
+
return out
|
|
771
|
+
if th.is_alive():
|
|
772
|
+
out["error"], out["status"] = ("任务总时限已到" if deadline is not None
|
|
773
|
+
else "响应超时"), 0
|
|
774
|
+
out["deadline_exceeded"] = deadline is not None
|
|
775
|
+
return out
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _m_openai(m):
|
|
779
|
+
"""内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
|
|
780
|
+
if m["role"] == "assistant":
|
|
781
|
+
out = {"role": "assistant", "content": m.get("content") or ""}
|
|
782
|
+
if m.get("tool_calls"):
|
|
783
|
+
out["tool_calls"] = [{"id": tc["id"], "type": "function",
|
|
784
|
+
"function": {"name": tc["name"],
|
|
785
|
+
"arguments": json.dumps(tc["args"], ensure_ascii=False)}}
|
|
786
|
+
for tc in m["tool_calls"]]
|
|
787
|
+
return out
|
|
788
|
+
if m["role"] == "tool_results":
|
|
789
|
+
return [{"role": "tool", "tool_call_id": cid, "content": text}
|
|
790
|
+
for cid, text in m["tool_results"]]
|
|
791
|
+
imgs = m.get("images") or []
|
|
792
|
+
if imgs: # 多模态:content 变数组,图跟在文本后(role=tool 只收文本,故图只走 user)
|
|
793
|
+
content = [{"type": "text", "text": m.get("content") or ""}]
|
|
794
|
+
content += [{"type": "image_url",
|
|
795
|
+
"image_url": {"url": "data:%s;base64,%s" % (mime, b64)}}
|
|
796
|
+
for mime, b64 in imgs]
|
|
797
|
+
return {"role": "user", "content": content}
|
|
798
|
+
return {"role": "user", "content": m.get("content") or ""}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _m_anthropic(m):
|
|
802
|
+
if m["role"] == "assistant":
|
|
803
|
+
blocks = []
|
|
804
|
+
if m.get("content"):
|
|
805
|
+
blocks.append({"type": "text", "text": m["content"]})
|
|
806
|
+
for tc in (m.get("tool_calls") or []):
|
|
807
|
+
blocks.append({"type": "tool_use", "id": tc["id"], "name": tc["name"],
|
|
808
|
+
"input": tc["args"]})
|
|
809
|
+
return {"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]}
|
|
810
|
+
if m["role"] == "tool_results":
|
|
811
|
+
return {"role": "user", "content": [
|
|
812
|
+
{"type": "tool_result", "tool_use_id": cid, "content": text}
|
|
813
|
+
for cid, text in m["tool_results"]]}
|
|
814
|
+
imgs = m.get("images") or []
|
|
815
|
+
if imgs:
|
|
816
|
+
blocks = [{"type": "text", "text": m.get("content") or ""}]
|
|
817
|
+
blocks += [{"type": "image",
|
|
818
|
+
"source": {"type": "base64", "media_type": mime, "data": b64}}
|
|
819
|
+
for mime, b64 in imgs]
|
|
820
|
+
return {"role": "user", "content": blocks}
|
|
821
|
+
return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
|
|
825
|
+
max_tokens=BASE_MAX_TOKENS):
|
|
826
|
+
"""按协议构造 (url, headers, body)。msgs 为内部统一形状。
|
|
827
|
+
|
|
828
|
+
stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
|
|
829
|
+
openai/anthropic 置 stream 字段(openai 另要 include_usage,否则收尾
|
|
830
|
+
chunk 不带 usage,这一步的 token 记账会凭空少一截)。"""
|
|
831
|
+
base = (base or "").rstrip("/")
|
|
832
|
+
if proto == "google":
|
|
833
|
+
if base.endswith("/v1beta"):
|
|
834
|
+
prefix = base + "/models/%s"
|
|
835
|
+
else:
|
|
836
|
+
prefix = base + "/v1beta/models/%s"
|
|
837
|
+
url = (prefix + (":streamGenerateContent?alt=sse" if stream
|
|
838
|
+
else ":generateContent")) % model
|
|
839
|
+
headers = {"x-goog-api-key": use_key}
|
|
840
|
+
contents = []
|
|
841
|
+
for m in msgs:
|
|
842
|
+
if m["role"] not in ("user", "assistant"):
|
|
843
|
+
continue
|
|
844
|
+
parts = [{"text": m.get("content") or ""}]
|
|
845
|
+
parts += [{"inline_data": {"mime_type": mime, "data": b64}}
|
|
846
|
+
for mime, b64 in (m.get("images") or [])]
|
|
847
|
+
contents.append({"role": ("user" if m["role"] == "user" else "model"),
|
|
848
|
+
"parts": parts})
|
|
849
|
+
body = {"contents": contents,
|
|
850
|
+
"systemInstruction": {"parts": [{"text": system}]},
|
|
851
|
+
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
852
|
+
return url, headers, body
|
|
853
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
854
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
855
|
+
if proto == "anthropic":
|
|
856
|
+
headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
|
|
857
|
+
body = {"model": model, "max_tokens": max_tokens, "system": system,
|
|
858
|
+
"messages": [_m_anthropic(m) for m in msgs]}
|
|
859
|
+
if with_tools:
|
|
860
|
+
body["tools"] = _anthropic_tools()
|
|
861
|
+
if stream:
|
|
862
|
+
body["stream"] = True
|
|
863
|
+
return url, headers, body
|
|
864
|
+
headers = {"Authorization": "Bearer " + use_key}
|
|
865
|
+
# tool_results 消息展开成多条 role=tool(_m_openai 对它返回列表,不能嵌套)
|
|
866
|
+
msgs_wire = []
|
|
867
|
+
for m in msgs:
|
|
868
|
+
w = _m_openai(m)
|
|
869
|
+
if isinstance(w, list):
|
|
870
|
+
msgs_wire.extend(w)
|
|
871
|
+
else:
|
|
872
|
+
msgs_wire.append(w)
|
|
873
|
+
body = {"model": model, "max_tokens": max_tokens,
|
|
874
|
+
"messages": [{"role": "system", "content": system}] + msgs_wire}
|
|
875
|
+
if with_tools:
|
|
876
|
+
body["tools"] = _openai_tools()
|
|
877
|
+
if stream:
|
|
878
|
+
body["stream"] = True
|
|
879
|
+
body["stream_options"] = {"include_usage": True}
|
|
880
|
+
return url, headers, body
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _parse_reply(proto, data):
|
|
884
|
+
"""从响应解析 → (文本, [tool_call], usage)。
|
|
885
|
+
tool_call = {id, name, args};usage = {input, output, cached, total}。"""
|
|
886
|
+
usage = {"input": 0, "output": 0, "cached": 0, "total": 0}
|
|
887
|
+
if proto == "google":
|
|
888
|
+
cand = (data.get("candidates") or [{}])[0]
|
|
889
|
+
parts = (cand.get("content") or {}).get("parts") or []
|
|
890
|
+
text = "\n".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
|
891
|
+
um = data.get("usageMetadata") or {}
|
|
892
|
+
usage["input"] = int(um.get("promptTokenCount") or 0)
|
|
893
|
+
usage["output"] = int(um.get("candidatesTokenCount") or 0)
|
|
894
|
+
usage["total"] = int(um.get("totalTokenCount") or 0)
|
|
895
|
+
return text.strip(), [], usage
|
|
896
|
+
if proto == "anthropic":
|
|
897
|
+
blocks = data.get("content") or []
|
|
898
|
+
text = "\n".join(b.get("text", "") for b in blocks
|
|
899
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
900
|
+
calls = [{"id": b.get("id") or "", "name": b.get("name") or "",
|
|
901
|
+
"args": b.get("input") or {}}
|
|
902
|
+
for b in blocks if isinstance(b, dict) and b.get("type") == "tool_use"]
|
|
903
|
+
u = data.get("usage") or {}
|
|
904
|
+
usage["input"] = int(u.get("input_tokens") or 0)
|
|
905
|
+
usage["output"] = int(u.get("output_tokens") or 0)
|
|
906
|
+
usage["cached"] = (int(u.get("cache_read_input_tokens") or 0)
|
|
907
|
+
+ int(u.get("cache_creation_input_tokens") or 0))
|
|
908
|
+
usage["total"] = usage["input"] + usage["output"] + usage["cached"]
|
|
909
|
+
return text.strip(), calls, usage
|
|
910
|
+
choice = (data.get("choices") or [{}])[0]
|
|
911
|
+
msg = choice.get("message") or {}
|
|
912
|
+
calls = []
|
|
913
|
+
for tc in (msg.get("tool_calls") or []):
|
|
914
|
+
fn = tc.get("function") or {}
|
|
915
|
+
try:
|
|
916
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
917
|
+
except Exception:
|
|
918
|
+
args = {}
|
|
919
|
+
if not isinstance(args, dict):
|
|
920
|
+
args = {}
|
|
921
|
+
calls.append({"id": tc.get("id") or ("call_%d" % (len(calls) + 1)),
|
|
922
|
+
"name": fn.get("name") or "", "args": args})
|
|
923
|
+
u = data.get("usage") or {}
|
|
924
|
+
usage["input"] = int(u.get("prompt_tokens") or 0)
|
|
925
|
+
usage["output"] = int(u.get("completion_tokens") or 0)
|
|
926
|
+
usage["total"] = int(u.get("total_tokens") or 0) or (usage["input"] + usage["output"])
|
|
927
|
+
return (msg.get("content") or "").strip(), calls, usage
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# ---------------------------------------------------------------- 主循环
|
|
931
|
+
|
|
932
|
+
def _norm_usage(usage):
|
|
933
|
+
"""三协议 usage → {input, output, cached, total}(缺 total 用三项相加补齐)。"""
|
|
934
|
+
out = {"input": 0, "output": 0, "cached": 0, "total": 0}
|
|
935
|
+
for k in out:
|
|
936
|
+
try:
|
|
937
|
+
out[k] = int((usage or {}).get(k) or 0)
|
|
938
|
+
except Exception:
|
|
939
|
+
out[k] = 0
|
|
940
|
+
if not out["total"]:
|
|
941
|
+
out["total"] = out["input"] + out["output"] + out["cached"]
|
|
942
|
+
return out
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
|
|
946
|
+
on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None):
|
|
947
|
+
"""跑一次内置智能体(内部自带工具循环直到给出最终回答)。
|
|
948
|
+
|
|
949
|
+
bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
|
|
950
|
+
images: 用户随本轮消息提供的图片绝对路径列表(任务附件/对话追话);
|
|
951
|
+
log: 追加一行日志的回调(步骤日志)。返回 runner 风格统一结果:
|
|
952
|
+
{ok, text, usage, error, model, provider_name, provider_id, iterations,
|
|
953
|
+
cost_usd, reasoning}。
|
|
954
|
+
|
|
955
|
+
思考过程可视化(2026-09-22 用户诉求「把思考过程打印出来」):stream=True 走
|
|
956
|
+
SSE,模型的思维链(reasoning_content / thinking 块)边收边经 on_reason 回调,
|
|
957
|
+
正文增量经 on_stream(累计值),工具活动经 on_activity——调用方据此在对话
|
|
958
|
+
时间线上实时打印,不再让用户干看三点打字动画。网关忽略 stream(零事件)时
|
|
959
|
+
自动回落非流式,思考字段仍从响应体兜底抽取(on_reason 补发一次)。"""
|
|
960
|
+
prov = bi["prov"]
|
|
961
|
+
model = bi["model"]
|
|
962
|
+
allow_private = bool(prov.get("allow_private"))
|
|
963
|
+
system = _SYSTEM_PROMPT + "\n\n## 工作目录\n%s" % os.path.abspath(workdir or ".")
|
|
964
|
+
imgs = _prep_images(images, log) if images else []
|
|
965
|
+
if imgs and not modelhub._model_image_in(prov, model):
|
|
966
|
+
# 能力闸门:模型未声明图片输入就不塞图(网关多半会拒),改为显式告知
|
|
967
|
+
if log:
|
|
968
|
+
log("[图片] 模型 %s 未开启图片输入,%d 张图不随消息发送" % (model, len(imgs)))
|
|
969
|
+
prompt = ("(用户提供了 %d 张图片,但当前模型未开启图片输入;请在回答开头提示用户"
|
|
970
|
+
"到绑定页为该模型开启「图」开关,或改绑多模态模型。本条回答请基于"
|
|
971
|
+
"文字部分完成。)\n\n%s" % (len(imgs), prompt))
|
|
972
|
+
imgs = []
|
|
973
|
+
msgs = [{"role": "user", "content": prompt, "images": imgs}]
|
|
974
|
+
candidates = list(modelhub._protocol_candidates(prov))
|
|
975
|
+
tools_ok = all(proto != "google" for proto, _ in candidates) # google wire 无工具协议
|
|
976
|
+
total_usage = {"input": 0, "cached": 0, "output": 0, "total": 0}
|
|
977
|
+
text = ""
|
|
978
|
+
iters = 0
|
|
979
|
+
last_err = ""
|
|
980
|
+
ok = False
|
|
981
|
+
reasons = [] # 各迭代的思考正文(按迭代顺序拼接,供步骤记录展示)
|
|
982
|
+
max_tokens = BASE_MAX_TOKENS # 思考占满预算时提额重试(跨迭代保持,见空正文分支)
|
|
983
|
+
escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
|
|
984
|
+
empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
|
|
985
|
+
perf = {"ttft": [], "tps": []} # 各次流式调用的首字延迟(ms)与吞吐(tok/s)
|
|
986
|
+
|
|
987
|
+
def _perf():
|
|
988
|
+
"""本次 run 的体验指标:跨迭代取均值;无流式样本时返回空 dict。"""
|
|
989
|
+
if not perf["ttft"] and not perf["tps"]:
|
|
990
|
+
return {}
|
|
991
|
+
return {"first_token_ms": (round(sum(perf["ttft"]) / len(perf["ttft"]), 1)
|
|
992
|
+
if perf["ttft"] else 0.0),
|
|
993
|
+
"tokens_per_sec": (round(sum(perf["tps"]) / len(perf["tps"]), 2)
|
|
994
|
+
if perf["tps"] else 0.0)}
|
|
995
|
+
|
|
996
|
+
def _deadline_fail():
|
|
997
|
+
return {"ok": False, "text": "", "usage": dict(total_usage),
|
|
998
|
+
"error": "任务总时限已到", "model": model,
|
|
999
|
+
"provider_name": bi["provider_name"],
|
|
1000
|
+
"provider_id": bi["provider_id"], "iterations": iters,
|
|
1001
|
+
"cost_usd": 0.0, "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS],
|
|
1002
|
+
"raw": {"timed_out": True, "deadline_exceeded": True}}
|
|
1003
|
+
|
|
1004
|
+
def _fire(cb, *cb_args):
|
|
1005
|
+
if cb is None:
|
|
1006
|
+
return
|
|
1007
|
+
try:
|
|
1008
|
+
cb(*cb_args)
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass # 可视化是增强:回调炸了不能拖垮生成
|
|
1011
|
+
|
|
1012
|
+
def _take_reason(reason):
|
|
1013
|
+
"""收一段思考:攒进本轮正文并实时回调(限长,防超长思维链撑爆 run.json)。"""
|
|
1014
|
+
if not reason:
|
|
1015
|
+
return
|
|
1016
|
+
if not reasons:
|
|
1017
|
+
reasons.append("")
|
|
1018
|
+
if len(reasons[-1]) < THINK_MAX_CHARS:
|
|
1019
|
+
reasons[-1] += reason
|
|
1020
|
+
_fire(on_reason, reason)
|
|
1021
|
+
|
|
1022
|
+
def _fail(err):
|
|
1023
|
+
return {"ok": False, "text": "", "usage": dict(total_usage), "error": err,
|
|
1024
|
+
"model": model, "provider_name": bi["provider_name"],
|
|
1025
|
+
"provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
|
|
1026
|
+
"raw": _perf(),
|
|
1027
|
+
"reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
|
|
1028
|
+
|
|
1029
|
+
def _cancel_fail():
|
|
1030
|
+
"""取消终态带结构化标记:下游惩罚账(KEY 冷却/供应商健康/评测作废)靠标记
|
|
1031
|
+
认出取消,不靠「已取消」字样匹配。"""
|
|
1032
|
+
out = _fail("已取消")
|
|
1033
|
+
out["cancelled"] = True
|
|
1034
|
+
return out
|
|
1035
|
+
|
|
1036
|
+
for it in range(1, MAX_TOOL_ITERS + 1):
|
|
1037
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1038
|
+
return _deadline_fail()
|
|
1039
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1040
|
+
return _cancel_fail()
|
|
1041
|
+
reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
|
|
1042
|
+
done = False
|
|
1043
|
+
reBump = False # 本轮要提额重试(同一迭代重跑,不烧迭代数)
|
|
1044
|
+
stopit = False # 本轮判死(连续零正文),直接终局不再空转
|
|
1045
|
+
for _bump in range(2): # 第 2 圈 = 提额后的同轮重试
|
|
1046
|
+
for proto, pbase in candidates:
|
|
1047
|
+
keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
|
|
1048
|
+
for kk in keys:
|
|
1049
|
+
got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
|
|
1050
|
+
stripped = False # thinking 对象被网关 400 拒后已摘除重发
|
|
1051
|
+
while True:
|
|
1052
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1053
|
+
return _deadline_fail()
|
|
1054
|
+
req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
|
|
1055
|
+
if deadline is not None else timeout
|
|
1056
|
+
url, headers, body = _build_request(
|
|
1057
|
+
proto, pbase, kk["key"], model, system, msgs,
|
|
1058
|
+
tools_ok, max_tokens=max_tokens)
|
|
1059
|
+
sbody = None
|
|
1060
|
+
if stream:
|
|
1061
|
+
# 流式体单独构造(+stream / include_usage);非流式体留给回落重发
|
|
1062
|
+
_, _, sbody = _build_request(
|
|
1063
|
+
proto, pbase, kk["key"], model, system, msgs,
|
|
1064
|
+
tools_ok, stream=True, max_tokens=max_tokens)
|
|
1065
|
+
effort = str(bi.get("reasoning_effort") or "").strip().lower()
|
|
1066
|
+
# reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
|
|
1067
|
+
# 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
|
|
1068
|
+
if effort in ("low", "medium", "high") and proto == "openai":
|
|
1069
|
+
body["reasoning_effort"] = effort
|
|
1070
|
+
if sbody is not None:
|
|
1071
|
+
sbody["reasoning_effort"] = effort
|
|
1072
|
+
if effort in ("low", "medium") and proto == "anthropic" \
|
|
1073
|
+
and not stripped:
|
|
1074
|
+
# 「思考程度」此前只对 openai 面生效;GLM 这类推理模型
|
|
1075
|
+
# 在 Anthropic 兼容面上默认全功率思考,能把 max_tokens
|
|
1076
|
+
# 整个烧光(2026-09-22 glm-5.3-flash 47 分钟零正文案)。
|
|
1077
|
+
# low/medium 显式给思考预算,high 留默认(模型自决=放开想)。
|
|
1078
|
+
body["thinking"] = {
|
|
1079
|
+
"type": "enabled",
|
|
1080
|
+
"budget_tokens": 1024 if effort == "low" else 4096}
|
|
1081
|
+
if sbody is not None:
|
|
1082
|
+
sbody["thinking"] = dict(body["thinking"])
|
|
1083
|
+
text, calls, usage = "", [], {}
|
|
1084
|
+
streamed = False
|
|
1085
|
+
if sbody is not None:
|
|
1086
|
+
part = _call_with_optional_deadline(
|
|
1087
|
+
_post_sse_stream,
|
|
1088
|
+
(url, headers, sbody, allow_private, req_timeout, proto),
|
|
1089
|
+
{"on_reason": _take_reason, "on_text": on_stream,
|
|
1090
|
+
"cancel_event": cancel_event}, deadline)
|
|
1091
|
+
if part.get("deadline_exceeded"):
|
|
1092
|
+
return _deadline_fail()
|
|
1093
|
+
if part["error"] == "已取消":
|
|
1094
|
+
return _cancel_fail()
|
|
1095
|
+
if not part["error"] and part["events"]:
|
|
1096
|
+
# 网关确实按流回了:正文/工具取增量累加结果
|
|
1097
|
+
text, calls = part["text"], part["calls"]
|
|
1098
|
+
usage = part["usage"]
|
|
1099
|
+
streamed = True
|
|
1100
|
+
if part.get("first_token_ms"):
|
|
1101
|
+
perf["ttft"].append(float(part["first_token_ms"]))
|
|
1102
|
+
if part.get("tokens_per_sec"):
|
|
1103
|
+
perf["tps"].append(float(part["tokens_per_sec"]))
|
|
1104
|
+
if not reasons or not reasons[-1]:
|
|
1105
|
+
# 调用方没挂实时回调(无可视化诉求)时思考只在
|
|
1106
|
+
# 累加器里:这里兜底收进结果,落库口径一致
|
|
1107
|
+
_take_reason(part["reasoning"])
|
|
1108
|
+
else:
|
|
1109
|
+
# 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
|
|
1110
|
+
# 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
|
|
1111
|
+
# 供应商稳妥;非流式也失败才走下面的记账/换链。
|
|
1112
|
+
last_err = part["error"]
|
|
1113
|
+
if not streamed:
|
|
1114
|
+
status, data, err = _call_with_optional_deadline(
|
|
1115
|
+
_post_interruptible,
|
|
1116
|
+
(url, headers, body, allow_private, req_timeout, cancel_event),
|
|
1117
|
+
{}, deadline)
|
|
1118
|
+
if err == "任务总时限已到":
|
|
1119
|
+
return _deadline_fail()
|
|
1120
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1121
|
+
# 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
|
|
1122
|
+
return _cancel_fail()
|
|
1123
|
+
if status == 0 or not (200 <= status < 300):
|
|
1124
|
+
msg = ""
|
|
1125
|
+
if isinstance(data, dict):
|
|
1126
|
+
e = data.get("error")
|
|
1127
|
+
msg = e.get("message", "") if isinstance(e, dict) else str(e)
|
|
1128
|
+
if status == 400 and isinstance(body.get("thinking"), dict) \
|
|
1129
|
+
and "thinking" in str(msg).lower() and not stripped:
|
|
1130
|
+
# 网关不认 thinking 对象:摘掉同 KEY 立即重发,
|
|
1131
|
+
# 别让「思考程度」偏好把健康 KEY 送进冷却
|
|
1132
|
+
body.pop("thinking", None)
|
|
1133
|
+
if sbody is not None:
|
|
1134
|
+
sbody.pop("thinking", None)
|
|
1135
|
+
stripped = True
|
|
1136
|
+
continue
|
|
1137
|
+
last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
|
|
1138
|
+
try:
|
|
1139
|
+
modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
|
|
1140
|
+
except Exception:
|
|
1141
|
+
pass
|
|
1142
|
+
break # 换下一个 KEY
|
|
1143
|
+
text, calls, usage = _parse_reply(proto, data)
|
|
1144
|
+
# 非流式响应里的思考字段:补发一次,可视化与流式路径同形
|
|
1145
|
+
_take_reason(_reason_from_message(proto, data))
|
|
1146
|
+
try:
|
|
1147
|
+
modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
|
|
1148
|
+
except Exception:
|
|
1149
|
+
pass
|
|
1150
|
+
usage = _norm_usage(usage)
|
|
1151
|
+
for k in total_usage:
|
|
1152
|
+
total_usage[k] += int(usage.get(k) or 0)
|
|
1153
|
+
iters = it
|
|
1154
|
+
got = True
|
|
1155
|
+
break
|
|
1156
|
+
if not got:
|
|
1157
|
+
continue # 本 KEY 失败,换下一个
|
|
1158
|
+
if calls:
|
|
1159
|
+
if log:
|
|
1160
|
+
log("[迭代 %d] %s 请求工具: %s" % (
|
|
1161
|
+
it, model, ", ".join(c["name"] for c in calls)))
|
|
1162
|
+
_fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
|
|
1163
|
+
results = []
|
|
1164
|
+
for c in calls:
|
|
1165
|
+
out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
|
|
1166
|
+
deadline=deadline)
|
|
1167
|
+
if log:
|
|
1168
|
+
brief = out if len(out) <= 120 else out[:120] + "…"
|
|
1169
|
+
log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
|
|
1170
|
+
_fire(on_activity, "%s %s" % (
|
|
1171
|
+
c["name"], str(c["args"].get("path")
|
|
1172
|
+
or c["args"].get("command") or "")[:80]))
|
|
1173
|
+
results.append((c["id"], out))
|
|
1174
|
+
msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
|
|
1175
|
+
msgs.append({"role": "tool_results", "tool_results": results})
|
|
1176
|
+
empty_streak = 0
|
|
1177
|
+
done = True
|
|
1178
|
+
break
|
|
1179
|
+
if (text or "").strip():
|
|
1180
|
+
if log:
|
|
1181
|
+
log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
|
|
1182
|
+
ok = True
|
|
1183
|
+
empty_streak = 0
|
|
1184
|
+
done = True
|
|
1185
|
+
break
|
|
1186
|
+
# —— 零正文零工具:分辨「思考占满输出预算」还是「模型真没说话」。
|
|
1187
|
+
# 推理模型的思考计入 max_tokens:想满了流会正常收,正文却是空的。
|
|
1188
|
+
rlen = len(reasons[-1] or "")
|
|
1189
|
+
out_tok = int(usage.get("output") or 0)
|
|
1190
|
+
exhausted = (rlen >= THINK_EXHAUST_MIN_CHARS
|
|
1191
|
+
or out_tok >= int(max_tokens * 0.9))
|
|
1192
|
+
empty_streak += 1
|
|
1193
|
+
if exhausted and not escalated:
|
|
1194
|
+
# 同轮提额重试一次:8K 想不完的 16K 大多就想完了
|
|
1195
|
+
escalated = True
|
|
1196
|
+
old = max_tokens
|
|
1197
|
+
max_tokens = min(max_tokens * 2, MAX_TOKENS_CAP)
|
|
1198
|
+
last_err = ("模型思考占满输出预算(max_tokens=%d,思考 %d 字零正文)"
|
|
1199
|
+
% (old, rlen))
|
|
1200
|
+
if log:
|
|
1201
|
+
log("[第 %d 轮] 思考 %d 字仍无正文/工具 → max_tokens %d→%d 同轮重试"
|
|
1202
|
+
% (it, rlen, old, max_tokens))
|
|
1203
|
+
_fire(on_activity, "思考占满输出预算,提高输出预算重试")
|
|
1204
|
+
reBump = True
|
|
1205
|
+
break
|
|
1206
|
+
if empty_streak >= EMPTY_TEXT_MAX_STREAK:
|
|
1207
|
+
last_err = ("模型思考连续占满输出预算(max_tokens 已提到 %d 仍无正文)"
|
|
1208
|
+
% max_tokens) if exhausted else \
|
|
1209
|
+
("模型连续 %d 轮零正文零工具" % empty_streak)
|
|
1210
|
+
done = True
|
|
1211
|
+
stopit = True
|
|
1212
|
+
break
|
|
1213
|
+
last_err = "模型未返回文本"
|
|
1214
|
+
if log:
|
|
1215
|
+
log("[第 %d 轮] 模型零正文零工具(思考 %d 字),重试" % (it, rlen))
|
|
1216
|
+
_fire(on_activity, "模型零正文,重试")
|
|
1217
|
+
done = True
|
|
1218
|
+
break
|
|
1219
|
+
if done or reBump:
|
|
1220
|
+
break
|
|
1221
|
+
if reBump:
|
|
1222
|
+
continue # 同一迭代用新预算重来
|
|
1223
|
+
break # 非提额路径一锤定音
|
|
1224
|
+
if not done:
|
|
1225
|
+
break # 所有 wire/KEY 都失败
|
|
1226
|
+
if ok or stopit:
|
|
1227
|
+
break
|
|
1228
|
+
if not ok and not text:
|
|
1229
|
+
return _fail(last_err or "工具循环达上限仍无最终回答")
|
|
1230
|
+
return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),
|
|
1231
|
+
"error": "", "model": model, "provider_name": bi["provider_name"],
|
|
1232
|
+
"provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0,
|
|
1233
|
+
"raw": _perf(),
|
|
1234
|
+
"reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
|