Files

886 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工具函数模块
整合错误处理、图像处理、常量定义等通用工具
"""
import json
import base64
import sys
import os
import shutil
import re
import io
from io import BytesIO
from PIL import Image
from typing import Optional, Dict, Any
import time
import random
import threading
# 修复 Windows 终端编码问题
# 解决 GBK 编码导致的 emoji 和特殊字符输出错误
if sys.platform == 'win32' and sys.stdout.encoding != 'utf-8':
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
except Exception:
pass # 静默失败,保持原有编码
# ==================== 统一显示宽度计算 ====================
def get_display_width(text: str) -> int:
"""
计算字符串在终端中的显示宽度(中文及部分 emoji 占2格,ASCII 占1格)
"""
width = 0
for char in text:
# 常见的中文字符编码范围
if ord(char) > 0x7F:
width += 2
else:
width += 1
return width
# ==================== 统一日志前缀常量 ====================
# 所有模块从此处导入,确保日志格式一致
PREFIX = "✨"
ERROR_PREFIX = "✨-❌"
PROCESS_PREFIX = "✨"
REQUEST_PREFIX = "✨"
WARN_PREFIX = "✨-⚠️"
# ==================== 任务类型常量 ====================
TASK_TRANSLATE = "翻译"
TASK_EXPAND = "提示词优化"
TASK_IMAGE_CAPTION = "图像反推"
TASK_VIDEO_CAPTION = "视频反推"
# ==================== 请求来源常量 ====================
SOURCE_NODE = "节点-"
SOURCE_FRONTEND = "前端-"
# ==================== 统一日志消息函数 ====================
def log_prepare(
task_type: str,
request_id: str,
source: str,
service_name: str,
model_name: str = None,
rule_name: str = None,
extra: dict = None
) -> None:
"""
输出统一格式的准备日志(换行输出)
格式: ✨ 🟡 {来源}{任务}准备 | 服务:{service} | 模型:{model} | 规则:{rule} | ID:{id}
"""
# 强制回到行首并清除当前行,确保不与之前的 progress 冲突
print(f"\r{_ANSI_CLEAR_EOL}", end="")
parts = [f"{PREFIX} 🟡 {source}{task_type}准备"]
parts.append(f"服务:{service_name}")
if model_name:
parts.append(f"模型:{model_name}")
if rule_name:
parts.append(f"规则:{rule_name}")
parts.append(f"ID:{request_id}")
# 处理额外字段
if extra:
for key, value in extra.items():
parts.append(f"{key}:{value}")
print(f"{parts[0]} | {' | '.join(parts[1:])}", flush=True)
def log_complete(
task_type: str,
request_id: str,
service_name: str,
char_count: int,
elapsed_ms: int,
model_unloaded: bool = None,
source: str = None
) -> None:
"""
输出统一格式的完成日志(换行输出)
格式: ✨ ✅ {来源}{任务}完成 | 服务:{service} | ID:{id} | 字符:{count} | 耗时:{time}
"""
# 强制回到行首且不换行清空当前行,然后输出新消息
print(f"\r{_ANSI_CLEAR_EOL}", end="")
elapsed_str = format_elapsed_time(elapsed_ms)
source_str = source if source else ""
parts = [f"{PREFIX} ✅ {source_str}{task_type}完成"]
parts.append(f"服务:{service_name}")
parts.append(f"ID:{request_id}")
parts.append(f"字符:{char_count}")
parts.append(f"耗时:{elapsed_str}")
# Ollama 模型卸载状态
if model_unloaded is not None:
unload_text = "模型已卸载" if model_unloaded else "模型保留"
parts.append(unload_text)
print(f"{parts[0]} | {' | '.join(parts[1:])}", flush=True)
def log_error(
task_type: str,
request_id: str,
error_msg: str,
source: str = None
) -> None:
"""
输出统一格式的错误日志(换行输出)
"""
# 强制回到行首并清除当前行
print(f"\r{_ANSI_CLEAR_EOL}", end="")
source_str = source if source else ""
print(f"{PREFIX} ❌ {source_str}{task_type}失败 | ID:{request_id} | 错误:{error_msg}", flush=True)
def generate_request_id(req_type: str, service_type: Optional[str] = None, node_id: str = "0") -> str:
"""
生成统一格式的请求ID
格式: 请求类型_服务类型(可选)_NodeID_四位时间戳
示例: trans_llm_12_3456
"""
timestamp = str(int(time.time()))[-4:]
parts = [req_type]
if service_type:
parts.append(service_type)
parts.append(str(node_id))
parts.append(timestamp)
return "_".join(parts)
# ---日志格式化辅助函数---
def simplify_model_name(model: str) -> str:
"""
简化模型名称显示
示例:
huihui_ai/qwen3-vl-abliterated:8b -> qwen3-vl-8b
huihui_ai/qwen3-abliterated:14b -> qwen3-14b
参数:
model: 完整模型名称
返回:
简化后的模型名称
"""
if '/' in model:
model = model.split('/')[-1]
if ':' in model:
name, size = model.split(':')
# 移除常见后缀
name = name.replace('-abliterated', '').replace('-instruct', '').replace('-chat', '')
return f"{name}-{size}"
return model
def format_model_with_thinking(model: str, thinking_disabled: bool = False) -> str:
"""
格式化模型名称,如果关闭思维链则添加🗯标识
参数:
model: 模型名称
thinking_disabled: 是否关闭思维链
返回:
格式化后的模型名称
"""
simplified = simplify_model_name(model)
if thinking_disabled:
return f"{simplified}💭"
return simplified
def format_elapsed_time(elapsed_ms: int) -> str:
"""
格式化耗时显示
参数:
elapsed_ms: 毫秒数
返回:
格式化后的时间字符串(如 "6.5s")
"""
return f"{elapsed_ms/1000:.1f}s"
# ====================进度日志系统====================
# 统一的进度条管理器,支持单行覆盖刷新
# ---ANSI 控制序列---
_ANSI_CLEAR_EOL = "\033[K" # 清除从光标位置到行末的内容
# ---全局状态:追踪上一次输出长度(使用锁保护以支持并发)---
_global_last_output_len = 0
_progress_lock = threading.Lock()
# ---Windows 虚拟终端初始化---
def _enable_windows_vt():
"""
启用 Windows 虚拟终端处理
解决 Windows CMD/PowerShell 中 ANSI 转义序列兼容性问题
"""
if os.name == 'nt':
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_ulong()
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
except Exception:
pass
_enable_windows_vt()
class ProgressBar:
"""
统一进度条管理器
管理请求的完整生命周期:等待 → 生成 → 完成
通过 streaming 参数控制刷新频率:
- streaming=True: 高频刷新(每次更新都刷新)
- streaming=False: 仅在状态变化时刷新(等待→生成→完成)
两种模式都使用单行覆盖(\r),区别仅在于刷新频率
"""
# 状态常量
STATE_WAITING = "waiting"
STATE_GENERATING = "generating"
STATE_DONE = "done"
def __init__(
self,
request_id: str,
service_name: str,
extra_info: str = None,
streaming: bool = True,
task_type: str = None,
source: str = None
):
"""
创建进度条
参数:
request_id: 请求ID
service_name: 服务名称(如 Ollama, OpenAI)
extra_info: 额外信息(如 Context:2048 | Timeout:60s)
streaming: True=高频刷新,False=仅状态变化时刷新
task_type: 任务类型(用于统一日志)
source: 来源(前端/节点)
"""
self._request_id = request_id
self._service_name = service_name
self._extra_info = extra_info
self._streaming = streaming
self._task_type = task_type
self._source = source
self._state = self.STATE_WAITING
self._char_count = 0
self._start_time = time.perf_counter()
self._last_refresh_time = 0.0 # 新增:记录最后一次刷新时间,用于限流
self._closed = False
self._stop_event = threading.Event()
self._timer_thread = None
# 1. 重置全局长度,开始新一轮进度跟踪
with _progress_lock:
global _global_last_output_len
_global_last_output_len = 0
# 立即显示"等待响应"
self._refresh(force=True)
# 仅在流式模式下启动定时刷新线程(非流式模式采用静态日志,无需跳秒刷新)
if self._streaming:
self._timer_thread = threading.Thread(target=self._timer_loop, daemon=True)
self._timer_thread.start()
def _format_elapsed(self) -> str:
"""格式化耗时"""
elapsed_sec = time.perf_counter() - self._start_time
if elapsed_sec < 60:
return f"{elapsed_sec:.1f}s"
else:
minutes = int(elapsed_sec // 60)
seconds = int(elapsed_sec % 60)
return f"{minutes}m{seconds}s"
def _render(self) -> str:
"""渲染当前进度条内容"""
elapsed = self._format_elapsed()
if self._state == self.STATE_WAITING:
# 等待响应:✨ 🟠 等待Ollama响应...
# 流式模式下添加计时,非流式模式保持静态
base = f"{PREFIX} 🟠 等待{self._service_name}响应..."
if not self._streaming:
return base
if self._extra_info:
return f"{base} | {self._extra_info} | {elapsed}"
else:
return f"{base} | {elapsed}"
elif self._state == self.STATE_GENERATING:
# 流式模式:显示字符数和时间
# 静态模式:只显示简单的 "生成中..."
if self._streaming:
return f"{PREFIX} 🔵 生成中 | {self._char_count}字符 | {elapsed}"
else:
return f"{PREFIX} 🔵 生成中..."
else:
return ""
def _refresh(self, force: bool = False) -> None:
"""内部刷新方法:单行覆盖输出"""
if self._closed:
return
# 降频处理:如果非强制刷新,则限制最高频率为 0.3 秒一次,避免因环境不支持动态覆盖而疯狂刷屏
now = time.perf_counter()
if not force and (now - self._last_refresh_time < 0.3):
return
self._last_refresh_time = now
output = self._render()
if not output:
return
with _progress_lock:
global _global_last_output_len
# 计算当前内容的显示宽度(解决中文/emoji 导致的 len() 不准问题)
current_width = get_display_width(output)
# 用空格填充以覆盖上一次更长的输出(兜底方案,应对 ANSI 失效)
padding = ""
if _global_last_output_len > current_width:
padding = " " * (_global_last_output_len - current_width)
# 使用 \r 回到行首,先发一次 ANSI 清行(如果环境支持,瞬间清空)
# 再输出内容 + 空格填充(应对 ANSI 失效)+ 再次清行(防止尾部残留)
# 增加 2 个空格缓冲避免与其他日志粘连
print(f"\r{_ANSI_CLEAR_EOL}{output}{padding}{_ANSI_CLEAR_EOL} ", end='', flush=True)
# 记录本次显示的宽度(包含缓冲空格)
_global_last_output_len = current_width + len(padding)
def _stop_timer(self):
"""停止计时器线程"""
self._stop_event.set()
# 强制将状态标为已关闭,防止重入
self._closed = True
def _timer_loop(self):
"""后台线程:仅在流式模式下定期刷新计时"""
try:
while not self._stop_event.is_set() and not self._closed:
# 定时刷新当前内容(主要用于更新 WAITING 阶段的时间)
self._refresh(force=True)
# 降低刷新频率:每 0.3 秒刷新一次,大幅减少不支持动态覆盖时的刷屏
if self._stop_event.wait(0.3):
break
except Exception:
pass # 守护线程异常不应影响主流程
def set_generating(self, char_count: int = 0) -> None:
"""
切换到"生成中"状态
参数:
char_count: 当前字符数
"""
if self._closed or self._state == self.STATE_GENERATING:
return
self._state = self.STATE_GENERATING
self._char_count = char_count
self._refresh(force=True) # 状态变化时总是强制刷新
def update(self, char_count: int) -> None:
"""
更新字符数
流式模式:每次调用都刷新
静态模式:不刷新(避免刷屏)
参数:
char_count: 当前字符数
"""
if self._closed:
return
self._char_count = char_count
# 流式模式:高频刷新 (实际频率受 _refresh 内的降频限制)
if self._streaming:
self._refresh(force=False)
# 静态模式:不在这里刷新,只有状态变化时才刷新
def done(self, message: str = None, char_count: int = None, elapsed_ms: int = None) -> None:
"""
完成请求
参数:
message: 自定义完成消息(可选)
char_count: 最终字符数(可选)
elapsed_ms: 耗时毫秒(可选,不传则自动计算)
"""
if self._closed:
return
self._stop_timer()
self._state = self.STATE_DONE
# 重置全局长度
with _progress_lock:
global _global_last_output_len
_global_last_output_len = 0
# 如果提供了 task_type,则使用统一日志
if hasattr(self, '_task_type') and self._task_type:
log_complete(
self._task_type or "任务",
self._request_id,
self._service_name,
char_count if char_count is not None else self._char_count,
elapsed_ms if elapsed_ms is not None else int((time.perf_counter() - self._start_time) * 1000),
source=getattr(self, '_source', None)
)
return
# 降级兼容:原始 done 逻辑
# 计算耗时
if elapsed_ms is not None:
elapsed = format_elapsed_time(elapsed_ms)
else:
elapsed = self._format_elapsed()
# 使用传入的字符数或当前记录的字符数
final_count = char_count if char_count is not None else self._char_count
# 生成完成消息
if message:
final_msg = message
else:
final_msg = f"{PREFIX} ✅ 完成 | 服务:{self._service_name} | ID:{self._request_id} | 字符:{final_count} | 耗时:{elapsed}"
# 直接调用 log_complete 的思想:换行输出,不覆盖之前的内容
print(f"\r{_ANSI_CLEAR_EOL}{final_msg}", flush=True)
def error(self, message: str) -> None:
"""
输出错误消息(换行输出,不覆盖)
参数:
message: 错误消息
"""
if self._closed:
return
self._stop_timer()
# 重置全局长度
with _progress_lock:
global _global_last_output_len
_global_last_output_len = 0
# 如果提供了 task_type,则使用统一日志
if hasattr(self, '_task_type') and self._task_type:
log_error(self._task_type or "任务", self._request_id, message, source=getattr(self, '_source', None))
return
# 降级模式
print(f"\r{_ANSI_CLEAR_EOL}{message}", flush=True)
def cancel(self, message: str = None) -> None:
"""
取消请求(换行输出,不覆盖)
参数:
message: 自定义取消消息(可选)
"""
if self._closed:
return
self._stop_timer()
# 重置全局长度
with _progress_lock:
global _global_last_output_len
_global_last_output_len = 0
cancel_msg = message or "任务被取消"
# 如果提供了 task_type,则使用统一日志
if hasattr(self, '_task_type') and self._task_type:
log_error(self._task_type or "任务", self._request_id, cancel_msg, source=getattr(self, '_source', None))
return
# 降级模式
print(f"\r{_ANSI_CLEAR_EOL}{WARN_PREFIX} {cancel_msg} | ID:{self._request_id}", flush=True)
def __enter__(self):
return self
def __exit__(self, *args):
if not self._closed:
# 退出上下文时,如果没有显式调用 done/error,则视为成功完成
self.done()
def __del__(self):
"""析构函数:确保对象被回收时停止计时器"""
try:
# 仅在计时器还在运行时尝试停止
if hasattr(self, '_stop_event') and not self._stop_event.is_set():
self._stop_timer()
except:
pass
# HTTP状态码到中文错误信息的映射
HTTP_STATUS_CODE_MESSAGES = {
400: "请求无效",
401: "身份验证失败-请检查您的API Key是否正确。",
403: "无权限访问-您没有权限访问此资源。",
404: "请求的资源不存在",
429: "请求频率过高-您已超出速率限制,请稍后再试。",
500: "服务器内部错误- 服务提供商端发生未知问题。",
502: "网关错误",
503: "服务不可用- 服务器当前无法处理请求,请稍后重试。",
504: "网关超时",
}
# 百度翻译API的错误码映射
BAIDU_ERROR_CODE_MESSAGES = {
'52001': '请求超时,请重试',
'52002': '系统错误,请重试',
'52003': '未授权用户,请检查appid是否正确或服务是否开通',
'54000': '必填参数为空,请检查是否少传参数',
'54001': '签名错误,请检查appid和secret_key是否正确',
'54003': '访问频率受限,请降低您的调用频率,或进行身份认证后切换为高级版/尊享版',
'54004': '账户余额不足,请前往管理控制台充值',
'54005': '长query请求频繁,请降低长query的发送频率,3s后再试',
'58000': '客户端IP非法,检查个人资料里填写的IP地址是否正确,可前往开发者信息-基本信息修改',
'58001': '译文语言方向不支持,检查译文语言是否在语言列表里',
'58002': '服务当前已关闭,请前往百度管理控制台开启服务',
'58003': '此IP已被封禁',
'90107': '认证未通过或未生效,请前往我的认证查看认证进度',
'20003': '请求内容存在安全风险',
}
# ---错误处理函数---
def _is_auth_error(error_text: str) -> bool:
"""
检查错误信息是否为认证相关错误
参数:
error_text: 错误文本(小写)
返回:
bool: 是否为认证错误
"""
auth_keywords = [
'invalid token',
'authorization',
'authenticate',
'api key',
'api_key',
'unauthorized',
'auth failed',
'invalid key',
'missing key',
'invalid credentials',
'身份验证',
'认证失败',
'token'
]
return any(keyword in error_text for keyword in auth_keywords)
def format_api_error(e: Exception, provider_display_name: str) -> str:
"""
格式化来自API的错误信息
纯httpx实现,不依赖openai库
参数:
e: 异常对象
provider_display_name: 服务商显示名称
返回:
str: 格式化后的错误信息
"""
# 处理编码异常
if isinstance(e, UnicodeEncodeError):
return f"{provider_display_name} 网络请求编码异常: 检测到非法字符 (\u2026 或其他非 ASCII 字符)。请检查服务商配置中的 API Key 或 URL 是否包含多余的省略号、引号或空格。"
# 处理httpx的HTTP错误
try:
import httpx
if isinstance(e, httpx.HTTPStatusError):
status_code = e.response.status_code
message = HTTP_STATUS_CODE_MESSAGES.get(status_code, "未知HTTP错误")
error_details_str = ""
detail_msg = ""
try:
error_details = e.response.json()
detail_msg = error_details.get("message", "")
if isinstance(error_details.get("error"), dict):
detail_msg = error_details["error"].get("message", detail_msg)
if detail_msg:
error_details_str = f" | 详情: {detail_msg}"
except (json.JSONDecodeError, AttributeError):
try:
if hasattr(e.response, 'text') and e.response.text:
detail_msg = e.response.text[:200]
error_details_str = f" | 原始响应: {detail_msg}"
except Exception:
pass
# ---智能识别认证错误并提供友好提示---
combined_error_text = f"{message} {detail_msg}".lower()
if status_code == 401 or _is_auth_error(combined_error_text):
return f"{provider_display_name} 认证失败: 未配置API Key或API Key无效,请在服务商配置中填写正确的API Key"
return f"{provider_display_name} API错误: {message} (状态码: {status_code}){error_details_str}"
except Exception:
pass
# 对于其他类型的异常,返回其类型和基本信息
return f"{provider_display_name} 服务请求异常: ({type(e).__name__}) {str(e)}"
def format_baidu_translate_error(error_data: dict) -> str:
"""
格式化百度翻译API的错误信息
参数:
error_data: 百度API返回的错误数据
返回:
str: 格式化后的错误信息
"""
if not isinstance(error_data, dict):
return "未知的百度翻译错误格式"
error_code = str(error_data.get('error_code'))
if error_code in BAIDU_ERROR_CODE_MESSAGES:
return f"百度翻译错误: {BAIDU_ERROR_CODE_MESSAGES[error_code]} (代码: {error_code})"
error_msg = error_data.get('error_msg', '未知错误')
return f"百度翻译错误: {error_msg} (代码: {error_code})"
# ---图像处理函数---
def get_optimal_image_params(image_count: int = 1) -> tuple:
"""
根据图像数量智能计算最佳的分辨率和质量参数
目标:保证API能返回完整结果,同时尽可能保持图像质量
参数:
image_count: 图像数量 (1-32)
返回:
tuple: (max_size: tuple, quality: int, compression_level: str)
"""
if image_count <= 1:
# 单图:使用中等质量
return (1024, 1024), 75, "中等"
elif image_count <= 3:
# 1-3帧:保持较高质量
return (1024, 1024), 70, "较高"
elif image_count <= 6:
# 4-6帧:降低分辨率,保持中等质量
return (768, 768), 70, "中等"
elif image_count <= 10:
# 7-10帧:进一步降低分辨率和质量
return (640, 640), 65, "较低"
elif image_count <= 16:
# 11-16帧:使用低分辨率
return (512, 512), 60, "低"
else:
# 17-32帧:最大压缩,保证能处理
return (480, 480), 55, "极低"
def preprocess_image(
image_data: str,
max_size: tuple = None, # 改为可选,支持自动计算
quality: int = None, # 改为可选,支持自动计算
request_id: Optional[str] = None,
silent: bool = False,
image_count: int = 1 # 新增:总图像数量,用于动态调整
) -> str:
"""
预处理图像数据(压缩和调整大小)
参数:
image_data: Base64编码的图像数据
max_size: 最大尺寸,默认为None(自动计算)
quality: JPEG压缩质量 (1-100),默认为None(自动计算)
request_id: 请求ID,用于日志输出
silent: 是否静默模式(不输出日志)
image_count: 总图像数量,用于多图场景的智能优化
返回:
str: 处理后的图像数据
"""
try:
# 智能计算最佳参数
if max_size is None or quality is None:
optimal_size, optimal_quality, compression_level = get_optimal_image_params(image_count)
max_size = max_size or optimal_size
quality = quality or optimal_quality
else:
compression_level = "自定义"
# 检查是否为base64编码的图像数据
if image_data.startswith('data:image'):
# 提取base64数据
header, encoded = image_data.split(",", 1)
image_bytes = base64.b64decode(encoded)
original_bytes = len(image_bytes)
# 打开图像
img = Image.open(BytesIO(image_bytes))
original_size = img.size
# 计算缩放比例
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# 转换为RGB(如果是RGBA)
if img.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
# 压缩图像
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=quality, optimize=True)
compressed_bytes = buffer.getvalue()
compressed_size = len(compressed_bytes)
# 编码为base64
compressed_b64 = base64.b64encode(compressed_bytes).decode('utf-8')
processed_image_data = f"data:image/jpeg;base64,{compressed_b64}"
# 输出日志
if not silent:
compression_ratio = (1 - compressed_size / original_bytes) * 100 if original_bytes > 0 else 0
# 多图场景显示压缩等级
if image_count > 1:
print(
f"{REQUEST_PREFIX} 🟡 图像预处理 | "
f"尺寸:{original_size}→{img.size} | "
f"大小:{original_bytes/1024:.1f}KB→{compressed_size/1024:.1f}KB | "
f"压缩率:{compression_ratio:.1f}% | "
f"等级:{compression_level} ({image_count}帧)"
)
else:
print(
f"{REQUEST_PREFIX} 🟡 图像预处理完成 | "
f"尺寸:{original_size}→{img.size} | "
f"大小:{original_bytes/1024:.1f}KB→{compressed_size/1024:.1f}KB | "
f"压缩率:{compression_ratio:.1f}%"
)
return processed_image_data
# 如果不是base64编码的图像数据,直接返回
return image_data
except Exception as e:
if not silent:
print(f"{WARN_PREFIX} ❌图像预处理失败 | 请求ID:{request_id} | 错误:{str(e)}")
# 预处理失败时返回原始图像数据
return image_data
def get_model_max_images(model: str) -> int:
"""
根据模型名称推断最大支持图像数
策略:乐观默认,已知模型给精确上限,未知模型给安全默认值(10)
"""
model_lower = (model or "").lower()
# Gemini系列:超大上下文支持大量图像
if "gemini" in model_lower or "google" in model_lower:
return 3000
# Qwen系列:直接给予 100 张上限
if "qwen" in model_lower:
return 100
# 智谱GLM系列
if "glm" in model_lower:
if "4.6v" in model_lower:
return 100
return 5
# GPT-4 / GPT-5 系列
if "gpt-4" in model_lower or "gpt-5" in model_lower:
return 100
# Claude 系列
if "claude" in model_lower:
return 20
# Grok 系列
if "grok" in model_lower:
return 20
# 含视觉关键词的模型(兜底)
if any(keyword in model_lower for keyword in ["vision", "visual", "vl", "multimodal"]):
return 100
# 默认:乐观允许,给安全上限 10
return 10
def check_multi_image_support(provider: str, model: str) -> tuple:
"""
兼容保留的旧 API,内部路由到 get_model_max_images。
"""
limit = get_model_max_images(model)
return (True, limit)