agent-Specialization/config/model_profiles.py

289 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
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(__file__).resolve().parent / "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")