From 10ca841a45712b2dbd352ea27bdc413d5ef1b643 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 20 May 2026 13:42:33 -0400 Subject: [PATCH] fix(xai): migrate current model config slots --- hermes_cli/xai_retirement.py | 39 ++++++++++++++++++++++--- tests/hermes_cli/test_migrate_xai.py | 22 ++++++++++++++ tests/hermes_cli/test_xai_retirement.py | 21 +++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/hermes_cli/xai_retirement.py b/hermes_cli/xai_retirement.py index 02ad903f7b356..854279a551c2a 100644 --- a/hermes_cli/xai_retirement.py +++ b/hermes_cli/xai_retirement.py @@ -63,7 +63,8 @@ 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`` + - ``model.default`` / ``model.model`` (current primary config shape) + - ``principal.model`` (legacy config shape) - ``auxiliary..model`` (introspective — covers future aux slots) - ``delegation.model`` - ``tts.xai.model`` @@ -89,6 +90,13 @@ def _check(path: str, model: Any) -> None: if not isinstance(config, dict): return issues + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + _check("model.default", model_cfg.get("default")) + _check("model.model", model_cfg.get("model")) + elif isinstance(model_cfg, str): + _check("model", model_cfg) + principal = config.get("principal") if isinstance(principal, dict): _check("principal.model", principal.get("model")) @@ -155,11 +163,16 @@ def _walk_to_parent(yaml_doc: Any, dotted_path: str) -> "tuple[Any, str]": """Resolve a dotted slot path to (parent_mapping, leaf_key). Example: "auxiliary.vision.model" -> (yaml_doc["auxiliary"]["vision"], "model"). + A root key such as "model" resolves to (yaml_doc, "model"). Raises KeyError if any intermediate node is missing or not a mapping. """ parts = dotted_path.split(".") - if len(parts) < 2: - raise ValueError(f"Path must have at least one parent: {dotted_path!r}") + if not parts or not parts[0]: + raise ValueError(f"Path must not be empty: {dotted_path!r}") + if len(parts) == 1: + if not isinstance(yaml_doc, dict): + raise KeyError(f"Root document is not a mapping: {dotted_path!r}") + return yaml_doc, parts[0] node = yaml_doc for segment in parts[:-1]: if not isinstance(node, dict) or segment not in node: @@ -168,6 +181,21 @@ def _walk_to_parent(yaml_doc: Any, dotted_path: str) -> "tuple[Any, str]": return node, parts[-1] +def _replacement_preserving_prefix(current_model: str, replacement: str) -> str: + """Return replacement while preserving explicit provider prefixes. + + OpenRouter users commonly store xAI model ids as ``x-ai/grok-*``. Detection + normalizes that prefix away, but applying the migration must not rewrite an + OpenRouter-routable value into a bare xAI model id. + """ + stripped = current_model.strip() + lowered = stripped.lower() + for prefix in ("x-ai/", "xai/"): + if lowered.startswith(prefix): + return f"{stripped[:len(prefix)]}{replacement}" + return replacement + + def apply_migration( config_path: Path, issues: List[RetirementIssue], @@ -221,7 +249,10 @@ def apply_migration( except KeyError: # Slot vanished between scan and apply — skip silently continue - parent[leaf] = issue.replacement + parent[leaf] = _replacement_preserving_prefix( + issue.current_model, + issue.replacement, + ) if issue.reasoning_effort: parent["reasoning_effort"] = issue.reasoning_effort resolved.append(issue) diff --git a/tests/hermes_cli/test_migrate_xai.py b/tests/hermes_cli/test_migrate_xai.py index 8a913e98bf237..ff0580c429232 100644 --- a/tests/hermes_cli/test_migrate_xai.py +++ b/tests/hermes_cli/test_migrate_xai.py @@ -22,6 +22,10 @@ def trap_config(tmp_path: Path) -> Path: p = tmp_path / "config.yaml" p.write_text( "# Hermes config (sample)\n" + "model:\n" + " provider: xai # current primary model config\n" + " default: grok-code-fast-1 # retiring\n" + " model: x-ai/grok-4-fast-non-reasoning # legacy key with prefix\n" "principal:\n" " provider: xai # the main model\n" " model: grok-4-1-fast-non-reasoning # retiring May 15\n" @@ -107,6 +111,23 @@ def test_replaces_principal_model(self, trap_config: Path): cfg = _parse(trap_config) assert cfg["principal"]["model"] == "grok-4.3" + def test_replaces_current_model_default_and_legacy_model(self, trap_config: Path): + issues = find_retired_xai_refs(_parse(trap_config)) + apply_migration(trap_config, issues) + cfg = _parse(trap_config) + assert cfg["model"]["default"] == "grok-4.3" + assert cfg["model"]["model"] == "x-ai/grok-4.3" + assert cfg["model"]["reasoning_effort"] == "none" + + def test_replaces_root_model_string(self, tmp_path: Path): + config = tmp_path / "config.yaml" + config.write_text("model: xai/grok-3\n", encoding="utf-8") + issues = find_retired_xai_refs(_parse(config)) + result = apply_migration(config, issues, backup=False) + assert result.config_changed is True + cfg = _parse(config) + assert cfg["model"] == "xai/grok-4.3" + def test_adds_reasoning_effort_for_non_reasoning_variant(self, trap_config: Path): issues = find_retired_xai_refs(_parse(trap_config)) apply_migration(trap_config, issues) @@ -166,6 +187,7 @@ def test_preserves_top_level_key_order(self, trap_config: Path): apply_migration(trap_config, issues) text = trap_config.read_text(encoding="utf-8") order = [ + text.index("model:"), text.index("principal:"), text.index("auxiliary:"), text.index("delegation:"), diff --git a/tests/hermes_cli/test_xai_retirement.py b/tests/hermes_cli/test_xai_retirement.py index c87214ff0f0dc..feddb2cf5879c 100644 --- a/tests/hermes_cli/test_xai_retirement.py +++ b/tests/hermes_cli/test_xai_retirement.py @@ -107,6 +107,27 @@ def test_principal_retired(self): assert issues[0].replacement == "grok-4.3" assert issues[0].reasoning_effort is None + def test_model_default_retired(self): + cfg = {"model": {"provider": "xai", "default": "grok-code-fast-1"}} + issues = find_retired_xai_refs(cfg) + assert len(issues) == 1 + assert issues[0].config_path == "model.default" + assert issues[0].current_model == "grok-code-fast-1" + assert issues[0].replacement == "grok-4.3" + + def test_model_model_retired_legacy_key(self): + cfg = {"model": {"provider": "xai", "model": "grok-4-fast-non-reasoning"}} + issues = find_retired_xai_refs(cfg) + assert len(issues) == 1 + assert issues[0].config_path == "model.model" + assert issues[0].reasoning_effort == "none" + + def test_root_model_string_retired(self): + cfg = {"model": "grok-3"} + issues = find_retired_xai_refs(cfg) + assert len(issues) == 1 + assert issues[0].config_path == "model" + def test_principal_with_x_ai_prefix(self): cfg = {"principal": {"model": "x-ai/grok-4-1-fast-non-reasoning"}} issues = find_retired_xai_refs(cfg)