refactor(models): use dynamic api model registry

This commit is contained in:
JOJO 2026-05-29 00:20:54 +08:00
parent 8da51b3b68
commit eb00a3522a
27 changed files with 355 additions and 735 deletions

126
README.md
View File

@ -443,135 +443,19 @@ static/src/
## 模型支持 ## 模型支持
系统支持多个 AI 模型提供商: 系统不再内置任何固定供应商模型。可用模型由 `config/custom_models.json`(或后续 API 扩展配置)动态注册,前后端均通过 `/api/v1/models` 获取当前模型列表。
### Kimi (月之暗面) 每个模型配置应提供 API 地址、密钥、模型 ID、上下文窗口、输出 token 上限、多模态能力和思考模式参数等信息;需要额外请求参数时,通过配置中的 `extra_parameter` / `thinkmode_status.*_extra_parameter` 注入。
- `kimi-k2`: K2 系列模型
- `kimi-k2.5`: K2.5 最新模型
- 支持 256K 上下文窗口
### 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` 文件): 主要配置项(通过环境变量或 `.env` 文件):
### API 配置 ### API 配置
- `API_BASE_KIMI`: Kimi API 地址 - `config/custom_models.json`: 动态模型注册文件
- `API_KEY_KIMI`: Kimi API 密钥 - `AGENT_DEFAULT_MODEL`: 可选,指定默认模型 key未设置时使用第一个可见动态模型
- `API_BASE_DEEPSEEK`: DeepSeek API 地址 - 模型配置中的 `url` / `apikey` 支持 `${ENV_NAME}`、`env:ENV_NAME`、`$ENV_NAME` 等环境变量引用
- `API_KEY_DEEPSEEK`: DeepSeek API 密钥
### 服务配置 ### 服务配置
- `WEB_SERVER_PORT`: Web 服务器端口(默认 8091 - `WEB_SERVER_PORT`: Web 服务器端口(默认 8091

View File

@ -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_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
API_KEY = _env("AGENT_API_KEY", required=True) API_KEY = _env("AGENT_API_KEY", "")
MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-chat") MODEL_ID = _env("AGENT_MODEL_ID", "")
# 推理模型配置(智能思考模式使用) # 推理模型配置(智能思考模式使用)
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL) THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)

View File

@ -3,8 +3,6 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional 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]: def _env_optional(name: str) -> Optional[str]:
value = os.environ.get(name) value = os.environ.get(name)
@ -29,270 +27,6 @@ def _env_optional(name: str) -> Optional[str]:
return value or None 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.2deepseek-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: def _normalize_multimodal(value: Any) -> str:
text = str(value or "none").strip().lower() text = str(value or "none").strip().lower()
if text in {"image", "video", "none", "image,video", "video,image"}: if text in {"image", "video", "none", "image,video", "video,image"}:
@ -326,6 +60,13 @@ def _parse_env_ref(raw: Any) -> Optional[str]:
return text 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]: def _reasoning_flags(capability: str) -> Dict[str, bool]:
normalized = str(capability or "fast").replace(" ", "").lower() normalized = str(capability or "fast").replace(" ", "").lower()
if normalized == "thinking": 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")) api_key = _parse_env_ref(item.get("apikey"))
if not base_url or not api_key: if not base_url or not api_key:
return None return None
context_window = item.get("context_window")
max_output_tokens = item.get("max_output_tokens") context_window = _to_optional_int(item.get("context_window"))
try: max_output_tokens = _to_optional_int(item.get("max_output_tokens"))
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
capability = str(item.get("reasoning_capability") or "fast").replace(" ", "").lower() capability = str(item.get("reasoning_capability") or "fast").replace(" ", "").lower()
flags = _reasoning_flags(capability) 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() 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 {} 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 {} 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 return None
fast_config = { fast_config = {
"base_url": base_url, "base_url": base_url.rstrip("/"),
"api_key": api_key, "api_key": api_key,
"model_id": fast_model_id, "model_id": fast_model_id,
"max_tokens": max_output_tokens, "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 {})}, "extra_params": {**extra_global, **(fast_extra or {})},
} }
profile: Dict[str, Any] = { profile: Dict[str, Any] = {
"name": key, "name": str(item.get("display_name") or key),
"description": str(item.get("description") or ""), "description": str(item.get("description") or ""),
"model_description": str(item.get("model_description") or ""), "model_description": str(item.get("model_description") or ""),
"is_custom_model": True, "is_custom_model": True,
@ -413,7 +147,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
profile["deep_only"] = True profile["deep_only"] = True
if flags["supports_thinking"]: if flags["supports_thinking"]:
profile["thinking"] = { profile["thinking"] = {
"base_url": base_url, "base_url": base_url.rstrip("/"),
"api_key": api_key, "api_key": api_key,
"model_id": thinking_model_id or fast_model_id, "model_id": thinking_model_id or fast_model_id,
"max_tokens": max_output_tokens, "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]]: def get_registered_model_profiles() -> Dict[str, Dict[str, Any]]:
merged = dict(MODEL_PROFILES) """返回 API 扩展注册的模型。项目不再内置任何供应商模型。"""
merged.update(_load_custom_models()) # 同名覆盖内置,不报错 return _load_custom_models()
return merged
def get_registered_model_keys(visible_only: bool = False) -> List[str]: 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 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]: def get_model_capabilities(key: str) -> Dict[str, Any]:
profile = get_model_profile(key) profile = get_model_profile(key)
multimodal = _normalize_multimodal(profile.get("multimodal")) 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: def get_model_prompt_replacements(key: str) -> dict:
"""获取模型相关的提示词替换字段,若缺失则回退到 Kimi 版本。""" """获取模型相关提示词字段。仅使用 API 扩展配置中的描述,不做模型名硬编码。"""
fallback = MODEL_PROMPT_OVERRIDES.get("kimi", {})
overrides = MODEL_PROMPT_OVERRIDES.get(key) or {}
custom_model_description = ""
is_custom_model = False
try: try:
custom_profile = get_registered_model_profiles().get(key) or {} profile = get_registered_model_profiles().get(resolve_model_key(key)) or {}
custom_model_description = str(custom_profile.get("model_description") or "")
is_custom_model = bool(custom_profile.get("is_custom_model"))
except Exception: except Exception:
custom_model_description = "" profile = {}
is_custom_model = False model_description = str(profile.get("model_description") or profile.get("description") or "")
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": "",
}
return { return {
"model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "", "model_description": model_description,
"thinking_model_line": overrides.get("thinking_model_line") or fallback.get("thinking_model_line") or "", "thinking_model_line": "",
"deep_thinking_line": overrides.get("deep_thinking_line") or fallback.get("deep_thinking_line") or "" "deep_thinking_line": "",
} }

View File

@ -1,9 +1,16 @@
"""OCR 配置DeepSeek-OCR 接口信息""" """OCR / VLM 配置。"""
OCR_API_BASE_URL = "https://api.siliconflow.cn" import os
OCR_API_KEY = "sk-suqqgewtlwajjkylvnotdhkzmsrshmrqptkakdqjmlrilaes"
OCR_MODEL_ID = "Qwen/Qwen3-VL-30B-A3B-Thinking"
OCR_MAX_TOKENS = 200 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__ = [ __all__ = [
"OCR_API_BASE_URL", "OCR_API_BASE_URL",

View File

@ -64,7 +64,7 @@ from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager from utils.context_manager import ContextManager
from utils.conversation_manager import ConversationManager from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug 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 ( from core.main_terminal_parts import (
MainTerminalCommandMixin, MainTerminalCommandMixin,
@ -101,7 +101,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.api_client = DeepSeekClient(thinking_mode=self.thinking_mode) self.api_client = DeepSeekClient(thinking_mode=self.thinking_mode)
self.api_client.project_path = project_path self.api_client.project_path = project_path
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode) 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.model_profile = get_model_profile(self.model_key)
self.apply_model_profile(self.model_profile) self.apply_model_profile(self.model_profile)
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir)) self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))

View File

@ -82,6 +82,7 @@ from config.model_profiles import (
get_model_profile, get_model_profile,
get_model_prompt_replacements, get_model_prompt_replacements,
get_model_context_window, get_model_context_window,
resolve_model_key,
model_supports_image, model_supports_image,
model_supports_video, model_supports_video,
) )
@ -795,6 +796,7 @@ class MainTerminalCommandMixin:
self.api_client.apply_profile(profile) self.api_client.apply_profile(profile)
def set_model(self, model_key: str) -> str: def set_model(self, model_key: str) -> str:
model_key = resolve_model_key(model_key)
profile = get_model_profile(model_key) profile = get_model_profile(model_key)
if getattr(self.context_manager, "has_images", False) and not model_supports_image(model_key): if getattr(self.context_manager, "has_images", False) and not model_supports_image(model_key):
raise ValueError("当前对话包含图片,目标模型不支持图片输入") raise ValueError("当前对话包含图片,目标模型不支持图片输入")

View File

@ -298,8 +298,8 @@ class MainTerminalContextMixin:
) )
except Exception: except Exception:
pass 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" 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) system_prompt = self.load_prompt(prompt_name)
@ -308,7 +308,7 @@ class MainTerminalContextMixin:
container_cpus = self.container_cpu_limit container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit container_memory = self.container_memory_limit
project_storage = self.project_storage_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) prompt_replacements = get_model_prompt_replacements(model_key)
system_prompt = system_prompt.format( system_prompt = system_prompt.format(
project_path=container_path, project_path=container_path,

View File

@ -123,7 +123,7 @@ class WebTerminal(MainTerminal):
# 新对话视为“干净”会话,清除图片限制便于切换模型 # 新对话视为“干净”会话,清除图片限制便于切换模型
self.context_manager.has_images = False self.context_manager.has_images = False
preferred_model = prefs.get("default_model") or "kimi" preferred_model = prefs.get("default_model")
try: try:
self.set_model(preferred_model) self.set_model(preferred_model)
except Exception as exc: except Exception as exc:

View File

@ -185,7 +185,7 @@ async fetchAndDisplayHistory(options = {}) {
} }
], ],
"metadata": { "metadata": {
"model_key": "deepseek-chat" "model_key": "示例模型"
} }
}, },
{ {

View File

@ -266,7 +266,7 @@ Resume conversation
```text ```text
Model Model
1. kimi-k2.5 当前模型 1. 示例模型 当前模型
2. gpt-5.5 高质量编码 2. gpt-5.5 高质量编码
3. gpt-5.4-mini 快速低成本 3. gpt-5.4-mini 快速低成本
@ -598,7 +598,7 @@ skill: docs
╭────────────────────────────────────────────────────────────────────╮ ╭────────────────────────────────────────────────────────────────────╮
│ >_ Agents CLI │ │ >_ Agents CLI │
│ │ │ │
│ Model: kimi-k2.5 │ Model: 示例模型
│ Directory: ~/Desktop/agents/正在修复中/agents │ │ Directory: ~/Desktop/agents/正在修复中/agents │
│ Workspace: agents │ │ Workspace: agents │
│ Permissions: auto_approval │ │ Permissions: auto_approval │

View File

@ -31,7 +31,7 @@ def load_approval_agent_config() -> Dict[str, Any]:
"name": "auto-approval-agent", "name": "auto-approval-agent",
"url": "", "url": "",
"key": "", "key": "",
"model": "deepseek-v4-flash", "model": "",
"extra_params": {}, "extra_params": {},
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS, "timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
"max_rounds": DEFAULT_MAX_ROUNDS, "max_rounds": DEFAULT_MAX_ROUNDS,
@ -45,7 +45,7 @@ def load_approval_agent_config() -> Dict[str, Any]:
base.update(raw) base.update(raw)
base["url"] = _resolve_env_token(base.get("url", "")) base["url"] = _resolve_env_token(base.get("url", ""))
base["key"] = _resolve_env_token(base.get("key", "")) 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["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["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)) 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() url = str(self.cfg.get("url") or "").strip()
key = str(self.cfg.get("key") 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"} out = {"decision": "rejected", "reason": "审批智能体配置缺失", "source": "approval_agent"}
_flush_trace(out) _flush_trace(out)
return out return out
@ -203,7 +204,7 @@ class ApprovalAgent:
if progress_cb: if progress_cb:
progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"}) progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"})
req = { req = {
"model": self.cfg.get("model"), "model": model,
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"tool_choice": "auto", "tool_choice": "auto",
@ -224,7 +225,7 @@ class ApprovalAgent:
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params # 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params
if extra_params: if extra_params:
retry_req = { retry_req = {
"model": self.cfg.get("model"), "model": model,
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"tool_choice": "auto", "tool_choice": "auto",

View File

@ -13,7 +13,7 @@ from modules.file_manager import FileManager
class OCRClient: class OCRClient:
"""封装 VLM(如 DeepSeek-OCR / Qwen3.5调用逻辑。""" """封装外部 VLM 调用逻辑。"""
def __init__(self, project_path: str, file_manager: FileManager): def __init__(self, project_path: str, file_manager: FileManager):
self.project_path = Path(project_path).resolve() self.project_path = Path(project_path).resolve()
@ -21,17 +21,17 @@ class OCRClient:
# 补全 base_url兼容是否包含 /v1 # 补全 base_url兼容是否包含 /v1
base_url = (OCR_API_BASE_URL or "").rstrip("/") 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" base_url = f"{base_url}/v1"
# httpx 0.28 起不再支持 proxies 参数,显式传入 http_client 以避免默认封装报错 # httpx 0.28 起不再支持 proxies 参数,显式传入 http_client 以避免默认封装报错
self.http_client = httpx.Client() self.http_client = httpx.Client()
self.client = OpenAI( self.client = OpenAI(
api_key=OCR_API_KEY, api_key=OCR_API_KEY,
base_url=base_url, base_url=base_url or None,
http_client=self.http_client, 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 self.max_tokens = OCR_MAX_TOKENS or 4096
# 默认大小上限10MB超出则警告并拒绝 # 默认大小上限10MB超出则警告并拒绝
@ -58,6 +58,8 @@ class OCRClient:
if not prompt or not str(prompt).strip(): if not prompt or not str(prompt).strip():
return {"success": False, "error": "prompt 不能为空", "warnings": warnings} 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: try:
data = full_path.read_bytes() data = full_path.read_bytes()

View File

@ -13,7 +13,7 @@ except ImportError:
THINKING_FAST_INTERVAL = 10 THINKING_FAST_INTERVAL = 10
from core.tool_config import TOOL_CATEGORIES 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_RUN_MODES = {"fast", "thinking", "deep"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} 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_sub_agent_enabled": False, # 强约束:子智能体系列工具需先阅读 sub-agent-guide
"skill_strict_run_command_foreground_enabled": False, # 强约束run_command 前台模式需先阅读 run-command-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 "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 "image_compression": "original", # original / 1080p / 720p / 540p
"auto_shallow_compress_enabled": False, "auto_shallow_compress_enabled": False,
"auto_deep_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: if isinstance(chosen_model, str) and chosen_model in allowed_models:
base["default_model"] = chosen_model base["default_model"] = chosen_model
elif allowed_models and base.get("default_model") not in allowed_models: 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")) img_mode = data.get("image_compression", base.get("image_compression"))

View File

@ -20,7 +20,7 @@ from datetime import timedelta
import time import time
from datetime import datetime from datetime import datetime
from collections import defaultdict, deque, Counter 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 import admin_policy_manager, balance_client
from modules.custom_tool_registry import CustomToolRegistry from modules.custom_tool_registry import CustomToolRegistry
import server.state as state # 共享单例 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") _title_debug_log("skip_empty_user_message")
return None return None
client = DeepSeekClient(thinking_mode=False, web_mode=True) 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))) _title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message)))
try: try:
prompt_text = TITLE_PROMPT_PATH.read_text(encoding="utf-8") prompt_text = TITLE_PROMPT_PATH.read_text(encoding="utf-8")

View File

@ -227,7 +227,7 @@ def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace
data_out["enabled_skills"] = enabled_skills data_out["enabled_skills"] = enabled_skills
compression_settings = resolve_context_compression_settings(data_out) compression_settings = resolve_context_compression_settings(data_out)
try: 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: except Exception:
model_window = None model_window = None
return jsonify({ return jsonify({
@ -314,7 +314,7 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
config_out["enabled_skills"] = enabled_skills config_out["enabled_skills"] = enabled_skills
compression_settings = resolve_context_compression_settings(config_out) compression_settings = resolve_context_compression_settings(config_out)
try: 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: except Exception:
model_window = None model_window = None
return jsonify({ return jsonify({

View File

@ -12,6 +12,7 @@ from config import TITLE_API_BASE_URL, TITLE_API_KEY, TITLE_MODEL_ID
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from config import LOGS_DIR from config import LOGS_DIR
from utils.api_client import DeepSeekClient 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_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug"
TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log" TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log"
@ -65,6 +66,12 @@ async def _generate_title_async(
return None return None
client = DeepSeekClient(thinking_mode=False, web_mode=True) 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_base = _env_optional("AGENT_TITLE_API_BASE_URL")
title_key = _env_optional("AGENT_TITLE_API_KEY") title_key = _env_optional("AGENT_TITLE_API_KEY")
title_model = _env_optional("AGENT_TITLE_MODEL_ID") title_model = _env_optional("AGENT_TITLE_MODEL_ID")

View File

@ -7,6 +7,8 @@ from typing import Any, Dict, Optional
from config.model_profiles import get_model_profile 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 .utils_common import debug_log, brief_log, log_backend_chunk
from .chat_flow_runner_helpers import extract_intent_from_partial from .chat_flow_runner_helpers import extract_intent_from_partial
from .chat_flow_task_support import wait_retry_delay, cancel_pending_tools 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") api_error = chunk.get("error")
break break
# 先尝试记录 usage有些平台会在最后一个 chunk 里携带 usage 但 choices 为空 # 尽可能从流式 chunk 的各种位置提取 token usage不按模型名做特例
usage_info = chunk.get("usage") usage_info = extract_usage_payload(chunk)
if usage_info: if usage_info:
last_usage_payload = 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为空列表") debug_log(f"Chunk {chunk_count}: choices为空列表")
continue continue
choice = chunk["choices"][0] 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", {}) delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason") finish_reason = choice.get("finish_reason")
if 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}" error_message = f"API 请求失败HTTP {error_status}"
else: else:
error_message = "API 请求失败" 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 = ( can_retry = (
api_attempt < max_api_retries api_attempt < max_api_retries
and not full_response and not full_response
@ -390,7 +379,7 @@ async def run_streaming_attempts(*, web_terminal, messages, tools, sender, clien
}) })
if can_retry: if can_retry:
try: 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) web_terminal.apply_model_profile(profile)
except Exception as exc: except Exception as exc:
debug_log(f"重试前更新模型配置失败: {exc}") debug_log(f"重试前更新模型配置失败: {exc}")

View File

@ -1003,13 +1003,13 @@ async def handle_task_with_sender(
messages = web_terminal.build_messages(context, message) messages = web_terminal.build_messages(context, message)
tools = web_terminal.define_tools() tools = web_terminal.define_tools()
try: 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) web_terminal.apply_model_profile(profile)
except Exception as exc: except Exception as exc:
debug_log(f"更新模型配置失败: {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) current_tokens = web_terminal.context_manager.get_current_context_tokens(conversation_id)
# 提前同步给底层客户端,动态收缩 max_tokens # 提前同步给底层客户端,动态收缩 max_tokens
web_terminal.api_client.update_context_budget(current_tokens, max_context_tokens) web_terminal.api_client.update_context_budget(current_tokens, max_context_tokens)

View File

@ -110,7 +110,7 @@ export const computed = {
}, },
currentModelLabel() { currentModelLabel() {
const modelStore = useModelStore(); const modelStore = useModelStore();
return modelStore.currentModel?.label || 'Kimi-k2.5'; return modelStore.currentModel?.label || '未选择模型';
}, },
policyUiBlocks() { policyUiBlocks() {
const store = usePolicyStore(); const store = usePolicyStore();

View File

@ -24,73 +24,21 @@ interface ModelState {
export const useModelStore = defineStore('model', { export const useModelStore = defineStore('model', {
state: (): ModelState => ({ state: (): ModelState => ({
currentModelKey: 'kimi-k2.5', currentModelKey: '',
models: [ 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
}
]
}), }),
getters: { getters: {
currentModel(state): ModelOption { currentModel(state): ModelOption | null {
return state.models.find((m) => m.key === state.currentModelKey) || state.models[0]; return state.models.find((m) => m.key === state.currentModelKey) || state.models[0] || null;
} }
}, },
actions: { actions: {
setModels(models: ModelOption[]) { setModels(models: ModelOption[]) {
if (!Array.isArray(models) || !models.length) return; if (!Array.isArray(models)) return;
this.models = models; this.models = models;
const exists = this.models.some((m) => m.key === this.currentModelKey); const exists = this.currentModelKey && this.models.some((m) => m.key === this.currentModelKey);
if (!exists && this.models[0]) { if (!exists) {
this.currentModelKey = this.models[0].key; this.currentModelKey = this.models[0]?.key || '';
} }
}, },
setModel(key: ModelKey) { setModel(key: ModelKey) {
@ -128,9 +76,7 @@ export const useModelStore = defineStore('model', {
deepOnly: !!item.deep_only deepOnly: !!item.deep_only
}; };
}); });
if (mapped.length) { this.setModels(mapped);
this.setModels(mapped);
}
} }
} }
}); });

View File

@ -118,7 +118,7 @@ const defaultForm = (): PersonalForm => ({
default_run_mode: null, default_run_mode: null,
default_permission_mode: 'unrestricted', default_permission_mode: 'unrestricted',
versioning_restore_mode: 'overwrite', versioning_restore_mode: 'overwrite',
default_model: 'kimi-k2.5', default_model: null,
image_compression: 'original', image_compression: 'original',
auto_shallow_compress_enabled: false, auto_shallow_compress_enabled: false,
auto_deep_compress_enabled: false, auto_deep_compress_enabled: false,
@ -242,11 +242,11 @@ export const usePersonalizationStore = defineStore('personalization', {
} }
}, },
applyPersonalizationData(data: any) { applyPersonalizationData(data: any) {
// 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退为 Kimi // 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退到内置模型
const fallbackModel = const fallbackModel =
(this.form && typeof this.form.default_model === 'string' (this.form && typeof this.form.default_model === 'string'
? this.form.default_model ? this.form.default_model
: null) || 'kimi-k2.5'; : null);
const fallbackTheme = this.form?.theme || loadCachedTheme(); const fallbackTheme = this.form?.theme || loadCachedTheme();
this.form = { this.form = {
enabled: !!data.enabled, enabled: !!data.enabled,

View File

@ -53,9 +53,10 @@ class APIClientMultimodalSanitizeTest(unittest.TestCase):
sanitized[0], 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 = DeepSeekClient(web_mode=True)
client.model_key = "deepseek" # 内置模型: multimodal=none client.model_key = ""
client.model_multimodal = "none"
messages = [ messages = [
{ {
@ -77,9 +78,10 @@ class APIClientMultimodalSanitizeTest(unittest.TestCase):
sanitized[0], 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 = DeepSeekClient(web_mode=True)
client.model_key = "qwen3-vl-plus" # 内置多模态模型 client.model_key = ""
client.model_multimodal = "image,video"
messages = [ messages = [
{ {

View 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()

View File

@ -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

View File

@ -1,5 +1,5 @@
# ========== api_client.py ========== # ========== api_client.py ==========
# utils/api_client.py - DeepSeek API 客户端支持Web模式- 简化版 # utils/api_client.py - OpenAI-compatible API 客户端支持Web模式
import httpx import httpx
import json import json
@ -40,7 +40,7 @@ except ImportError:
THINKING_MODEL_ID THINKING_MODEL_ID
) )
class DeepSeekClient: class DeepSeekClient: # legacy name; generic OpenAI-compatible client
def __init__(self, thinking_mode: bool = True, web_mode: bool = False): def __init__(self, thinking_mode: bool = True, web_mode: bool = False):
self.fast_api_config = { self.fast_api_config = {
"base_url": API_BASE_URL, "base_url": API_BASE_URL,
@ -84,39 +84,6 @@ class DeepSeekClient:
self.request_dump_dir.mkdir(parents=True, exist_ok=True) self.request_dump_dir.mkdir(parents=True, exist_ok=True)
self.debug_log_path = Path(__file__).resolve().parents[1] / "logs" / "api_debug.log" 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: def _debug_log(self, payload: Dict[str, Any]) -> None:
try: try:
entry = { entry = {
@ -204,7 +171,6 @@ class DeepSeekClient:
videos = videos or [] videos = videos or []
if not images and not videos: if not images and not videos:
return text return text
qwen_video_fps = 2
parts: List[Dict[str, Any]] = [] parts: List[Dict[str, Any]] = []
extra_videos: List[Any] = [] extra_videos: List[Any] = []
if text: if text:
@ -248,8 +214,8 @@ class DeepSeekClient:
"type": "video_url", "type": "video_url",
"video_url": {"url": f"data:{mime};base64,{b64}"} "video_url": {"url": f"data:{mime};base64,{b64}"}
} }
if self.model_key == "qwen3-vl-plus": if isinstance(item, dict) and item.get("fps") is not None:
payload["fps"] = qwen_video_fps payload["fps"] = item.get("fps")
parts.append(payload) parts.append(payload)
except Exception: except Exception:
continue continue
@ -668,7 +634,7 @@ class DeepSeekClient:
stream: bool = True stream: bool = True
) -> AsyncGenerator[Dict, None]: ) -> AsyncGenerator[Dict, None]:
""" """
异步调用DeepSeek API 异步调用 OpenAI-compatible API
Args: Args:
messages: 消息列表 messages: 消息列表
@ -679,8 +645,8 @@ class DeepSeekClient:
响应内容块 响应内容块
""" """
# 检查API密钥 # 检查API密钥
if not self.api_key or self.api_key == "your-deepseek-api-key": if not self.api_key or self.api_key.startswith("your-"):
self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置在config.py中设置API_KEY") self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置检查模型配置")
return return
# 决定是否使用思考模式 # 决定是否使用思考模式
@ -721,9 +687,6 @@ class DeepSeekClient:
else: else:
max_tokens = min(max_tokens, available) 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._merge_system_messages(messages)
final_messages = self._sanitize_messages_for_model_capability(final_messages) final_messages = self._sanitize_messages_for_model_capability(final_messages)
@ -732,35 +695,14 @@ class DeepSeekClient:
"messages": final_messages, "messages": final_messages,
"stream": stream, "stream": stream,
} }
if is_minimax: payload["max_tokens"] = max_tokens
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 extra_params = self.thinking_extra_params if current_thinking_mode else self.fast_extra_params
if extra_params: if extra_params:
payload.update(extra_params) payload.update(extra_params)
if tools: if tools:
payload["tools"] = tools payload["tools"] = tools
if not is_minimax: payload["tool_choice"] = "auto"
payload["tool_choice"] = "auto"
# 将本次请求落盘,便于出错时快速定位 # 将本次请求落盘,便于出错时快速定位
try: try:
@ -815,7 +757,6 @@ class DeepSeekClient:
self.last_error_info["error_message"] = err.get("message") self.last_error_info["error_message"] = err.get("message")
except Exception: except Exception:
pass pass
self._maybe_mark_aliyun_quota(error_text)
self._debug_log({ self._debug_log({
"event": "http_error_stream", "event": "http_error_stream",
"status_code": response.status_code, "status_code": response.status_code,
@ -870,7 +811,6 @@ class DeepSeekClient:
self.last_error_info["error_message"] = err.get("message") self.last_error_info["error_message"] = err.get("message")
except Exception: except Exception:
pass pass
self._maybe_mark_aliyun_quota(error_text)
self._debug_log({ self._debug_log({
"event": "http_error", "event": "http_error",
"status_code": response.status_code, "status_code": response.status_code,
@ -908,7 +848,6 @@ class DeepSeekClient:
"model_id": api_config.get("model_id"), "model_id": api_config.get("model_id"),
"model_key": self.model_key "model_key": self.model_key
} }
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
self._debug_log({ self._debug_log({
"event": "connect_error", "event": "connect_error",
"status_code": None, "status_code": None,
@ -933,7 +872,6 @@ class DeepSeekClient:
"model_id": api_config.get("model_id"), "model_id": api_config.get("model_id"),
"model_key": self.model_key "model_key": self.model_key
} }
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
self._debug_log({ self._debug_log({
"event": "timeout", "event": "timeout",
"status_code": None, "status_code": None,
@ -958,7 +896,6 @@ class DeepSeekClient:
"model_id": api_config.get("model_id"), "model_id": api_config.get("model_id"),
"model_key": self.model_key "model_key": self.model_key
} }
self._maybe_mark_aliyun_quota(self.last_error_info.get("error_text"))
self._debug_log({ self._debug_log({
"event": "exception", "event": "exception",
"status_code": None, "status_code": None,

View File

@ -57,6 +57,7 @@ except ImportError:
from utils.conversation_manager import ConversationManager from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
from utils.media_store import MediaStore from utils.media_store import MediaStore
from utils.token_usage import normalize_usage_payload
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩" AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
AUTO_SHALLOW_TOOL_WHITELIST = { AUTO_SHALLOW_TOOL_WHITELIST = {
@ -455,14 +456,12 @@ class ContextManager:
""" """
根据模型返回的 usage 字段更新token统计 根据模型返回的 usage 字段更新token统计
""" """
try: normalized_usage = normalize_usage_payload(usage) or {}
prompt_tokens = int(usage.get("prompt_tokens") or 0) prompt_tokens = int(normalized_usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0) completion_tokens = int(normalized_usage.get("completion_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens)) total_tokens = int(normalized_usage.get("total_tokens") or (prompt_tokens + completion_tokens))
# 当前上下文长度优先取专用字段;缺失时回退到 prompt_tokens # 当前上下文长度优先取专用字段;缺失时回退到 prompt_tokens
current_context_tokens = int(usage.get("current_context_tokens") or prompt_tokens) current_context_tokens = int(normalized_usage.get("current_context_tokens") or prompt_tokens)
except (TypeError, ValueError):
prompt_tokens = completion_tokens = total_tokens = current_context_tokens = 0
try: try:
self._increment_workspace_token_totals(prompt_tokens, completion_tokens, total_tokens) self._increment_workspace_token_totals(prompt_tokens, completion_tokens, total_tokens)
@ -2177,10 +2176,6 @@ class ContextManager:
parts: List[Dict[str, Any]] = [] parts: List[Dict[str, Any]] = []
extra_videos: List[Any] = [] extra_videos: List[Any] = []
extra_notes: List[str] = [] 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 media_payload_attached = False
if media_refs: if media_refs:
@ -2198,8 +2193,6 @@ class ContextManager:
if kind == "video": if kind == "video":
payload: Dict[str, Any] = {"type": "video_url", "video_url": {"url": data_url}} payload: Dict[str, Any] = {"type": "video_url", "video_url": {"url": data_url}}
fps_value = ref.get("fps") 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: if fps_value is not None:
payload["fps"] = fps_value payload["fps"] = fps_value
parts.append(payload) parts.append(payload)
@ -2269,8 +2262,6 @@ class ContextManager:
data_url = f"data:{mime};base64,{b64}" data_url = f"data:{mime};base64,{b64}"
payload = {"type": "video_url", "video_url": {"url": data_url}} payload = {"type": "video_url", "video_url": {"url": data_url}}
fps_value = item.get("fps") if isinstance(item, dict) else None 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: if fps_value is not None:
payload["fps"] = fps_value payload["fps"] = fps_value
parts.append(payload) parts.append(payload)

149
utils/token_usage.py Normal file
View 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"]