super-code-assistant 3.3.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- tui/rendering.py +135 -0
core/file_state.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Snippet 系统核心数据模型 —— 会话级文件状态 & 编辑凭证追踪。
|
|
2
|
+
|
|
3
|
+
Phase 1: 基础数据模型 + read 集成。
|
|
4
|
+
Phase 2: edit 接入。
|
|
5
|
+
Phase 3: write 刷新 + engine 集成 + /resume 重建。
|
|
6
|
+
Phase 4: compact 失效 + 边界情况。
|
|
7
|
+
|
|
8
|
+
设计原则:
|
|
9
|
+
- read 时创建 snippet(含行范围 + 文件版本号),作为"编辑凭证"
|
|
10
|
+
- edit 必须在 snippet 限定的行范围内搜索替换
|
|
11
|
+
- 文件被修改后旧 snippet 自动失效(version 不匹配)
|
|
12
|
+
- 会话恢复时从 JSONL 历史重建注册表
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, Optional
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# 数据类
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileState:
|
|
28
|
+
"""会话内文件的缓存状态。"""
|
|
29
|
+
|
|
30
|
+
file_path: str
|
|
31
|
+
content: str
|
|
32
|
+
mtime: float # 读取时的文件修改时间
|
|
33
|
+
version: int = 1 # 会话内修改次数(每次 edit/write 后 +1)
|
|
34
|
+
encoding: str = "utf-8"
|
|
35
|
+
line_endings: str = "LF" # "LF" | "CRLF"(Phase 4 编辑时保留换行符用)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FileSnippet:
|
|
40
|
+
"""一次 read 操作生成的"编辑凭证"。
|
|
41
|
+
|
|
42
|
+
edit 必须携带有效的 snippet_id,且替换仅在 [start_line, end_line] 范围内搜索。
|
|
43
|
+
file_version 用于检测"自读取后文件是否被修改过"。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str # "snp_3" 或 "full_0"
|
|
47
|
+
file_path: str
|
|
48
|
+
start_line: int # 1-based
|
|
49
|
+
end_line: int # 1-based, inclusive
|
|
50
|
+
file_version: int # 创建时的文件版本号
|
|
51
|
+
scope_type: str # "full"(全文件)或 "snippet"(部分)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# 模块级注册表(按 session_id 隔离)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# file_path → FileState
|
|
59
|
+
_file_states: Dict[str, Dict[str, FileState]] = {}
|
|
60
|
+
|
|
61
|
+
# snippet_id → FileSnippet
|
|
62
|
+
_snippets: Dict[str, Dict[str, FileSnippet]] = {}
|
|
63
|
+
|
|
64
|
+
# file_path → 当前 version
|
|
65
|
+
_file_versions: Dict[str, Dict[str, int]] = {}
|
|
66
|
+
|
|
67
|
+
# 每个 session 的 snippet 计数器
|
|
68
|
+
_snippet_counters: Dict[str, int] = {}
|
|
69
|
+
_full_counters: Dict[str, int] = {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# 公开 API
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_session(session_id: str) -> None:
|
|
78
|
+
"""惰性初始化 session 的所有 dict。"""
|
|
79
|
+
if session_id not in _file_states:
|
|
80
|
+
_file_states[session_id] = {}
|
|
81
|
+
_snippets[session_id] = {}
|
|
82
|
+
_file_versions[session_id] = {}
|
|
83
|
+
_snippet_counters[session_id] = 0
|
|
84
|
+
_full_counters[session_id] = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_session_state(session_id: str) -> None:
|
|
88
|
+
"""清空指定会话的所有状态。"""
|
|
89
|
+
_file_states.pop(session_id, None)
|
|
90
|
+
_snippets.pop(session_id, None)
|
|
91
|
+
_file_versions.pop(session_id, None)
|
|
92
|
+
_snippet_counters.pop(session_id, None)
|
|
93
|
+
_full_counters.pop(session_id, None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- 文件版本 ---
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_file_version(session_id: str, file_path: str) -> int:
|
|
100
|
+
"""获取文件当前版本号(未记录则返回 0)。"""
|
|
101
|
+
return _file_versions.get(session_id, {}).get(_normalize(file_path), 0)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _bump_file_version(session_id: str, file_path: str) -> int:
|
|
105
|
+
"""自增版本号并返回新值。"""
|
|
106
|
+
key = _normalize(file_path)
|
|
107
|
+
versions = _file_versions.setdefault(session_id, {})
|
|
108
|
+
versions[key] = versions.get(key, 0) + 1
|
|
109
|
+
return versions[key]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- 文件状态 ---
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def record_file_state(
|
|
116
|
+
session_id: str,
|
|
117
|
+
file_path: str,
|
|
118
|
+
content: str,
|
|
119
|
+
mtime: float = 0.0,
|
|
120
|
+
*,
|
|
121
|
+
bump_version: bool = False,
|
|
122
|
+
) -> FileState:
|
|
123
|
+
"""记录文件当前状态。bump_version=True 时自增版本号(edit/write 后调用)。
|
|
124
|
+
|
|
125
|
+
首次记录时版本号默认1为 1(非 0),确保 snippet 版本校验的初始状态正确。
|
|
126
|
+
"""
|
|
127
|
+
_ensure_session(session_id)
|
|
128
|
+
key = _normalize(file_path)
|
|
129
|
+
versions = _file_versions[session_id]
|
|
130
|
+
if key not in versions:
|
|
131
|
+
versions[key] = 1 # 首次记录初始化为 1
|
|
132
|
+
if bump_version:
|
|
133
|
+
_bump_file_version(session_id, file_path)
|
|
134
|
+
state = FileState(
|
|
135
|
+
file_path=key,
|
|
136
|
+
content=content,
|
|
137
|
+
mtime=mtime,
|
|
138
|
+
version=get_file_version(session_id, file_path),
|
|
139
|
+
)
|
|
140
|
+
_file_states[session_id][key] = state
|
|
141
|
+
return state
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_file_state(session_id: str, file_path: str) -> Optional[FileState]:
|
|
145
|
+
"""获取文件状态(未记录则返回 None)。"""
|
|
146
|
+
return _file_states.get(session_id, {}).get(_normalize(file_path))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def file_was_read(session_id: str, file_path: str) -> bool:
|
|
150
|
+
"""文件是否在当前会话中被读过。"""
|
|
151
|
+
return get_file_state(session_id, file_path) is not None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- Snippet ---
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_snippet(
|
|
158
|
+
session_id: str,
|
|
159
|
+
file_path: str,
|
|
160
|
+
start_line: int,
|
|
161
|
+
end_line: int,
|
|
162
|
+
*,
|
|
163
|
+
scope_type: str = "snippet",
|
|
164
|
+
) -> FileSnippet:
|
|
165
|
+
"""为一次 read 操作创建编辑凭证。
|
|
166
|
+
|
|
167
|
+
读全文件时 scope_type="full",部分读时 scope_type="snippet"。
|
|
168
|
+
"""
|
|
169
|
+
_ensure_session(session_id)
|
|
170
|
+
key = _normalize(file_path)
|
|
171
|
+
version = get_file_version(session_id, file_path)
|
|
172
|
+
|
|
173
|
+
if scope_type == "full":
|
|
174
|
+
_full_counters[session_id] += 1
|
|
175
|
+
sid = f"full_{_full_counters[session_id] - 1}"
|
|
176
|
+
else:
|
|
177
|
+
_snippet_counters[session_id] += 1
|
|
178
|
+
sid = f"snp_{_snippet_counters[session_id] - 1}"
|
|
179
|
+
|
|
180
|
+
snippet = FileSnippet(
|
|
181
|
+
id=sid,
|
|
182
|
+
file_path=key,
|
|
183
|
+
start_line=start_line,
|
|
184
|
+
end_line=end_line,
|
|
185
|
+
file_version=version,
|
|
186
|
+
scope_type=scope_type,
|
|
187
|
+
)
|
|
188
|
+
_snippets[session_id][sid] = snippet
|
|
189
|
+
return snippet
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_snippet(session_id: str, snippet_id: str) -> Optional[FileSnippet]:
|
|
193
|
+
"""按 id 查找 snippet。"""
|
|
194
|
+
return _snippets.get(session_id, {}).get(snippet_id)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_snippet_stale(session_id: str, snippet: FileSnippet) -> bool:
|
|
198
|
+
"""snippet 是否已过期(文件版本升级)。"""
|
|
199
|
+
current_version = get_file_version(session_id, snippet.file_path)
|
|
200
|
+
return current_version > snippet.file_version
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def invalidate_snippets_for_file(session_id: str, file_path: str) -> None:
|
|
204
|
+
"""让某个文件的所有已存在 snippet 失效(通过将 file_version 设为极高值)。
|
|
205
|
+
|
|
206
|
+
注意:已存在的 snippet 实例不会变,但后续 is_snippet_stale() 会因为
|
|
207
|
+
file_version 不匹配而返回 True。
|
|
208
|
+
"""
|
|
209
|
+
_ensure_session(session_id)
|
|
210
|
+
key = _normalize(file_path)
|
|
211
|
+
# 将版本号设为极大值,让所有旧 snippet 失效
|
|
212
|
+
_file_versions.setdefault(session_id, {})[key] = 999_999_999
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def invalidate_all_snippets(session_id: str) -> None:
|
|
216
|
+
"""失效当前会话所有已存在 snippet(Phase 4: compact 后调用)。
|
|
217
|
+
|
|
218
|
+
将所有已追踪文件的版本号设为极大值,让 compact 前的所有 snippet 全部过期。
|
|
219
|
+
模型必须重新 read 才能获得新的有效 snippet。
|
|
220
|
+
"""
|
|
221
|
+
_ensure_session(session_id)
|
|
222
|
+
for key in list(_file_versions.get(session_id, {})):
|
|
223
|
+
_file_versions[session_id][key] = 999_999_999
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- 会话恢复(Phase 3 用到,提前定义接口)---
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def rebuild_snippet(
|
|
230
|
+
session_id: str,
|
|
231
|
+
snippet_id: str,
|
|
232
|
+
file_path: str,
|
|
233
|
+
start_line: int,
|
|
234
|
+
end_line: int,
|
|
235
|
+
scope_type: str = "snippet",
|
|
236
|
+
) -> Optional[FileSnippet]:
|
|
237
|
+
"""从 JSONL 历史重建一个 snippet(会话恢复时使用)。"""
|
|
238
|
+
_ensure_session(session_id)
|
|
239
|
+
key = _normalize(file_path)
|
|
240
|
+
version = get_file_version(session_id, file_path)
|
|
241
|
+
|
|
242
|
+
snippet = FileSnippet(
|
|
243
|
+
id=snippet_id,
|
|
244
|
+
file_path=key,
|
|
245
|
+
start_line=start_line,
|
|
246
|
+
end_line=end_line,
|
|
247
|
+
file_version=version,
|
|
248
|
+
scope_type=scope_type,
|
|
249
|
+
)
|
|
250
|
+
_snippets[session_id][snippet_id] = snippet
|
|
251
|
+
|
|
252
|
+
# 调整计数器,避免 id 冲突
|
|
253
|
+
if snippet_id.startswith("full_"):
|
|
254
|
+
try:
|
|
255
|
+
num = int(snippet_id.split("_", 1)[1]) + 1
|
|
256
|
+
_full_counters[session_id] = max(_full_counters.get(session_id, 0), num)
|
|
257
|
+
except ValueError:
|
|
258
|
+
pass
|
|
259
|
+
elif snippet_id.startswith("snp_"):
|
|
260
|
+
try:
|
|
261
|
+
num = int(snippet_id.rsplit("_", 1)[1]) + 1
|
|
262
|
+
_snippet_counters[session_id] = max(_snippet_counters.get(session_id, 0), num)
|
|
263
|
+
except ValueError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
return snippet
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# 内部工具
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _normalize(file_path: str) -> str:
|
|
275
|
+
"""用 resolve 后的绝对路径做 key,避免同一个文件因相对/绝对路径不同被记成两条。"""
|
|
276
|
+
try:
|
|
277
|
+
return str(Path(file_path).resolve())
|
|
278
|
+
except Exception:
|
|
279
|
+
return file_path
|
core/llm.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Optional, Iterator
|
|
4
|
+
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
# Provider,目前只实现openai,后续接入其他厂商
|
|
8
|
+
OPENAI = 'openai'
|
|
9
|
+
VALID_PROVIDERS = [OPENAI]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_provider(provider):
|
|
13
|
+
if provider not in VALID_PROVIDERS:
|
|
14
|
+
raise ValueError(f"Invalid provider: {provider}")
|
|
15
|
+
return provider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class StreamMessage:
|
|
20
|
+
"""Represents the final message from a stream."""
|
|
21
|
+
content: Any
|
|
22
|
+
usage: Any | None = None
|
|
23
|
+
reasoning_content: str | None = None # DeepSeek 等思考模型的推理内容,需原样透传
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAIStream:
|
|
27
|
+
|
|
28
|
+
def __init__(self, *,
|
|
29
|
+
client: Any,
|
|
30
|
+
model: str,
|
|
31
|
+
messages: list[dict[str, Any]],
|
|
32
|
+
temperature: float = 0.7,
|
|
33
|
+
max_tokens: Optional[int] = None,
|
|
34
|
+
tools: Optional[list[dict]] = None,
|
|
35
|
+
extra_body: Optional[dict] = None):
|
|
36
|
+
self._client = client
|
|
37
|
+
self._params = {
|
|
38
|
+
"model": model,
|
|
39
|
+
"messages": messages,
|
|
40
|
+
"temperature": temperature,
|
|
41
|
+
"max_tokens": max_tokens,
|
|
42
|
+
"stream": True,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# 添加工具参数
|
|
46
|
+
if tools:
|
|
47
|
+
self._params["tools"] = tools
|
|
48
|
+
self._params["tool_choice"] = "auto"
|
|
49
|
+
|
|
50
|
+
# 模型特定额外参数(如 GLM thinking 控制)
|
|
51
|
+
if extra_body:
|
|
52
|
+
self._params["extra_body"] = extra_body
|
|
53
|
+
|
|
54
|
+
# 请求流式 usage 数据(OpenAI 需要显式开启)
|
|
55
|
+
self._params["stream_options"] = {"include_usage": True}
|
|
56
|
+
|
|
57
|
+
self._stream = None
|
|
58
|
+
self._text_parts: list[str] = []
|
|
59
|
+
self._reasoning_parts: list[str] = [] # 捕获 reasoning_content(DeepSeek 思考模型)
|
|
60
|
+
self._tool_calls: dict[int, dict[str, Any]] = {}
|
|
61
|
+
self._usage: dict[str, int] | None = None
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> "OpenAIStream":
|
|
64
|
+
self._stream = self._client.chat.completions.create(**self._params)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
69
|
+
self.close()
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def close(self):
|
|
73
|
+
if self._stream and hasattr(self._stream, "close"):
|
|
74
|
+
self._stream.close()
|
|
75
|
+
|
|
76
|
+
def __iter__(self) -> Iterator[str]:
|
|
77
|
+
_toolgen_sent = False
|
|
78
|
+
for chunk in self._stream:
|
|
79
|
+
# 从流式响应中捕获 usage 数据
|
|
80
|
+
usage = getattr(chunk, "usage", None)
|
|
81
|
+
if usage is not None:
|
|
82
|
+
self._usage = {
|
|
83
|
+
"input_tokens": getattr(usage, "prompt_tokens", 0) or 0,
|
|
84
|
+
"output_tokens": getattr(usage, "completion_tokens", 0) or 0,
|
|
85
|
+
}
|
|
86
|
+
for choice in _value(chunk, "choices", []) or []:
|
|
87
|
+
delta = _value(choice, "delta", {}) or {}
|
|
88
|
+
content = _value(delta, "content")
|
|
89
|
+
if content:
|
|
90
|
+
self._text_parts.append(content)
|
|
91
|
+
yield content
|
|
92
|
+
# 捕获 reasoning_content(DeepSeek 思考模型),yield 事件供 TUI 显示进度
|
|
93
|
+
reasoning = _value(delta, "reasoning_content")
|
|
94
|
+
if reasoning:
|
|
95
|
+
self._reasoning_parts.append(reasoning)
|
|
96
|
+
yield f"\x00thinking\x00{reasoning}"
|
|
97
|
+
for tool_call in _value(delta, "tool_calls", []) or []:
|
|
98
|
+
# 首次生成 tool_call 时发送 sentinel,让上层提前显示等待指示
|
|
99
|
+
if not _toolgen_sent:
|
|
100
|
+
_toolgen_sent = True
|
|
101
|
+
yield "\x00toolgen\x00"
|
|
102
|
+
index = int(_value(tool_call, "index", 0) or 0)
|
|
103
|
+
entry = self._tool_calls.setdefault(index, {
|
|
104
|
+
"id": "",
|
|
105
|
+
"name": "",
|
|
106
|
+
"arguments": "",
|
|
107
|
+
})
|
|
108
|
+
tool_id = _value(tool_call, "id")
|
|
109
|
+
if tool_id:
|
|
110
|
+
entry["id"] = tool_id
|
|
111
|
+
function = _value(tool_call, "function", {}) or {}
|
|
112
|
+
name = _value(function, "name")
|
|
113
|
+
if name:
|
|
114
|
+
entry["name"] = name
|
|
115
|
+
arguments = _value(function, "arguments")
|
|
116
|
+
if arguments:
|
|
117
|
+
entry["arguments"] += arguments
|
|
118
|
+
|
|
119
|
+
# 获取完整响应
|
|
120
|
+
def final(self) -> StreamMessage:
|
|
121
|
+
content: list[dict[str, Any]] = []
|
|
122
|
+
text = "".join(self._text_parts)
|
|
123
|
+
if text:
|
|
124
|
+
content.append({"type": "text", "text": text})
|
|
125
|
+
for index in sorted(self._tool_calls):
|
|
126
|
+
tool_call = self._tool_calls[index]
|
|
127
|
+
raw_args = tool_call.get("arguments", "").strip()
|
|
128
|
+
parsed_args: Any = {}
|
|
129
|
+
if raw_args:
|
|
130
|
+
try:
|
|
131
|
+
parsed_args = json.loads(raw_args)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
parsed_args = {}
|
|
134
|
+
content.append({
|
|
135
|
+
"type": "tool_use",
|
|
136
|
+
"id": tool_call.get("id", ""),
|
|
137
|
+
"name": tool_call.get("name", ""),
|
|
138
|
+
"input": parsed_args if isinstance(parsed_args, dict) else {},
|
|
139
|
+
})
|
|
140
|
+
return StreamMessage(content=content, usage=self._usage,
|
|
141
|
+
reasoning_content="".join(self._reasoning_parts) or None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _lookup_extra_body(model: str, profiles: dict) -> Optional[dict]:
|
|
145
|
+
"""按模型名子串匹配 model_profiles,最长 key 优先(精确优先),大小写不敏感。"""
|
|
146
|
+
for key in sorted(profiles, key=len, reverse=True):
|
|
147
|
+
if key.lower() in model.lower():
|
|
148
|
+
eb = profiles[key].get("extra_body")
|
|
149
|
+
return eb if eb else None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# 构建客户端
|
|
154
|
+
class LLMClient:
|
|
155
|
+
def __init__(self, *, provider: str, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
156
|
+
timeout: float = 300.0, model_profiles: Optional[dict] = None):
|
|
157
|
+
self.provider = validate_provider(provider)
|
|
158
|
+
self._model_profiles: dict = model_profiles or {}
|
|
159
|
+
if self.provider == OPENAI:
|
|
160
|
+
self._client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
161
|
+
|
|
162
|
+
# 非流式输出(用于 compact 摘要等场景)
|
|
163
|
+
def create(self, *, model: str, max_tokens: int, messages: list[dict[str, Any]],
|
|
164
|
+
system: str | None = None, strip_thinking: bool = False) -> "StreamMessage":
|
|
165
|
+
if self.provider == OPENAI:
|
|
166
|
+
params: dict[str, Any] = {
|
|
167
|
+
"model": model,
|
|
168
|
+
"messages": _to_openai_messages(system, messages),
|
|
169
|
+
"max_tokens": max_tokens,
|
|
170
|
+
}
|
|
171
|
+
# 摘要等场景不需要思考:跳过 model_profiles(extra_body 均为思考控制参数),
|
|
172
|
+
# 走 API 默认(非思考)路径——思考模型非流式会全量输出到 reasoning_content、
|
|
173
|
+
# content 为空 → 摘要为空。
|
|
174
|
+
if not strip_thinking:
|
|
175
|
+
eb = _lookup_extra_body(model, self._model_profiles)
|
|
176
|
+
if eb:
|
|
177
|
+
params["extra_body"] = eb
|
|
178
|
+
resp = self._client.chat.completions.create(**params)
|
|
179
|
+
choice = resp.choices[0].message if resp.choices else None
|
|
180
|
+
text = (choice.content or "") if choice else ""
|
|
181
|
+
# 非流式响应同样捕获 usage(DeepSeek/OpenAI 均返回 prompt_tokens),
|
|
182
|
+
# 归一化字段名与流式路径(__iter__)保持一致;无 usage 时保持 None。
|
|
183
|
+
usage = None
|
|
184
|
+
raw_usage = getattr(resp, "usage", None)
|
|
185
|
+
if raw_usage is not None:
|
|
186
|
+
usage = {
|
|
187
|
+
"input_tokens": getattr(raw_usage, "prompt_tokens", 0) or 0,
|
|
188
|
+
"output_tokens": getattr(raw_usage, "completion_tokens", 0) or 0,
|
|
189
|
+
}
|
|
190
|
+
return StreamMessage(content=[{"type": "text", "text": text}], usage=usage)
|
|
191
|
+
raise NotImplementedError(f"create not implemented for provider: {self.provider}")
|
|
192
|
+
|
|
193
|
+
# 流式输出
|
|
194
|
+
def stream(self, *, model: str, system_prompt: str, messages: list[dict[str, Any]], temperature: float = 0.7,
|
|
195
|
+
max_tokens: Optional[int] = None, tools: Optional[list[dict]] = None) -> "OpenAIStream":
|
|
196
|
+
if self.provider == OPENAI:
|
|
197
|
+
return OpenAIStream(
|
|
198
|
+
client=self._client,
|
|
199
|
+
model=model,
|
|
200
|
+
max_tokens=max_tokens,
|
|
201
|
+
messages=_to_openai_messages(system_prompt, messages),
|
|
202
|
+
temperature=temperature,
|
|
203
|
+
tools=tools,
|
|
204
|
+
extra_body=_lookup_extra_body(model, self._model_profiles),
|
|
205
|
+
)
|
|
206
|
+
return NotImplementedError
|
|
207
|
+
|
|
208
|
+
def _value(obj: Any, key: str, default: Any = None) -> Any:
|
|
209
|
+
if obj is None:
|
|
210
|
+
return default
|
|
211
|
+
if isinstance(obj, dict):
|
|
212
|
+
return obj.get(key, default)
|
|
213
|
+
return getattr(obj, key, default)
|
|
214
|
+
|
|
215
|
+
def _to_openai_messages(system: str | None, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
216
|
+
out: list[dict[str, Any]] = []
|
|
217
|
+
if system:
|
|
218
|
+
out.append({"role": "system", "content": system})
|
|
219
|
+
|
|
220
|
+
for message in messages:
|
|
221
|
+
role = message.get("role")
|
|
222
|
+
content = message.get("content", "")
|
|
223
|
+
|
|
224
|
+
if role == "user" and isinstance(content, list):
|
|
225
|
+
tool_results = [
|
|
226
|
+
block for block in content
|
|
227
|
+
if isinstance(block, dict) and block.get("type") == "tool_result"
|
|
228
|
+
]
|
|
229
|
+
if tool_results and len(tool_results) == len(content):
|
|
230
|
+
for block in tool_results:
|
|
231
|
+
out.append({
|
|
232
|
+
"role": "tool",
|
|
233
|
+
"tool_call_id": block.get("tool_use_id", ""),
|
|
234
|
+
"content": _tool_result_to_text(block.get("content", "")),
|
|
235
|
+
})
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
out.append({
|
|
239
|
+
"role": "user",
|
|
240
|
+
"content": _user_content_blocks_to_openai(content),
|
|
241
|
+
})
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if role == "assistant" and isinstance(content, list):
|
|
245
|
+
text_parts: list[str] = []
|
|
246
|
+
tool_calls: list[dict[str, Any]] = []
|
|
247
|
+
for block in content:
|
|
248
|
+
if not isinstance(block, dict):
|
|
249
|
+
continue
|
|
250
|
+
block_type = block.get("type")
|
|
251
|
+
if block_type == "text":
|
|
252
|
+
text_parts.append(block.get("text", ""))
|
|
253
|
+
elif block_type == "tool_use":
|
|
254
|
+
tool_calls.append({
|
|
255
|
+
"id": block.get("id", ""),
|
|
256
|
+
"type": "function",
|
|
257
|
+
"function": {
|
|
258
|
+
"name": block.get("name", ""),
|
|
259
|
+
"arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
text = "".join(text_parts)
|
|
263
|
+
assistant_message: dict[str, Any] = {"role": "assistant"}
|
|
264
|
+
if text:
|
|
265
|
+
assistant_message["content"] = text
|
|
266
|
+
elif not tool_calls:
|
|
267
|
+
assistant_message["content"] = ""
|
|
268
|
+
if tool_calls:
|
|
269
|
+
assistant_message["tool_calls"] = tool_calls
|
|
270
|
+
# 透传 reasoning_content(DeepSeek 要求下一轮必须带回)
|
|
271
|
+
if message.get("reasoning_content"):
|
|
272
|
+
assistant_message["reasoning_content"] = message["reasoning_content"]
|
|
273
|
+
out.append(assistant_message)
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
out.append({
|
|
277
|
+
"role": role,
|
|
278
|
+
"content": content,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _user_content_blocks_to_openai(content: list[Any]) -> list[dict[str, Any]]:
|
|
285
|
+
parts: list[dict[str, Any]] = []
|
|
286
|
+
for block in content:
|
|
287
|
+
if not isinstance(block, dict):
|
|
288
|
+
continue
|
|
289
|
+
block_type = block.get("type")
|
|
290
|
+
if block_type == "text":
|
|
291
|
+
parts.append({"type": "text", "text": block.get("text", "")})
|
|
292
|
+
elif block_type == "image":
|
|
293
|
+
source = block.get("source", {})
|
|
294
|
+
media_type = source.get("media_type", "image/png")
|
|
295
|
+
data = source.get("data", "")
|
|
296
|
+
parts.append({
|
|
297
|
+
"type": "image_url",
|
|
298
|
+
"image_url": {"url": f"data:{media_type};base64,{data}"},
|
|
299
|
+
})
|
|
300
|
+
if not parts:
|
|
301
|
+
return [{"type": "text", "text": ""}]
|
|
302
|
+
return parts
|
|
303
|
+
|
|
304
|
+
def _tool_result_to_text(content: Any) -> str:
|
|
305
|
+
if isinstance(content, str):
|
|
306
|
+
return content
|
|
307
|
+
if content is None:
|
|
308
|
+
return ""
|
|
309
|
+
return json.dumps(content, ensure_ascii=False)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""模型能力表:静态判断常见模型是否支持视觉输入(supports_vision)。
|
|
2
|
+
|
|
3
|
+
业界 Aider 同款做法(model-metadata.json 的 supports_vision 标记)。本表只做
|
|
4
|
+
"确定支持 / 确定不支持"的保守判断;**未知模型返回 None,由调用方默认放行**
|
|
5
|
+
(试一次,API 400 再提示),避免误伤新模型。
|
|
6
|
+
|
|
7
|
+
匹配规则:子串匹配 + 长度倒序(先精确后宽松)。大小写不敏感。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# 确定支持视觉的模型关键词(按长度倒序匹配,长 key 优先)
|
|
12
|
+
_VISION_KEYS = sorted([
|
|
13
|
+
# 智谱 GLM
|
|
14
|
+
"glm-4v", "glm-4.5v", "glm-4.6v", "glm-5v",
|
|
15
|
+
# 阿里 Qwen-VL
|
|
16
|
+
"qwen-vl", "qwen2-vl", "qwen2.5-vl", "qwen3-vl",
|
|
17
|
+
# DeepSeek
|
|
18
|
+
"deepseek-vl", "deepseek-v4-flash-vision",
|
|
19
|
+
# 兜底:型号名自带 vision / vl 特征(实验版、定制名)
|
|
20
|
+
"vision", "-vl",
|
|
21
|
+
], key=len, reverse=True)
|
|
22
|
+
|
|
23
|
+
# 确定不支持视觉的模型关键词(仅列明确文本为主的老型号)
|
|
24
|
+
_NON_VISION_KEYS = sorted([
|
|
25
|
+
"gpt-3.5",
|
|
26
|
+
"deepseek-chat", "deepseek-reasoner", "deepseek-v3",
|
|
27
|
+
], key=len, reverse=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def supports_vision(model: str) -> bool | None:
|
|
31
|
+
"""判断模型是否支持视觉输入。
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True 支持 / False 不支持 / None 未知(调用方默认放行)。
|
|
35
|
+
"""
|
|
36
|
+
if not model:
|
|
37
|
+
return None
|
|
38
|
+
m = model.lower()
|
|
39
|
+
for key in _VISION_KEYS:
|
|
40
|
+
if key in m:
|
|
41
|
+
return True
|
|
42
|
+
for key in _NON_VISION_KEYS:
|
|
43
|
+
if key in m:
|
|
44
|
+
return False
|
|
45
|
+
return None
|