Field Report // NO. 006
How I Consolidated Model Config Across 13 Profiles Into One File
Thirteen copy-pasted model configs, one typo breaking a profile for a weekend. The fix: one models.yaml, tiers instead of per-profile chains, and a sync script you can copy. Here is the whole recipe.
I was running LLM access across three paid subscriptions — MiniMax, OpenCode Go, and OpenRouter — through 13 different Hermes profiles. Each profile had its own fallback chain: if MiniMax hit a rate limit, the conversation would automatically route to OpenCode Go, then OpenRouter. The chains were already working. The problem was managing them.
Every time I wanted to change a model, add a subscription, or reorder the fallback chain, I had to open all 13 config files and edit each one by hand. One missed hyphen was all it took to break a profile for a weekend. And if I added a new subscription, I had to update every file.
The fix was one file and one script. ~/.hermes/models.yaml holds every routing decision; hermes-models rewrites the model keys in every profile’s config. Change a model, change it once. Add a profile, add one line. The script that does it is short enough to show in full below, so you can copy it, adapt it, and never hand-edit a profile’s model block again.
The one file
~/.hermes/models.yaml has four sections: providers, tiers, auxiliary routing, and profile assignments.
Providers — where your API keys live and what endpoint to hit:
providers:
minimax:
type: openai-compatible
base_url: https://api.minimax.io/v1
api_key_file: ~/.opencode/minimax.key # 0600, single source of the key
opencode-go:
type: openai-compatible
# OPENCODE_GO_API_KEY is already in ~/.hermes/.env (or per-profile .env)
api_key_env: OPENCODE_GO_API_KEY
openrouter:
type: openai-compatible
api_key_env: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
api_key_file points to a key on disk; api_key_env names an environment variable that is already in your shell dotfiles. Either way the profile config never holds the key itself — only a reference.
Tiers — ordered fallback chains, named by workload type. Index 0 is primary; the rest are tried in order until one succeeds:
tiers:
heavy:
chain:
- { provider: minimax, model: minimax-m3 }
- { provider: opencode-go, model: deepseek-v4-pro }
- { provider: openrouter, model: deepseek/deepseek-v4-pro }
medium:
chain:
- { provider: minimax, model: minimax-m2.7 }
- { provider: opencode-go, model: deepseek-v4-flash }
- { provider: openrouter, model: deepseek/deepseek-v4-flash }
simple:
chain:
- { provider: minimax, model: minimax-m2.5-highspeed }
- { provider: opencode-go, model: deepseek-v4-nano }
- { provider: openrouter, model: deepseek/deepseek-v4-nano }
vision:
chain:
- { provider: minimax, model: minimax-m3 }
- { provider: openrouter, model: google/gemini-2.5-flash }
Four tiers cover the workload spread: heavy for the hardest reasoning, medium for routine work, simple for cheap background tasks, and vision for image input. The first three differ by capability and cost; vision differs by required capability.
Auxiliary routing — background tasks that run on every profile use the same cheap chain everywhere:
auxiliary:
vision: vision
web_extract: simple
compression: simple
title_generation: simple
session_search: simple
approval: medium
Background tasks mostly route to simple. Approval (which I want to be deliberate) routes to medium. Vision routes to the vision tier alias because images need multimodal input.
Profile assignments — one line per profile:
profiles:
personal: { tier: heavy }
system-admin: { tier: heavy }
engineering-lead: { tier: heavy }
engineer: { tier: heavy }
junior-engineer: { tier: medium }
architect: { tier: heavy }
devops: { tier: heavy }
product: { tier: heavy }
trainer: { tier: medium }
accountant: { tier: medium }
reviewer: { tier: medium }
fishing: { tier: simple }
the-agent-files-editor: { tier: medium }
The tier assignment is the only per-profile decision. Change a profile’s tier and the sync script recomputes everything. Most profiles land on heavy — the cost of being wrong is higher than the cost of a slightly-more-expensive token. The cheap ones (fishing, junior-engineer, the editor) earn their tier by being genuinely low-stakes work.
The script
hermes-models reads ~/.hermes/models.yaml and rewrites the owned model keys in every profile’s config.yaml. The whole thing — the version I run today — is below. It is 324 lines because it also snapshots every profile before writing, so rollback always works. The core is three functions: resolve each profile’s tier into concrete provider/model pairs, diff the owned keys, then write them back without touching anything else.
Save it to ~/.hermes/scripts/hermes-models, make it executable with chmod +x ~/.hermes/scripts/hermes-models, and install PyYAML in the Python environment you invoke it from (pip install pyyaml). It needs no other dependencies.
#!/usr/bin/env python3
"""hermes-models — sync model routing from models.yaml into every profile's config.yaml.
Why: hand-editing model/provider pairs across N profile configs is how one
missed hyphen breaks a profile for a weekend. This script makes models.yaml
the single source of truth; profiles only declare which tier they run on.
Usage:
hermes-models diff [PROFILE] Show what would change, write nothing.
hermes-models sync [--apply] Sync profiles. --apply forces a write;
default is dry-run.
hermes-models rollback Restore the most recent snapshot.
hermes-models doctor Check provider keys and tier names.
Files this script owns in each profile's config.yaml:
- model (full overwrite)
- fallback_providers (full overwrite)
- auxiliary (full overwrite)
- sync_meta (writes last-synced timestamp)
Everything else in config.yaml is left strictly untouched.
"""
from __future__ import annotations
import argparse
import difflib
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
import yaml # PyYAML
except ImportError:
sys.exit("Missing dependency: PyYAML. Install with `pip install pyyaml`.")
HERMES_HOME = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes").resolve()
PROFILES_DIR = HERMES_HOME / "profiles"
SNAPSHOTS_DIR = PROFILES_DIR / ".snapshots"
MODELS_FILE = Path(os.environ.get("HERMES_MODELS_FILE") or HERMES_HOME / "models.yaml")
# Keys we own. Anything not in this set is left untouched in profile configs.
OWNED_KEYS = ("model", "fallback_providers", "auxiliary", "sync_meta")
# ─── resolution ────────────────────────────────────────────────────────────
def _load_models() -> dict:
if not MODELS_FILE.is_file():
sys.exit(f"Missing {MODELS_FILE}. Create it before running sync.")
with MODELS_FILE.open() as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
sys.exit(f"{MODELS_FILE} is not a YAML mapping at the top level.")
return data
def _build_chain(tier_block: dict, providers: dict) -> list[dict]:
"""Turn a tier's `chain:` list into {provider, model} hops, priority order."""
out = []
for hop in tier_block["chain"]:
prov_name = hop["provider"]
if prov_name not in providers:
sys.exit(f"Unknown provider {prov_name!r} in tier chain. "
f"Defined providers: {list(providers)}")
out.append({"provider": prov_name, "model": hop["model"]})
return out
def _resolve_all(data: dict, profile_name: str) -> dict:
"""models.yaml -> owned keys for one profile, in Hermes's native shape."""
entry = data.get("profiles", {}).get(profile_name)
if entry is None:
sys.exit(f"Profile {profile_name!r} not present in models.yaml under "
f"`profiles:`. Add it before syncing.")
tier_name = entry.get("tier")
if not tier_name:
sys.exit(f"Profile {profile_name!r} has no `tier:` in models.yaml.")
tiers = data.get("tiers", {})
if tier_name not in tiers:
sys.exit(f"Profile {profile_name!r} references unknown tier "
f"{tier_name!r}. Defined tiers: {list(tiers)}")
providers = data.get("providers", {})
chains = {t: _build_chain(b, providers) for t, b in tiers.items()}
chain = chains[tier_name]
# Auxiliary: a tier name as reference resolves to that tier's primary hop,
# with the tier's tail as the task's own fallback chain.
aux_block: dict = {}
for task, ref in (data.get("auxiliary", {}) or {}).items():
if isinstance(ref, str):
if ref not in chains:
sys.exit(f"Auxiliary task {task!r} -> unknown tier {ref!r}. "
f"Defined tiers: {sorted(chains)}")
primary = chains[ref][0]
aux_block[task] = {"provider": primary["provider"],
"model": primary["model"]}
tail = [{"provider": h["provider"], "model": h["model"]}
for h in chains[ref][1:]]
if tail:
aux_block[task]["fallback_chain"] = tail
elif isinstance(ref, dict) and "provider" in ref and "model" in ref:
aux_block[task] = {"provider": ref["provider"], "model": ref["model"]}
else:
sys.exit(f"Auxiliary task {task!r} -> bad reference {ref!r}. "
f"Use a tier name or a {{provider, model}} pair.")
return {
"model": {"provider": chain[0]["provider"], "default": chain[0]["model"]},
"fallback_providers": [
{"provider": h["provider"], "model": h["model"]} for h in chain[1:]
],
"auxiliary": aux_block,
"sync_meta": {
"last_sync_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"source": str(MODELS_FILE),
"profile_tier": tier_name,
},
}
def _split_owned(config: dict) -> tuple[dict, dict]:
owned = {k: v for k, v in config.items() if k in OWNED_KEYS}
rest = {k: v for k, v in config.items() if k not in OWNED_KEYS}
return owned, rest
def _profile_config_path(name: str) -> Path:
return PROFILES_DIR / name / "config.yaml"
def _profile_tier(data: dict, name: str) -> str:
return (data.get("profiles", {}).get(name) or {}).get("tier", "?")
# ─── commands ──────────────────────────────────────────────────────────────
def cmd_diff(data: dict, profile: str | None) -> None:
targets = ([profile] if profile else sorted(
p.name for p in PROFILES_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")))
any_changes = False
for name in targets:
cfg_path = _profile_config_path(name)
if not cfg_path.is_file():
continue
with cfg_path.open() as f:
before_cfg = yaml.safe_load(f) or {}
owned_before, _ = _split_owned(before_cfg)
owned_after = _resolve_all(data, name)
if owned_before == owned_after:
print(f"[{name}] no changes (tier={_profile_tier(data, name)})")
continue
any_changes = True
before = yaml.safe_dump(owned_before, sort_keys=False,
default_flow_style=False, width=100)
after = yaml.safe_dump(owned_after, sort_keys=False,
default_flow_style=False, width=100)
print(f"\n=== {name} (tier={_profile_tier(data, name)}) ===")
print("".join(difflib.unified_diff(before.splitlines(True),
after.splitlines(True),
fromfile="before", tofile="after")))
if not any_changes:
print("\nAll profiles already match models.yaml.")
def _snapshot(dirs: list[Path]) -> Path:
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
snap_root = SNAPSHOTS_DIR / ts
for d in dirs:
cfg = d / "config.yaml"
if not cfg.is_file():
continue
target = snap_root / d.name
target.mkdir(parents=True, exist_ok=True)
shutil.copy2(cfg, target / "config.yaml")
snaps = sorted(p for p in SNAPSHOTS_DIR.iterdir() if p.is_dir())
for old in snaps[:-5]:
shutil.rmtree(old, ignore_errors=True)
return snap_root
def cmd_sync(data: dict, profile: str | None, *, apply: bool) -> None:
targets = ([profile] if profile else sorted(
p.name for p in PROFILES_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")))
dirs = [PROFILES_DIR / n for n in targets if (PROFILES_DIR / n).is_dir()]
if not dirs:
sys.exit("No profiles to sync.")
if apply:
snap = _snapshot(dirs)
print(f"snapshot created at {snap}")
changed: list[str] = []
unchanged: list[str] = []
for prof_dir in dirs:
name = prof_dir.name
cfg_path = _profile_config_path(name)
with cfg_path.open() as f:
before_cfg = yaml.safe_load(f) or {}
owned_before, rest = _split_owned(before_cfg)
owned_after = _resolve_all(data, name)
if owned_before == owned_after:
unchanged.append(name)
continue
changed.append(name)
if not apply:
print(f"[DRY-RUN] would rewrite {name}/config.yaml "
f"(tier={_profile_tier(data, name)})")
continue
merged = {**rest, **owned_after}
cfg_path.write_text(yaml.safe_dump(merged, sort_keys=True,
default_flow_style=False, width=100))
print(f"[applied] {name} -> tier={_profile_tier(data, name)}")
summary = (f"{len(changed)} changed, {len(unchanged)} unchanged"
if apply else
f"{len(changed)} would change, {len(unchanged)} unchanged")
print(f"\n{summary}. {'wrote config files.' if apply else 'rerun with --apply to write.'}")
def cmd_rollback() -> None:
snaps = sorted(p for p in SNAPSHOTS_DIR.iterdir()
if p.is_dir()
and any((sub / "config.yaml").is_file() for sub in p.iterdir()))
if not snaps:
sys.exit("No snapshots found.")
latest = snaps[-1]
restored = 0
for profile_dir in latest.iterdir():
if not profile_dir.is_dir():
continue
src = profile_dir / "config.yaml"
target = _profile_config_path(profile_dir.name)
if not src.is_file():
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
restored += 1
print(f"restored {profile_dir.name}/config.yaml")
print(f"\nrolled back {restored} profiles from {latest}")
def cmd_doctor(data: dict) -> None:
providers = data.get("providers", {}) or {}
tiers = data.get("tiers", {}) or {}
profiles = data.get("profiles", {}) or {}
issues: list[str] = []
for name, block in providers.items():
if "api_key_env" in block and not os.environ.get(block["api_key_env"]):
issues.append(f"provider {name!r}: env var "
f"{block['api_key_env']!r} not set in this shell")
if "api_key_file" in block:
p = Path(os.path.expanduser(block["api_key_file"]))
if not p.is_file():
issues.append(f"provider {name!r}: api_key_file {p} missing")
for tier_name, tier in tiers.items():
for hop in tier.get("chain", []):
if hop["provider"] not in providers:
issues.append(f"tier {tier_name!r}: unknown provider "
f"{hop['provider']!r}")
for prof_name, entry in profiles.items():
t = entry.get("tier") if isinstance(entry, dict) else None
if not t:
issues.append(f"profile {prof_name!r}: missing `tier:`")
elif t not in tiers:
issues.append(f"profile {prof_name!r}: unknown tier {t!r}")
aux = data.get("auxiliary", {}) or {}
for task, ref in aux.items():
if isinstance(ref, str) and ref not in tiers:
issues.append(f"global auxiliary task {task!r}: unknown tier {ref!r}")
if not issues:
print("all providers resolvable, all profiles map to known tiers.")
else:
print(f"{len(issues)} issue(s):")
for i in issues:
print(f" - {i}")
sys.exit(1)
# ─── entrypoint ────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="hermes-models",
description="Sync model routing from models.yaml "
"into Hermes profile configs.")
sub = p.add_subparsers(dest="cmd", required=True)
p_diff = sub.add_parser("diff", help="Show what would change (no writes).")
p_diff.add_argument("profile", nargs="?", help="Limit diff to one profile.")
p_sync = sub.add_parser("sync", help="Sync profiles from models.yaml.")
p_sync.add_argument("profile", nargs="?", help="Limit sync to one profile.")
p_sync.add_argument("--apply", action="store_true",
help="Write changes (default is dry-run).")
sub.add_parser("rollback", help="Restore most recent snapshot.")
sub.add_parser("doctor", help="Validate models.yaml.")
args = p.parse_args(argv)
data = _load_models()
if args.cmd == "diff":
cmd_diff(data, args.profile)
elif args.cmd == "sync":
cmd_sync(data, args.profile, apply=args.apply)
elif args.cmd == "rollback":
cmd_rollback()
elif args.cmd == "doctor":
cmd_doctor(data)
return 0
if __name__ == "__main__":
sys.exit(main())
How to run it
Four commands. Dry-run is the default — sync without --apply shows the diff but writes nothing:
# See what would change — writes nothing
~/.hermes/scripts/hermes-models diff
# Apply the changes to all profiles
~/.hermes/scripts/hermes-models sync --apply
# Undo the last sync (restores the pre-sync snapshot)
~/.hermes/scripts/hermes-models rollback
# Check that all provider keys exist and tier names are valid
~/.hermes/scripts/hermes-models doctor
The script snapshots every profile before touching anything, so rollback always works.
What changes in a profile
The sync script owns four keys in ~/.hermes/profiles/<name>/config.yaml. Everything else is left untouched. Here is what a profile on the medium tier looks like after sync:
model:
provider: minimax
default: minimax-m2.7
fallback_providers:
- { provider: opencode-go, model: deepseek-v4-flash }
- { provider: openrouter, model: deepseek/deepseek-v4-flash }
auxiliary:
vision: { provider: minimax, model: minimax-m3 }
web_extract: { provider: minimax, model: minimax-m2.5-highspeed }
compression: { provider: minimax, model: minimax-m2.5-highspeed }
title_generation: { provider: minimax, model: minimax-m2.5-highspeed }
session_search: { provider: minimax, model: minimax-m2.5-highspeed }
approval: { provider: minimax, model: minimax-m2.7 }
sync_meta:
last_sync_utc: "2026-07-24T09:15:00+00:00"
source: /home/your-user/.hermes/models.yaml
profile_tier: medium
Note that approval here resolves to medium’s primary (minimax-m2.7), because the profile is on medium — approval wants deliberation, not speed. On a simple profile the same line resolves to minimax-m2.5-highspeed. The script also writes a sync_meta block recording the timestamp, the source file, and the tier, so you can always tell where a config came from.
A real change end-to-end
Here is what actually changing something looks like.
You want to add Gemini Flash as a new fallback for heavy, before OpenRouter. Edit models.yaml:
heavy:
chain:
- { provider: minimax, model: minimax-m3 }
- { provider: opencode-go, model: deepseek-v4-pro }
- { provider: openrouter, model: google/gemini-2.5-flash }
- { provider: openrouter, model: deepseek/deepseek-v4-pro }
Run the diff:
~/.hermes/scripts/hermes-models diff
The output shows every profile whose heavy chain will change, with the before and after. Review it. Then apply:
~/.hermes/scripts/hermes-models sync --apply
Every profile on heavy now routes to the new chain. That is the entire workflow: edit one line, diff, apply.
One gotcha with YAML
You cannot have two fallback: keys in the same mapping. YAML silently keeps the last one and discards the first. The tier chains use chain: as a named list to avoid this. Order is the priority — array index 0 is primary, index 1 is first fallback, and so on.
What I would do differently now
This article captures the July 2026 state of the setup: raw providers and a single global file, with openai-api routes pointing straight at the providers. Since then the stack routes openai-api through a LiteLLM gateway, and the tier aliases in models.yaml (heavy, medium, simple) became real fallback chains inside the gateway — hermes-07 walks through that migration and the env-var trap that made my models vanish on restart. The file-walkthrough and tier-selection framework live in hermes-11; the full 13-profile map and the tier-creation rule live in hermes-12. The recipe here — one file, tiers, sync script — is still the mechanism underneath all of that.
The takeaway
The lesson is not that I have 13 profiles. It is the pattern: when N copies of a decision exist, the copies become the maintenance burden. Move the decision into one file, make the per-instance value a reference to that file, and let a script do the copying. The tier system is that pattern applied to model routing — and the script that applies it is the part worth keeping. When the config stops being hand-edited, the weekend-killing typo stops being a category of bug.