model_settings.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """Read-only Bot model catalogue and runtime resolution. Never writes Profile config."""
  2. from contextlib import contextmanager
  3. import re
  4. class ModelSettingsError(Exception):
  5. pass
  6. @contextmanager
  7. def profile_scope(profile):
  8. from hermes_cli.profiles import profile_exists
  9. if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile) or not profile_exists(profile):
  10. raise ModelSettingsError("基础机器人不存在")
  11. # Match Hermes' own model picker: home AND credentials must be scoped.
  12. # A home-only override raises UnscopedSecretError under multi-profile hosting.
  13. # The native scope also preserves launch credentials and hydrates named profiles
  14. # without borrowing process-global credentials or taking the skills lock.
  15. try:
  16. from hermes_cli.web_server_profiles import _config_profile_scope
  17. except ModuleNotFoundError as exc:
  18. if exc.name != "hermes_cli.web_server_profiles":
  19. raise
  20. # Older Hermes versions kept the same helper in the monolithic server.
  21. from hermes_cli.web_server import _config_profile_scope
  22. with _config_profile_scope(profile):
  23. yield
  24. def defaults(ctx, config):
  25. agent = config.get("agent") or {}
  26. effort = agent.get("reasoning_effort", "medium")
  27. tier = agent.get("service_tier") or "normal"
  28. return {"provider": ctx.current_provider, "model": ctx.current_model,
  29. "reasoningEffort": "none" if effort is False else str(effort or "medium"),
  30. "fastMode": {"priority": "fast"}.get(tier, tier)}
  31. def route_url(ctx, provider):
  32. if provider == ctx.current_provider:
  33. return ctx.current_base_url
  34. entry = (ctx.user_providers or {}).get(provider)
  35. if isinstance(entry, dict):
  36. return str(entry.get("base_url") or "")
  37. for entry in ctx.custom_providers or []:
  38. if provider in (entry.get("name"), entry.get("id")):
  39. return str(entry.get("base_url") or "")
  40. return ""
  41. def capability(provider, model, base_url="", hint=None):
  42. from hermes_constants import VALID_REASONING_EFFORTS
  43. from hermes_cli.models import resolve_fast_mode_overrides
  44. hint = hint or {}
  45. known = hint.get("reasoning") is False
  46. reasoning = hint.get("reasoning", True)
  47. try:
  48. from agent.models_dev import get_model_capabilities
  49. meta = get_model_capabilities(provider, model)
  50. if meta is not None:
  51. known = not meta.supports_reasoning or known
  52. reasoning = bool(meta.supports_reasoning) and reasoning
  53. except Exception:
  54. pass
  55. efforts = (["none"] if hint.get("can_disable_reasoning", True) else []) + list(VALID_REASONING_EFFORTS) if reasoning else []
  56. if provider == "copilot":
  57. from hermes_cli.models import github_model_reasoning_efforts
  58. declared = github_model_reasoning_efforts(model)
  59. if declared:
  60. efforts, known = list(declared), True
  61. fast = resolve_fast_mode_overrides(model, provider=provider, base_url=base_url) is not None
  62. return {"provider": provider, "model": model, "name": model,
  63. "reasoningEfforts": efforts, "reasoningKnown": known,
  64. "fastModes": ["normal", "fast", "auto", "cold"] if fast else ["normal"]}
  65. def options(profile):
  66. from hermes_cli.config import load_config
  67. from hermes_cli.inventory import load_picker_context, build_model_options_payload
  68. with profile_scope(profile):
  69. ctx = load_picker_context()
  70. payload = build_model_options_payload(ctx, explicit_only=True)
  71. rows = []
  72. for provider in payload.get("providers", []):
  73. slug = provider["slug"]
  74. for model in provider.get("models", []):
  75. if isinstance(model, str):
  76. rows.append(capability(slug, model, route_url(ctx, slug), (provider.get("capabilities") or {}).get(model)))
  77. return {"version": 1, "defaults": defaults(ctx, load_config()), "models": rows}
  78. def resolve(profile, settings):
  79. from hermes_cli.config import load_config
  80. from hermes_cli.inventory import load_picker_context, _reasoning_catalog_reader
  81. from hermes_cli.model_switch import switch_model
  82. from hermes_cli.model_selection_guards import combined_selection_warning
  83. from hermes_constants import parse_reasoning_effort
  84. if settings is None:
  85. settings = {}
  86. if not isinstance(settings, dict) or set(settings) - {"provider", "model", "reasoningEffort", "fastMode"}:
  87. raise ModelSettingsError("模型配置格式无效")
  88. with profile_scope(profile):
  89. ctx, config = load_picker_context(), load_config()
  90. value = defaults(ctx, config)
  91. value.update({key: item for key, item in settings.items() if item is not None})
  92. if bool(settings.get("provider")) != bool(settings.get("model")):
  93. raise ModelSettingsError("模型与 Provider 必须一起选择")
  94. for key in ("model", "provider"):
  95. item = value[key]
  96. if not isinstance(item, str) or not item or len(item) > 512 or re.search(r"[\s\"'\\]", item) or item.startswith("-"):
  97. raise ModelSettingsError("基础机器人尚未配置有效的模型与 Provider")
  98. model_config = config.get("model") or {}
  99. current_key = model_config.get("api_key", "") if isinstance(model_config, dict) else ""
  100. result = switch_model(raw_input=value["model"], current_provider=ctx.current_provider,
  101. current_model=ctx.current_model, current_base_url=ctx.current_base_url,
  102. current_api_key=current_key, is_global=False, explicit_provider=value["provider"],
  103. user_providers=ctx.user_providers, custom_providers=ctx.custom_providers)
  104. if not result.success:
  105. # Provider errors may include secrets/URLs; keep this boundary deliberately generic.
  106. raise ModelSettingsError("所选模型或 Provider 当前不可用,请检查基础机器人的模型服务")
  107. value.update(provider=result.target_provider, model=result.new_model)
  108. hint = {}
  109. reader = _reasoning_catalog_reader(result.target_provider)
  110. detail = reader(result.new_model) if reader else None
  111. if detail:
  112. hint = {"reasoning": detail.get("supports_reasoning", True), "can_disable_reasoning": not detail.get("mandatory")}
  113. caps = capability(value["provider"], value["model"], result.base_url or route_url(ctx, value["provider"]), hint)
  114. if not isinstance(value["reasoningEffort"], str) or parse_reasoning_effort(value["reasoningEffort"]) is None:
  115. raise ModelSettingsError("思考等级无效")
  116. if settings.get("reasoningEffort") is not None and value["reasoningEffort"] not in caps["reasoningEfforts"]:
  117. raise ModelSettingsError("该模型不支持所选思考等级,请重新选择或继承基础机器人")
  118. if value["fastMode"] not in caps["fastModes"]:
  119. raise ModelSettingsError("该模型或 Provider 不支持所选速度,请重新选择普通速度")
  120. warning = combined_selection_warning(value["model"], provider=value["provider"],
  121. base_url=result.base_url, api_key=result.api_key, model_info=result.model_info)
  122. return {"version": 1, "effective": value, "confirmationMessage": warning.message if warning else None}