bf53ff00a7
Four writers each dropped their own uniquely-named copy of config.yaml next to the real file and none of them ever deleted anything: hermes setup (config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai (config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration (config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants with no way to tell which mattered. hermes_cli/config_backups.py::backup_config is now the single writer: backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest copy for that reason is byte-identical, rotated to the newest five per reason. backups/ is already excluded from full backups so nothing nests. Legacy siblings written by the old schemes are moved into the dir on first use; hand-named copies (config.yaml.bak-my-note) are left alone. Live: three `hermes setup --non-interactive` runs against an unchanged config went from three .bak files in HERMES_HOME to one pre-setup copy under backups/config/; repeated loads of broken YAML produce one corrupt copy instead of one per process (deduped by content).
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""Detect xAI models retired on May 15, 2026 and migrate config.yaml references."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
MIGRATION_GUIDE_URL = "https://docs.x.ai/developers/migration/may-15-retirement"
|
|
RETIREMENT_DATE = "May 15, 2026"
|
|
|
|
|
|
# Official mapping per xAI migration guide. ``grok-4.3`` reasons by default, so ``*-non-reasoning``
|
|
# variants need ``reasoning_effort="none"`` to emulate their behavior.
|
|
_RETIRED_MODELS: Dict[str, Dict[str, Optional[str]]] = {
|
|
"grok-4-0709": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
|
"grok-4-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
|
"grok-4-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
|
"grok-4-1-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
|
"grok-4-1-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
|
"grok-code-fast-1": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
|
"grok-3": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
|
"grok-imagine-image-pro": {"replacement": "grok-imagine-image-quality", "reasoning_effort": None, "note": None},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RetirementIssue:
|
|
"""A reference to a retired xAI model found in a Hermes config."""
|
|
|
|
config_path: str # e.g. "principal.model" or "auxiliary.vision.model"
|
|
current_model: str # exact value found in config (preserves casing/prefix)
|
|
replacement: str
|
|
reasoning_effort: Optional[str] = None # set for non-reasoning variant migration
|
|
note: Optional[str] = None
|
|
|
|
|
|
def _normalize(model_id: str) -> str:
|
|
"""Strip provider prefix (``x-ai/grok-4`` → ``grok-4``) and lowercase."""
|
|
m = model_id.strip().lower()
|
|
for prefix in ("x-ai/", "xai/"):
|
|
if m.startswith(prefix):
|
|
return m[len(prefix):]
|
|
return m
|
|
|
|
|
|
def _looks_like_xai(model_id: Optional[str]) -> bool:
|
|
return isinstance(model_id, str) and _normalize(model_id).startswith("grok-")
|
|
|
|
|
|
def find_retired_xai_refs(config: Dict[str, Any]) -> List[RetirementIssue]:
|
|
"""Walk all model slots in a Hermes config and return retirement issues.
|
|
|
|
Slots scanned: ``principal.model``, ``auxiliary.<any>.model`` (introspective, covers future
|
|
aux slots), ``delegation.model``, ``tts.xai.model``, ``plugins.image_gen.xai.model``.
|
|
"""
|
|
issues: List[RetirementIssue] = []
|
|
if not isinstance(config, dict):
|
|
return issues
|
|
|
|
def _check(path: str, model: Any) -> None:
|
|
entry = _RETIRED_MODELS.get(_normalize(model)) if _looks_like_xai(model) else None
|
|
if entry is not None:
|
|
issues.append(RetirementIssue(
|
|
config_path=path,
|
|
current_model=model,
|
|
replacement=entry["replacement"],
|
|
reasoning_effort=entry.get("reasoning_effort"),
|
|
note=entry.get("note")))
|
|
|
|
def _section(*keys: str) -> Optional[Dict[str, Any]]:
|
|
node: Any = config
|
|
for key in keys:
|
|
if not isinstance(node, dict):
|
|
return None
|
|
node = node.get(key)
|
|
return node if isinstance(node, dict) else None
|
|
|
|
def _check_section(*path: str) -> None:
|
|
section = _section(*path)
|
|
if section is not None:
|
|
_check(".".join(path) + ".model", section.get("model"))
|
|
|
|
_check_section("principal")
|
|
for slot_name, slot_cfg in (_section("auxiliary") or {}).items():
|
|
if isinstance(slot_cfg, dict):
|
|
_check(f"auxiliary.{slot_name}.model", slot_cfg.get("model"))
|
|
for path in (("delegation",), ("tts", "xai"), ("plugins", "image_gen", "xai")):
|
|
_check_section(*path)
|
|
return issues
|
|
|
|
|
|
def format_issue(issue: RetirementIssue) -> str:
|
|
"""One-line human-readable rendering of a retirement issue."""
|
|
parts = [f"{issue.config_path}: {issue.current_model!r} → use {issue.replacement!r}"]
|
|
if issue.reasoning_effort:
|
|
parts.append(f'(set reasoning_effort: "{issue.reasoning_effort}")')
|
|
if issue.note:
|
|
parts.append(f"[note: {issue.note}]")
|
|
return " ".join(parts)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ApplyResult:
|
|
"""Outcome of an apply_migration call."""
|
|
|
|
file_path: Path
|
|
backup_path: Optional[Path]
|
|
issues_resolved: List[RetirementIssue]
|
|
config_changed: bool
|
|
|
|
|
|
def _walk_to_parent(yaml_doc: Any, dotted_path: str) -> "tuple[Any, str]":
|
|
"""Resolve a dotted slot path to (parent_mapping, leaf_key)."""
|
|
*parents, leaf = dotted_path.split(".")
|
|
if not parents:
|
|
raise ValueError(f"Path must have at least one parent: {dotted_path!r}")
|
|
node = yaml_doc
|
|
for segment in parents:
|
|
if not isinstance(node, dict) or segment not in node:
|
|
raise KeyError(f"Path segment {segment!r} missing in {dotted_path!r}")
|
|
node = node[segment]
|
|
return node, leaf
|
|
|
|
|
|
def apply_migration(
|
|
config_path: Path, issues: List[RetirementIssue], backup: bool = True) -> ApplyResult:
|
|
"""Rewrite ``config_path`` in place (ruamel round-trip: comments, order, type literals kept).
|
|
|
|
Unless ``backup=False`` a copy goes to ``backups/config/`` (reason ``pre-migrate-xai``).
|
|
"""
|
|
from ruamel.yaml import YAML # local import — avoid hard dep at module load
|
|
config_path = Path(config_path)
|
|
if not config_path.exists():
|
|
raise FileNotFoundError(config_path)
|
|
unchanged = ApplyResult(file_path=config_path, backup_path=None, issues_resolved=[], config_changed=False)
|
|
if not issues:
|
|
return unchanged
|
|
|
|
yaml = YAML(typ="rt")
|
|
yaml.preserve_quotes = True
|
|
with config_path.open("r", encoding="utf-8") as fh:
|
|
doc = yaml.load(fh)
|
|
if doc is None:
|
|
return unchanged
|
|
|
|
resolved: List[RetirementIssue] = []
|
|
for issue in issues:
|
|
try:
|
|
parent, leaf = _walk_to_parent(doc, issue.config_path)
|
|
except KeyError:
|
|
continue # slot vanished between scan and apply
|
|
parent[leaf] = issue.replacement
|
|
if issue.reasoning_effort:
|
|
parent["reasoning_effort"] = issue.reasoning_effort
|
|
resolved.append(issue)
|
|
if not resolved:
|
|
return unchanged
|
|
|
|
backup_path: Optional[Path] = None
|
|
if backup:
|
|
from hermes_cli.config_backups import backup_config
|
|
backup_path = backup_config(config_path, "pre-migrate-xai")
|
|
|
|
from hermes_cli.config import require_readable_config_before_write
|
|
from utils import atomic_write_text
|
|
require_readable_config_before_write(config_path)
|
|
# Dump to a buffer, then atomic-write: ``open(path, "w")`` truncates before the dump runs, so a
|
|
# crash mid-write would leave config.yaml empty (and with ``--no-backup`` that is the only
|
|
# copy; the ``doc is None`` early return would then hide the damage). atomic_replace also keeps
|
|
# a symlinked config.yaml intact. preserve_mode keeps permission bits AND owner (managed NixOS
|
|
# 0640 / container installs; a root-run migration must not flip ownership).
|
|
buf = io.StringIO()
|
|
yaml.dump(doc, buf)
|
|
atomic_write_text(config_path, buf.getvalue(), preserve_mode=True)
|
|
return ApplyResult(file_path=config_path, backup_path=backup_path, issues_resolved=resolved, config_changed=True)
|