"""Effective model resolution and ``/model`` command handling (M2 + M3).""" from __future__ import annotations from contextlib import contextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Iterator, cast from butler.configuration.settings import ModelConfig, get_butler_settings, save_butler_config import logging logger = logging.getLogger(__name__) if TYPE_CHECKING: from butler.configuration.settings import ButlerSettings from butler.project import Project _ROLE_ALIASES: dict[str, str] = { "dev": "dev_agent", "content": "content_agent", "review": "review_agent", "lead": "butler", } _LIST_ROLES: tuple[str, ...] = ( "butler", "dev_agent", "content_agent", "review_agent", ) _PROJECT_PERSIST_ROLES: frozenset[str] = frozenset( {"dev_agent", "content_agent", "review_agent"} ) @dataclass(frozen=True) class EffectiveModel: """Merged model for a role with provenance labels (inner → outer).""" config: ModelConfig sources: tuple[str, ...] def normalize_role(role: str) -> str: key = (role or "").strip().lower() return _ROLE_ALIASES.get(key, key) def parse_model_spec(spec: str) -> ModelConfig: """Parse ``provider/model`` or ``provider:model`` or bare model name.""" raw = (spec or "").strip() if not raw: raise ValueError("模型规格为空") if "/" in raw: provider, model = raw.split("/", 1) return ModelConfig(provider=provider.strip(), model=model.strip()) if ":" in raw: provider, model = raw.split(":", 1) return ModelConfig(provider=provider.strip(), model=model.strip()) return ModelConfig(model=raw) def resolve_effective_model( role: str, *, project: Project | None = None, settings: ButlerSettings | None = None, ) -> EffectiveModel: """Merge L0 system → L1 butler YAML → L2 project → L3 runtime (last wins).""" settings = settings or get_butler_settings() role = normalize_role(role) sources: list[str] = [] cfg = settings._system_default_for_role(role) if not cfg.is_empty(): sources.append("system") yaml_layer = settings._yaml_merged_for_role(role) if not yaml_layer.is_empty(): cfg = cfg.merge_with(yaml_layer) role_cfg = settings.models.get(role) if role_cfg and not role_cfg.is_empty(): sources.append(f"global:{role}") elif not settings.models.butler.is_empty(): sources.append("global:butler") if project is not None: if proj_cfg := project.models.get(role): if not proj_cfg.is_empty(): cfg = cfg.merge_with(proj_cfg) sources.append(f"project:{project.name}") if runtime := settings._runtime_model_overrides.get(role): if not runtime.is_empty(): cfg = cfg.merge_with(runtime) sources.append("runtime") if not sources: sources.append("system") return EffectiveModel(config=cfg, sources=tuple(sources)) def _format_sources(sources: tuple[str, ...]) -> str: if not sources: return "system" return " + ".join(sources) def format_effective_models( *, project: Project | None = None, project_label: str | None = None, settings: ButlerSettings | None = None, ) -> str: settings = settings or get_butler_settings() lines = ["当前有效模型:"] if project_label: lines[0] += f"(项目: {project_label})" elif project is not None: lines[0] += f"(项目: {project.name})" else: lines[0] += "(无当前项目 — 厂长/委派均用管家层)" for role in _LIST_ROLES: em = resolve_effective_model(role, project=project, settings=settings) c = em.config spec = f"{c.provider or '-'}/{c.model or '-'}" lines.append(f" {role:14} {spec:28} [{_format_sources(em.sources)}]") lines.append("") lines.append( "用法:\n" " /model 本表\n" " /model <角色> 本会话临时(runtime)\n" " /model save <角色>

持久化(butler→~/.butler;dev/content/review→当前项目)\n" " /model reset <角色> 清除临时覆盖" ) return "\n".join(lines) def format_model_diagnostic_lines( *, project: Project | None = None, settings: ButlerSettings | None = None, ) -> list[str]: """Compact model block for ``/诊断`` (no /model usage footer).""" settings = settings or get_butler_settings() lines = ["--- 有效模型 ---"] for role in _LIST_ROLES: em = resolve_effective_model(role, project=project, settings=settings) c = em.config spec = f"{c.provider or '-'}/{c.model or '-'}" lines.append(f" {role}: {spec} [{_format_sources(em.sources)}]") from butler.model_resolve_ops import ( append_auxiliary_diagnostic_lines, append_embedding_diagnostic_line, append_llm_fallback_diagnostic_line, ) append_auxiliary_diagnostic_lines(lines) append_embedding_diagnostic_line(lines) append_llm_fallback_diagnostic_line(lines, project=project, settings=settings) return lines def _persist_global_role(settings: ButlerSettings, role: str, cfg: ModelConfig) -> None: role = normalize_role(role) settings.models.set(role, cfg) settings.set_runtime_model_override(role, None) save_butler_config() def _persist_project_role(project: Project, role: str, cfg: ModelConfig) -> None: role = normalize_role(role) project.set_model(role, cfg) get_butler_settings().set_runtime_model_override(role, None) def handle_model_command( arg: str, *, settings: ButlerSettings | None = None, project: Project | None = None, project_label: str | None = None, ) -> tuple[str, bool]: """Handle ``/model`` body. Returns ``(reply, reset_session_loop)``.""" settings = settings or get_butler_settings() text = (arg or "").strip() if not text: return format_effective_models( project=project, project_label=project_label, settings=settings, ), False from butler.model_resolve_ops import try_handle_preset_model_command_safe preset_out = try_handle_preset_model_command_safe( text, project=project, project_label=project_label, ) if preset_out is not None: return cast(tuple[str, bool], preset_out) parts = text.split(maxsplit=2) verb = parts[0].lower() if verb == "reset": if len(parts) < 2: return "用法: /model reset <角色>", False role = normalize_role(parts[1]) settings.set_runtime_model_override(role, None) return f"已清除 {role} 的临时模型覆盖(YAML 未改)。", True persist = verb == "save" if persist: if len(parts) < 3: return "用法: /model save <角色> ", False role = normalize_role(parts[1]) try: cfg = parse_model_spec(parts[2]) except ValueError as exc: return str(exc), False else: if len(parts) < 2: return ( "用法: /model <角色> 或 /model save … 或 /model reset <角色>", False, ) role = normalize_role(parts[0]) try: cfg = parse_model_spec(parts[1]) except ValueError as exc: return str(exc), False spec = f"{cfg.provider}/{cfg.model}" if cfg.provider else cfg.model if persist: if role == "butler": _persist_global_role(settings, role, cfg) return f"已持久化 {role} → {spec}(~/.butler/config.yaml)", True if role in _PROJECT_PERSIST_ROLES: if project is None: return ( f"保存 {role} 需要先 /切换 到项目(写入 project.yaml)。" f" 或 /model save butler … 写管家层。", False, ) _persist_project_role(project, role, cfg) return f"已持久化 {role} → {spec}(项目 {project.name})", True return f"未知角色: {role}", False settings.set_runtime_model_override(role, cfg) return f"已临时设置 {role} → {spec}(本会话 runtime,重启进程后丢失)", True def workflow_step_spawn_model_config(step_model: ModelConfig | None) -> dict[str, str] | None: """Dict for :class:`~butler.task_orchestrator.AgentSpawnConfig.model_config`.""" if step_model is None or step_model.is_empty(): return None d = step_model.to_dict() if not d: return None return cast(dict[str, str], d) @contextmanager def temporary_model_override( spec: str, *, role: str = "dev_agent", ) -> Iterator[ModelConfig]: """Apply a runtime model override for the duration of a benchmark or eval.""" settings = get_butler_settings() role_n = normalize_role(role) prev = settings._runtime_model_overrides.get(role_n) cfg = parse_model_spec(spec) settings.set_runtime_model_override(role_n, cfg) try: yield cfg finally: if prev is None: settings.set_runtime_model_override(role_n, None) else: settings.set_runtime_model_override(role_n, prev) def model_config_to_credentials( mc: ModelConfig, *, settings: ButlerSettings | None = None, ) -> dict[str, Any]: """Turn ``ModelConfig`` into orchestrator credential kwargs.""" settings = settings or get_butler_settings() prov_name = (mc.provider or settings.default_provider or "").strip() pc = settings.providers.get(prov_name) if prov_name else None api_key = getattr(pc, "api_key", "") or "" if pc else "" base_url = getattr(pc, "base_url", "") or "" if pc else "" model = (mc.model or (getattr(pc, "model", "") or "")) if pc else mc.model or "" out: dict[str, Any] = { "provider": prov_name or None, "model": model or "", "api_key": api_key, "base_url": base_url, } if mc.max_tokens is not None: out["max_tokens"] = mc.max_tokens if mc.context_length is not None: out["context_length"] = mc.context_length return out def resolve_auxiliary_config(task: str = "compression") -> ModelConfig: """Re-export: auxiliary stack (compression, post_session, …).""" from butler.transport.auxiliary_client import resolve_auxiliary_config as _resolve return _resolve(task) def resolve_embedding_config() -> tuple[str, str]: """Re-export: embedding provider/model (yaml → env → defaults).""" from butler.memory.semantic_config import resolve_embedding_config as _resolve return cast(tuple[str, str], _resolve()) __all__ = [ "EffectiveModel", "format_effective_models", "format_model_diagnostic_lines", "handle_model_command", "model_config_to_credentials", "normalize_role", "parse_model_spec", "resolve_auxiliary_config", "resolve_effective_model", "resolve_embedding_config", "temporary_model_override", "workflow_step_spawn_model_config", ]