Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions hermes_cli/xai_retirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<any>.model`` (introspective — covers future aux slots)
- ``delegation.model``
- ``tts.xai.model``
Expand All @@ -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"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model.model is a fallback alias, not a second active model slot: runtime_provider._get_model_config() uses it only when model.default is empty. Please make this an elif/fallback check and test that a valid default plus a retired legacy alias does not generate an alias migration or set reasoning_effort on the active model block.

_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"))
Expand Down Expand Up @@ -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:
Expand All @@ -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],
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/hermes_cli/test_migrate_xai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:"),
Expand Down
21 changes: 21 additions & 0 deletions tests/hermes_cli/test_xai_retirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down