Skip to content

Commit b5c9ba4

Browse files
committed
feat: config-driven multimodal detection, add_vision_model, streamline model presets
- is_multimodal_model now reads from vision_model.json instead of hardcoded patterns - Add add_vision_model() with idempotent upsert (default/fallback) and field whitelist - configure_new_model prompts for multimodal + chains configure_langsmith - Trim ModelScope presets (14->11), LongCat presets (4->1), update GLM-5 to GLM-5.2 - Comprehensive tests for add_vision_model and multimodal config flow
1 parent 51dab08 commit b5c9ba4

8 files changed

Lines changed: 411 additions & 97 deletions

File tree

chcode/config.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,29 @@ async def configure_new_model() -> dict | None:
267267
_merge_and_save_config(config)
268268
console.print(f"[green]模型配置已保存: {config['model']}[/green]")
269269

270+
# 多模态询问(仅手动配置入口触发;魔搭/LongCat 快捷配置各自已处理视觉)
271+
if await confirm("该模型是否为多模态(视觉)模型?", default=False):
272+
from chcode.vision_config import add_vision_model
273+
274+
try:
275+
role = add_vision_model(config)
276+
if role == "default":
277+
console.print(f"[green]已加入视觉模型清单(默认): {config['model']}(图片将直接处理、vision 工具将停用)[/green]")
278+
elif role == "fallback":
279+
console.print(f"[green]已加入视觉模型清单(备用): {config['model']}(图片将直接处理、vision 工具将停用)[/green]")
280+
else:
281+
console.print("[yellow]未写入视觉配置(已存在相同配置)[/yellow]")
282+
except Exception as e:
283+
# 视觉配置失败不阻断主流程(模型本身已保存)
284+
console.print(f"[yellow]视觉模型配置失败(不影响主配置): {e}[/yellow]")
285+
270286
await configure_tavily()
287+
await configure_langsmith()
271288
return config
272289

273290

274291
async def _configure_modelscope_with_test() -> dict | None:
275-
"""魔搭快捷配置:收集 API Key → 测试连接 → 保存 12 个预定义模型。"""
292+
"""魔搭快捷配置:收集 API Key → 测试连接 → 保存预定义模型。"""
276293
from chcode.prompts import configure_modelscope
277294

278295
ms_config = await configure_modelscope()
@@ -322,7 +339,7 @@ async def _configure_modelscope_with_test() -> dict | None:
322339

323340

324341
async def _configure_longcat_with_test() -> dict | None:
325-
"""LongCat 快捷配置:收集 API Key → 测试连接 → 保存 4 个预定义模型。"""
342+
"""LongCat 快捷配置:收集 API Key → 测试连接 → 保存预定义模型。"""
326343
lc_config = await configure_longcat()
327344
if lc_config is None:
328345
return None
@@ -442,19 +459,16 @@ def save_tavily_api_key(api_key: str) -> None:
442459
"deepseek-v4-pro": 1048576,
443460
"deepseek-v4-flash": 1048576,
444461
"glm-5.1": 200000,
462+
"glm-5.2": 1048576,
445463
"glm-5": 200000,
446464
"glm-4.7": 200000,
447465
"minimax-m2": 204800,
448466
"minimax-m2.5": 200000,
449467
"kimi-k2": 262144,
450-
"mimo-v2-flash": 262144,
451468
"qwen3.5-plus": 1048576,
452469
"qwen3.6-plus": 1048576,
453470
"qwen": 256000,
454471
"longcat-2.0-preview": 1048576,
455-
"longcat-flash-chat": 262144,
456-
"longcat-flash-thinking": 262144,
457-
"longcat-flash-lite": 262144,
458472
}
459473

460474
_DEFAULT_CONTEXT_WINDOW = 204800

chcode/prompts.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,14 @@ async def select_or_custom(
109109

110110
# 每个模型只需声明差异字段,base_url / stream_usage 由生成器统一填充
111111
_MODELSCOPE_MODELS: list[dict] = [
112-
{"model": "ZhipuAI/GLM-5", "temperature": 1.0, "top_p": 0.95},
112+
{"model": "ZhipuAI/GLM-5.2", "temperature": 1.0, "top_p": 0.95},
113113
{"model": "Qwen/Qwen3-235B-A22B-Thinking-2507", "temperature": 0.6, "top_p": 0.95, "extra_body": {"top_k": 20}},
114114
{"model": "Qwen/Qwen3-235B-A22B-Instruct-2507", "temperature": 0.7, "top_p": 0.8, "extra_body": {"top_k": 20}},
115115
{"model": "Qwen/Qwen3.5-397B-A17B", "temperature": 0.6, "top_p": 0.95, "extra_body": {"top_k": 20, "repetition_penalty": 1.0}},
116116
{"model": "deepseek-ai/DeepSeek-V3.2", "temperature": 1.0, "top_p": 0.95},
117117
{"model": "MiniMax/MiniMax-M2.5", "temperature": 1.0, "top_p": 0.95, "extra_body": {"top_k": 40}},
118118
{"model": "moonshotai/Kimi-K2.5", "temperature": 1.0, "top_p": 0.95},
119119
{"model": "ZhipuAI/GLM-5.1", "temperature": 1.0, "top_p": 0.95},
120-
{"model": "Qwen/Qwen3-Coder-480B-A35B-Instruct", "temperature": 0.7, "top_p": 0.8, "extra_body": {"top_k": 20, "repetition_penalty": 1.05}},
121-
{"model": "XiaomiMiMo/MiMo-V2-Flash", "temperature": 0.3, "top_p": 0.95},
122-
{"model": "deepseek-ai/DeepSeek-R1-0528", "temperature": 0.6, "top_p": 0.95},
123120
{"model": "Qwen/Qwen3-Next-80B-A3B-Thinking", "temperature": 0.6, "top_p": 0.95, "extra_body": {"top_k": 20}},
124121
{"model": "deepseek-ai/DeepSeek-V4-Pro", "temperature": 1.0, "top_p": 1.0},
125122
{"model": "deepseek-ai/DeepSeek-V4-Flash", "temperature": 1.0, "top_p": 1.0},
@@ -435,7 +432,7 @@ async def model_config_form(
435432

436433

437434
async def configure_modelscope() -> dict | None:
438-
"""魔搭快捷配置 — 只需 API Key,自动生成 12 个预定义模型。"""
435+
"""魔搭快捷配置 — 只需 API Key,自动生成预定义模型配置。"""
439436
# 收集 API Key
440437
env_choices = [
441438
f"{var} ({desc})"
@@ -474,32 +471,11 @@ async def configure_modelscope() -> dict | None:
474471
"top_p": 0.95,
475472
"stream_usage": True,
476473
},
477-
{
478-
"model": "LongCat-Flash-Chat",
479-
"base_url": LONGCAT_BASE_URL,
480-
"temperature": 1.0,
481-
"top_p": 0.95,
482-
"stream_usage": True,
483-
},
484-
{
485-
"model": "LongCat-Flash-Thinking",
486-
"base_url": LONGCAT_BASE_URL,
487-
"temperature": 0.6,
488-
"top_p": 0.95,
489-
"stream_usage": True,
490-
},
491-
{
492-
"model": "LongCat-Flash-Lite",
493-
"base_url": LONGCAT_BASE_URL,
494-
"temperature": 1.0,
495-
"top_p": 0.95,
496-
"stream_usage": True,
497-
},
498474
]
499475

500476

501477
async def configure_longcat() -> dict | None:
502-
"""LongCat 快捷配置 — 只需 API Key,自动生成 4 个预定义模型。"""
478+
"""LongCat 快捷配置 — 只需 API Key,自动生成预定义模型配置。"""
503479
env_choices = [
504480
f"{var} ({desc})"
505481
for var, desc in API_KEY_ENV_VARS

chcode/utils/multimodal.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,34 @@
1717

1818
from langchain_core.messages import HumanMessage
1919

20-
# ─── Multimodal model patterns ──────────────────────────────────
21-
22-
# Models whose short name (after /) or full name matches these patterns
23-
# are considered multimodal (native vision capability).
24-
MULTIMODAL_MODEL_PATTERNS: list[str] = [
25-
# Kimi K2.5 series
26-
"Kimi-K2",
27-
# Qwen3 VL (dedicated vision-language)
28-
"Qwen3-VL",
29-
# Qwen3.5 MoE models with vision
30-
"Qwen3.5-397B",
31-
"Qwen3.5-122B",
32-
"Qwen3.5-35B",
33-
"Qwen3.5-27B",
34-
# Intern-S1 series
35-
"Intern-S1",
36-
]
20+
# ─── Multimodal model detection ────────────────────────────────
3721

3822

3923
def is_multimodal_model(model_name: str) -> bool:
4024
"""Check if a model name indicates native multimodal (vision) capability.
4125
42-
Handles both short names (e.g., "Kimi-K2.5") and full names
43-
(e.g., "moonshotai/Kimi-K2.5"). Case-insensitive.
26+
判断依据:该模型是否在视觉模型清单(vision_model.json)中。取清单里每个模型的
27+
短名(斜杠后部分),用短名子串匹配 model_name 的短名或全名,大小写不敏感。
28+
清单为空时返回 False(没有视觉模型配置)。
4429
"""
4530
if not model_name:
4631
return False
32+
from chcode.vision_config import get_vision_default_model, get_vision_fallback_models
33+
34+
names: list[str] = []
35+
default = get_vision_default_model()
36+
if default and default.get("model"):
37+
names.append(default["model"])
38+
names.extend(m.get("model", "") for m in get_vision_fallback_models())
39+
if not names:
40+
return False
41+
4742
short_name = model_name.split("/")[-1]
4843
lower_name = model_name.lower()
4944
lower_short = short_name.lower()
50-
for pattern in MULTIMODAL_MODEL_PATTERNS:
51-
lower_pattern = pattern.lower()
52-
if lower_pattern in lower_short or lower_pattern in lower_name:
45+
for vm in names:
46+
vshort = vm.split("/")[-1].lower()
47+
if vshort and (vshort in lower_short or vshort in lower_name):
5348
return True
5449
return False
5550

chcode/vision_config.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,73 @@ def auto_configure_vision() -> dict | None:
162162
return data["default"]
163163

164164

165+
# 视觉相关字段白名单(与消费侧 tools.py 视觉请求所读字段对齐)
166+
_VISION_FIELDS = ("model", "base_url", "api_key", "temperature", "top_p", "stream_usage")
167+
168+
169+
def _vision_equal(existing: dict, vision_cfg: dict) -> bool:
170+
"""现有视觉条目与新配置在白名单字段上是否一致(用于幂等判断)。"""
171+
if not isinstance(existing, dict):
172+
return False
173+
return all(existing.get(k) == vision_cfg.get(k) for k in _VISION_FIELDS)
174+
175+
176+
def add_vision_model(config: dict) -> str | None:
177+
"""把一个模型配置加入视觉模型列表;同名模型就地更新。
178+
179+
- 同名已在 default → 更新 default
180+
- 同名已在 fallback → 更新该 fallback 条目
181+
- 新模型:有有效 default(api_key 非空)→ 加入 fallback;否则设为 default
182+
- 内容完全一致时跳过写入(幂等);缺 model/api_key 返回 None。
183+
184+
Returns: "default" | "fallback" 表示实际落入/更新的角色;None 表示未写入。
185+
"""
186+
model = (config or {}).get("model", "")
187+
api_key = (config or {}).get("api_key", "")
188+
if not model or not api_key:
189+
return None
190+
191+
# 只保留视觉相关字段,丢弃 extra_body/stop_sequences 等文本侧字段
192+
vision_cfg = {k: config[k] for k in _VISION_FIELDS if k in config}
193+
194+
data = copy.deepcopy(load_vision_json())
195+
existing_default = data.get("default", {})
196+
existing_fallback: dict = dict(data.get("fallback", {}))
197+
198+
# 同名已在 default → 就地更新
199+
if existing_default.get("model") == model:
200+
if _vision_equal(existing_default, vision_cfg):
201+
return None
202+
data["default"] = vision_cfg
203+
data["fallback"] = existing_fallback
204+
save_vision_json(data)
205+
return "default"
206+
207+
# 同名已在 fallback → 就地更新
208+
if model in existing_fallback:
209+
if _vision_equal(existing_fallback[model], vision_cfg):
210+
return None
211+
existing_fallback[model] = vision_cfg
212+
data["default"] = existing_default
213+
data["fallback"] = existing_fallback
214+
save_vision_json(data)
215+
return "fallback"
216+
217+
# 新模型:有有效 default → 加入 fallback
218+
if existing_default and existing_default.get("api_key"):
219+
existing_fallback[model] = vision_cfg
220+
data["default"] = existing_default
221+
data["fallback"] = existing_fallback
222+
save_vision_json(data)
223+
return "fallback"
224+
225+
# 无有效 default → 设为 default(保留已存在的 fallback)
226+
data["default"] = vision_cfg
227+
data["fallback"] = existing_fallback
228+
save_vision_json(data)
229+
return "default"
230+
231+
165232
async def configure_vision_interactive() -> dict | None:
166233
"""交互式配置视觉模型(/vision 命令调用)"""
167234
ensure_config_dir()

0 commit comments

Comments
 (0)