agent-Specialization/config/model_profiles.py
JOJO b8a51c1c63 refactor(config): 移除硬编码模型残留,部署级配置外置到 ~/.agents
## 模型逻辑清理
早期把模型 API 端点/密钥/模型 ID 硬编码的残留(AGENT_API_* / THINKING_* /
TITLE_* 三件套)已彻底移除。模型配置统一由 config/custom_models.json
(经 model_profiles 解析为档案)描述,运行时通过 apply_profile 注入;
没有任何可用模型时按既定行为报错。

- config/api.py: 删 9 个三件套符号,仅保留 DEFAULT_RESPONSE_MAX_TOKENS
- utils/api_client.py: client 改为空配置起步,移除三件套 import
- server/chat_flow_helpers.py: 删死 import(TITLE_* 符号)

## 部署级配置外置(按"决策权归谁"分类)
config/*.json 不再一概锚定源码树:
- 程序能力(docker_risk_markers / skill_hints)留源码树,随版本演进
- 部署者自定义(custom_models / host_workspaces / auto_approval /
  goal_review / forbidden_commands / host_sandbox_policy)外置到
  ~/.agents/<mode>/config/

新增统一解析机制(config/paths.py):
- DEPLOY_CONFIG_DIR(默认 ~/.agents/<mode>/config,可用环境变量覆盖)
- resolve_deploy_config():只读,回退链 部署目录→源码树.json→源码树.example
  (开发环境不必先跑 setup 也能用源码树种子)
- deploy_config_path():写回用,稳定指向部署目录

6 个加载点改用上述解析;顺带修复 approval_agent / goal_review_agent 的
DEBUG_TRANSCRIPT_DIR 仍指旧源码树 logs/ 的漏迁问题。

## 安全与运维
- 含密钥/机器特定的 5 个文件停止 git 追踪(git rm --cached,本地保留)
  并加入 .gitignore,仓库仅留 .example 种子;forbidden_commands 保留
  追踪作默认黑名单
- scripts/setup.py: 模型配置写到部署目录(用与 paths 一致的独立推导,
  不 import config 以免过早锁定路径)
- scripts/migrate_runtime_data.py: 新增 config/*.json → 部署目录迁移
  (备份 + 不覆盖已存在)

## 关联:P1 配置收敛 + P2 首启向导
- config/server.py: Web 端口/监听地址/debug/NODE_BIN/PYTHON_BIN 单一事实源
- 消灭 8091 多处重复定义(state.py 死代码、app_legacy、main.py 各读各的)
- 修复 sub_agent_manager 命令数组硬编码 "node" → NODE_BIN(便携包内置
  Node 的前提)
- scripts/setup.py: 终端首启向导(模式/端口/管理员/密钥/模型)

## 测试
test_config_paths_resolution 更新以反映新行为(host_workspaces 锚定部署
目录、新增 DEPLOY_CONFIG_DIR 用例);全部离线用例通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:59:34 +08:00

291 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from .paths import resolve_deploy_config
def _env_optional(name: str) -> Optional[str]:
value = os.environ.get(name)
if value is None:
# 回退读取 .env支持运行中更新
env_path = Path(__file__).resolve().parents[1] / ".env"
if env_path.exists():
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
if key.strip() == name:
value = val.strip().strip('"').strip("'")
break
except Exception:
value = None
if value is None:
return None
value = value.strip()
return value or None
def _normalize_multimodal(value: Any) -> str:
text = str(value or "none").strip().lower()
if text in {"image", "video", "none", "image,video", "video,image"}:
return "image,video" if "image" in text and "video" in text else text
return "none"
def _parse_env_ref(raw: Any) -> Optional[str]:
text = str(raw or "").strip()
if not text:
return None
# 1) ${ENV_KEY}
if text.startswith("${") and text.endswith("}") and len(text) > 3:
key = text[2:-1].strip()
return _env_optional(key)
# 2) env:ENV_KEY
if text.lower().startswith("env:") and len(text) > 4:
key = text[4:].strip()
resolved = _env_optional(key)
return resolved if resolved is not None else None
# 3) $ENV_KEY
if text.startswith("$") and len(text) > 1:
key = text[1:].strip()
resolved = _env_optional(key)
return resolved if resolved is not None else None
# 4) 直接写 ENV_KEY若环境/.env存在同名键则读取其值否则按字面量
if text.replace("_", "").isalnum() and text.upper() == text:
resolved = _env_optional(text)
if resolved is not None:
return resolved
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":
return {"supports_thinking": True, "fast_only": False, "deep_only": True}
if normalized == "fast,thinking":
return {"supports_thinking": True, "fast_only": False, "deep_only": False}
return {"supports_thinking": False, "fast_only": True, "deep_only": False}
def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
key = str(item.get("model_name") or "").strip()
if not key:
return None
base_url = _parse_env_ref(item.get("url"))
api_key = _parse_env_ref(item.get("apikey"))
if not base_url or not api_key:
return 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") 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 {}
if not fast_extra and isinstance(thinkmode.get("fast_parameter"), dict):
fast_extra = thinkmode.get("fast_parameter")
thinking_extra = thinkmode.get("thinking_extra_parameter") if isinstance(thinkmode.get("thinking_extra_parameter"), dict) else {}
if not thinking_extra and isinstance(thinkmode.get("thinking_parameter"), dict):
thinking_extra = thinkmode.get("thinking_parameter")
fast_model_id = None
thinking_model_id = None
if mode_type == "switch_model":
fast_model_id = str(thinkmode.get("fast_model_id") or "").strip()
thinking_model_id = str(thinkmode.get("thinking_model_id") or "").strip()
elif mode_type == "param_toggle":
common_model = str(thinkmode.get("model_id") or "").strip()
fast_model_id = common_model
thinking_model_id = common_model
else:
common_model = str(thinkmode.get("model_id") or "").strip()
if not common_model:
common_model = str(item.get("model_id") or "").strip()
fast_model_id = common_model
thinking_model_id = common_model
if not fast_model_id:
fast_model_id = thinking_model_id
if not thinking_model_id:
thinking_model_id = fast_model_id
if not fast_model_id:
return None
fast_config = {
"base_url": base_url.rstrip("/"),
"api_key": api_key,
"model_id": fast_model_id,
"max_tokens": max_output_tokens,
"context_window": context_window,
"extra_params": {**extra_global, **(fast_extra or {})},
}
profile: Dict[str, Any] = {
"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,
"hidden": not bool(item.get("visible", True)),
"multimodal": _normalize_multimodal(item.get("multimodal")),
"context_window": context_window,
"fast": fast_config,
"supports_thinking": bool(flags["supports_thinking"]),
"fast_only": bool(flags["fast_only"]),
}
if flags["deep_only"]:
profile["deep_only"] = True
if flags["supports_thinking"]:
profile["thinking"] = {
"base_url": base_url.rstrip("/"),
"api_key": api_key,
"model_id": thinking_model_id or fast_model_id,
"max_tokens": max_output_tokens,
"context_window": context_window,
"extra_params": {**extra_global, **(thinking_extra or {})},
}
else:
profile["thinking"] = None
return profile
def _load_custom_models() -> Dict[str, Dict[str, Any]]:
custom_path = Path(resolve_deploy_config("custom_models.json"))
if not custom_path.exists():
return {}
try:
data = json.loads(custom_path.read_text(encoding="utf-8"))
except Exception:
return {}
items = data if isinstance(data, list) else data.get("models", [])
if not isinstance(items, list):
return {}
output: Dict[str, Dict[str, Any]] = {}
for item in items:
if not isinstance(item, dict):
continue
profile = _build_custom_profile(item)
if not profile:
continue
model_name = str(item.get("model_name") or "").strip()
if not model_name:
continue
output[model_name] = profile
return output
def get_registered_model_profiles() -> Dict[str, Dict[str, Any]]:
"""返回 API 扩展注册的模型。项目不再内置任何供应商模型。"""
return _load_custom_models()
def get_registered_model_keys(visible_only: bool = False) -> List[str]:
keys: List[str] = []
for key, profile in get_registered_model_profiles().items():
if visible_only and profile.get("hidden"):
continue
keys.append(key)
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"))
supports_image = multimodal in {"image", "image,video"}
supports_video = multimodal in {"video", "image,video"}
return {
"supports_image": supports_image,
"supports_video": supports_video,
"multimodal": multimodal,
"supports_thinking": bool(profile.get("supports_thinking", False)),
"fast_only": bool(profile.get("fast_only", False)),
"deep_only": bool(profile.get("deep_only", False)),
}
def model_supports_image(key: str) -> bool:
try:
return bool(get_model_capabilities(key).get("supports_image"))
except Exception:
return False
def model_supports_video(key: str) -> bool:
try:
return bool(get_model_capabilities(key).get("supports_video"))
except Exception:
return False
def get_model_prompt_replacements(key: str) -> dict:
"""获取模型相关提示词字段。仅使用 API 扩展配置中的描述,不做模型名硬编码。"""
try:
profile = get_registered_model_profiles().get(resolve_model_key(key)) or {}
except Exception:
profile = {}
model_description = str(profile.get("model_description") or profile.get("description") or "")
return {
"model_description": model_description,
"thinking_model_line": "",
"deep_thinking_line": "",
}
def get_model_context_window(key: str) -> Optional[int]:
"""
获取模型的最大上下文窗口token 数)。
优先使用 profile.context_window其次回退到 fast.context_window。
"""
profile = get_model_profile(key)
ctx = profile.get("context_window")
if ctx:
return ctx
fast = profile.get("fast") or {}
return fast.get("context_window")