atlas-iac/services/hermes/scripts/hermes_model_routing.py

485 lines
18 KiB
Python

#!/usr/bin/env python3
"""Keep Hermes agent provider catalogs and managed profiles current."""
from __future__ import annotations
import copy
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, Iterable
import yaml
from provider_model_catalog import (
CAPABILITY_ROLES,
CAPABILITY_TIERS,
EFFORTS,
EFFORT_TIERS,
Catalog,
apply_verified_evaluations,
capability_pool,
legacy_selector_matches,
model_records,
model_version,
select_tier_model,
unique_models,
)
from provider_model_discovery import discover_claude_models, discover_codex_models
from model_evaluation_evidence import load_store
CODEX_BASELINE = "gpt-5.6-terra"
CLAUDE_BASELINE = "claude-opus-5"
ATLAS_FALLBACK = {
"provider": "custom",
"model": "qwen2.5:14b-instruct-q4_0",
"base_url": "http://hermes-model-gate.hermes.svc.cluster.local:11434/v1",
"api_key": "ollama",
}
# Backwards-compatible name used by the focused unit tests and status tooling.
LOCAL_FALLBACK = ATLAS_FALLBACK
SWITCHYARD_PROVIDER = "atlas-switchyard"
SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1"
SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum"
ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip()
MANAGED_ENV_KEYS = {"GIT_ASKPASS", "GIT_TERMINAL_PROMPT"}
RUNTIME_SECRET_ENV_KEYS = {
"ANTHROPIC_API_KEY", "API_SERVER_KEY", "CLAUDE_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN", "GITEA_USERNAME", "HERMES_IMAGE_BROKER_KEY", "OPENAI_API_KEY",
}
def choose_codex_model(models: Iterable[str], current: str = CODEX_BASELINE, *, balanced: bool = False) -> str:
"""Compatibility helper selecting a declared advanced or balanced Codex tier."""
effort = "medium" if balanced else "xhigh"
tier = "balanced" if balanced else "advanced"
return select_tier_model("codex", models, {}, tier, effort, current, legacy_compat=True)[0]
def choose_codex_for_effort(models: Iterable[str], effort: str, current: str = CODEX_BASELINE) -> str:
"""Compatibility helper selecting an effort's generic Codex capability tier."""
if effort not in EFFORTS:
raise ValueError(f"unsupported effort: {effort}")
return select_tier_model("codex", models, {}, EFFORT_TIERS[effort], effort, current, legacy_compat=True)[0]
def choose_claude_model(models: Iterable[str], current: str = CLAUDE_BASELINE) -> str:
"""Compatibility helper selecting the declared advanced Claude tier."""
return select_tier_model("claude", models, {}, "advanced", "xhigh", current, legacy_compat=True)[0]
def choose_claude_for_effort(
models: Iterable[str], effort: str, current: str = CLAUDE_BASELINE
) -> str:
"""Compatibility helper selecting an effort's generic Claude capability tier."""
if effort not in EFFORTS:
raise ValueError(f"unsupported effort: {effort}")
return select_tier_model("claude", models, {}, EFFORT_TIERS[effort], effort, current, legacy_compat=True)[0]
# The coordinator retains these private spellings for compact call sites while
# provider_model_catalog owns the metadata policy and its independent tests.
_unique_models = unique_models
_model_records = model_records
_select_tier_model = select_tier_model
def _read_yaml(path: Path) -> dict[str, Any]:
"""Read a mapping from YAML, returning an empty mapping when unavailable."""
if not path.is_file():
return {}
try:
value = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return {}
return value if isinstance(value, dict) else {}
def _atomic_write(path: Path, content: str, mode: int | None = None) -> bool:
"""Replace a file only when its content changes."""
path.parent.mkdir(parents=True, exist_ok=True)
try:
if path.read_text(encoding="utf-8") == content:
if mode is not None:
path.chmod(mode)
return False
except OSError:
pass
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(content, encoding="utf-8")
if mode is not None:
temporary.chmod(mode)
os.replace(temporary, path)
return True
def _write_yaml(path: Path, value: dict[str, Any], mode: int | None = None) -> bool:
"""Serialize a mapping and atomically update the target YAML file."""
return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode)
def _read_json(path: Path) -> dict[str, Any]:
"""Read a JSON mapping without treating a partial write as valid state."""
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _previous_provider_models(
previous: dict[str, Any], provider: str
) -> tuple[dict[str, str], dict[str, str], list[str], dict[str, dict[str, Any]]]:
"""Return last-known-good effort, selector, models, and metadata values."""
providers = previous.get("providers", {})
record = providers.get(provider, {}) if isinstance(providers, dict) else {}
if not isinstance(record, dict):
return {}, {}, [], {}
resolved = record.get("resolved", {})
tiers = record.get("tiers", {})
models = record.get("models", [])
metadata = record.get("model_metadata", {})
return (
dict(resolved) if isinstance(resolved, dict) else {},
dict(tiers) if isinstance(tiers, dict) else {},
_unique_models(models if isinstance(models, list) else []),
{
str(model): dict(details)
for model, details in metadata.items()
if isinstance(model, str) and isinstance(details, dict)
}
if isinstance(metadata, dict)
else {},
)
def build_routing_catalog(
codex: Catalog, claude: Catalog, previous: dict[str, Any] | None = None,
evaluations: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a current catalog while retaining only outage-safe known routes."""
previous = previous or {}
checked_at = int(time.time())
providers: dict[str, Any] = {}
specifications = (
(
"codex",
codex,
{"luna": "economy", "terra": "balanced", "sol": "advanced"},
),
(
"claude",
claude,
{
"haiku": "economy",
"fable": "frontier",
"sonnet": "balanced",
"opus": "advanced",
},
),
)
for name, discovered, legacy_selectors in specifications:
old_resolved, old_tiers, old_models, old_metadata = _previous_provider_models(
previous, name
)
old_providers = previous.get("providers", {})
old_record = old_providers.get(name, {}) if isinstance(old_providers, dict) else {}
previous_success = (
int(old_record.get("last_success_at"))
if isinstance(old_record, dict) and isinstance(old_record.get("last_success_at"), int)
else None
)
source_models = discovered.models if discovered.live else old_models
source_metadata = apply_verified_evaluations(
name, source_models, discovered.metadata if discovered.live else old_metadata,
evaluations.get("evaluations") if isinstance(evaluations, dict) else None,
)
observations: dict[str, Any] = {}
old_capabilities = old_record.get("capability_resolved", {}) if isinstance(old_record, dict) else {}
old_capabilities = old_capabilities if isinstance(old_capabilities, dict) else {}
capability_pools = {
role: capability_pool(name, source_models, source_metadata, role)
for role in CAPABILITY_ROLES
}
capability_resolved: dict[str, dict[str, str]] = {}
for role in CAPABILITY_ROLES:
prior = old_capabilities.get(role, {})
prior = prior if isinstance(prior, dict) else {}
routes: dict[str, str] = {}
for effort in EFFORTS:
current = str(prior.get(effort) or (
old_resolved.get(effort) if role == EFFORT_TIERS[effort] else ""
))
selected, observed = _select_tier_model(
name, source_models, source_metadata, role, effort, current,
allow_current_fallback=not discovered.live,
)
routes[effort] = selected
observations.update(observed)
capability_resolved[role] = routes
resolved = {
effort: capability_resolved[EFFORT_TIERS[effort]][effort]
for effort in EFFORTS
}
tiers: dict[str, str] = {}
representative_effort = {
"economy": "low", "balanced": "medium", "advanced": "high", "frontier": "xhigh",
}
for capability in CAPABILITY_TIERS:
effort = representative_effort[capability]
tiers[capability] = capability_resolved[capability][effort]
for selector, capability in legacy_selectors.items():
# Explicit historical family picks are not generic capability
# requests. They must remain exact or become unavailable, never
# silently change to a newer family such as Astra.
exact = [
model for model in source_models
if legacy_selector_matches(name, selector, model)
]
tiers[selector] = (
exact[0]
if exact
else ("" if discovered.live else str(old_tiers.get(selector) or ""))
)
providers[name] = {
"provenance": (
"live-account" if discovered.live
else ("last-known-good" if old_models else "bootstrap-fallback")
),
"checked_at": checked_at,
"last_success_at": checked_at if discovered.live else previous_success,
"state": discovered.state,
"connected": discovered.connected,
"live": discovered.live,
"models": _unique_models(
discovered.models
if discovered.live
else (old_models or discovered.models)
),
"model_metadata": source_metadata,
"candidates": observations,
"capability_pools": capability_pools,
"capability_resolved": capability_resolved,
"resolved": resolved,
"tiers": tiers,
}
return {
"schema_version": 3,
"updated_at": checked_at,
"providers": providers,
}
def write_routing_catalog(
path: Path, codex: Catalog, claude: Catalog
) -> dict[str, Any]:
"""Atomically publish the catalog consumed by hosted and worker brokers."""
evidence_store = load_store(path.with_name("model-evaluations.json"))
evaluations: dict[str, dict[str, dict[str, Any]]] = {"codex": {}, "claude": {}}
for record in evidence_store.get("evaluations", {}).values():
if not isinstance(record, dict):
continue
provider, model = record.get("provider"), record.get("model")
if provider in evaluations and isinstance(model, str):
evaluations[provider][model] = record
catalog = build_routing_catalog(
codex, claude, _read_json(path), {"evaluations": evaluations}
)
_atomic_write(path, json.dumps(catalog, indent=2, sort_keys=True) + "\n", 0o644)
return catalog
def _read_env(path: Path) -> dict[str, str]:
"""Read the small dotenv subset used by Hermes provider credentials."""
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for line in lines:
value = line.strip()
if not value or value.startswith("#") or "=" not in value:
continue
key, raw = value.removeprefix("export ").split("=", 1)
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
raw = raw[1:-1]
values[key.strip()] = raw
return values
def _update_profile_env(path: Path, source: dict[str, str]) -> None:
"""Refresh non-secret managed settings and remove stale credential copies."""
try:
old_lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
old_lines = []
kept = [
line
for line in old_lines
if not any(
line.lstrip().startswith(f"{key}=")
for key in MANAGED_ENV_KEYS | RUNTIME_SECRET_ENV_KEYS
)
]
kept.extend(
f"{key}={source[key]}" for key in sorted(MANAGED_ENV_KEYS) if source.get(key)
)
_atomic_write(path, "\n".join(kept).rstrip() + "\n", 0o600)
def codex_cli_authenticated() -> bool:
"""Return whether the installed Codex CLI has a usable local login."""
codex = shutil.which("codex")
if codex:
try:
status = subprocess.run(
[codex, "login", "status"],
capture_output=True,
check=False,
text=True,
timeout=10,
)
detail = f"{status.stdout}\n{status.stderr}".lower()
if status.returncode == 0 and (
"logged in" in detail or "authenticated" in detail
):
return True
except (OSError, subprocess.SubprocessError):
pass
return False
def _switchyard_profile_config(
base: dict[str, Any], route: str, effort: str
) -> dict[str, Any]:
"""Derive a profile that cannot bypass the Switchyard authority."""
config = copy.deepcopy(base)
providers = config.setdefault("providers", {})
if not isinstance(providers, dict):
providers = {}
config["providers"] = providers
providers[SWITCHYARD_PROVIDER] = {
"name": "Atlas Switchyard",
"api": SWITCHYARD_API,
"api_key": "atlas-switchyard",
"default_model": route,
"transport": "chat_completions",
}
config["model"] = {
"provider": SWITCHYARD_PROVIDER,
"default": route,
"model": route,
}
config["fallback_providers"] = []
config["model_catalog"] = {"enabled": True, "ttl_hours": 1}
agent = config.setdefault("agent", {})
if isinstance(agent, dict):
agent["reasoning_effort"] = effort
config["toolsets"] = []
return config
def _write_profile(
root: Path,
name: str,
description: str,
soul: str,
config: dict[str, Any],
env_values: dict[str, str],
) -> None:
"""Create or refresh a managed Hermes worker profile."""
profile = root / "profiles" / name
for directory in ("logs", "sessions", "skills", "workspace", "home"):
(profile / directory).mkdir(parents=True, exist_ok=True)
_write_yaml(profile / "config.yaml", config)
_write_yaml(
profile / "profile.yaml",
{"description": description, "description_auto": False},
)
_atomic_write(profile / "SOUL.md", soul.rstrip() + "\n")
_update_profile_env(profile / ".env", env_values)
def configure_routes(
root: Path,
codex: Catalog,
claude: Catalog,
catalog_path: Path | None = None,
) -> dict[str, Any]:
"""Refresh catalogs while keeping every Hermes profile on Switchyard."""
config_path = root / "config.yaml"
base = _read_yaml(config_path)
resolved_catalog_path = catalog_path or (
Path(ROUTING_CATALOG_PATH)
if ROUTING_CATALOG_PATH
else root / "routing-catalog.json"
)
catalog = write_routing_catalog(resolved_catalog_path, codex, claude)
providers = catalog["providers"]
codex_models = providers["codex"]["resolved"]
claude_models = providers["claude"]["resolved"]
coordinator_toolsets = copy.deepcopy(base.get("toolsets"))
base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high")
# The coordinator uses its configured toolsets. Worker profiles below are
# deliberately toolset-empty so Hermes resolves their native defaults.
if coordinator_toolsets is None:
base.pop("toolsets", None)
else:
base["toolsets"] = coordinator_toolsets
_write_yaml(config_path, base)
env_values = _read_env(root / ".env")
routes: dict[str, list[str]] = {}
for effort in EFFORTS:
codex_name = f"codex-{effort}"
claude_name = f"claude-{effort}"
codex_route = f"atlas/manual/codex/auto/{effort}"
claude_route = f"atlas/manual/claude/auto/{effort}"
_write_profile(
root,
codex_name,
f"Codex implementation preference at {effort} effort, enforced by Switchyard.",
"You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.",
_switchyard_profile_config(base, codex_route, effort),
env_values,
)
_write_profile(
root,
claude_name,
f"Claude analysis preference at {effort} effort, enforced by Switchyard.",
"You are an architecture and review worker. Analyze the assigned task deeply, change files only when asked, and return concise conclusions, evidence, and risks to the coordinator.",
_switchyard_profile_config(base, claude_route, effort),
env_values,
)
routes[codex_name] = [codex_route]
routes[claude_name] = [claude_route]
_write_profile(
root,
"synthesis-xhigh",
"Cross-provider synthesis and critical review, capped at xhigh effort.",
"Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
_switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"),
env_values,
)
routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE]
_write_yaml(
root / "profile.yaml",
{
"description": "Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries.",
"description_auto": False,
},
)
routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE]
routes["catalog"] = [
*(f"openai-codex/{model}" for model in codex_models.values()),
*(f"anthropic/{model}" for model in claude_models.values()),
]
return routes