Adapt the rebased MoA raw read/merge/save to current fail-closed config reads and hold the existing reentrant config lock across the full update. Preserve concurrent partial writes from non-HTTP callers. Exercise transient read refusal, HTTP autosave/restart/fallback merging, sparse YAML preservation, and deterministic named/legacy concurrent saves. Shared backup timestamp freshness remains a separately documented upstream limitation.
428 lines
22 KiB
Python
428 lines
22 KiB
Python
"""Model assignment dashboard routes: model info/options/recommended default, auxiliary + MoA slots, /api/model/set.
|
|
|
|
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
|
|
``web_server`` stay there and are resolved late at call time (cycle-safe).
|
|
"""
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from hermes_cli.web_deps import LateState, late
|
|
from hermes_cli.web_server_config import (
|
|
_AUX_TASK_SLOTS, _UNSET, _apply_model_assignment_sync, _dashboard_code_skew_guard,
|
|
_plugin_aux_tasks, _prepare_main_assignment,
|
|
)
|
|
from agent.model_metadata import is_local_endpoint
|
|
from starlette.concurrency import run_in_threadpool
|
|
from hermes_cli.web_models import ModelAssignment, MoaConfigPayload, MoaModelSlot
|
|
from hermes_cli.web_routers._common import _CONFIG_MUTATION_LOCK, config_write_scope, http_failure
|
|
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
router = APIRouter()
|
|
|
|
# Late-bound so a test's monkeypatch on the owning module wins at call time.
|
|
_config_profile_scope = late("_config_profile_scope", "hermes_cli.web_server_profiles")
|
|
_profile_scope = late("_profile_scope", "hermes_cli.web_server_profiles")
|
|
load_config = late("load_config", "hermes_cli.config")
|
|
save_config = late("save_config", "hermes_cli.config")
|
|
require_readable_config_before_write = late("require_readable_config_before_write", "hermes_cli.config")
|
|
|
|
|
|
_EMPTY_MODEL_INFO: dict = {
|
|
"model": "", "provider": "", "auto_context_length": 0, "config_context_length": 0,
|
|
"effective_context_length": 0, "capabilities": {},
|
|
}
|
|
_CAPABILITY_FIELDS = ("supports_tools", "supports_vision", "supports_reasoning", "context_window",
|
|
"max_output_tokens", "model_family")
|
|
|
|
|
|
def _main_model_fields(model_cfg) -> tuple[str, str]:
|
|
"""(model, provider) from config's ``model`` section, which may be a plain string."""
|
|
if isinstance(model_cfg, dict):
|
|
return model_cfg.get("default", model_cfg.get("name", "")), model_cfg.get("provider", "")
|
|
return (str(model_cfg) if model_cfg else ""), ""
|
|
|
|
|
|
def _load_config_scoped(profile: Optional[str]) -> dict:
|
|
with _profile_scope(profile):
|
|
return load_config()
|
|
|
|
|
|
# Blocking budget for /api/model/info's context-length resolution. The resolver
|
|
# chain (agent.model_metadata.get_model_context_length) runs several sequential
|
|
# provider probes, each with its own multi-second timeout, so an unreachable or
|
|
# blackholed model.base_url can hold this response for tens of seconds — and the
|
|
# Desktop Model Settings page waits on it (#63214).
|
|
_MODEL_INFO_PROBE_BUDGET_S = 5.0
|
|
|
|
|
|
def _bounded_context_length_probe(model: str, base_url: str, provider: str) -> int:
|
|
"""``get_model_context_length`` with the route's blocking budget.
|
|
|
|
On timeout the abandoned probe keeps running in its worker thread (bounded
|
|
by its own per-request timeouts) while the response degrades to
|
|
``auto_context_length = 0`` ("auto-detected: unknown").
|
|
"""
|
|
from agent.model_metadata import get_model_context_length
|
|
|
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="model-info-probe")
|
|
try:
|
|
return pool.submit(
|
|
get_model_context_length, model=model, base_url=base_url, provider=provider,
|
|
config_context_length=None
|
|
).result(timeout=_MODEL_INFO_PROBE_BUDGET_S)
|
|
except concurrent.futures.TimeoutError:
|
|
_log.warning(
|
|
"GET /api/model/info: context-length probe for %r at %s exceeded %.1fs — returning unknown",
|
|
model, base_url or "<default>", _MODEL_INFO_PROBE_BUDGET_S,
|
|
)
|
|
return 0
|
|
finally:
|
|
# wait=False: never block the response (or interpreter exit) on the
|
|
# abandoned probe.
|
|
pool.shutdown(wait=False)
|
|
|
|
|
|
@router.get("/api/model/info")
|
|
def get_model_info(profile: Optional[str] = None):
|
|
"""Resolved metadata for the configured model: auto-detected vs configured
|
|
context length (so the UI can show "Auto-detected: 200K" beside the
|
|
override) plus models.dev capabilities when available."""
|
|
try:
|
|
model_cfg = _load_config_scoped(profile).get("model", "")
|
|
model_name, provider = _main_model_fields(model_cfg)
|
|
base_url = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
|
|
config_ctx = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
|
|
|
|
if not model_name:
|
|
return dict(_EMPTY_MODEL_INFO, provider=provider)
|
|
|
|
try:
|
|
# config_context_length=None: ignore the override — we want the auto value.
|
|
# Bounded: the resolver's provider probes can hang for tens of seconds
|
|
# when model.base_url is unreachable (#63214).
|
|
auto_ctx = _bounded_context_length_probe(model_name, base_url, provider)
|
|
except Exception:
|
|
auto_ctx = 0
|
|
|
|
config_ctx_int = config_ctx if isinstance(config_ctx, int) and config_ctx > 0 else 0
|
|
|
|
caps = {}
|
|
try:
|
|
from agent.models_dev import get_model_capabilities
|
|
mc = get_model_capabilities(provider=provider, model=model_name)
|
|
if mc is not None:
|
|
caps = {name: getattr(mc, name) for name in _CAPABILITY_FIELDS}
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"model": model_name, "provider": provider, "auto_context_length": auto_ctx,
|
|
"config_context_length": config_ctx_int,
|
|
"effective_context_length": config_ctx_int or auto_ctx, # what the agent actually uses
|
|
"capabilities": caps,
|
|
}
|
|
except HTTPException:
|
|
# Unknown/invalid profile must surface as 404, not degrade into a
|
|
# 200 with empty model info (which would render as "no model set").
|
|
raise
|
|
except Exception:
|
|
_log.exception("GET /api/model/info failed")
|
|
return dict(_EMPTY_MODEL_INFO)
|
|
|
|
|
|
@router.get("/api/model/options")
|
|
async def get_model_options(
|
|
profile: Optional[str] = None,
|
|
refresh: bool = False,
|
|
include_unconfigured: bool = False,
|
|
explicit_only: bool = False,
|
|
):
|
|
"""Authenticated providers + curated model lists — REST twin of the ``model.options``
|
|
JSON-RPC on tui_gateway, same response shape so ``ModelPickerDialog`` shares the types.
|
|
``profile`` scopes the picker context so the Models page reads the SAME profile
|
|
/api/model/set writes. ``refresh`` busts the per-provider model-id disk cache
|
|
(picker's explicit "Refresh Models"); normal opens stay on the 1h cache."""
|
|
with http_failure("GET /api/model/options failed", 500, detail="Failed to list model options"):
|
|
skew_msg = _dashboard_code_skew_guard()
|
|
if skew_msg:
|
|
_log.warning("GET /api/model/options refused: %s", skew_msg)
|
|
raise HTTPException(status_code=503, detail=f"Restart required: {skew_msg}")
|
|
|
|
from hermes_cli.inventory import build_model_options_payload, load_picker_context
|
|
|
|
def _build_payload_scoped() -> dict:
|
|
# Full sync picker build off the event loop under the requested profile.
|
|
# _config_profile_scope (contextvar only, no skill-module lock): the build can
|
|
# block 15s on a models.dev cache miss, and _profile_scope's RLock held across
|
|
# that starves concurrent /api/config and freezes the server.
|
|
with _config_profile_scope(profile):
|
|
return build_model_options_payload(
|
|
load_picker_context(), explicit_only=bool(explicit_only),
|
|
include_unconfigured=bool(include_unconfigured), refresh=bool(refresh))
|
|
|
|
return await run_in_threadpool(_build_payload_scoped)
|
|
|
|
|
|
def _nous_recommended_default() -> dict:
|
|
from hermes_cli.models import recommended_nous_default_model
|
|
return recommended_nous_default_model()
|
|
|
|
|
|
@router.get("/api/model/recommended-default")
|
|
def get_recommended_default_model(provider: str = "", profile: Optional[str] = None):
|
|
"""Recommended default model for a freshly-authenticated provider, mirroring
|
|
``hermes model``'s curation so GUI onboarding lands on a sensible default.
|
|
Nous honors the user's free/paid tier. Any other provider gets the preferred
|
|
silent default when its curated list carries it, else the first curated model —
|
|
aggregator lists lead with the priciest Anthropic flagship, which must never be
|
|
the model a user lands on without explicitly picking it.
|
|
Response: {"provider", "model", "free_tier": bool | None} — free_tier only for
|
|
Nous; ``model`` may be empty (caller degrades gracefully)."""
|
|
slug = (provider or "").strip().lower()
|
|
|
|
if slug == "nous":
|
|
try:
|
|
# The tier, Portal URL and recommendation caches are all per profile home.
|
|
with _config_profile_scope(profile):
|
|
return _nous_recommended_default()
|
|
except HTTPException:
|
|
raise # an unknown ?profile= is the scope's 404, not an empty recommendation
|
|
except Exception:
|
|
_log.exception("GET /api/model/recommended-default (nous) failed")
|
|
return {"provider": "nous", "model": "", "free_tier": None}
|
|
|
|
try:
|
|
from hermes_cli.inventory import build_models_payload, load_picker_context
|
|
from hermes_cli.models import pick_silent_default_model
|
|
|
|
# build_models_payload -> list_authenticated_providers -> _save_discovered_models_to_config:
|
|
# this GET lazily PERSISTS discovered custom-provider models, so it needs the scope too.
|
|
with _config_profile_scope(profile):
|
|
payload = build_models_payload(load_picker_context())
|
|
for row in payload.get("providers", []):
|
|
if str(row.get("slug", "")).lower() == slug:
|
|
models = [str(m) for m in (row.get("models") or [])]
|
|
return {"provider": slug, "model": pick_silent_default_model(models, provider=slug), "free_tier": None}
|
|
return {"provider": slug, "model": "", "free_tier": None}
|
|
except HTTPException:
|
|
raise # an unknown ?profile= is the scope's 404, not an empty recommendation
|
|
except Exception:
|
|
_log.exception("GET /api/model/recommended-default failed")
|
|
return {"provider": slug, "model": "", "free_tier": None}
|
|
|
|
|
|
@router.get("/api/model/auxiliary")
|
|
def get_auxiliary_models(profile: Optional[str] = None):
|
|
"""Current auxiliary task assignments: ``{"tasks": [{task, provider, model,
|
|
base_url}, ...], "main": {provider, model}}``. ``profile`` scopes the read —
|
|
without it the Models page would show the dashboard profile's pins while
|
|
/api/model/set wrote the selected profile's.
|
|
|
|
Built-in slots come first; plugin-registered tasks follow, each carrying the
|
|
``label``/``hint``/``plugin`` the plugin declared (built-ins are labelled client-side) and
|
|
``inherit_from`` (base slot key or null). An inheriting row also carries ``effective``: the
|
|
route it resolves to right now, which is the base's while the row itself is unpinned.
|
|
``provider``/``model``/``base_url`` stay the slot's own stored values on every row."""
|
|
from agent.auxiliary_client import _get_auxiliary_task_config
|
|
|
|
with http_failure("GET /api/model/auxiliary failed", 500, detail="Failed to read auxiliary config"):
|
|
with _profile_scope(profile):
|
|
cfg = load_config()
|
|
# Inside the scope on purpose: plugin discovery and the resolver key on the same
|
|
# context-local home, so profile B's rows come from B's plugins and B's config.
|
|
plugin_tasks = _plugin_aux_tasks()
|
|
effective = {entry["key"]: _get_auxiliary_task_config(entry["key"])
|
|
for entry in plugin_tasks if entry.get("inherit_from")}
|
|
aux_cfg = cfg.get("auxiliary", {})
|
|
if not isinstance(aux_cfg, dict):
|
|
aux_cfg = {}
|
|
|
|
def _row(slot: str) -> dict:
|
|
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
|
|
base_url = str(slot_cfg.get("base_url", "") or "")
|
|
return {
|
|
"task": slot, "provider": str(slot_cfg.get("provider", "auto") or "auto"),
|
|
"model": str(slot_cfg.get("model", "") or ""), "base_url": base_url,
|
|
"reasoning_effort": str(slot_cfg.get("reasoning_effort") or "") or None,
|
|
# Lets the UI tell a free local/LAN pin from a forgotten paid-provider pin.
|
|
"local_endpoint": is_local_endpoint(base_url),
|
|
}
|
|
|
|
tasks = [_row(slot) for slot in _AUX_TASK_SLOTS]
|
|
tasks.extend({
|
|
**_row(entry["key"]),
|
|
"label": str(entry.get("display_name") or entry["key"]),
|
|
"hint": str(entry.get("description") or ""),
|
|
"plugin": str(entry.get("plugin") or ""),
|
|
"inherit_from": entry.get("inherit_from") or None,
|
|
**({"effective": {field: str(effective[entry["key"]].get(field) or "")
|
|
for field in ("provider", "model", "base_url")}}
|
|
if entry["key"] in effective else {}),
|
|
} for entry in plugin_tasks)
|
|
|
|
model, provider = _main_model_fields(cfg.get("model", {}))
|
|
return {"tasks": tasks, "main": {"provider": str(provider or ""), "model": str(model or "")}}
|
|
|
|
|
|
@router.get("/api/model/moa")
|
|
def get_moa_models(profile: Optional[str] = None):
|
|
"""Return the configured Mixture-of-Agents provider/model slots."""
|
|
with http_failure("GET /api/model/moa failed", 500, detail="Failed to read MoA config"):
|
|
from hermes_cli.moa_config import normalize_moa_config
|
|
|
|
with _profile_scope(profile):
|
|
cfg = load_config()
|
|
return normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {})
|
|
|
|
|
|
_MOA_PRESET_FIELDS = (
|
|
"reference_temperature", "aggregator_temperature", "reference_timeout",
|
|
"degraded_reference_policy", "fanout", "enabled",
|
|
)
|
|
|
|
|
|
def _slot_dict(slot: MoaModelSlot) -> dict:
|
|
# Drop unset optionals so saved slots stay minimal ({provider, model}).
|
|
return {k: v for k, v in slot.dict().items() if v is not None}
|
|
|
|
|
|
def _preset_dict(preset) -> dict:
|
|
"""Raw preset dict from a MoaPresetPayload or the flat MoaConfigPayload fields."""
|
|
return {
|
|
"reference_models": [_slot_dict(slot) for slot in preset.reference_models],
|
|
"aggregator": _slot_dict(preset.aggregator),
|
|
**{name: getattr(preset, name) for name in _MOA_PRESET_FIELDS},
|
|
}
|
|
|
|
|
|
@router.put("/api/model/moa")
|
|
def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
|
|
"""Persist the Mixture-of-Agents provider/model slots."""
|
|
with http_failure("PUT /api/model/moa failed", 500, detail="Failed to save MoA config"):
|
|
from hermes_cli import config as config_mod
|
|
from hermes_cli.moa_config import normalize_moa_config, validate_moa_payload
|
|
|
|
# Serialize the full RMW with dashboard autosaves and with non-HTTP
|
|
# save_config callers, which only hold the config module's reentrant lock.
|
|
with config_write_scope(body.profile or profile), config_mod._CONFIG_LOCK:
|
|
legacy_flat_update = not body.presets
|
|
if not legacy_flat_update:
|
|
raw = {
|
|
"default_preset": body.default_preset,
|
|
"active_preset": body.active_preset,
|
|
"presets": {name: _preset_dict(preset) for name, preset in body.presets.items()},
|
|
}
|
|
else:
|
|
raw = _preset_dict(body) # legacy flat payload from older clients
|
|
|
|
# Reject-don't-repair: normalize_moa_config() silently swaps any preset with
|
|
# incomplete slots for the hardcoded defaults — correct tolerance at READ time,
|
|
# silent data loss at WRITE time (desktop autosave of a half-filled slot replaced
|
|
# the user's whole preset). Refuse loudly so no client can corrupt config here.
|
|
# See #64156.
|
|
problems = validate_moa_payload(raw)
|
|
if problems:
|
|
raise HTTPException(status_code=422, detail="Invalid MoA config: " + "; ".join(problems))
|
|
|
|
# Read the raw profile file because the effective config contains defaults and
|
|
# ``merge_existing=True`` deep-merges dicts, which resurrects omitted preset names.
|
|
# Keep unknown MoA keys and sibling sections by rebuilding only the MoA section on
|
|
# the raw document, while treating the submitted named map as authoritative.
|
|
# Strict read: the merge below builds a new dict and would discard the
|
|
# failed-read marker, allowing a transient fallback to replace the file.
|
|
raw_cfg = require_readable_config_before_write()
|
|
existing_moa = raw_cfg.get("moa")
|
|
if isinstance(existing_moa, dict) and "privacy_filter" in existing_moa:
|
|
# ``privacy_filter`` is emitted by the normalizer but is not declared by
|
|
# MoaConfigPayload; an editor round-trip must not reset it.
|
|
raw["privacy_filter"] = existing_moa["privacy_filter"]
|
|
normalized = normalize_moa_config(raw)
|
|
if legacy_flat_update and isinstance(existing_moa, dict):
|
|
existing_presets = existing_moa.get("presets")
|
|
existing_default = existing_moa.get("default_preset")
|
|
if isinstance(existing_presets, dict):
|
|
if isinstance(existing_default, str) and existing_default in existing_presets:
|
|
# Older clients update the flat default preset; keep the existing
|
|
# default name and any valid active selection from the stored map.
|
|
synthesized_default = normalized.get("default_preset")
|
|
if (
|
|
isinstance(synthesized_default, str)
|
|
and synthesized_default in normalized["presets"]
|
|
):
|
|
normalized["presets"][existing_default] = normalized["presets"].pop(
|
|
synthesized_default
|
|
)
|
|
normalized["default_preset"] = existing_default
|
|
existing_active = existing_moa.get("active_preset")
|
|
if isinstance(existing_active, str) and existing_active in existing_presets:
|
|
normalized["active_preset"] = existing_active
|
|
updated = config_mod._merge_partial_save(raw_cfg, {"moa": normalized})
|
|
if not legacy_flat_update:
|
|
# ``_merge_partial_save`` retains undeclared metadata on surviving presets;
|
|
# filter after that merge so omitted names are authoritative deletions.
|
|
merged_presets = updated["moa"]["presets"]
|
|
updated["moa"]["presets"] = {
|
|
name: merged_presets[name] for name in normalized["presets"]
|
|
}
|
|
save_config(updated)
|
|
return {"ok": True, **normalized}
|
|
|
|
|
|
@router.post("/api/model/set")
|
|
async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None):
|
|
"""Assign a model to the main slot or an auxiliary task slot. Writes
|
|
``~/.hermes/config.yaml`` — applies to **new** sessions only; a running chat
|
|
PTY hot-swaps via the ``/model`` slash command instead."""
|
|
scope, task = (body.scope or "").strip().lower(), (body.task or "").strip().lower()
|
|
provider, model = (body.provider or "").strip(), (body.model or "").strip()
|
|
base_url, api_key = (body.base_url or "").strip(), (body.api_key or "").strip()
|
|
|
|
if scope not in {"main", "auxiliary"}:
|
|
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
|
|
|
|
with http_failure("POST /api/model/set failed", 500, detail="Failed to save model assignment"):
|
|
# #99859 (R2): the options picker already refuses on code skew; the WRITE path
|
|
# must too — a stale process persisting a post-update model string is the
|
|
# invalid-model-serving failure the reporter hit.
|
|
skew_msg = _dashboard_code_skew_guard()
|
|
if skew_msg:
|
|
_log.warning("POST /api/model/set refused: %s", skew_msg)
|
|
raise HTTPException(status_code=503, detail=f"Restart required: {skew_msg}")
|
|
|
|
# Expensive-model warning runs BEFORE the profile scope is entered: _profile_scope
|
|
# must never be held across an await (the RLock is reentrant per-thread, so a second
|
|
# coroutine interleaving on the event-loop thread could cross-restore module globals).
|
|
if model and not body.confirm_expensive_model:
|
|
try:
|
|
from hermes_cli.model_selection_guards import combined_selection_warning
|
|
|
|
# Pricing lookup can hit models.dev / a /models endpoint on a cache miss — off the loop.
|
|
warning = await asyncio.to_thread(combined_selection_warning, model, provider=provider, base_url=base_url)
|
|
except Exception:
|
|
warning = None
|
|
if warning is not None:
|
|
return {"ok": False, "scope": scope, "provider": provider, "model": model,
|
|
"confirm_required": True, "confirm_message": warning.message}
|
|
|
|
reasoning_effort = body.reasoning_effort if "reasoning_effort" in body.model_fields_set else _UNSET
|
|
|
|
def _apply_assignment():
|
|
# Same RMW span as PUT /api/config: applyMainModel fires this while the
|
|
# settings-page autosave is in flight — hold the mutation lock. switch_model's
|
|
# catalog fetches / endpoint probes are network I/O, so they run BEFORE the lock;
|
|
# only load→apply→save holds it.
|
|
with _profile_scope(body.profile or profile):
|
|
prepared = (_prepare_main_assignment(load_config(), provider, model, base_url, api_key)
|
|
if scope == "main" else None)
|
|
with _CONFIG_MUTATION_LOCK:
|
|
return _apply_model_assignment_sync(
|
|
scope, provider, model, task, base_url, api_key,
|
|
reasoning_effort=reasoning_effort, prepared=prepared)
|
|
|
|
return await asyncio.to_thread(_apply_assignment)
|