Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,29 @@ def migrate_model_config(self, config: Optional[Dict[str, Any]] = None) -> None:

model_str = model_str.strip()

# Resolve a model alias against the OpenClaw model catalog.
# OpenClaw stores agents.defaults.model as either a bare string or
# {"primary": "<value>"}, and that value can be either:
# - a full provider/model API ID (e.g. "anthropic/claude-opus-4-6"), or
# - a display alias (e.g. "Claude Opus 4.6") that maps to one.
# The catalog at agents.defaults.models is keyed by the full
# provider/model API ID with an "alias" field on the value, e.g.:
# {"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"}}
# If model_str matches an alias in the catalog, rewrite it to the
# catalog key (the real API ID). If it's already an API ID or has
# no catalog match, leave it alone and let downstream pass it through.
model_catalog = config.get("agents", {}).get("defaults", {}).get("models", {})
if isinstance(model_catalog, dict) and model_str not in model_catalog:
for api_id, entry in model_catalog.items():
if not isinstance(api_id, str):
continue
if isinstance(entry, dict) and entry.get("alias") == model_str:
model_str = api_id
break
if isinstance(entry, str) and entry == model_str:
model_str = api_id
break

if yaml is None:
self.record("model-config", source_path, destination, "error", "PyYAML is not available")
return
Expand Down
137 changes: 137 additions & 0 deletions tests/skills/test_openclaw_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,3 +970,140 @@ def test_migrate_soul_rebrands_content(tmp_path):
result = (target_root / "SOUL.md").read_text(encoding="utf-8")
assert "OpenClaw" not in result
assert "You are Hermes" in result


# ── migrate_model_config: alias resolution (issue #16745) ──────────────────

def _run_model_migration(tmp_path: Path, openclaw_json: dict) -> dict:
"""Helper: run just migrate_model_config on an openclaw.json and return
the parsed destination config.yaml."""
import yaml

mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
source.mkdir(parents=True)
target.mkdir(parents=True)
(source / "openclaw.json").write_text(json.dumps(openclaw_json), encoding="utf-8")

migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=True,
migrate_secrets=False,
output_dir=target / "migration-report",
)
migrator.migrate_model_config()

cfg_path = target / "config.yaml"
if not cfg_path.exists():
return {}
return yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}


def _extract_model(parsed: dict) -> str | None:
model = parsed.get("model")
if isinstance(model, dict):
return model.get("default")
return model


def test_migrate_model_config_resolves_alias_against_real_openclaw_schema(tmp_path: Path):
"""Regression for #16745 — OpenClaw's catalog is keyed by the full
provider/model API ID with an "alias" field on the value. The migration
must reverse-lookup the alias to find the API ID."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": {"primary": "Claude Opus 4.6"},
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
"openai/gpt-5.2": {"alias": "GPT"},
},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-opus-4-6"


def test_migrate_model_config_resolves_alias_with_bare_string_model(tmp_path: Path):
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "Sonnet",
"models": {"anthropic/claude-sonnet-4-7": {"alias": "Sonnet"}},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-sonnet-4-7"


def test_migrate_model_config_passes_through_existing_api_id(tmp_path: Path):
"""If the model value is already a provider/model API ID that appears as
a key in the catalog, it should be written verbatim — not double-rewritten."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-6",
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
},
}
}
},
)
assert _extract_model(parsed) == "anthropic/claude-opus-4-6"


def test_migrate_model_config_passes_through_unknown_alias(tmp_path: Path):
"""If the model value matches no catalog entry, leave it alone and let
downstream surface the mismatch."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "Totally Unknown Name",
"models": {
"anthropic/claude-opus-4-6": {"alias": "Claude Opus 4.6"},
},
}
}
},
)
assert _extract_model(parsed) == "Totally Unknown Name"


def test_migrate_model_config_handles_string_valued_catalog_entries(tmp_path: Path):
"""Belt-and-suspenders: some catalogs store the alias as a plain string
value instead of a dict with an "alias" field."""
parsed = _run_model_migration(
tmp_path,
{
"agents": {
"defaults": {
"model": "MyModel",
"models": {"provider/some-id": "MyModel"},
}
}
},
)
assert _extract_model(parsed) == "provider/some-id"


def test_migrate_model_config_no_catalog_leaves_value_alone(tmp_path: Path):
parsed = _run_model_migration(
tmp_path,
{"agents": {"defaults": {"model": "some-model-id"}}},
)
assert _extract_model(parsed) == "some-model-id"
Loading