refactor(models): use dynamic api model registry
This commit is contained in:
parent
8da51b3b68
commit
eb00a3522a
126
README.md
126
README.md
@ -443,135 +443,19 @@ static/src/
|
||||
|
||||
## 模型支持
|
||||
|
||||
系统支持多个 AI 模型提供商:
|
||||
系统不再内置任何固定供应商模型。可用模型由 `config/custom_models.json`(或后续 API 扩展配置)动态注册,前后端均通过 `/api/v1/models` 获取当前模型列表。
|
||||
|
||||
### Kimi (月之暗面)
|
||||
- `kimi-k2`: K2 系列模型
|
||||
- `kimi-k2.5`: K2.5 最新模型
|
||||
- 支持 256K 上下文窗口
|
||||
每个模型配置应提供 API 地址、密钥、模型 ID、上下文窗口、输出 token 上限、多模态能力和思考模式参数等信息;需要额外请求参数时,通过配置中的 `extra_parameter` / `thinkmode_status.*_extra_parameter` 注入。
|
||||
|
||||
### DeepSeek
|
||||
- `deepseek-chat`: 快速模式
|
||||
- `deepseek-reasoner`: 推理模式
|
||||
|
||||
### Qwen (通义千问)
|
||||
- `qwen3-max`: 最大模型
|
||||
- `qwen3.5-plus`: 增强版本
|
||||
- `qwen3-vl-plus`: 视觉语言模型
|
||||
|
||||
### MiniMax
|
||||
- `MiniMax-M2.5`: M2.5 模型
|
||||
- 支持 204K 上下文窗口
|
||||
|
||||
## 技能系统 (AgentSkills)
|
||||
|
||||
可扩展的技能包系统,提供预定义的工作流:
|
||||
|
||||
- **agent-build-standard**: 标准构建流程
|
||||
- **docx**: 文档处理
|
||||
- **frontend-design**: 前端设计辅助
|
||||
- **skill-creator**: 技能创建工具
|
||||
- **sub-agent-guide**: 子智能体使用指南
|
||||
- **terminal-guide**: 终端操作指南
|
||||
|
||||
### 安全特性
|
||||
|
||||
#### 宿主机模式安全
|
||||
|
||||
- **路径白名单**: 禁止访问系统敏感目录
|
||||
- **路径遍历防护**: 防止 `..` 等路径攻击
|
||||
- **根路径保护**: 禁止操作根目录和系统目录
|
||||
- **符号链接检查**: 防止符号链接逃逸
|
||||
- **Linux 安全模式**: 可选的额外安全限制
|
||||
|
||||
#### Docker 模式安全
|
||||
|
||||
- **完全隔离**: 容器级别的进程和文件系统隔离
|
||||
- **资源配额**: 强制的 CPU、内存、存储限制
|
||||
- **网络隔离**: 可配置的网络访问控制
|
||||
- **只读挂载**: 支持只读文件系统挂载
|
||||
- **用户映射**: 容器内外用户权限映射
|
||||
|
||||
#### 通用安全
|
||||
|
||||
### 上传安全
|
||||
- 文件类型白名单
|
||||
- 文件大小限制
|
||||
- 恶意文件扫描
|
||||
- 压缩包安全检查
|
||||
|
||||
### 路径安全
|
||||
- 禁止访问系统目录
|
||||
- 路径遍历防护
|
||||
- 符号链接检查
|
||||
|
||||
### API 安全
|
||||
- CSRF 保护
|
||||
- 速率限制
|
||||
- Token 验证
|
||||
- Session 管理
|
||||
|
||||
### API 安全
|
||||
- CSRF 保护
|
||||
- 速率限制
|
||||
- Token 验证
|
||||
- Session 管理
|
||||
|
||||
## 使用场景建议
|
||||
|
||||
### 宿主机模式适用场景
|
||||
|
||||
1. **个人开发环境**
|
||||
- 本地开发和测试
|
||||
- 需要访问本地工具链
|
||||
- 频繁的文件系统操作
|
||||
|
||||
2. **可信环境**
|
||||
- 单用户使用
|
||||
- 内网环境
|
||||
- 完全可控的代码执行
|
||||
|
||||
3. **性能优先**
|
||||
- 大量文件操作
|
||||
- 编译构建任务
|
||||
- 需要原生性能的场景
|
||||
|
||||
4. **特殊工具依赖**
|
||||
- 需要特定系统工具
|
||||
- 硬件设备访问
|
||||
- 系统级操作
|
||||
|
||||
### Docker 模式适用场景
|
||||
|
||||
1. **多用户服务**
|
||||
- 公共服务部署
|
||||
- 多租户环境
|
||||
- 需要用户隔离
|
||||
|
||||
2. **生产环境**
|
||||
- 对外提供服务
|
||||
- 需要稳定性保证
|
||||
- 资源配额管理
|
||||
|
||||
3. **安全优先**
|
||||
- 执行不可信代码
|
||||
- 需要严格隔离
|
||||
- 防止恶意操作
|
||||
|
||||
4. **环境标准化**
|
||||
- 统一运行环境
|
||||
- 依赖管理
|
||||
- 可复现的执行环境
|
||||
|
||||
## 配置说明
|
||||
|
||||
主要配置项(通过环境变量或 `.env` 文件):
|
||||
|
||||
### API 配置
|
||||
- `API_BASE_KIMI`: Kimi API 地址
|
||||
- `API_KEY_KIMI`: Kimi API 密钥
|
||||
- `API_BASE_DEEPSEEK`: DeepSeek API 地址
|
||||
- `API_KEY_DEEPSEEK`: DeepSeek API 密钥
|
||||
- `config/custom_models.json`: 动态模型注册文件
|
||||
- `AGENT_DEFAULT_MODEL`: 可选,指定默认模型 key;未设置时使用第一个可见动态模型
|
||||
- 模型配置中的 `url` / `apikey` 支持 `${ENV_NAME}`、`env:ENV_NAME`、`$ENV_NAME` 等环境变量引用
|
||||
|
||||
### 服务配置
|
||||
- `WEB_SERVER_PORT`: Web 服务器端口(默认 8091)
|
||||
|
||||
@ -11,8 +11,8 @@ def _env(name: str, default: str = "", required: bool = False) -> str:
|
||||
|
||||
|
||||
API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
|
||||
API_KEY = _env("AGENT_API_KEY", required=True)
|
||||
MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-chat")
|
||||
API_KEY = _env("AGENT_API_KEY", "")
|
||||
MODEL_ID = _env("AGENT_MODEL_ID", "")
|
||||
|
||||
# 推理模型配置(智能思考模式使用)
|
||||
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
|
||||
|
||||
@ -3,8 +3,6 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
def _env_optional(name: str) -> Optional[str]:
|
||||
value = os.environ.get(name)
|
||||
@ -29,270 +27,6 @@ def _env_optional(name: str) -> Optional[str]:
|
||||
return value or None
|
||||
|
||||
|
||||
# 模型上下文窗口(单位: token)
|
||||
CONTEXT_WINDOWS = {
|
||||
"kimi": 256_000,
|
||||
"kimi-k2.5": 256_000,
|
||||
"qwen3-max": 256_000,
|
||||
"qwen3-vl-plus": 256_000,
|
||||
"minimax-m2.5": 204_800,
|
||||
"deepseek": 128_000,
|
||||
}
|
||||
|
||||
|
||||
# 默认(Kimi)
|
||||
KIMI_BASE = _env("API_BASE_KIMI", _env("AGENT_API_BASE_URL", "https://api.moonshot.cn/v1"))
|
||||
KIMI_KEY = _env("API_KEY_KIMI", _env("AGENT_API_KEY", ""))
|
||||
KIMI_BASE_OFFICIAL = _env_optional("API_BASE_KIMI_OFFICIAL")
|
||||
KIMI_KEY_OFFICIAL = _env_optional("API_KEY_KIMI_OFFICIAL")
|
||||
KIMI_FAST_MODEL = _env("MODEL_KIMI_FAST", _env("AGENT_MODEL_ID", "kimi-k2-0905-preview"))
|
||||
KIMI_THINK_MODEL = _env("MODEL_KIMI_THINK", _env("AGENT_THINKING_MODEL_ID", "kimi-k2-thinking"))
|
||||
KIMI_25_MODEL = _env("MODEL_KIMI_25", "kimi-k2.5")
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_BASE = _env("API_BASE_DEEPSEEK", "https://api.deepseek.com")
|
||||
DEEPSEEK_KEY = _env("API_KEY_DEEPSEEK", _env("AGENT_DEEPSEEK_API_KEY", ""))
|
||||
DEEPSEEK_FAST_MODEL = _env("MODEL_DEEPSEEK_FAST", "deepseek-chat")
|
||||
DEEPSEEK_THINK_MODEL = _env("MODEL_DEEPSEEK_THINK", "deepseek-reasoner")
|
||||
|
||||
# Qwen
|
||||
QWEN_BASE = _env("API_BASE_QWEN", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
QWEN_KEY = _env("API_KEY_QWEN", _env("DASHSCOPE_API_KEY", ""))
|
||||
QWEN_BASE_OFFICIAL = _env_optional("API_BASE_QWEN_OFFICIAL")
|
||||
QWEN_KEY_OFFICIAL = _env_optional("API_KEY_QWEN_OFFICIAL")
|
||||
QWEN_MAX_MODEL = _env("MODEL_QWEN_MAX", "qwen3-max")
|
||||
QWEN_VL_MODEL = _env("MODEL_QWEN_VL", "qwen3.5-plus")
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_BASE = _env("API_BASE_MINIMAX", "https://api.minimaxi.com/v1")
|
||||
MINIMAX_KEY = _env("API_KEY_MINIMAX", "")
|
||||
MINIMAX_BASE_OFFICIAL = _env_optional("API_BASE_MINIMAX_OFFICIAL")
|
||||
MINIMAX_KEY_OFFICIAL = _env_optional("API_KEY_MINIMAX_OFFICIAL")
|
||||
MINIMAX_MODEL = _env("MODEL_MINIMAX", "MiniMax-M2.5")
|
||||
|
||||
|
||||
MODEL_PROFILES = {
|
||||
"kimi": {
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
"fast": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_FAST_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_THINK_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Kimi-k2",
|
||||
"description": "综合能力较强",
|
||||
"multimodal": "none",
|
||||
},
|
||||
"kimi-k2.5": {
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"fast": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_25_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"extra_params": {"thinking": {"type": "disabled"}}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_25_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"extra_params": {"thinking": {"type": "enabled"}, "enable_thinking": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Kimi-k2.5",
|
||||
"description": "新版 Kimi,支持图文 & 思考开关",
|
||||
"multimodal": "image,video",
|
||||
},
|
||||
"deepseek": {
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"],
|
||||
"fast": {
|
||||
"base_url": DEEPSEEK_BASE,
|
||||
"api_key": DEEPSEEK_KEY,
|
||||
"model_id": DEEPSEEK_FAST_MODEL,
|
||||
"max_tokens": 8192,
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"]
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": DEEPSEEK_BASE,
|
||||
"api_key": DEEPSEEK_KEY,
|
||||
"model_id": DEEPSEEK_THINK_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"]
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "DeepSeek",
|
||||
"description": "数学能力较强",
|
||||
"multimodal": "none",
|
||||
},
|
||||
"qwen3-max": {
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-max"],
|
||||
"fast": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_MAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-max"]
|
||||
},
|
||||
"thinking": None, # 不支持思考
|
||||
"supports_thinking": False,
|
||||
"fast_only": True,
|
||||
"name": "Qwen3-Max",
|
||||
"description": "仅快速模式",
|
||||
"multimodal": "none",
|
||||
"hidden": True,
|
||||
},
|
||||
"qwen3-vl-plus": {
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"fast": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_VL_MODEL,
|
||||
"max_tokens": 32768,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"extra_params": {}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_VL_MODEL,
|
||||
"max_tokens": 32768,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"extra_params": {"enable_thinking": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Qwen3.5",
|
||||
"description": "图文视频多模态 + 深度思考",
|
||||
"multimodal": "image,video",
|
||||
},
|
||||
"minimax-m2.5": {
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"fast": {
|
||||
"base_url": MINIMAX_BASE,
|
||||
"api_key": MINIMAX_KEY,
|
||||
"model_id": MINIMAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"extra_params": {"reasoning_split": True}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": MINIMAX_BASE,
|
||||
"api_key": MINIMAX_KEY,
|
||||
"model_id": MINIMAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"extra_params": {"reasoning_split": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"deep_only": True,
|
||||
"name": "MiniMax-M2.5",
|
||||
"description": "仅深度思考,超长上下文",
|
||||
"multimodal": "none",
|
||||
}
|
||||
}
|
||||
|
||||
MODEL_PROMPT_OVERRIDES = {
|
||||
"kimi": {
|
||||
"model_description": "你的基础模型是 Kimi-k2,由月之暗面公司开发,是一个开源的 MoE 架构模型,拥有 1T 参数和 32B 激活参数,当前智能助手应用由火山引擎提供 API 服务。",
|
||||
"thinking_model_line": "思考模式时,第一次请求的模型不是 Kimi-k2,而是 Kimi-k2-Thinking,一个更善于分析复杂问题、规划复杂流程的模型,在后续请求时模型会换回 Kimi-k2。",
|
||||
"deep_thinking_line": "在深度思考模式中,请求的模型是 Kimi-k2-Thinking,一个更善于分析复杂问题、规划复杂流程的模型。"
|
||||
},
|
||||
"kimi-k2.5": {
|
||||
"model_description": "你的基础模型是 Kimi-k2.5,支持图文多模态,并通过 thinking 参数开启/关闭思考能力。",
|
||||
"thinking_model_line": "思考模式时使用同一个 Kimi-k2.5 模型,但会在请求中注入 thinking={\"type\": \"enabled\"} 来开启思考;快速模式则传递 thinking={\"type\": \"disabled\"}。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求都会携带 thinking={\"type\": \"enabled\"},以获得持续的推理能力。"
|
||||
},
|
||||
"deepseek": {
|
||||
"model_description": "你的基础模型是 DeepSeek-V3.2(deepseek-chat),由 DeepSeek 提供,数学与推理能力较强,当前通过官方 API 调用。",
|
||||
"thinking_model_line": "思考模式时,第一次请求使用 DeepSeek-Reasoner,一个强化推理的模型,后续请求会切回 DeepSeek-V3.2。",
|
||||
"deep_thinking_line": "在深度思考模式中,请求的模型是 DeepSeek-Reasoner,用于深入分析复杂问题并规划步骤。"
|
||||
},
|
||||
"qwen3-max": {
|
||||
"model_description": "你的基础模型是 Qwen3-Max,由通义千问提供,当前仅支持快速模式,不提供思考或深度思考能力。",
|
||||
"thinking_model_line": "Qwen3-Max 仅支持快速模式,思考模式会被自动关闭。",
|
||||
"deep_thinking_line": "Qwen3-Max 不支持深度思考模式,将保持快速模式。"
|
||||
},
|
||||
"qwen3-vl-plus": {
|
||||
"model_description": "你的基础模型是 Qwen3.5,由通义千问提供,支持图文多模态理解。",
|
||||
"thinking_model_line": "思考模式时仍使用 Qwen3.5,并开启思考能力。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求都将启用思考能力,以获得更强的分析表现。"
|
||||
},
|
||||
"minimax-m2.5": {
|
||||
"model_description": "你的基础模型是 MiniMax-M2.5,支持超长上下文,当前仅以深度思考模式运行。",
|
||||
"thinking_model_line": "MiniMax-M2.5 为思考模型,快速模式不会使用。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求持续输出思考过程并给出最终回答。"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_model_profile(key: str) -> dict:
|
||||
profiles = get_registered_model_profiles()
|
||||
if key not in profiles:
|
||||
raise ValueError(f"未知模型 key: {key}")
|
||||
profile = profiles[key]
|
||||
try:
|
||||
from utils.aliyun_fallback import is_fallback_active
|
||||
except Exception:
|
||||
is_fallback_active = None
|
||||
|
||||
if is_fallback_active and is_fallback_active(key):
|
||||
if key == "kimi-k2.5":
|
||||
kimi_base_official = _env_optional("API_BASE_KIMI_OFFICIAL") or KIMI_BASE_OFFICIAL
|
||||
kimi_key_official = _env_optional("API_KEY_KIMI_OFFICIAL") or KIMI_KEY_OFFICIAL
|
||||
if kimi_base_official and kimi_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": kimi_base_official, "api_key": kimi_key_official})
|
||||
thinking.update({"base_url": kimi_base_official, "api_key": kimi_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
elif key == "qwen3-vl-plus":
|
||||
qwen_base_official = _env_optional("API_BASE_QWEN_OFFICIAL") or QWEN_BASE_OFFICIAL
|
||||
qwen_key_official = _env_optional("API_KEY_QWEN_OFFICIAL") or QWEN_KEY_OFFICIAL
|
||||
if qwen_base_official and qwen_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": qwen_base_official, "api_key": qwen_key_official})
|
||||
thinking.update({"base_url": qwen_base_official, "api_key": qwen_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
elif key == "minimax-m2.5":
|
||||
minimax_base_official = _env_optional("API_BASE_MINIMAX_OFFICIAL") or MINIMAX_BASE_OFFICIAL
|
||||
minimax_key_official = _env_optional("API_KEY_MINIMAX_OFFICIAL") or MINIMAX_KEY_OFFICIAL
|
||||
if minimax_base_official and minimax_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": minimax_base_official, "api_key": minimax_key_official})
|
||||
thinking.update({"base_url": minimax_base_official, "api_key": minimax_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
# 基础校验:必须有 fast 段且有 key
|
||||
fast = profile.get("fast") or {}
|
||||
if not fast.get("api_key"):
|
||||
raise ValueError(f"模型 {key} 缺少 API Key 配置")
|
||||
return profile
|
||||
|
||||
|
||||
def _normalize_multimodal(value: Any) -> str:
|
||||
text = str(value or "none").strip().lower()
|
||||
if text in {"image", "video", "none", "image,video", "video,image"}:
|
||||
@ -326,6 +60,13 @@ def _parse_env_ref(raw: Any) -> Optional[str]:
|
||||
return text
|
||||
|
||||
|
||||
def _to_optional_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _reasoning_flags(capability: str) -> Dict[str, bool]:
|
||||
normalized = str(capability or "fast").replace(" ", "").lower()
|
||||
if normalized == "thinking":
|
||||
@ -343,20 +84,13 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
api_key = _parse_env_ref(item.get("apikey"))
|
||||
if not base_url or not api_key:
|
||||
return None
|
||||
context_window = item.get("context_window")
|
||||
max_output_tokens = item.get("max_output_tokens")
|
||||
try:
|
||||
context_window = int(context_window) if context_window is not None else None
|
||||
except (TypeError, ValueError):
|
||||
context_window = None
|
||||
try:
|
||||
max_output_tokens = int(max_output_tokens) if max_output_tokens is not None else None
|
||||
except (TypeError, ValueError):
|
||||
max_output_tokens = None
|
||||
|
||||
context_window = _to_optional_int(item.get("context_window"))
|
||||
max_output_tokens = _to_optional_int(item.get("max_output_tokens"))
|
||||
|
||||
capability = str(item.get("reasoning_capability") or "fast").replace(" ", "").lower()
|
||||
flags = _reasoning_flags(capability)
|
||||
thinkmode = item.get("thinkmode_status") or {}
|
||||
thinkmode = item.get("thinkmode_status") if isinstance(item.get("thinkmode_status"), dict) else {}
|
||||
mode_type = str((thinkmode.get("type") or thinkmode.get("mode") or "")).strip().lower()
|
||||
extra_global = item.get("extra_parameter") if isinstance(item.get("extra_parameter"), dict) else {}
|
||||
fast_extra = thinkmode.get("fast_extra_parameter") if isinstance(thinkmode.get("fast_extra_parameter"), dict) else {}
|
||||
@ -390,7 +124,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
fast_config = {
|
||||
"base_url": base_url,
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_key": api_key,
|
||||
"model_id": fast_model_id,
|
||||
"max_tokens": max_output_tokens,
|
||||
@ -398,7 +132,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"extra_params": {**extra_global, **(fast_extra or {})},
|
||||
}
|
||||
profile: Dict[str, Any] = {
|
||||
"name": key,
|
||||
"name": str(item.get("display_name") or key),
|
||||
"description": str(item.get("description") or ""),
|
||||
"model_description": str(item.get("model_description") or ""),
|
||||
"is_custom_model": True,
|
||||
@ -413,7 +147,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
profile["deep_only"] = True
|
||||
if flags["supports_thinking"]:
|
||||
profile["thinking"] = {
|
||||
"base_url": base_url,
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_key": api_key,
|
||||
"model_id": thinking_model_id or fast_model_id,
|
||||
"max_tokens": max_output_tokens,
|
||||
@ -451,9 +185,8 @@ def _load_custom_models() -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
|
||||
def get_registered_model_profiles() -> Dict[str, Dict[str, Any]]:
|
||||
merged = dict(MODEL_PROFILES)
|
||||
merged.update(_load_custom_models()) # 同名覆盖内置,不报错
|
||||
return merged
|
||||
"""返回 API 扩展注册的模型。项目不再内置任何供应商模型。"""
|
||||
return _load_custom_models()
|
||||
|
||||
|
||||
def get_registered_model_keys(visible_only: bool = False) -> List[str]:
|
||||
@ -465,6 +198,40 @@ def get_registered_model_keys(visible_only: bool = False) -> List[str]:
|
||||
return keys
|
||||
|
||||
|
||||
def get_default_model_key(visible_only: bool = True) -> str:
|
||||
"""解析默认模型:环境变量优先,其次第一个已注册模型。"""
|
||||
profiles = get_registered_model_profiles()
|
||||
candidates = [key for key, profile in profiles.items() if not visible_only or not profile.get("hidden")]
|
||||
preferred = _env_optional("AGENT_DEFAULT_MODEL") or _env_optional("DEFAULT_MODEL_KEY")
|
||||
if preferred and preferred in profiles and (not visible_only or not profiles[preferred].get("hidden")):
|
||||
return preferred
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
raise ValueError("未配置可用模型,请在 config/custom_models.json 中添加至少一个可用模型")
|
||||
|
||||
|
||||
def resolve_model_key(candidate: Optional[str], *, visible_only: bool = False) -> str:
|
||||
"""将缺失的模型 key 解析为默认模型;过期/未知 key 仍显式报错。"""
|
||||
profiles = get_registered_model_profiles()
|
||||
if not candidate:
|
||||
return get_default_model_key(visible_only=visible_only)
|
||||
if candidate in profiles and (not visible_only or not profiles[candidate].get("hidden")):
|
||||
return candidate
|
||||
raise ValueError(f"未知模型 key: {candidate}")
|
||||
|
||||
|
||||
def get_model_profile(key: Optional[str]) -> dict:
|
||||
resolved_key = resolve_model_key(key)
|
||||
profiles = get_registered_model_profiles()
|
||||
profile = profiles[resolved_key]
|
||||
fast = profile.get("fast") or {}
|
||||
if not fast.get("api_key"):
|
||||
raise ValueError(f"模型 {resolved_key} 缺少 API Key 配置")
|
||||
if not fast.get("base_url") or not fast.get("model_id"):
|
||||
raise ValueError(f"模型 {resolved_key} 缺少 API 地址或模型 ID 配置")
|
||||
return profile
|
||||
|
||||
|
||||
def get_model_capabilities(key: str) -> Dict[str, Any]:
|
||||
profile = get_model_profile(key)
|
||||
multimodal = _normalize_multimodal(profile.get("multimodal"))
|
||||
@ -495,29 +262,17 @@ def model_supports_video(key: str) -> bool:
|
||||
|
||||
|
||||
def get_model_prompt_replacements(key: str) -> dict:
|
||||
"""获取模型相关的提示词替换字段,若缺失则回退到 Kimi 版本。"""
|
||||
fallback = MODEL_PROMPT_OVERRIDES.get("kimi", {})
|
||||
overrides = MODEL_PROMPT_OVERRIDES.get(key) or {}
|
||||
custom_model_description = ""
|
||||
is_custom_model = False
|
||||
"""获取模型相关提示词字段。仅使用 API 扩展配置中的描述,不做模型名硬编码。"""
|
||||
try:
|
||||
custom_profile = get_registered_model_profiles().get(key) or {}
|
||||
custom_model_description = str(custom_profile.get("model_description") or "")
|
||||
is_custom_model = bool(custom_profile.get("is_custom_model"))
|
||||
profile = get_registered_model_profiles().get(resolve_model_key(key)) or {}
|
||||
except Exception:
|
||||
custom_model_description = ""
|
||||
is_custom_model = False
|
||||
if is_custom_model:
|
||||
profile = {}
|
||||
model_description = str(profile.get("model_description") or profile.get("description") or "")
|
||||
return {
|
||||
"model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "",
|
||||
"model_description": model_description,
|
||||
"thinking_model_line": "",
|
||||
"deep_thinking_line": "",
|
||||
}
|
||||
return {
|
||||
"model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "",
|
||||
"thinking_model_line": overrides.get("thinking_model_line") or fallback.get("thinking_model_line") or "",
|
||||
"deep_thinking_line": overrides.get("deep_thinking_line") or fallback.get("deep_thinking_line") or ""
|
||||
}
|
||||
|
||||
|
||||
def get_model_context_window(key: str) -> Optional[int]:
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
"""OCR 配置:DeepSeek-OCR 接口信息。"""
|
||||
"""OCR / VLM 配置。"""
|
||||
|
||||
OCR_API_BASE_URL = "https://api.siliconflow.cn"
|
||||
OCR_API_KEY = "sk-suqqgewtlwajjkylvnotdhkzmsrshmrqptkakdqjmlrilaes"
|
||||
OCR_MODEL_ID = "Qwen/Qwen3-VL-30B-A3B-Thinking"
|
||||
OCR_MAX_TOKENS = 200
|
||||
import os
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
OCR_API_BASE_URL = _env("OCR_API_BASE_URL", "")
|
||||
OCR_API_KEY = _env("OCR_API_KEY", "")
|
||||
OCR_MODEL_ID = _env("OCR_MODEL_ID", "")
|
||||
OCR_MAX_TOKENS = int(_env("OCR_MAX_TOKENS", "4096") or "4096")
|
||||
|
||||
__all__ = [
|
||||
"OCR_API_BASE_URL",
|
||||
|
||||
@ -64,7 +64,7 @@ from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager
|
||||
from utils.conversation_manager import ConversationManager
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from config.model_profiles import get_model_profile
|
||||
from config.model_profiles import get_default_model_key, get_model_profile
|
||||
|
||||
from core.main_terminal_parts import (
|
||||
MainTerminalCommandMixin,
|
||||
@ -101,7 +101,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
self.api_client = DeepSeekClient(thinking_mode=self.thinking_mode)
|
||||
self.api_client.project_path = project_path
|
||||
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode)
|
||||
self.model_key = "kimi-k2.5"
|
||||
self.model_key = get_default_model_key()
|
||||
self.model_profile = get_model_profile(self.model_key)
|
||||
self.apply_model_profile(self.model_profile)
|
||||
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
|
||||
|
||||
@ -82,6 +82,7 @@ from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
resolve_model_key,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
@ -795,6 +796,7 @@ class MainTerminalCommandMixin:
|
||||
self.api_client.apply_profile(profile)
|
||||
|
||||
def set_model(self, model_key: str) -> str:
|
||||
model_key = resolve_model_key(model_key)
|
||||
profile = get_model_profile(model_key)
|
||||
if getattr(self.context_manager, "has_images", False) and not model_supports_image(model_key):
|
||||
raise ValueError("当前对话包含图片,目标模型不支持图片输入")
|
||||
|
||||
@ -298,8 +298,8 @@ class MainTerminalContextMixin:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 加载系统提示(Qwen3.5 使用专用提示)
|
||||
current_model = getattr(self, "model_key", "kimi")
|
||||
# 根据当前模型多模态能力选择系统提示
|
||||
current_model = getattr(self, "model_key", None)
|
||||
prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
|
||||
system_prompt = self.load_prompt(prompt_name)
|
||||
|
||||
@ -308,7 +308,7 @@ class MainTerminalContextMixin:
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
model_key = getattr(self, "model_key", "kimi")
|
||||
model_key = getattr(self, "model_key", None)
|
||||
prompt_replacements = get_model_prompt_replacements(model_key)
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=container_path,
|
||||
|
||||
@ -123,7 +123,7 @@ class WebTerminal(MainTerminal):
|
||||
# 新对话视为“干净”会话,清除图片限制便于切换模型
|
||||
self.context_manager.has_images = False
|
||||
|
||||
preferred_model = prefs.get("default_model") or "kimi"
|
||||
preferred_model = prefs.get("default_model")
|
||||
try:
|
||||
self.set_model(preferred_model)
|
||||
except Exception as exc:
|
||||
|
||||
@ -185,7 +185,7 @@ async fetchAndDisplayHistory(options = {}) {
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"model_key": "deepseek-chat"
|
||||
"model_key": "示例模型"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -266,7 +266,7 @@ Resume conversation
|
||||
```text
|
||||
Model
|
||||
|
||||
› 1. kimi-k2.5 当前模型
|
||||
› 1. 示例模型 当前模型
|
||||
2. gpt-5.5 高质量编码
|
||||
3. gpt-5.4-mini 快速低成本
|
||||
|
||||
@ -598,7 +598,7 @@ skill: docs
|
||||
╭────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ Agents CLI │
|
||||
│ │
|
||||
│ Model: kimi-k2.5 │
|
||||
│ Model: 示例模型 │
|
||||
│ Directory: ~/Desktop/agents/正在修复中/agents │
|
||||
│ Workspace: agents │
|
||||
│ Permissions: auto_approval │
|
||||
|
||||
@ -31,7 +31,7 @@ def load_approval_agent_config() -> Dict[str, Any]:
|
||||
"name": "auto-approval-agent",
|
||||
"url": "",
|
||||
"key": "",
|
||||
"model": "deepseek-v4-flash",
|
||||
"model": "",
|
||||
"extra_params": {},
|
||||
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
|
||||
"max_rounds": DEFAULT_MAX_ROUNDS,
|
||||
@ -45,7 +45,7 @@ def load_approval_agent_config() -> Dict[str, Any]:
|
||||
base.update(raw)
|
||||
base["url"] = _resolve_env_token(base.get("url", ""))
|
||||
base["key"] = _resolve_env_token(base.get("key", ""))
|
||||
base["model"] = str(base.get("model") or "deepseek-v4-flash").strip()
|
||||
base["model"] = str(base.get("model") or "").strip()
|
||||
base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {}
|
||||
base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
base["max_rounds"] = max(1, int(base.get("max_rounds") or DEFAULT_MAX_ROUNDS))
|
||||
@ -124,7 +124,8 @@ class ApprovalAgent:
|
||||
|
||||
url = str(self.cfg.get("url") or "").strip()
|
||||
key = str(self.cfg.get("key") or "").strip()
|
||||
if not url or not key:
|
||||
model = str(self.cfg.get("model") or "").strip()
|
||||
if not url or not key or not model:
|
||||
out = {"decision": "rejected", "reason": "审批智能体配置缺失", "source": "approval_agent"}
|
||||
_flush_trace(out)
|
||||
return out
|
||||
@ -203,7 +204,7 @@ class ApprovalAgent:
|
||||
if progress_cb:
|
||||
progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"})
|
||||
req = {
|
||||
"model": self.cfg.get("model"),
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
@ -224,7 +225,7 @@ class ApprovalAgent:
|
||||
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params)
|
||||
if extra_params:
|
||||
retry_req = {
|
||||
"model": self.cfg.get("model"),
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
|
||||
@ -13,7 +13,7 @@ from modules.file_manager import FileManager
|
||||
|
||||
|
||||
class OCRClient:
|
||||
"""封装 VLM(如 DeepSeek-OCR / Qwen3.5)调用逻辑。"""
|
||||
"""封装外部 VLM 调用逻辑。"""
|
||||
|
||||
def __init__(self, project_path: str, file_manager: FileManager):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
@ -21,17 +21,17 @@ class OCRClient:
|
||||
|
||||
# 补全 base_url,兼容是否包含 /v1
|
||||
base_url = (OCR_API_BASE_URL or "").rstrip("/")
|
||||
if not base_url.endswith("/v1"):
|
||||
if base_url and not base_url.endswith("/v1"):
|
||||
base_url = f"{base_url}/v1"
|
||||
|
||||
# httpx 0.28 起不再支持 proxies 参数,显式传入 http_client 以避免默认封装报错
|
||||
self.http_client = httpx.Client()
|
||||
self.client = OpenAI(
|
||||
api_key=OCR_API_KEY,
|
||||
base_url=base_url,
|
||||
base_url=base_url or None,
|
||||
http_client=self.http_client,
|
||||
)
|
||||
self.model = OCR_MODEL_ID or "deepseek-ai/DeepSeek-OCR"
|
||||
self.model = OCR_MODEL_ID
|
||||
self.max_tokens = OCR_MAX_TOKENS or 4096
|
||||
|
||||
# 默认大小上限(10MB),超出则警告并拒绝
|
||||
@ -58,6 +58,8 @@ class OCRClient:
|
||||
|
||||
if not prompt or not str(prompt).strip():
|
||||
return {"success": False, "error": "prompt 不能为空", "warnings": warnings}
|
||||
if not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
|
||||
return {"success": False, "error": "VLM 配置缺失,请设置 OCR_API_BASE_URL / OCR_API_KEY / OCR_MODEL_ID", "warnings": warnings}
|
||||
|
||||
try:
|
||||
data = full_path.read_bytes()
|
||||
|
||||
@ -13,7 +13,7 @@ except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from config.model_profiles import get_registered_model_keys
|
||||
from config.model_profiles import get_default_model_key, get_registered_model_keys
|
||||
|
||||
ALLOWED_RUN_MODES = {"fast", "thinking", "deep"}
|
||||
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
|
||||
@ -70,7 +70,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"skill_strict_sub_agent_enabled": False, # 强约束:子智能体系列工具需先阅读 sub-agent-guide
|
||||
"skill_strict_run_command_foreground_enabled": False, # 强约束:run_command 前台模式需先阅读 run-command-guide
|
||||
"skill_strict_run_command_background_enabled": False, # 强约束:run_command 后台模式需先阅读 run-command-guide
|
||||
"default_model": "kimi-k2.5",
|
||||
"default_model": None,
|
||||
"image_compression": "original", # original / 1080p / 720p / 540p
|
||||
"auto_shallow_compress_enabled": False,
|
||||
"auto_deep_compress_enabled": False,
|
||||
@ -272,7 +272,10 @@ def sanitize_personalization_payload(
|
||||
if isinstance(chosen_model, str) and chosen_model in allowed_models:
|
||||
base["default_model"] = chosen_model
|
||||
elif allowed_models and base.get("default_model") not in allowed_models:
|
||||
base["default_model"] = "kimi-k2.5"
|
||||
try:
|
||||
base["default_model"] = get_default_model_key(visible_only=True)
|
||||
except Exception:
|
||||
base["default_model"] = None
|
||||
|
||||
# 图片压缩模式
|
||||
img_mode = data.get("image_compression", base.get("image_compression"))
|
||||
|
||||
@ -20,7 +20,7 @@ from datetime import timedelta
|
||||
import time
|
||||
from datetime import datetime
|
||||
from collections import defaultdict, deque, Counter
|
||||
from config.model_profiles import get_model_profile, get_registered_model_keys
|
||||
from config.model_profiles import get_default_model_key, get_model_profile, get_registered_model_keys
|
||||
from modules import admin_policy_manager, balance_client
|
||||
from modules.custom_tool_registry import CustomToolRegistry
|
||||
import server.state as state # 共享单例
|
||||
@ -468,6 +468,12 @@ async def _generate_title_async(user_message: str) -> Optional[str]:
|
||||
_title_debug_log("skip_empty_user_message")
|
||||
return None
|
||||
client = DeepSeekClient(thinking_mode=False, web_mode=True)
|
||||
try:
|
||||
default_model = get_default_model_key()
|
||||
client.model_key = default_model
|
||||
client.apply_profile(get_model_profile(default_model))
|
||||
except Exception as exc:
|
||||
_title_debug_log("default_title_model_profile_failed", error=str(exc))
|
||||
_title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message)))
|
||||
try:
|
||||
prompt_text = TITLE_PROMPT_PATH.read_text(encoding="utf-8")
|
||||
|
||||
@ -227,7 +227,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
|
||||
data_out["enabled_skills"] = enabled_skills
|
||||
compression_settings = resolve_context_compression_settings(data_out)
|
||||
try:
|
||||
model_window = get_model_context_window(getattr(terminal, "model_key", None) or "kimi-k2.5")
|
||||
model_window = get_model_context_window(getattr(terminal, "model_key", None))
|
||||
except Exception:
|
||||
model_window = None
|
||||
return jsonify({
|
||||
@ -314,7 +314,7 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
|
||||
config_out["enabled_skills"] = enabled_skills
|
||||
compression_settings = resolve_context_compression_settings(config_out)
|
||||
try:
|
||||
model_window = get_model_context_window(getattr(terminal, "model_key", None) or "kimi-k2.5")
|
||||
model_window = get_model_context_window(getattr(terminal, "model_key", None))
|
||||
except Exception:
|
||||
model_window = None
|
||||
return jsonify({
|
||||
|
||||
@ -12,6 +12,7 @@ from config import TITLE_API_BASE_URL, TITLE_API_KEY, TITLE_MODEL_ID
|
||||
from core.web_terminal import WebTerminal
|
||||
from config import LOGS_DIR
|
||||
from utils.api_client import DeepSeekClient
|
||||
from config.model_profiles import get_default_model_key, get_model_profile
|
||||
|
||||
TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug"
|
||||
TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log"
|
||||
@ -65,6 +66,12 @@ async def _generate_title_async(
|
||||
return None
|
||||
|
||||
client = DeepSeekClient(thinking_mode=False, web_mode=True)
|
||||
try:
|
||||
default_model = get_default_model_key()
|
||||
client.model_key = default_model
|
||||
client.apply_profile(get_model_profile(default_model))
|
||||
except Exception as exc:
|
||||
_title_debug_log("default_title_model_profile_failed", error=str(exc))
|
||||
title_base = _env_optional("AGENT_TITLE_API_BASE_URL")
|
||||
title_key = _env_optional("AGENT_TITLE_API_KEY")
|
||||
title_model = _env_optional("AGENT_TITLE_MODEL_ID")
|
||||
|
||||
@ -7,6 +7,8 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config.model_profiles import get_model_profile
|
||||
|
||||
from utils.token_usage import extract_usage_payload
|
||||
|
||||
from .utils_common import debug_log, brief_log, log_backend_chunk
|
||||
from .chat_flow_runner_helpers import extract_intent_from_partial
|
||||
from .chat_flow_task_support import wait_retry_delay, cancel_pending_tools
|
||||
@ -82,8 +84,8 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
|
||||
api_error = chunk.get("error")
|
||||
break
|
||||
|
||||
# 先尝试记录 usage(有些平台会在最后一个 chunk 里携带 usage 但 choices 为空)
|
||||
usage_info = chunk.get("usage")
|
||||
# 尽可能从流式 chunk 的各种位置提取 token usage(不按模型名做特例)
|
||||
usage_info = extract_usage_payload(chunk)
|
||||
if usage_info:
|
||||
last_usage_payload = usage_info
|
||||
|
||||
@ -94,9 +96,6 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
|
||||
debug_log(f"Chunk {chunk_count}: choices为空列表")
|
||||
continue
|
||||
choice = chunk["choices"][0]
|
||||
if not usage_info and isinstance(choice, dict) and choice.get("usage"):
|
||||
# 兼容部分供应商将 usage 放在 choice 内的格式(例如部分 Kimi/Qwen 返回)
|
||||
last_usage_payload = choice.get("usage")
|
||||
delta = choice.get("delta", {})
|
||||
finish_reason = choice.get("finish_reason")
|
||||
if finish_reason:
|
||||
@ -358,16 +357,6 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
|
||||
error_message = f"API 请求失败(HTTP {error_status})"
|
||||
else:
|
||||
error_message = "API 请求失败"
|
||||
# 若命中阿里云配额错误,立即写入状态并切换到官方 API
|
||||
try:
|
||||
from utils.aliyun_fallback import compute_disabled_until, set_disabled_until
|
||||
disabled_until, reason = compute_disabled_until(error_message)
|
||||
if disabled_until and reason:
|
||||
set_disabled_until(getattr(web_terminal, "model_key", None) or "kimi-k2.5", disabled_until, reason)
|
||||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||||
web_terminal.apply_model_profile(profile)
|
||||
except Exception as exc:
|
||||
debug_log(f"处理阿里云配额回退失败: {exc}")
|
||||
can_retry = (
|
||||
api_attempt < max_api_retries
|
||||
and not full_response
|
||||
@ -390,7 +379,7 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
|
||||
})
|
||||
if can_retry:
|
||||
try:
|
||||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||||
profile = get_model_profile(getattr(web_terminal, "model_key", None))
|
||||
web_terminal.apply_model_profile(profile)
|
||||
except Exception as exc:
|
||||
debug_log(f"重试前更新模型配置失败: {exc}")
|
||||
|
||||
@ -1003,13 +1003,13 @@ async def handle_task_with_sender(
|
||||
messages = web_terminal.build_messages(context, message)
|
||||
tools = web_terminal.define_tools()
|
||||
try:
|
||||
profile = get_model_profile(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||||
profile = get_model_profile(getattr(web_terminal, "model_key", None))
|
||||
web_terminal.apply_model_profile(profile)
|
||||
except Exception as exc:
|
||||
debug_log(f"更新模型配置失败: {exc}")
|
||||
|
||||
# === 上下文预算与安全校验(避免超出模型上下文) ===
|
||||
max_context_tokens = get_model_context_window(getattr(web_terminal, "model_key", None) or "kimi-k2.5")
|
||||
max_context_tokens = get_model_context_window(getattr(web_terminal, "model_key", None))
|
||||
current_tokens = web_terminal.context_manager.get_current_context_tokens(conversation_id)
|
||||
# 提前同步给底层客户端,动态收缩 max_tokens
|
||||
web_terminal.api_client.update_context_budget(current_tokens, max_context_tokens)
|
||||
|
||||
@ -110,7 +110,7 @@ export const computed = {
|
||||
},
|
||||
currentModelLabel() {
|
||||
const modelStore = useModelStore();
|
||||
return modelStore.currentModel?.label || 'Kimi-k2.5';
|
||||
return modelStore.currentModel?.label || '未选择模型';
|
||||
},
|
||||
policyUiBlocks() {
|
||||
const store = usePolicyStore();
|
||||
|
||||
@ -24,73 +24,21 @@ interface ModelState {
|
||||
|
||||
export const useModelStore = defineStore('model', {
|
||||
state: (): ModelState => ({
|
||||
currentModelKey: 'kimi-k2.5',
|
||||
models: [
|
||||
{
|
||||
key: 'kimi-k2.5',
|
||||
label: 'Kimi-k2.5',
|
||||
description: '新版 Kimi,支持图文 & 思考开关',
|
||||
multimodal: 'image,video',
|
||||
supportsImage: true,
|
||||
supportsVideo: true,
|
||||
fastOnly: false,
|
||||
supportsThinking: true
|
||||
},
|
||||
{
|
||||
key: 'kimi',
|
||||
label: 'Kimi-k2',
|
||||
description: '综合能力较强',
|
||||
multimodal: 'none',
|
||||
supportsImage: false,
|
||||
supportsVideo: false,
|
||||
fastOnly: false,
|
||||
supportsThinking: true
|
||||
},
|
||||
{
|
||||
key: 'deepseek',
|
||||
label: 'Deepseek-V3.2',
|
||||
description: '数学能力较强',
|
||||
multimodal: 'none',
|
||||
supportsImage: false,
|
||||
supportsVideo: false,
|
||||
fastOnly: false,
|
||||
supportsThinking: true
|
||||
},
|
||||
{
|
||||
key: 'qwen3-vl-plus',
|
||||
label: 'Qwen3.5',
|
||||
description: '图文视频多模态 + 深度思考',
|
||||
multimodal: 'image,video',
|
||||
supportsImage: true,
|
||||
supportsVideo: true,
|
||||
fastOnly: false,
|
||||
supportsThinking: true
|
||||
},
|
||||
{
|
||||
key: 'minimax-m2.5',
|
||||
label: 'MiniMax-M2.5',
|
||||
description: '仅深度思考,超长上下文',
|
||||
multimodal: 'none',
|
||||
supportsImage: false,
|
||||
supportsVideo: false,
|
||||
fastOnly: false,
|
||||
supportsThinking: true,
|
||||
deepOnly: true
|
||||
}
|
||||
]
|
||||
currentModelKey: '',
|
||||
models: []
|
||||
}),
|
||||
getters: {
|
||||
currentModel(state): ModelOption {
|
||||
return state.models.find((m) => m.key === state.currentModelKey) || state.models[0];
|
||||
currentModel(state): ModelOption | null {
|
||||
return state.models.find((m) => m.key === state.currentModelKey) || state.models[0] || null;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setModels(models: ModelOption[]) {
|
||||
if (!Array.isArray(models) || !models.length) return;
|
||||
if (!Array.isArray(models)) return;
|
||||
this.models = models;
|
||||
const exists = this.models.some((m) => m.key === this.currentModelKey);
|
||||
if (!exists && this.models[0]) {
|
||||
this.currentModelKey = this.models[0].key;
|
||||
const exists = this.currentModelKey && this.models.some((m) => m.key === this.currentModelKey);
|
||||
if (!exists) {
|
||||
this.currentModelKey = this.models[0]?.key || '';
|
||||
}
|
||||
},
|
||||
setModel(key: ModelKey) {
|
||||
@ -128,9 +76,7 @@ export const useModelStore = defineStore('model', {
|
||||
deepOnly: !!item.deep_only
|
||||
};
|
||||
});
|
||||
if (mapped.length) {
|
||||
this.setModels(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -118,7 +118,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
default_run_mode: null,
|
||||
default_permission_mode: 'unrestricted',
|
||||
versioning_restore_mode: 'overwrite',
|
||||
default_model: 'kimi-k2.5',
|
||||
default_model: null,
|
||||
image_compression: 'original',
|
||||
auto_shallow_compress_enabled: false,
|
||||
auto_deep_compress_enabled: false,
|
||||
@ -242,11 +242,11 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
}
|
||||
},
|
||||
applyPersonalizationData(data: any) {
|
||||
// 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退为 Kimi
|
||||
// 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退到内置模型
|
||||
const fallbackModel =
|
||||
(this.form && typeof this.form.default_model === 'string'
|
||||
? this.form.default_model
|
||||
: null) || 'kimi-k2.5';
|
||||
: null);
|
||||
const fallbackTheme = this.form?.theme || loadCachedTheme();
|
||||
this.form = {
|
||||
enabled: !!data.enabled,
|
||||
|
||||
@ -53,9 +53,10 @@ class APIClientMultimodalSanitizeTest(unittest.TestCase):
|
||||
sanitized[0],
|
||||
)
|
||||
|
||||
def test_builtin_text_only_model_strips_media_parts(self):
|
||||
def test_profile_text_only_fallback_strips_media_parts(self):
|
||||
client = DeepSeekClient(web_mode=True)
|
||||
client.model_key = "deepseek" # 内置模型: multimodal=none
|
||||
client.model_key = ""
|
||||
client.model_multimodal = "none"
|
||||
|
||||
messages = [
|
||||
{
|
||||
@ -77,9 +78,10 @@ class APIClientMultimodalSanitizeTest(unittest.TestCase):
|
||||
sanitized[0],
|
||||
)
|
||||
|
||||
def test_multimodal_model_keeps_image_parts(self):
|
||||
def test_profile_multimodal_model_keeps_image_parts(self):
|
||||
client = DeepSeekClient(web_mode=True)
|
||||
client.model_key = "qwen3-vl-plus" # 内置多模态模型
|
||||
client.model_key = ""
|
||||
client.model_multimodal = "image,video"
|
||||
|
||||
messages = [
|
||||
{
|
||||
|
||||
42
test/test_token_usage_extractor.py
Normal file
42
test/test_token_usage_extractor.py
Normal file
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.token_usage import extract_usage_payload, normalize_usage_payload
|
||||
|
||||
|
||||
class TokenUsageExtractorTest(unittest.TestCase):
|
||||
def test_normalizes_common_aliases(self):
|
||||
usage = normalize_usage_payload({"input_tokens": 10, "outputTokens": 5})
|
||||
self.assertEqual(usage, {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"current_context_tokens": 10,
|
||||
})
|
||||
|
||||
def test_extracts_top_level_usage(self):
|
||||
usage = extract_usage_payload({"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}})
|
||||
self.assertEqual(usage["total_tokens"], 10)
|
||||
|
||||
def test_extracts_choice_nested_usage(self):
|
||||
usage = extract_usage_payload({
|
||||
"choices": [
|
||||
{"delta": {"usage": {"inputTokens": 9, "outputTokens": 4}}}
|
||||
]
|
||||
})
|
||||
self.assertEqual(usage["prompt_tokens"], 9)
|
||||
self.assertEqual(usage["completion_tokens"], 4)
|
||||
self.assertEqual(usage["total_tokens"], 13)
|
||||
|
||||
def test_extracts_response_metadata_usage(self):
|
||||
usage = extract_usage_payload({
|
||||
"response_metadata": {
|
||||
"token_usage": {"promptTokens": 11, "completionTokens": 6, "totalTokens": 17}
|
||||
}
|
||||
})
|
||||
self.assertEqual(usage["current_context_tokens"], 11)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,103 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
FALLBACK_MODELS = {"qwen3-vl-plus", "kimi-k2.5", "minimax-m2.5"}
|
||||
STATE_PATH = Path(__file__).resolve().parents[1] / "data" / "aliyun_fallback_state.json"
|
||||
|
||||
|
||||
def _read_state() -> Dict:
|
||||
if not STATE_PATH.exists():
|
||||
return {"models": {}}
|
||||
try:
|
||||
data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"models": {}}
|
||||
if not isinstance(data, dict):
|
||||
return {"models": {}}
|
||||
if "models" not in data or not isinstance(data["models"], dict):
|
||||
data["models"] = {}
|
||||
return data
|
||||
|
||||
|
||||
def _write_state(data: Dict) -> None:
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def get_disabled_until(model_key: str) -> Optional[float]:
|
||||
data = _read_state()
|
||||
entry = (data.get("models") or {}).get(model_key) or {}
|
||||
ts = entry.get("disabled_until")
|
||||
try:
|
||||
return float(ts) if ts is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_fallback_active(model_key: str, now_ts: Optional[float] = None) -> bool:
|
||||
if model_key not in FALLBACK_MODELS:
|
||||
return False
|
||||
now_ts = float(now_ts) if now_ts is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
disabled_until = get_disabled_until(model_key)
|
||||
return bool(disabled_until and disabled_until > now_ts)
|
||||
|
||||
|
||||
def set_disabled_until(model_key: str, disabled_until_ts: float, reason: str = "") -> None:
|
||||
if model_key not in FALLBACK_MODELS:
|
||||
return
|
||||
data = _read_state()
|
||||
models = data.setdefault("models", {})
|
||||
models[model_key] = {
|
||||
"disabled_until": float(disabled_until_ts),
|
||||
"reason": reason,
|
||||
"updated_at": datetime.now(tz=timezone.utc).timestamp(),
|
||||
}
|
||||
_write_state(data)
|
||||
|
||||
|
||||
def _next_monday_utc8(now: datetime) -> datetime:
|
||||
# Monday = 0
|
||||
weekday = now.weekday()
|
||||
days_ahead = (7 - weekday) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7
|
||||
target = (now + timedelta(days=days_ahead)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return target
|
||||
|
||||
|
||||
def _next_month_same_day_utc8(now: datetime) -> datetime:
|
||||
year = now.year
|
||||
month = now.month + 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
# clamp day to last day of next month
|
||||
if month == 12:
|
||||
next_month = datetime(year + 1, 1, 1, tzinfo=now.tzinfo)
|
||||
else:
|
||||
next_month = datetime(year, month + 1, 1, tzinfo=now.tzinfo)
|
||||
last_day = (next_month - timedelta(days=1)).day
|
||||
day = min(now.day, last_day)
|
||||
return datetime(year, month, day, 0, 0, 0, tzinfo=now.tzinfo)
|
||||
|
||||
|
||||
def compute_disabled_until(error_text: str) -> Tuple[Optional[float], Optional[str]]:
|
||||
if not error_text:
|
||||
return None, None
|
||||
text = str(error_text).lower()
|
||||
tz8 = timezone(timedelta(hours=8))
|
||||
now = datetime.now(tz=tz8)
|
||||
|
||||
if "hour allocated quota exceeded" in text or "每 5 小时请求额度已用完" in text:
|
||||
until = now + timedelta(hours=5)
|
||||
return until.astimezone(timezone.utc).timestamp(), "hour_quota"
|
||||
if "week allocated quota exceeded" in text or "每周请求额度已用完" in text:
|
||||
until = _next_monday_utc8(now)
|
||||
return until.astimezone(timezone.utc).timestamp(), "week_quota"
|
||||
if "month allocated quota exceeded" in text or "每月请求额度已用完" in text:
|
||||
until = _next_month_same_day_utc8(now)
|
||||
return until.astimezone(timezone.utc).timestamp(), "month_quota"
|
||||
return None, None
|
||||
@ -1,5 +1,5 @@
|
||||
# ========== api_client.py ==========
|
||||
# utils/api_client.py - DeepSeek API 客户端(支持Web模式)- 简化版
|
||||
# utils/api_client.py - OpenAI-compatible API 客户端(支持Web模式)
|
||||
|
||||
import httpx
|
||||
import json
|
||||
@ -40,7 +40,7 @@ except ImportError:
|
||||
THINKING_MODEL_ID
|
||||
)
|
||||
|
||||
class DeepSeekClient:
|
||||
class DeepSeekClient: # legacy name; generic OpenAI-compatible client
|
||||
def __init__(self, thinking_mode: bool = True, web_mode: bool = False):
|
||||
self.fast_api_config = {
|
||||
"base_url": API_BASE_URL,
|
||||
@ -84,39 +84,6 @@ class DeepSeekClient:
|
||||
self.request_dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.debug_log_path = Path(__file__).resolve().parents[1] / "logs" / "api_debug.log"
|
||||
|
||||
def _maybe_mark_aliyun_quota(self, error_text: str) -> None:
|
||||
if not error_text or not self.model_key:
|
||||
return
|
||||
try:
|
||||
from utils.aliyun_fallback import compute_disabled_until, set_disabled_until
|
||||
except Exception:
|
||||
return
|
||||
disabled_until, reason = compute_disabled_until(error_text)
|
||||
if disabled_until and reason:
|
||||
set_disabled_until(self.model_key, disabled_until, reason)
|
||||
# 立即切换到官方 API(仅在有配置时)
|
||||
base_env_key = None
|
||||
key_env_key = None
|
||||
if self.model_key == "kimi-k2.5":
|
||||
base_env_key = "API_BASE_KIMI_OFFICIAL"
|
||||
key_env_key = "API_KEY_KIMI_OFFICIAL"
|
||||
elif self.model_key == "qwen3-vl-plus":
|
||||
base_env_key = "API_BASE_QWEN_OFFICIAL"
|
||||
key_env_key = "API_KEY_QWEN_OFFICIAL"
|
||||
elif self.model_key == "minimax-m2.5":
|
||||
base_env_key = "API_BASE_MINIMAX_OFFICIAL"
|
||||
key_env_key = "API_KEY_MINIMAX_OFFICIAL"
|
||||
if base_env_key and key_env_key:
|
||||
official_base = self._resolve_env_value(base_env_key)
|
||||
official_key = self._resolve_env_value(key_env_key)
|
||||
if official_base and official_key:
|
||||
self.fast_api_config["base_url"] = official_base
|
||||
self.fast_api_config["api_key"] = official_key
|
||||
self.thinking_api_config["base_url"] = official_base
|
||||
self.thinking_api_config["api_key"] = official_key
|
||||
self.api_base_url = official_base
|
||||
self.api_key = official_key
|
||||
|
||||
def _debug_log(self, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
entry = {
|
||||
@ -204,7 +171,6 @@ class DeepSeekClient:
|
||||
videos = videos or []
|
||||
if not images and not videos:
|
||||
return text
|
||||
qwen_video_fps = 2
|
||||
parts: List[Dict[str, Any]] = []
|
||||
extra_videos: List[Any] = []
|
||||
if text:
|
||||
@ -248,8 +214,8 @@ class DeepSeekClient:
|
||||
"type": "video_url",
|
||||
"video_url": {"url": f"data:{mime};base64,{b64}"}
|
||||
}
|
||||
if self.model_key == "qwen3-vl-plus":
|
||||
payload["fps"] = qwen_video_fps
|
||||
if isinstance(item, dict) and item.get("fps") is not None:
|
||||
payload["fps"] = item.get("fps")
|
||||
parts.append(payload)
|
||||
except Exception:
|
||||
continue
|
||||
@ -668,7 +634,7 @@ class DeepSeekClient:
|
||||
stream: bool = True
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""
|
||||
异步调用DeepSeek API
|
||||
异步调用 OpenAI-compatible API
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
@ -679,8 +645,8 @@ class DeepSeekClient:
|
||||
响应内容块
|
||||
"""
|
||||
# 检查API密钥
|
||||
if not self.api_key or self.api_key == "your-deepseek-api-key":
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请在config.py中设置API_KEY")
|
||||
if not self.api_key or self.api_key.startswith("your-"):
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请检查模型配置")
|
||||
return
|
||||
|
||||
# 决定是否使用思考模式
|
||||
@ -721,9 +687,6 @@ class DeepSeekClient:
|
||||
else:
|
||||
max_tokens = min(max_tokens, available)
|
||||
|
||||
lower_base_url = (api_config.get("base_url") or "").lower()
|
||||
is_minimax = self.model_key == "minimax-m2.5" or "minimax" in lower_base_url
|
||||
|
||||
final_messages = self._merge_system_messages(messages)
|
||||
final_messages = self._sanitize_messages_for_model_capability(final_messages)
|
||||
|
||||
@ -732,34 +695,13 @@ class DeepSeekClient:
|
||||
"messages": final_messages,
|
||||
"stream": stream,
|
||||
}
|
||||
if is_minimax:
|
||||
payload["max_completion_tokens"] = max_tokens
|
||||
else:
|
||||
payload["max_tokens"] = max_tokens
|
||||
# 部分平台(如 Qwen、DeepSeek)需要显式请求 usage 才会在流式尾包返回
|
||||
if stream:
|
||||
should_include_usage = False
|
||||
if self.model_key in {"qwen3-max", "qwen3-vl-plus", "deepseek", "minimax-m2.5"}:
|
||||
should_include_usage = True
|
||||
# 兜底:根据 base_url 识别 openai 兼容的提供商
|
||||
if api_config["base_url"]:
|
||||
lower_url = api_config["base_url"].lower()
|
||||
if any(keyword in lower_url for keyword in ["dashscope", "aliyuncs", "deepseek.com"]):
|
||||
should_include_usage = True
|
||||
if should_include_usage:
|
||||
if is_minimax:
|
||||
# MiniMax 流式需要 stream_options.include_usage 才会返回有效 usage
|
||||
payload["include_usage"] = True
|
||||
payload.setdefault("stream_options", {})["include_usage"] = True
|
||||
else:
|
||||
payload.setdefault("stream_options", {})["include_usage"] = True
|
||||
# 注入模型额外参数(如 Qwen enable_thinking)
|
||||
# 注入模型配置中的额外参数
|
||||
extra_params = self.thinking_extra_params if current_thinking_mode else self.fast_extra_params
|
||||
if extra_params:
|
||||
payload.update(extra_params)
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if not is_minimax:
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
# 将本次请求落盘,便于出错时快速定位
|
||||
@ -815,7 +757,6 @@ class DeepSeekClient:
|
||||
self.last_error_info["error_message"] = err.get("message")
|
||||
except Exception:
|
||||
pass
|
||||
self._maybe_mark_aliyun_quota(error_text)
|
||||
self._debug_log({
|
||||
"event": "http_error_stream",
|
||||
"status_code": response.status_code,
|
||||
@ -870,7 +811,6 @@ class DeepSeekClient:
|
||||
self.last_error_info["error_message"] = err.get("message")
|
||||
except Exception:
|
||||
pass
|
||||
self._maybe_mark_aliyun_quota(error_text)
|
||||
self._debug_log({
|
||||
"event": "http_error",
|
||||
"status_code": response.status_code,
|
||||
@ -908,7 +848,6 @@ class DeepSeekClient:
|
||||
"model_id": api_config.get("model_id"),
|
||||
"model_key": self.model_key
|
||||
}
|
||||
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
|
||||
self._debug_log({
|
||||
"event": "connect_error",
|
||||
"status_code": None,
|
||||
@ -933,7 +872,6 @@ class DeepSeekClient:
|
||||
"model_id": api_config.get("model_id"),
|
||||
"model_key": self.model_key
|
||||
}
|
||||
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
|
||||
self._debug_log({
|
||||
"event": "timeout",
|
||||
"status_code": None,
|
||||
@ -958,7 +896,6 @@ class DeepSeekClient:
|
||||
"model_id": api_config.get("model_id"),
|
||||
"model_key": self.model_key
|
||||
}
|
||||
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
|
||||
self._debug_log({
|
||||
"event": "exception",
|
||||
"status_code": None,
|
||||
|
||||
@ -57,6 +57,7 @@ except ImportError:
|
||||
from utils.conversation_manager import ConversationManager
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.media_store import MediaStore
|
||||
from utils.token_usage import normalize_usage_payload
|
||||
|
||||
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
|
||||
AUTO_SHALLOW_TOOL_WHITELIST = {
|
||||
@ -455,14 +456,12 @@ class ContextManager:
|
||||
"""
|
||||
根据模型返回的 usage 字段更新token统计
|
||||
"""
|
||||
try:
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
|
||||
normalized_usage = normalize_usage_payload(usage) or {}
|
||||
prompt_tokens = int(normalized_usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(normalized_usage.get("completion_tokens") or 0)
|
||||
total_tokens = int(normalized_usage.get("total_tokens") or (prompt_tokens + completion_tokens))
|
||||
# 当前上下文长度优先取专用字段;缺失时回退到 prompt_tokens
|
||||
current_context_tokens = int(usage.get("current_context_tokens") or prompt_tokens)
|
||||
except (TypeError, ValueError):
|
||||
prompt_tokens = completion_tokens = total_tokens = current_context_tokens = 0
|
||||
current_context_tokens = int(normalized_usage.get("current_context_tokens") or prompt_tokens)
|
||||
|
||||
try:
|
||||
self._increment_workspace_token_totals(prompt_tokens, completion_tokens, total_tokens)
|
||||
@ -2177,10 +2176,6 @@ class ContextManager:
|
||||
parts: List[Dict[str, Any]] = []
|
||||
extra_videos: List[Any] = []
|
||||
extra_notes: List[str] = []
|
||||
current_model = getattr(getattr(self, "main_terminal", None), "model_key", None)
|
||||
supports_video_fps = current_model == "qwen3-vl-plus"
|
||||
qwen_video_fps = 2
|
||||
|
||||
media_payload_attached = False
|
||||
|
||||
if media_refs:
|
||||
@ -2198,8 +2193,6 @@ class ContextManager:
|
||||
if kind == "video":
|
||||
payload: Dict[str, Any] = {"type": "video_url", "video_url": {"url": data_url}}
|
||||
fps_value = ref.get("fps")
|
||||
if supports_video_fps and fps_value is None:
|
||||
fps_value = qwen_video_fps
|
||||
if fps_value is not None:
|
||||
payload["fps"] = fps_value
|
||||
parts.append(payload)
|
||||
@ -2269,8 +2262,6 @@ class ContextManager:
|
||||
data_url = f"data:{mime};base64,{b64}"
|
||||
payload = {"type": "video_url", "video_url": {"url": data_url}}
|
||||
fps_value = item.get("fps") if isinstance(item, dict) else None
|
||||
if supports_video_fps and fps_value is None:
|
||||
fps_value = qwen_video_fps
|
||||
if fps_value is not None:
|
||||
payload["fps"] = fps_value
|
||||
parts.append(payload)
|
||||
|
||||
149
utils/token_usage.py
Normal file
149
utils/token_usage.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Token usage extraction helpers.
|
||||
|
||||
The project intentionally avoids model/provider-name special cases. These helpers
|
||||
normalize common OpenAI-compatible and provider-specific response shapes by
|
||||
looking for usage-like payloads in known response locations and field aliases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
|
||||
INPUT_TOKEN_KEYS = (
|
||||
"prompt_tokens",
|
||||
"input_tokens",
|
||||
"inputTokens",
|
||||
"promptTokens",
|
||||
"prefill_tokens",
|
||||
)
|
||||
OUTPUT_TOKEN_KEYS = (
|
||||
"completion_tokens",
|
||||
"output_tokens",
|
||||
"outputTokens",
|
||||
"completionTokens",
|
||||
"generated_tokens",
|
||||
"generatedTokens",
|
||||
)
|
||||
TOTAL_TOKEN_KEYS = (
|
||||
"total_tokens",
|
||||
"totalTokens",
|
||||
"total_token_count",
|
||||
"totalTokenCount",
|
||||
)
|
||||
CURRENT_CONTEXT_KEYS = (
|
||||
"current_context_tokens",
|
||||
"currentContextTokens",
|
||||
"context_tokens",
|
||||
"contextTokens",
|
||||
)
|
||||
KNOWN_CONTAINER_KEYS = {
|
||||
"usage",
|
||||
"token_usage",
|
||||
"tokenUsage",
|
||||
"token_usages",
|
||||
"response_metadata",
|
||||
"responseMetadata",
|
||||
"metadata",
|
||||
"meta",
|
||||
}
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number >= 0 else None
|
||||
|
||||
|
||||
def _first_int(payload: Dict[str, Any], keys: Iterable[str]) -> Optional[int]:
|
||||
for key in keys:
|
||||
if key in payload:
|
||||
value = _to_int(payload.get(key))
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def normalize_usage_payload(raw: Any) -> Optional[Dict[str, int]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
prompt_tokens = _first_int(raw, INPUT_TOKEN_KEYS)
|
||||
completion_tokens = _first_int(raw, OUTPUT_TOKEN_KEYS)
|
||||
total_tokens = _first_int(raw, TOTAL_TOKEN_KEYS)
|
||||
current_context_tokens = _first_int(raw, CURRENT_CONTEXT_KEYS)
|
||||
|
||||
prompt_details = raw.get("prompt_tokens_details") or raw.get("input_tokens_details")
|
||||
if isinstance(prompt_details, dict):
|
||||
cached = _first_int(prompt_details, ("cached_tokens", "cachedTokens"))
|
||||
# cached tokens are still part of prompt tokens in most APIs. Keep the
|
||||
# detail accessible for callers that need it, but do not add it again.
|
||||
completion_details = raw.get("completion_tokens_details") or raw.get("output_tokens_details")
|
||||
if isinstance(completion_details, dict):
|
||||
reasoning = _first_int(completion_details, ("reasoning_tokens", "reasoningTokens"))
|
||||
if completion_tokens is None and reasoning is not None:
|
||||
completion_tokens = reasoning
|
||||
|
||||
if prompt_tokens is None and completion_tokens is None and total_tokens is None:
|
||||
return None
|
||||
|
||||
if prompt_tokens is None:
|
||||
prompt_tokens = max(0, (total_tokens or 0) - (completion_tokens or 0)) if total_tokens is not None else 0
|
||||
if completion_tokens is None:
|
||||
completion_tokens = max(0, (total_tokens or 0) - prompt_tokens) if total_tokens is not None else 0
|
||||
if total_tokens is None:
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
if current_context_tokens is None:
|
||||
current_context_tokens = prompt_tokens
|
||||
|
||||
return {
|
||||
"prompt_tokens": int(prompt_tokens),
|
||||
"completion_tokens": int(completion_tokens),
|
||||
"total_tokens": int(total_tokens),
|
||||
"current_context_tokens": int(current_context_tokens),
|
||||
}
|
||||
|
||||
|
||||
def _usage_score(payload: Dict[str, int]) -> int:
|
||||
return int(payload.get("total_tokens", 0)) + int(payload.get("prompt_tokens", 0)) + int(payload.get("completion_tokens", 0))
|
||||
|
||||
|
||||
def extract_usage_payload(obj: Any) -> Optional[Dict[str, int]]:
|
||||
"""Find and normalize the best token usage payload in a response chunk/object."""
|
||||
best: Optional[Dict[str, int]] = None
|
||||
|
||||
def consider(value: Any) -> None:
|
||||
nonlocal best
|
||||
normalized = normalize_usage_payload(value)
|
||||
if not normalized:
|
||||
return
|
||||
if best is None or _usage_score(normalized) >= _usage_score(best):
|
||||
best = normalized
|
||||
|
||||
def walk(value: Any, *, depth: int = 0, in_known_container: bool = False) -> None:
|
||||
if depth > 8:
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
if in_known_container:
|
||||
consider(value)
|
||||
else:
|
||||
# Also accept dicts that directly look like usage payloads.
|
||||
consider(value)
|
||||
for key, child in value.items():
|
||||
child_known = in_known_container or key in KNOWN_CONTAINER_KEYS
|
||||
if key in KNOWN_CONTAINER_KEYS:
|
||||
consider(child)
|
||||
walk(child, depth=depth + 1, in_known_container=child_known)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child, depth=depth + 1, in_known_container=in_known_container)
|
||||
|
||||
walk(obj)
|
||||
return best
|
||||
|
||||
|
||||
__all__ = ["extract_usage_payload", "normalize_usage_payload"]
|
||||
Loading…
Reference in New Issue
Block a user