From eb00a3522a20c04ee2f8022aae31af0742e35ea3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 29 May 2026 00:20:54 +0800 Subject: [PATCH] refactor(models): use dynamic api model registry --- README.md | 126 +------ config/api.py | 4 +- config/model_profiles.py | 359 ++++---------------- config/ocr.py | 17 +- core/main_terminal.py | 4 +- core/main_terminal_parts/commands.py | 2 + core/main_terminal_parts/context.py | 6 +- core/web_terminal.py | 2 +- docs/analysis-conversation-loading.md | 2 +- docs/cli_slash_commands_spec.md | 4 +- modules/approval_agent.py | 11 +- modules/ocr_client.py | 10 +- modules/personalization_manager.py | 9 +- server/app_legacy.py | 8 +- server/chat.py | 4 +- server/chat_flow_helpers.py | 7 + server/chat_flow_stream_loop.py | 21 +- server/chat_flow_task_main.py | 4 +- static/src/app/computed.ts | 2 +- static/src/stores/model.ts | 72 +--- static/src/stores/personalization.ts | 6 +- test/test_api_client_multimodal_sanitize.py | 10 +- test/test_token_usage_extractor.py | 42 +++ utils/aliyun_fallback.py | 103 ------ utils/api_client.py | 83 +---- utils/context_manager.py | 23 +- utils/token_usage.py | 149 ++++++++ 27 files changed, 355 insertions(+), 735 deletions(-) create mode 100644 test/test_token_usage_extractor.py delete mode 100644 utils/aliyun_fallback.py create mode 100644 utils/token_usage.py diff --git a/README.md b/README.md index 92babe2..26cf778 100644 --- a/README.md +++ b/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) diff --git a/config/api.py b/config/api.py index eeb86c6..88cdad6 100644 --- a/config/api.py +++ b/config/api.py @@ -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) diff --git a/config/model_profiles.py b/config/model_profiles.py index 694b152..dc2c5e9 100644 --- a/config/model_profiles.py +++ b/config/model_profiles.py @@ -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,28 +262,16 @@ 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: - return { - "model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "", - "thinking_model_line": "", - "deep_thinking_line": "", - } + 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 "", - "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 "" + "model_description": model_description, + "thinking_model_line": "", + "deep_thinking_line": "", } diff --git a/config/ocr.py b/config/ocr.py index be567a8..dd582fb 100644 --- a/config/ocr.py +++ b/config/ocr.py @@ -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", diff --git a/core/main_terminal.py b/core/main_terminal.py index 1bedb4b..7b18cd1 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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)) diff --git a/core/main_terminal_parts/commands.py b/core/main_terminal_parts/commands.py index c009d1e..f9313d7 100644 --- a/core/main_terminal_parts/commands.py +++ b/core/main_terminal_parts/commands.py @@ -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("当前对话包含图片,目标模型不支持图片输入") diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index 527c05e..d6d0d81 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -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, diff --git a/core/web_terminal.py b/core/web_terminal.py index d51dee6..fe150ae 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -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: diff --git a/docs/analysis-conversation-loading.md b/docs/analysis-conversation-loading.md index b6828b5..ca4e054 100644 --- a/docs/analysis-conversation-loading.md +++ b/docs/analysis-conversation-loading.md @@ -185,7 +185,7 @@ async fetchAndDisplayHistory(options = {}) { } ], "metadata": { - "model_key": "deepseek-chat" + "model_key": "示例模型" } }, { diff --git a/docs/cli_slash_commands_spec.md b/docs/cli_slash_commands_spec.md index 541fbb8..21723df 100644 --- a/docs/cli_slash_commands_spec.md +++ b/docs/cli_slash_commands_spec.md @@ -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 │ diff --git a/modules/approval_agent.py b/modules/approval_agent.py index bbff24c..ac6fa01 100644 --- a/modules/approval_agent.py +++ b/modules/approval_agent.py @@ -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", diff --git a/modules/ocr_client.py b/modules/ocr_client.py index 23a1b4d..a5e103a 100644 --- a/modules/ocr_client.py +++ b/modules/ocr_client.py @@ -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() diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index cc58b56..274fb6c 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -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")) diff --git a/server/app_legacy.py b/server/app_legacy.py index f98a7ec..1e25390 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -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") diff --git a/server/chat.py b/server/chat.py index 91c494f..6d6f19a 100644 --- a/server/chat.py +++ b/server/chat.py @@ -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({ diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index c5b09e7..d04128e 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -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") diff --git a/server/chat_flow_stream_loop.py b/server/chat_flow_stream_loop.py index 5820ecf..0d1b146 100644 --- a/server/chat_flow_stream_loop.py +++ b/server/chat_flow_stream_loop.py @@ -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}") diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index d3e32cf..7d184c4 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -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) diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 261f0cb..0a1d71b 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -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(); diff --git a/static/src/stores/model.ts b/static/src/stores/model.ts index ee5fbb3..d065f2b 100644 --- a/static/src/stores/model.ts +++ b/static/src/stores/model.ts @@ -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); - } + this.setModels(mapped); } } }); diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 05e46bd..b4d58fa 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -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, diff --git a/test/test_api_client_multimodal_sanitize.py b/test/test_api_client_multimodal_sanitize.py index fdeffe4..541dafe 100644 --- a/test/test_api_client_multimodal_sanitize.py +++ b/test/test_api_client_multimodal_sanitize.py @@ -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 = [ { diff --git a/test/test_token_usage_extractor.py b/test/test_token_usage_extractor.py new file mode 100644 index 0000000..129a303 --- /dev/null +++ b/test/test_token_usage_extractor.py @@ -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() diff --git a/utils/aliyun_fallback.py b/utils/aliyun_fallback.py deleted file mode 100644 index 40f9a9d..0000000 --- a/utils/aliyun_fallback.py +++ /dev/null @@ -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 diff --git a/utils/api_client.py b/utils/api_client.py index 9ecb330..cb72ea8 100644 --- a/utils/api_client.py +++ b/utils/api_client.py @@ -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,35 +695,14 @@ 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) + payload["max_tokens"] = max_tokens + # 注入模型配置中的额外参数 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" + payload["tool_choice"] = "auto" # 将本次请求落盘,便于出错时快速定位 try: @@ -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, diff --git a/utils/context_manager.py b/utils/context_manager.py index e9c7b35..978f0c1 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -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)) - # 当前上下文长度优先取专用字段;缺失时回退到 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 + 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(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) diff --git a/utils/token_usage.py b/utils/token_usage.py new file mode 100644 index 0000000..1364725 --- /dev/null +++ b/utils/token_usage.py @@ -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"]