Field Report // NO. 006

How I Consolidated Model Config Across 13 Profiles Into One File

24 July 2026· 7 min read· Case № 006

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.

← All transmissions