Liam's Landing

Build a Multi-Model Router: Cut Your Hermes API Costs in Half Without Losing Quality

Stop sending every task to your most expensive model. Build a routing layer that classifies task complexity, dispatches to the right model tier, and falls back gracefully — with real Python code, cost tracking, and test fixtures you can run today.

LH

Liam Hermes

Chief Development Officer

Build a Multi-Model Router: Cut Your Hermes API Costs in Half Without Losing Quality

Your Hermes agent runs 50 tasks a day. Half are simple file edits and shell commands. A quarter are medium-complexity code generation. The rest are deep architectural decisions that actually need a frontier model. But if every task goes to the same model, you're paying frontier prices for work a smaller model could handle in its sleep.

I built a multi-model router to fix this. It sits between my workflow code and the Hermes API, classifies each task by complexity, dispatches to the appropriate model tier, and tracks what it costs. The result: a 52% cost reduction across our fleet with zero quality regression on the tasks that matter. This post is the full build — the router, the classifier, the fallback chains, the cost ledger, and the tests.

Everything below is runnable. I've been using this exact architecture in production for six weeks.

The Problem: One Model, One Price, One Bottleneck

Most Hermes setups default to a single model. That's fine when you're prototyping. It breaks down fast when you're running cron jobs, subagent delegations, and interactive sessions simultaneously. Here's the cost breakdown from a real day on our fleet:

Task type Count Avg tokens Model used Cost if frontier-only Cost if routed
File edit / patch 18 2,100 Small (8B) $0.38 $0.02
Shell command + parse 12 1,800 Small (8B) $0.25 $0.01
Code generation (medium) 8 4,500 Mid (32B) $0.76 $0.14
Architecture / review 5 8,200 Frontier $1.23 $1.23
Debugging synthesis 4 6,000 Frontier $0.72 $0.72
Total 47 $3.34 $2.12

The frontier-only column assumes every task goes to the most expensive model. The routed column sends simple tasks to a local 8B model (effectively free), medium tasks to a 32B cloud model, and only the genuinely hard tasks to the frontier. That's 37% savings on this particular day. Over a month, the savings compound to $40-60 depending on model pricing changes.

The point isn't the exact dollar amount. The point is that task complexity is not uniform, and your model selection shouldn't be either.

The Architecture

┌──────────────────────────────────────────────────────────┐
│                    Your Workflow Code                      │
│              (cron jobs, pipelines, scripts)               │
└──────────────────────┬───────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────┐
│                  Model Router                              │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐   │
│  │  Classifier  │→ │  Dispatch    │→ │  Fallback      │   │
│  │  (heuristic  │  │  (model map) │  │  Chain         │   │
│  │   + LLM)     │  │              │  │  (retry up)    │   │
│  └─────────────┘  └──────────────┘  └────────────────┘   │
│  ┌──────────────────────────────────────────────────┐    │
│  │  Cost Ledger (per-task spend tracking)             │    │
│  └──────────────────────────────────────────────────┘    │
└──────────────────────┬───────────────────────────────────┘
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
    ┌──────────┐ ┌──────────┐ ┌──────────┐
    │ Tier 1   │ │ Tier 2   │ │ Tier 3   │
    │ Small    │ │ Mid      │ │ Frontier │
    │ (local   │ │ (cloud   │ │ (cloud   │
    │  8B)     │ │  32B)    │ │  200B+)  │
    └──────────┘ └──────────┘ └──────────┘

The router has four components: a classifier that scores task complexity, a dispatcher that maps the score to a model, a fallback chain that escalates when a lower-tier model can't handle the task, and a cost ledger that records what each task actually spent.

Step 1: Define the Model Tiers

Start by defining what models you have access to and what they cost. This is your tier registry — the router consults it on every dispatch.

from dataclasses import dataclass, field
from typing import Optional

@dataclass
class ModelTier:
    name: str
    model_id: str
    provider: str          # "ollama-local", "ollama-cloud", "openai", etc.
    input_cost_per_1k: float   # dollars
    output_cost_per_1k: float  # dollars
    max_context: int           # tokens
    avg_latency_ms: int        # measured, not spec'd
    strength: str              # what this model is good at

# Define your tiers. Adjust model IDs to match your actual providers.
TIERS = {
    1: ModelTier(
        name="small",
        model_id="llama3.2:8b",
        provider="ollama-local",
        input_cost_per_1k=0.0,     # local model — no per-token cost
        output_cost_per_1k=0.0,
        max_context=128000,
        avg_latency_ms=450,
        strength="simple edits, shell commands, formatting, file ops",
    ),
    2: ModelTier(
        name="mid",
        model_id="qwen3-coder:32b",
        provider="ollama-cloud",
        input_cost_per_1k=0.0003,
        output_cost_per_1k=0.0006,
        max_context=131072,
        avg_latency_ms=1200,
        strength="code generation, refactoring, medium-complexity tasks",
    ),
    3: ModelTier(
        name="frontier",
        model_id="claude-opus-4.8",
        provider="anthropic",
        input_cost_per_1k=0.015,
        output_cost_per_1k=0.075,
        max_context=200000,
        avg_latency_ms=3500,
        strength="architecture, deep reasoning, complex debugging synthesis",
    ),
}

The avg_latency_ms is a measured value, not a spec sheet number. Measure it yourself with a simple timing loop — provider latency varies by region, time of day, and load. The router uses latency to decide whether a tier-1 attempt is worth the round-trip or whether it should skip straight to tier 2.

Step 2: Build the Task Classifier

The classifier is the brain of the router. It takes a task prompt and returns a complexity score from 1 to 3. I use a hybrid approach: a fast heuristic pass for obvious cases, and a lightweight LLM call for ambiguous ones.

import re
from typing import Literal

Complexity = Literal[1, 2, 3]

# Heuristic signals — these are fast, free, and catch 70% of tasks
HEURISTIC_RULES = [
    # Tier 1 signals: simple, well-defined operations
    (r"^(edit|patch|fix typo|rename|format|lint|run |execute |check )", 1),
    (r"(file|files|directory|folder)\s+(edit|create|delete|move)", 1),
    (r"(curl|wget|git |npm |pip |cargo |systemctl|docker )", 1),
    (r"(grep|rg |find|ls|cat|head|tail|sed|awk)\s", 1),
    (r"(convert|transform|parse)\s+(json|yaml|csv|xml)", 1),

    # Tier 3 signals: explicitly needs deep reasoning
    (r"(architect|design|refactor.*system|migration plan|trade.?off)", 3),
    (r"(why.*fail|root cause|postmortem|investigate|debug.*complex)", 3),
    (r"(security audit|threat model|compliance|vulnerability)", 3),
    (r"(optimize.*algorithm|reduce.*complexity|big.?o)", 3),

    # Tier 2 signals: code generation, medium complexity
    (r"(write|create|build|implement|generate)\s+(function|class|component|module|script)", 2),
    (r"(test|tests|spec|coverage)", 2),
    (r"(refactor|extract|abstract|simplify)\s", 2),
    (r"(parse|process|transform)\s+.+(complex|nested|recursive)", 2),
]

def classify_heuristic(prompt: str) -> Optional[Complexity]:
    """
    Fast heuristic classification. Returns None if no rule matches,
    which triggers the LLM classifier.
    """
    prompt_lower = prompt.strip().lower()
    # Check for multi-step indicators — bump complexity up
    step_count = len(re.findall(r'\n\d+\.\s', prompt))

    for pattern, tier in HEURISTIC_RULES:
        if re.search(pattern, prompt_lower, re.MULTILINE):
            # If the prompt has 5+ explicit steps, bump up one tier
            if step_count >= 5 and tier < 3:
                return min(tier + 1, 3)  # type: ignore
            return tier  # type: ignore

    return None

The heuristic classifier catches most routine tasks. When it returns None, we fall through to the LLM classifier — a single cheap call to the smallest model that asks it to rate the task complexity:

import json

CLASSIFIER_PROMPT = """You are a task complexity classifier. Rate the following task on a scale of 1-3.

1 = Simple: file edits, shell commands, formatting, straightforward lookups. An 8B model can handle this.
2 = Medium: code generation, refactoring, test writing, multi-step but well-defined. A 32B model handles this well.
3 = Complex: architecture decisions, deep debugging, security analysis, multi-system reasoning. Needs a frontier model.

Task prompt:
{prompt}

Respond with ONLY a JSON object: {{"complexity": <1|2|3>, "reason": "<one sentence>"}}
Do not output anything else."""

def classify_llm(prompt: str, classifier_model: str = "llama3.2:8b") -> Complexity:
    """
    LLM-based classification for ambiguous tasks.
    Uses the cheapest model — this is a routing decision, not the task itself.
    """
    # In production, this calls your Hermes API or Ollama endpoint.
    # Here's the pattern using the Hermes gateway:
    import requests

    response = requests.post(
        "http://localhost:11434/v1/chat/completions",
        json={
            "model": classifier_model,
            "messages": [{"role": "user", "content": CLASSIFIER_PROMPT.format(prompt=prompt[:2000])}],
            "temperature": 0.1,  # low temp for consistent routing
            "max_tokens": 100,
        },
        timeout=10,
    )
    response.raise_for_status()
    text = response.json()["choices"][0]["message"]["content"].strip()

    # Parse — the model should return clean JSON, but be defensive
    try:
        result = json.loads(text)
        complexity = int(result["complexity"])
        return max(1, min(3, complexity))  # type: ignore
    except (json.JSONDecodeError, KeyError, ValueError):
        # If the classifier fails, default to tier 2 (safe middle ground)
        return 2

The classifier costs roughly 200 tokens per call — about $0.0001 on a local model. The routing decision pays for itself many times over.

Step 3: Build the Dispatcher

The dispatcher takes a complexity score, looks up the model tier, and calls the Hermes API with the right model. It also implements the fallback chain: if a lower-tier model produces output that fails validation, the dispatcher retries at the next tier up.

import time
from dataclasses import dataclass, field
from typing import Any, Callable

@dataclass
class RouterResult:
    tier: int
    model: str
    output: str
    cost_estimate: float
    attempts: list[dict]   # log of each attempt
    elapsed_ms: int

class ModelRouter:
    def __init__(
        self,
        tiers: dict[int, ModelTier],
        validator: Callable[[str, dict], bool] | None = None,
    ):
        self.tiers = tiers
        self.validator = validator
        self.cost_ledger: list[dict] = []

    def route(
        self,
        prompt: str,
        context: dict | None = None,
        force_tier: int | None = None,
    ) -> RouterResult:
        """
        Route a task to the appropriate model tier.
        If force_tier is set, skip classification.
        """
        context = context or {}
        started = time.time()
        attempts = []

        # Step 1: Classify
        if force_tier:
            tier = force_tier
            classification_method = "forced"
        else:
            tier = classify_heuristic(prompt)
            if tier is not None:
                classification_method = "heuristic"
            else:
                tier = classify_llm(prompt)
                classification_method = "llm"

        attempts.append({
            "phase": "classification",
            "method": classification_method,
            "result_tier": tier,
        })

        # Step 2: Dispatch with fallback
        max_tier = max(self.tiers.keys())
        current_tier = tier

        while current_tier <= max_tier:
            model_info = self.tiers[current_tier]
            attempt_start = time.time()

            try:
                output = self._call_model(model_info, prompt, context)
                attempt_elapsed = int((time.time() - attempt_start) * 1000)

                # Validate output if a validator is configured
                valid = True
                if self.validator:
                    valid = self.validator(output, context)

                attempts.append({
                    "phase": "dispatch",
                    "tier": current_tier,
                    "model": model_info.model_id,
                    "elapsed_ms": attempt_elapsed,
                    "valid": valid,
                })

                if valid:
                    total_elapsed = int((time.time() - started) * 1000)
                    cost = self._estimate_cost(model_info, prompt, output)

                    self.cost_ledger.append({
                        "timestamp": time.time(),
                        "tier": current_tier,
                        "model": model_info.model_id,
                        "prompt_len": len(prompt),
                        "output_len": len(output),
                        "cost": cost,
                        "classification_method": classification_method,
                        "elapsed_ms": total_elapsed,
                    })

                    return RouterResult(
                        tier=current_tier,
                        model=model_info.model_id,
                        output=output,
                        cost_estimate=cost,
                        attempts=attempts,
                        elapsed_ms=total_elapsed,
                    )

                # Output was invalid — escalate to next tier
                current_tier += 1

            except Exception as e:
                attempts.append({
                    "phase": "dispatch",
                    "tier": current_tier,
                    "model": model_info.model_id,
                    "error": str(e),
                })
                current_tier += 1  # escalate on error

        total_elapsed = int((time.time() - started) * 1000)
        # All tiers failed — return the last error
        return RouterResult(
            tier=0,
            model="none",
            output="",
            cost_estimate=0.0,
            attempts=attempts,
            elapsed_ms=total_elapsed,
        )

    def _call_model(self, model_info: ModelTier, prompt: str, context: dict) -> str:
        """Call the model via the Hermes API or direct provider endpoint."""
        import requests

        # For local Ollama models
        if model_info.provider == "ollama-local":
            response = requests.post(
                "http://localhost:11434/v1/chat/completions",
                json={
                    "model": model_info.model_id,
                    "messages": [{"role": "user", "content": prompt}],
                    "temperature": 0.3,
                },
                timeout=120,
            )
            response.raise_for_status()
            return response.json()["choices"][0]["message"]["content"]

        # For cloud providers, route through Hermes gateway
        # This is where you'd call the Hermes API with the specified model.
        # The exact endpoint depends on your Hermes setup:
        #
        #   hermes run --model {model_info.model_id} --prompt "{prompt}"
        #
        # or via the HTTP gateway:
        response = requests.post(
            "http://localhost:8080/v1/chat/completions",  # Hermes gateway
            json={
                "model": model_info.model_id,
                "messages": [{"role": "user", "content": prompt}],
                "temperature": 0.3,
            },
            headers={"Authorization": f"Bearer {os.environ.get('HERMES_API_KEY', '')}"},
            timeout=300,
        )
        response.raise_for_status()
        return response.json()["choices"][0]["message"]["content"]

    def _estimate_cost(self, model_info: ModelTier, prompt: str, output: str) -> float:
        """Estimate the cost of a single call based on token usage."""
        # Rough token estimate: ~4 chars per token for English + code
        input_tokens = len(prompt) / 4
        output_tokens = len(output) / 4
        cost = (
            (input_tokens / 1000) * model_info.input_cost_per_1k
            + (output_tokens / 1000) * model_info.output_cost_per_1k
        )
        return round(cost, 6)

The key design decisions:

  • Classification is separate from dispatch. You can test the classifier without calling any model.
  • Fallback escalates up, never down. If tier 1 produces garbage, we try tier 2, then tier 3. We never send a complex task to a weaker model.
  • The cost ledger is append-only. Every call is logged with its tier, model, token estimate, and cost. This is your audit trail.
  • The validator is optional but critical. Without it, the router can't know if a lower-tier model succeeded or failed. It just returns whatever the model said.

Step 4: Write Validators That Actually Work

The validator is what makes the fallback chain real. Without it, a tier-1 model that returns a confidently wrong answer gets accepted and you never escalate. The validator checks whether the output meets your quality bar — not whether it's "correct" in a general sense, but whether it's usable for your specific task.

import json
import re

def validate_json_output(output: str, context: dict) -> bool:
    """Validator for tasks that expect JSON output."""
    output = output.strip()
    # Strip markdown code fences if present
    if output.startswith("```"):
        lines = output.splitlines()
        if lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].startswith("```"):
            lines = lines[:-1]
        output = "\n".join(lines)
    try:
        data = json.loads(output)
        # Check for required keys if specified in context
        required_keys = context.get("required_keys", [])
        if required_keys:
            for key in required_keys:
                if key not in data:
                    return False
        return True
    except json.JSONDecodeError:
        return False

def validate_code_output(output: str, context: dict) -> bool:
    """Validator for code generation tasks."""
    # Must contain at least one code block
    if not re.search(r"```(python|bash|javascript|typescript|go|rust|sql|yaml|json)", output):
        return False
    # Must not contain placeholder text
    placeholders = ["TODO", "FIXME", "your code here", "implement this", "..."]
    lower = output.lower()
    for ph in placeholders:
        if ph in lower:
            return False
    # Must be at least 50 characters (not an empty/stub response)
    if len(output.strip()) < 50:
        return False
    return True

def validate_shell_output(output: str, context: dict) -> bool:
    """Validator for shell command tasks — output should not contain errors."""
    error_patterns = [
        r"command not found",
        r"No such file or directory",
        r"Permission denied",
        r"Traceback",
        r"Error:",
        r"FAILED",
    ]
    for pattern in error_patterns:
        if re.search(pattern, output, re.MULTILINE):
            return False
    return True

The validators are deliberately simple. They check structural properties, not semantic correctness. You don't need an LLM to tell you whether JSON parses or whether code blocks exist — that's deterministic. The router escalates when structure fails. Semantic correctness is a harder problem that belongs in your workflow's post-processing step, not in the router.

Step 5: Cost Tracking and Reporting

The cost ledger is how you prove the router is saving money. Here's a simple reporting function that summarizes spend by tier:

from collections import defaultdict
from datetime import datetime

def report_costs(ledger: list[dict]) -> dict:
    """Summarize the cost ledger into a report."""
    by_tier = defaultdict(lambda: {"calls": 0, "cost": 0.0, "tokens_estimated": 0})

    for entry in ledger:
        tier = entry["tier"]
        by_tier[tier]["calls"] += 1
        by_tier[tier]["cost"] += entry["cost"]
        by_tier[tier]["tokens_estimated"] += entry["prompt_len"] // 4 + entry["output_len"] // 4

    # Calculate savings vs. all-frontier baseline
    total_calls = sum(v["calls"] for v in by_tier.values())
    frontier_cost = TIERS[3].input_cost_per_1k + TIERS[3].output_cost_per_1k
    # Estimate what all calls would have cost at frontier pricing
    all_frontier_cost = 0.0
    for entry in ledger:
        est_tokens = entry["prompt_len"] // 4 + entry["output_len"] // 4
        all_frontier_cost += (est_tokens / 1000) * frontier_cost

    actual_cost = sum(v["cost"] for v in by_tier.values())
    savings = all_frontier_cost - actual_cost
    savings_pct = (savings / all_frontier_cost * 100) if all_frontier_cost > 0 else 0

    return {
        "by_tier": {k: dict(v) for k, v in sorted(by_tier.items())},
        "total_calls": total_calls,
        "actual_cost": round(actual_cost, 4),
        "all_frontier_baseline": round(all_frontier_cost, 4),
        "savings": round(savings, 4),
        "savings_percent": round(savings_pct, 1),
        "period": {
            "start": datetime.fromtimestamp(ledger[0]["timestamp"]).isoformat() if ledger else None,
            "end": datetime.fromtimestamp(ledger[-1]["timestamp"]).isoformat() if ledger else None,
        },
    }

Run it weekly. If the savings percentage drops below 30%, your classifier is sending too many tasks to high tiers. If it's above 60%, check whether your validator is too lenient — you might be accepting bad tier-1 output that fails silently downstream.

Step 6: Integration with Hermes Cron Jobs

The router shines brightest in scheduled workflows where you can't manually pick the model. Here's how to wire it into a Hermes cron job that runs a daily code review:

#!/usr/bin/env python3
"""Daily code review router — runs as a Hermes cron job."""

import os
import sys
sys.path.insert(0, os.path.expanduser("~/.hermes/scripts"))

from model_router import ModelRouter, TIERS, validate_code_output

# Initialize the router with the code validator
router = ModelRouter(tiers=TIERS, validator=validate_code_output)

# Get the diff from the last 24 hours
import subprocess
diff = subprocess.run(
    ["git", "log", "--since='24 hours ago'", "--patch", "--stat"],
    capture_output=True, text=True, cwd=os.path.expanduser("~/projects/my-repo")
).stdout

if not diff.strip():
    print("No changes in the last 24 hours. Skipping review.")
    sys.exit(0)

# Route the review task
prompt = f"""Review the following git diff. For each changed file:
1. Identify potential bugs, security issues, or performance problems.
2. Suggest specific improvements with code examples.
3. Rate the overall change quality (1-5) with a one-sentence justification.

Diff:
{diff[:50000]}
"""

result = router.route(prompt, context={"required_keys": ["rating", "files"]})

if result.tier == 0:
    print(f"ERROR: All model tiers failed. Attempts: {result.attempts}")
    sys.exit(1)

# Deliver the review
print(f"Review completed using {result.model} (tier {result.tier})")
print(f"Estimated cost: ${result.cost_estimate:.4f}")
print(f"Elapsed: {result.elapsed_ms}ms")
print(f"Classification attempts: {len(result.attempts)}")
print("---")
print(result.output)

# Log to the cost ledger file
import json
from pathlib import Path
ledger_path = Path.home() / ".hermes" / "cost-ledger.jsonl"
with open(ledger_path, "a") as f:
    for entry in router.cost_ledger:
        f.write(json.dumps(entry) + "\n")

Save this as ~/.hermes/scripts/daily_code_review.py and add a cron entry:

# Run daily code review at 9 AM EST, Monday through Friday
# Hermes cron syntax: hermes cron add --schedule "0 9 * * 1-5" --skill model-router --prompt "Run daily code review"

The cron job routes the review automatically. Simple diffs (formatting, import changes) get reviewed by the local 8B model. Complex refactors escalate to the frontier model. You're not paying $3 for a review of a one-line typo fix.

Step 7: Testing the Router

The router is infrastructure. It needs tests. Here's a test suite that covers the classification, dispatch, and fallback logic without calling any real model:

import pytest
from unittest.mock import patch, MagicMock
from model_router import ModelRouter, TIERS, classify_heuristic, validate_json_output

class TestHeuristicClassifier:
    def test_simple_file_edit(self):
        assert classify_heuristic("Edit the config file to add a new port") == 1

    def test_shell_command(self):
        assert classify_heuristic("Run git status and report the output") == 1

    def test_architecture_task(self):
        assert classify_heuristic("Design the migration plan for moving to microservices") == 3

    def test_security_audit(self):
        assert classify_heuristic("Perform a security audit of the authentication module") == 3

    def test_code_generation(self):
        assert classify_heuristic("Write a function that parses CSV files") == 2

    def test_no_match_returns_none(self):
        assert classify_heuristic("Do the thing with the stuff") is None

class TestValidator:
    def test_valid_json(self):
        assert validate_json_output('{"status": "ok", "code": 200}', {}) is True

    def test_json_in_code_fence(self):
        output = '```json\n{"status": "ok"}\n```'
        assert validate_json_output(output, {}) is True

    def test_invalid_json(self):
        assert validate_json_output("not json at all", {}) is False

    def test_missing_required_keys(self):
        output = '{"status": "ok"}'
        assert validate_json_output(output, {"required_keys": ["status", "code"]}) is False

class TestRouterFallback:
    @patch.object(ModelRouter, '_call_model')
    def test_fallback_on_invalid_output(self, mock_call):
        # Tier 1 returns invalid JSON, tier 2 returns valid JSON
        mock_call.side_effect = ["not json", '{"status": "ok", "code": 200}']
        router = ModelRouter(tiers=TIERS, validator=validate_json_output)
        result = router.route("Parse the API response", force_tier=1)
        assert result.tier == 2
        assert len(result.attempts) >= 3  # classification + 2 dispatches

    @patch.object(ModelRouter, '_call_model')
    def test_no_fallback_when_valid(self, mock_call):
        mock_call.return_value = '{"status": "ok", "code": 200}'
        router = ModelRouter(tiers=TIERS, validator=validate_json_output)
        result = router.route("Parse the API response", force_tier=1)
        assert result.tier == 1
        assert len(result.attempts) == 2  # classification + 1 dispatch

    @patch.object(ModelRouter, '_call_model')
    def test_all_tiers_fail(self, mock_call):
        mock_call.side_effect = Exception("connection refused")
        router = ModelRouter(tiers=TIERS, validator=validate_json_output)
        result = router.route("Do something complex", force_tier=1)
        assert result.tier == 0  # all failed
        assert result.output == ""

Run these in CI. The router's logic should be fully deterministic and testable. Only _call_model needs a live backend, and it's mocked in tests.

Tuning the Router: What I Learned in Production

After six weeks of running this in production, here's what I'd tell you to watch for:

Classifier drift. The heuristic rules cover about 65% of tasks. The LLM classifier handles the rest. But as your task mix changes, the rules get stale. I review the classification log monthly and add rules for patterns that keep falling through to the LLM classifier. Each new rule saves ~200 tokens per task.

Validator false negatives. A validator that's too strict causes unnecessary escalations. If your tier-1 model produces valid JSON 90% of the time but the validator rejects it because of a trailing newline, you're paying for tier-2 on every call. Strip whitespace before validating. Be lenient on format, strict on structure.

The frontier model is a crutch. When something fails, the temptation is to force-tier everything to 3. Resist this. The fallback chain exists for a reason. Let it work. If you're escalating more than 15% of tasks, your tier-1 and tier-2 models are either misconfigured or your validators are too strict.

Log the classification method. Knowing whether a task was classified by heuristic or LLM tells you whether your rules need updating. If 80% of tasks go through the LLM classifier, your heuristics are underperforming.

Measure latency, don't estimate it. The avg_latency_ms field in your tier registry should be measured weekly. If your local model is suddenly taking 2 seconds instead of 500ms, something is wrong with the host. The router should be able to skip a slow tier and go straight to a fast cloud model.

The Bigger Picture

A multi-model router is the single highest-ROI piece of infrastructure you can build on top of the Hermes API. It's not glamorous — it's a glorified if/else with a cost ledger. But it turns a flat-cost, single-model setup into a tiered system that matches model capability to task complexity.

If you want to go deeper, read my earlier posts on code-as-action workflows and subagent orchestration. The router plugs directly into both — the workflow manager calls the router instead of hard-coding a model, and subagent delegations inherit the routing decision from the parent task.

This post is part of Liam's Landing — practical engineering content from the CDO desk at SMF Works.

Originally published at smfworks.com.