hermes: make Switchyard the routing authority
All checks were successful
Tests / Declarative: Post Actions passed: 240

This commit is contained in:
jenkins 2026-08-11 20:22:26 -03:00
parent 597e9c1c2e
commit cef66ef0ca
35 changed files with 2377 additions and 2370 deletions

View File

@ -22,6 +22,10 @@ spec:
kind: Deployment kind: Deployment
name: hermes-model-gate name: hermes-model-gate
namespace: hermes namespace: hermes
- apiVersion: apps/v1
kind: Deployment
name: hermes-switchyard
namespace: hermes
- apiVersion: apps/v1 - apiVersion: apps/v1
kind: Deployment kind: Deployment
name: hermes name: hermes

View File

@ -476,7 +476,9 @@ route_after = route_before + ''' def _resolve_request_route(self, body: Dict[
provider = body.get("provider") provider = body.get("provider")
model = body.get("model") model = body.get("model")
allowed_providers = {"openai-codex", "atlas-codex", "anthropic"} # Every WebUI selection is a public Switchyard route. Direct provider
# picks would create a second routing control plane and bypass failover.
allowed_providers = {"atlas-switchyard"}
if provider not in allowed_providers or not isinstance(model, str): if provider not in allowed_providers or not isinstance(model, str):
return None return None
model = model.strip() model = model.strip()

View File

@ -0,0 +1,25 @@
# dockerfiles/Dockerfile.hermes-switchyard
FROM rust:1.96.1-slim-bookworm@sha256:39decc9f9f4a87e03db5069b2424c0ee13c3d7e4116010980d4f96849e823bc4 AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN cargo install \
--locked \
--version 0.2.0 \
--root /opt/switchyard \
switchyard-server
FROM debian:bookworm-slim@sha256:362e64223cc0da95422b3b13c045186fc0a81250e765d31c025fbddf257f6143
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /opt/switchyard/bin/switchyard-server /usr/local/bin/switchyard-server
USER 10000:10000
EXPOSE 9005
ENTRYPOINT ["/usr/local/bin/switchyard-server"]

View File

@ -2,7 +2,7 @@
# dockerfiles/Dockerfile.hermes-webui # dockerfiles/Dockerfile.hermes-webui
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
FROM registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 FROM registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
USER root USER root
@ -72,7 +72,16 @@ sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
panels = Path("/opt/hermes-webui/static/panels.js") panels = Path("/opt/hermes-webui/static/panels.js")
source = panels.read_text(encoding="utf-8") source = panels.read_text(encoding="utf-8")
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n" before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
after = " if (typeof p.model === 'string' && p.model) meta.push('profile default: ' + p.model.split('/').pop());\n" after = ''' if (typeof p.model === 'string' && p.model) {
const routeLabels = {
'atlas/auto/fast': 'Automatic · Fast',
'atlas/auto/balanced': 'Automatic · Balanced',
'atlas/auto/deep': 'Automatic · Deep',
'atlas/auto/maximum': 'Automatic · Maximum',
};
meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
}
'''
if source.count(before) != 2: if source.count(before) != 2:
raise SystemExit("Hermes WebUI profile-model label patch context changed") raise SystemExit("Hermes WebUI profile-model label patch context changed")
panels.write_text(source.replace(before, after, 2), encoding="utf-8") panels.write_text(source.replace(before, after, 2), encoding="utf-8")
@ -93,12 +102,13 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \ && ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \ && grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \ && grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
&& grep -Fq "profile default: ' + p.model" /opt/hermes-webui/static/panels.js \ && grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \ && grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \ && grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \ && grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \ && grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \ && grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'AUTO · Fast'" /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \ && grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
&& /opt/hermes/.venv/bin/python -m py_compile \ && /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes-webui/api/routes.py \ /opt/hermes-webui/api/routes.py \

View File

@ -5,6 +5,51 @@
const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'}; const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'};
const STORAGE_KEY='atlas.hermes.routing-priority'; const STORAGE_KEY='atlas.hermes.routing-priority';
const EXPLICIT_EFFORT_KEY='atlas.hermes.explicit-reasoning'; const EXPLICIT_EFFORT_KEY='atlas.hermes.explicit-reasoning';
const ROUTE_LABELS={
'atlas/auto/fast':'AUTO · Fast',
'atlas/auto/balanced':'AUTO · Balanced',
'atlas/auto/deep':'AUTO · Deep',
'atlas/auto/maximum':'AUTO · Maximum',
'atlas/manual/codex/luna':'Codex · Luna',
'atlas/manual/codex/terra':'Codex · Terra',
'atlas/manual/codex/sol':'Codex · SOL',
'atlas/manual/claude/haiku':'Claude · Haiku',
'atlas/manual/claude/sonnet':'Claude · Sonnet',
'atlas/manual/claude/opus':'Claude · Opus',
'atlas/manual/local/qwen-14b':'Local · Qwen 14B'
};
function routeId(value){
const raw=String(value||'').trim();
if(ROUTE_LABELS[raw]) return raw;
const marker=raw.indexOf(':atlas/');
return marker>=0?raw.slice(marker+1):raw;
}
function friendlyRouteLabel(value){
return ROUTE_LABELS[routeId(value)]||'';
}
function labelModelOptions(){
['modelSelect','settingsModel'].forEach(function(id){
const select=document.getElementById(id);
if(!select) return;
Array.from(select.options||[]).forEach(function(option){
const label=friendlyRouteLabel(option.value);
if(label&&option.textContent!==label) option.textContent=label;
});
});
if(typeof window.syncModelChip==='function') window.syncModelChip();
if(typeof window.syncSettingsModelChip==='function') window.syncSettingsModelChip();
}
function watchModelOptions(id){
const select=document.getElementById(id);
if(!select||select.dataset.atlasFriendlyRoutes==='1') return;
select.dataset.atlasFriendlyRoutes='1';
new MutationObserver(labelModelOptions).observe(select,{childList:true,subtree:true});
select.addEventListener('change',labelModelOptions);
}
function currentPriority(){ function currentPriority(){
let value='auto'; let value='auto';
@ -71,9 +116,13 @@
explicit_reasoning_effort:explicitReasoning&&effort?effort:undefined explicit_reasoning_effort:explicitReasoning&&effort?effort:undefined
}; };
}; };
window.hermesFriendlyRouteLabel=friendlyRouteLabel;
document.addEventListener('DOMContentLoaded',function(){ document.addEventListener('DOMContentLoaded',function(){
render(); render();
labelModelOptions();
watchModelOptions('modelSelect');
watchModelOptions('settingsModel');
const chip=document.getElementById('composerRoutingChip'); const chip=document.getElementById('composerRoutingChip');
if(chip) chip.addEventListener('click',toggle); if(chip) chip.addEventListener('click',toggle);
}); });

View File

@ -60,6 +60,10 @@ spec:
value: "512" value: "512"
- name: JETSON_JETPACK - name: JETSON_JETPACK
value: "5" value: "5"
- name: OLLAMA_LLM_LIBRARY
value: cuda_jetpack5
- name: LD_LIBRARY_PATH
value: /lib/ollama:/lib/ollama/cuda_jetpack5:/usr/lib/aarch64-linux-gnu/tegra
command: command:
- /bin/sh - /bin/sh
- -c - -c
@ -120,6 +124,10 @@ spec:
value: compute,utility value: compute,utility
- name: JETSON_JETPACK - name: JETSON_JETPACK
value: "5" value: "5"
- name: OLLAMA_LLM_LIBRARY
value: cuda_jetpack5
- name: LD_LIBRARY_PATH
value: /lib/ollama:/lib/ollama/cuda_jetpack5:/usr/lib/aarch64-linux-gnu/tegra
command: command:
- /bin/sh - /bin/sh
- -c - -c

View File

@ -9,29 +9,28 @@ metadata:
data: data:
config.yaml: | config.yaml: |
model: model:
provider: openai-codex provider: atlas-switchyard
default: gpt-5.6-terra default: atlas/auto/maximum
model: gpt-5.6-terra model: atlas/auto/maximum
# Reuse the owner's authenticated Codex CLI instead of maintaining a
# second rotating OAuth token in Hermes' provider store.
openai_runtime: codex_app_server
fallback_providers: providers:
- provider: anthropic atlas-switchyard:
model: claude-sonnet-5 name: Atlas Switchyard
- provider: custom api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
model: qwen2.5:14b-instruct-q4_0 api_key: atlas-switchyard
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1 default_model: atlas/auto/maximum
api_key: ollama transport: chat_completions
fallback_providers: []
agent: agent:
api_max_retries: 1 api_max_retries: 1
# The coordinator supervises native children and durable CLI workers; # The coordinator supervises native children and durable CLI workers;
# give it enough room to inspect, steer, review, and synthesize. # give it enough room to inspect, steer, review, and synthesize.
max_turns: 180 max_turns: 180
# This is the fail-safe when the router is unavailable. AUTO normally # Switchyard independently classifies every user, internal, delegated,
# classifies every user, internal, delegated, and durable-worker turn. # and durable-worker boundary. Explicit UI effort is forwarded as an
reasoning_effort: high # override; AUTO leaves effort unset so the selected target owns it.
delegation: delegation:
# Native Hermes owns decomposition and fan-out. Every child is routed # Native Hermes owns decomposition and fan-out. Every child is routed
@ -160,8 +159,9 @@ data:
the initial project. Use native Hermes delegation as the normal planning, the initial project. Use native Hermes delegation as the normal planning,
fan-out, and synthesis path. Durable real Codex and Claude Code CLI work is fan-out, and synthesis path. Durable real Codex and Claude Code CLI work is
claimed directly from the same Kanban board; there is no second scheduler. claimed directly from the same Kanban board; there is no second scheduler.
You remain responsible for planning, routing, fallback, review, and the You remain responsible for planning, decomposition, review, and the final
final synthesized answer. synthesized answer. Switchyard is the sole authority for provider, model,
effort, and capacity failover at every model-call boundary.
Prefer Codex for implementation, debugging, test loops, and focused repo Prefer Codex for implementation, debugging, test loops, and focused repo
changes. Prefer Claude Code for architecture, long-context investigation, changes. Prefer Claude Code for architecture, long-context investigation,
@ -183,10 +183,11 @@ data:
tasks when an objective benefits from persistent Codex or Claude Code CLI tasks when an objective benefits from persistent Codex or Claude Code CLI
execution that survives browser disconnects and can resume after restarts. execution that survives browser disconnects and can resume after restarts.
Local Jetson inference is the first provider-independent fallback. Use it The Jetson classifier is mandatory for AUTO selection. Switchyard may use
for bounded classification, summaries, and continuity when hosted capacity local Qwen for bounded low-risk responses and continuity, or spill to a
is constrained. Do not silently treat a local fallback as equivalent to a hosted provider when local capability is insufficient. Do not describe a
high-risk xhigh review; disclose the downgrade and preserve the task. local response as equivalent to a high-risk xhigh review; preserve the
task and make any downgrade visible in routing evidence.
AGENTS.md: | AGENTS.md: |
# Hermes project coordinator # Hermes project coordinator
@ -198,9 +199,9 @@ data:
## Difficulty routing ## Difficulty routing
The coordinator starts in `/route auto`. AUTO classifies every user task The coordinator starts in `/route auto`. AUTO classifies every user task
before inference, uses the Jetson routing model when it answers within the before inference and always uses the Jetson routing model; it never skips
latency budget, and falls back to deterministic policy without delaying the classification because the prompt looks simple. `/route status` explains
conversation. `/route status` explains the live selection. Use the public route contract. Use
`/route manual <codex|claude> <low|medium|high|xhigh> [model]` for a `/route manual <codex|claude> <low|medium|high|xhigh> [model]` for a
persistent manual override, and `/route auto` to return control to Hermes. persistent manual override, and `/route auto` to return control to Hermes.
@ -211,11 +212,13 @@ data:
critical final review. `xhigh` is the hard maximum; never request max or critical final review. `xhigh` is the hard maximum; never request max or
ultracode. ultracode.
Read `/opt/data/workspace/coordinator/model-routing.json` before naming a `/opt/data/workspace/coordinator/model-routing.json` is catalog and health
model. The hourly steward discovers the models currently available to both evidence, not a routing control plane. The hourly steward discovers the
accounts and preserves the last working route during catalog outages. models currently available to both accounts, preserves its last known-good
Profiles are `codex-{low,medium,high,xhigh}` and catalog during outages, and keeps every generated profile on a public
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`. Switchyard route. Profiles are `codex-{low,medium,high,xhigh}` and
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`; selecting one is a
Switchyard constraint and never a direct provider bypass.
## Decomposition and delegation ## Decomposition and delegation
@ -224,20 +227,20 @@ data:
referenced plan from recent context, identify bounded leaf tasks and their referenced plan from recent context, identify bounded leaf tasks and their
dependencies, then use `delegate_task` for independent leaves. Run only dependencies, then use `delegate_task` for independent leaves. Run only
dependency-free leaves in parallel. Each native child and every nested dependency-free leaves in parallel. Each native child and every nested
child is independently classified by the Jetson before its first model child and each subsequent internal continuation is independently routed by
request, so cheap leaves may use low effort while difficult or risky leaves Switchyard using the Jetson classifier, so cheap leaves may use low effort
are raised to high or xhigh. Verify and synthesize all child evidence in the while difficult or risky leaves are raised to high or xhigh. Verify and
foreground coordinator. Do not delegate a one-tool mechanical action merely synthesize all child evidence in the foreground coordinator. Do not
to create an agent. delegate a one-tool mechanical action merely to create an agent.
For persistent real Codex or Claude Code CLI work, create a bounded Kanban For persistent real Codex or Claude Code CLI work, create a bounded Kanban
worktree task assigned to `cli-auto`. The direct lane reserves the task atomically, worktree task assigned to `cli-auto`. The direct lane reserves the task atomically,
sends every start/retry/continuation boundary through the Jetson classifier, sends every start/retry/continuation boundary through Switchyard and its
records provider/model/effort and session identifiers on the task, streams Jetson classifier, records provider/model/effort and session identifiers on the task, streams
logs into the task worker log, and resumes the provider session after a pod logs into the task worker log, and resumes the provider session after a pod
restart. Manual lanes are `cli-codex-{low,medium,high,xhigh}` and restart. Manual lanes are `cli-codex-{low,medium,high,xhigh}` and
`cli-claude-{low,medium,high,xhigh}`; they still call the Jetson for the audit `cli-claude-{low,medium,high,xhigh}`; those manual constraints are still
record, then apply the explicit override. Observe workers through Kanban and enforced and recorded by Switchyard. Observe workers through Kanban and
the dashboard session/task lists, not terminal panes. If Codex reports its first-use the dashboard session/task lists, not terminal panes. If Codex reports its first-use
login requirement, run `codex login --device-auth` once in `/terminal/` and login requirement, run `codex login --device-auth` once in `/terminal/` and
ask Brad to complete the displayed code. ask Brad to complete the displayed code.
@ -262,9 +265,10 @@ data:
dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban, dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban,
skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the
raw full-screen Hermes TUI. Give Hermes raw full-screen Hermes TUI. Give Hermes
the outcome you want and it will decompose dependent work, classify every the outcome you want and it will decompose dependent work, route every
delegated leaf on the Jetson, choose Codex or Claude, preserve the task on model-call boundary through Switchyard and its Jetson classifier, choose
the Cassandra board, and synthesize the evidence. Persistent real Codex and local Qwen, Codex, or Claude as appropriate, preserve the task on the
Cassandra board, and synthesize the evidence. Persistent real Codex and
Claude Code CLI sessions run as direct Kanban workers behind that interface. Claude Code CLI sessions run as direct Kanban workers behind that interface.
Use `/route status` to inspect the current decision, `/route auto` Use `/route status` to inspect the current decision, `/route auto`
for automatic routing, or `/route manual <codex|claude> for automatic routing, or `/route manual <codex|claude>

View File

@ -180,7 +180,7 @@ spec:
requests: {cpu: 25m, memory: 32Mi} requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi} limits: {cpu: 100m, memory: 64Mi}
- name: install-agent-tools - name: install-agent-tools
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- sh - sh
@ -228,7 +228,7 @@ spec:
requests: {cpu: 100m, memory: 256Mi} requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi} limits: {cpu: "1", memory: 1Gi}
- name: patch-auth - name: patch-auth
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -251,7 +251,7 @@ spec:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi} limits: {cpu: 100m, memory: 128Mi}
- name: patch-tui-gateway - name: patch-tui-gateway
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -274,7 +274,7 @@ spec:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi} limits: {cpu: 100m, memory: 128Mi}
- name: patch-codex-runtime - name: patch-codex-runtime
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -307,7 +307,7 @@ spec:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi} limits: {cpu: 100m, memory: 128Mi}
- name: bootstrap-coordinator - name: bootstrap-coordinator
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -320,6 +320,7 @@ spec:
- {name: CODEX_HOME, value: /opt/data/home/.codex} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: PYTHONPATH, value: /opt/hermes} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@ -332,11 +333,12 @@ spec:
- {name: provider-auth, mountPath: /shared-auth} - {name: provider-auth, mountPath: /shared-auth}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi} limits: {cpu: 500m, memory: 512Mi}
- name: configure-agent-clients - name: configure-agent-clients
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- sh - sh
@ -358,6 +360,7 @@ spec:
- {name: CODEX_HOME, value: /opt/data/home/.codex} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: PYTHONPATH, value: /opt/hermes} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@ -370,11 +373,12 @@ spec:
- {name: provider-auth, mountPath: /shared-auth} - {name: provider-auth, mountPath: /shared-auth}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
resources: resources:
requests: {cpu: 25m, memory: 32Mi} requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 250m, memory: 128Mi} limits: {cpu: 250m, memory: 128Mi}
- name: prepare-ttyd-index - name: prepare-ttyd-index
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -396,7 +400,7 @@ spec:
limits: {cpu: 250m, memory: 128Mi} limits: {cpu: 250m, memory: 128Mi}
containers: containers:
- name: hermes - name: hermes
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/init, /opt/hermes/docker/main-wrapper.sh] command: [/init, /opt/hermes/docker/main-wrapper.sh]
args: [gateway, run] args: [gateway, run]
@ -532,7 +536,7 @@ spec:
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true} - {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
- {name: oauth-tmp, mountPath: /tmp} - {name: oauth-tmp, mountPath: /tmp}
- name: terminal - name: terminal
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -626,7 +630,7 @@ spec:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 500m, memory: 512Mi} limits: {cpu: 500m, memory: 512Mi}
- name: cli-lane-runner - name: cli-lane-runner
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -668,7 +672,7 @@ spec:
requests: {cpu: 100m, memory: 256Mi} requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "3", memory: 6Gi} limits: {cpu: "3", memory: 6Gi}
- name: model-steward - name: model-steward
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"] command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"]
env: env:
@ -678,6 +682,7 @@ spec:
- {name: CODEX_HOME, value: /opt/data/home/.codex} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: PYTHONPATH, value: /opt/hermes} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@ -690,11 +695,12 @@ spec:
- {name: provider-auth, mountPath: /shared-auth} - {name: provider-auth, mountPath: /shared-auth}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
resources: resources:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 512Mi} limits: {cpu: 250m, memory: 512Mi}
- name: image-broker - name: image-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -744,7 +750,7 @@ spec:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi} limits: {cpu: "1", memory: 1Gi}
- name: codex-broker - name: codex-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -762,6 +768,7 @@ spec:
- {name: CODEX_HOME, value: /opt/data/home/.codex} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: PYTHONPATH, value: /opt/hermes} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"} - {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
readinessProbe: readinessProbe:
tcpSocket: {port: codex-broker} tcpSocket: {port: codex-broker}
initialDelaySeconds: 5 initialDelaySeconds: 5
@ -786,6 +793,7 @@ spec:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi} limits: {cpu: "1", memory: 1Gi}
@ -796,6 +804,9 @@ spec:
- name: provider-auth - name: provider-auth
persistentVolumeClaim: persistentVolumeClaim:
claimName: hermes-provider-auth claimName: hermes-provider-auth
- name: routing-catalog
persistentVolumeClaim:
claimName: hermes-routing-catalog
- name: config - name: config
configMap: configMap:
name: hermes-agent-config name: hermes-agent-config

View File

@ -9,27 +9,20 @@ metadata:
data: data:
config.yaml: | config.yaml: |
model: model:
provider: atlas-codex provider: atlas-switchyard
default: gpt-5.6-terra default: atlas/auto/fast
model: gpt-5.6-terra model: atlas/auto/fast
providers: providers:
atlas-codex: atlas-switchyard:
name: Atlas Codex name: Automatic Router
api: http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1 api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
key_env: HERMES_IMAGE_BROKER_KEY api_key: atlas-switchyard
default_model: gpt-5.6-terra default_model: atlas/auto/fast
transport: codex_responses transport: chat_completions
fallback_providers: fallback_providers: []
- provider: anthropic
model: claude-sonnet-5
- provider: custom
model: qwen2.5:14b-instruct-q4_0
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
api_key: ollama
agent: agent:
api_max_retries: 2 api_max_retries: 2
max_turns: 120 max_turns: 120
reasoning_effort: high
delegation: delegation:
max_concurrent_children: 2 max_concurrent_children: 2
max_iterations: 80 max_iterations: 80
@ -62,9 +55,17 @@ data:
enabled: true enabled: true
extra: extra:
model_routes: model_routes:
gpt-5.6-terra: atlas/auto/fast: {provider: atlas-switchyard, model: atlas/auto/fast}
provider: atlas-codex atlas/auto/balanced: {provider: atlas-switchyard, model: atlas/auto/balanced}
model: gpt-5.6-terra atlas/auto/deep: {provider: atlas-switchyard, model: atlas/auto/deep}
atlas/auto/maximum: {provider: atlas-switchyard, model: atlas/auto/maximum}
atlas/manual/codex/luna: {provider: atlas-switchyard, model: atlas/manual/codex/luna}
atlas/manual/codex/terra: {provider: atlas-switchyard, model: atlas/manual/codex/terra}
atlas/manual/codex/sol: {provider: atlas-switchyard, model: atlas/manual/codex/sol}
atlas/manual/claude/haiku: {provider: atlas-switchyard, model: atlas/manual/claude/haiku}
atlas/manual/claude/sonnet: {provider: atlas-switchyard, model: atlas/manual/claude/sonnet}
atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}
dashboard: dashboard:
public_url: https://chat.hermes.bstein.dev public_url: https://chat.hermes.bstein.dev
display: display:

View File

@ -158,7 +158,7 @@ spec:
requests: {cpu: 25m, memory: 32Mi} requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi} limits: {cpu: 100m, memory: 64Mi}
- name: patch-auth - name: patch-auth
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -179,7 +179,7 @@ spec:
limits: {cpu: 100m, memory: 128Mi} limits: {cpu: 100m, memory: 128Mi}
containers: containers:
- name: hermes - name: hermes
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
@ -239,7 +239,7 @@ spec:
requests: {cpu: 250m, memory: 512Mi} requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "1", memory: 2Gi} limits: {cpu: "1", memory: 2Gi}
- name: webui - name: webui
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5 image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -9,23 +9,23 @@ metadata:
data: data:
config.yaml: | config.yaml: |
model: model:
provider: anthropic provider: atlas-switchyard
default: claude-opus-5 default: atlas/auto/deep
model: claude-opus-5 model: atlas/auto/deep
fallback_providers: providers:
- provider: openai-codex atlas-switchyard:
model: gpt-5.6-terra name: Atlas Switchyard
- provider: custom api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
model: qwen2.5:14b-instruct-q4_0 api_key: atlas-switchyard
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1 default_model: atlas/auto/deep
api_key: ollama transport: chat_completions
fallback_providers: []
agent: agent:
api_max_retries: 1 api_max_retries: 1
# A static high default is the fail-safe if routing is unavailable. In # AUTO leaves effort unset so Switchyard's selected target owns it.
# AUTO, the Jetson classifier independently selects every turn.
reasoning_effort: high
plugins: plugins:
enabled: enabled:

View File

@ -186,7 +186,7 @@ spec:
cpu: 100m cpu: 100m
memory: 64Mi memory: 64Mi
- name: patch-auth - name: patch-auth
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /opt/hermes/.venv/bin/python - /opt/hermes/.venv/bin/python
@ -238,7 +238,7 @@ spec:
memory: 64Mi memory: 64Mi
containers: containers:
- name: hermes - name: hermes
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/opt/hermes/.venv/bin/hermes] command: [/opt/hermes/.venv/bin/hermes]
args: args:
@ -351,7 +351,7 @@ spec:
cpu: "2" cpu: "2"
memory: 4Gi memory: 4Gi
- name: webui - name: webui
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5 image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -8,9 +8,12 @@ resources:
- configmap.yaml - configmap.yaml
- agent-configmap.yaml - agent-configmap.yaml
- chat-configmap.yaml - chat-configmap.yaml
- switchyard-configmap.yaml
- rbac.yaml - rbac.yaml
- agent-rbac.yaml - agent-rbac.yaml
- pvc.yaml - pvc.yaml
- switchyard-pvc.yaml
- routing-catalog-pvc.yaml
- chat-pvcs.yaml - chat-pvcs.yaml
- oauth-session-store.yaml - oauth-session-store.yaml
- model-gate-rbac.yaml - model-gate-rbac.yaml
@ -19,6 +22,7 @@ resources:
- model-gate-state.yaml - model-gate-state.yaml
- model-gate-configmap.yaml - model-gate-configmap.yaml
- model-gate-deployment.yaml - model-gate-deployment.yaml
- switchyard-deployment.yaml
- image-policy-configmap.yaml - image-policy-configmap.yaml
- networkpolicy.yaml - networkpolicy.yaml
- local-image-deployment.yaml - local-image-deployment.yaml
@ -29,6 +33,7 @@ resources:
- chat-sandbox.yaml - chat-sandbox.yaml
- chat-router.yaml - chat-router.yaml
- service.yaml - service.yaml
- switchyard-service.yaml
- oauth2-proxy.yaml - oauth2-proxy.yaml
- agent-certificate.yaml - agent-certificate.yaml
- agent-ingress.yaml - agent-ingress.yaml
@ -55,6 +60,8 @@ configMapGenerator:
- codex=scripts/codex - codex=scripts/codex
- configure_agent_clients.py=scripts/configure_agent_clients.py - configure_agent_clients.py=scripts/configure_agent_clients.py
- codex_broker.py=scripts/codex_broker.py - codex_broker.py=scripts/codex_broker.py
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
- worker_route_broker.py=scripts/worker_route_broker.py
- gitea_askpass.sh=scripts/gitea_askpass.sh - gitea_askpass.sh=scripts/gitea_askpass.sh
- hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_model_routing.py=scripts/hermes_model_routing.py
@ -66,6 +73,7 @@ configMapGenerator:
- patch_codex_runtime.py=scripts/patch_codex_runtime.py - patch_codex_runtime.py=scripts/patch_codex_runtime.py
- patch_tui_gateway.py=scripts/patch_tui_gateway.py - patch_tui_gateway.py=scripts/patch_tui_gateway.py
- patch_ttyd_index.py=scripts/patch_ttyd_index.py - patch_ttyd_index.py=scripts/patch_ttyd_index.py
- routing_catalog.py=scripts/routing_catalog.py
options: options:
disableNameSuffixHash: true disableNameSuffixHash: true
- name: hermes-agent-kubeconfig - name: hermes-agent-kubeconfig

View File

@ -52,7 +52,7 @@ data:
def _normalize_reasoning(body: bytes | None) -> bytes | None: def _normalize_reasoning(body: bytes | None) -> bytes | None:
"""Clamp hosted-only effort names to the local server's supported tier.""" """Translate routed aliases and clamp effort to the local server tier."""
if not body: if not body:
return body return body
@ -64,6 +64,10 @@ data:
return body return body
changed = False changed = False
model = payload.get("model")
if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"):
payload["model"] = "qwen2.5:14b-instruct-q4_0"
changed = True
for key in ("reasoning_effort", "reasoning"): for key in ("reasoning_effort", "reasoning"):
value = payload.get(key) value = payload.get(key)
if isinstance(value, str) and value.lower() in {"xhigh", "max"}: if isinstance(value, str) and value.lower() in {"xhigh", "max"}:

View File

@ -36,7 +36,7 @@ spec:
matchExpressions: matchExpressions:
- key: app - key: app
operator: In operator: In
values: [hermes, hermes-agent, hermes-chat-tenant] values: [hermes, hermes-agent, hermes-chat-tenant, hermes-switchyard]
ports: ports:
- {protocol: TCP, port: 8080} - {protocol: TCP, port: 8080}
- from: - from:
@ -103,6 +103,12 @@ spec:
ports: ports:
- {protocol: TCP, port: 9002} - {protocol: TCP, port: 9002}
- {protocol: TCP, port: 9003} - {protocol: TCP, port: 9003}
- from:
- podSelector:
matchLabels:
app: hermes-switchyard
ports:
- {protocol: TCP, port: 9003}
# agent.hermes.bstein.dev is an owner-only engineering workstation. The # agent.hermes.bstein.dev is an owner-only engineering workstation. The
# browser boundary remains OAuth-protected, while its workers need to reach # browser boundary remains OAuth-protected, while its workers need to reach
# every cluster namespace, Atlas LAN service, and hosted provider endpoint. # every cluster namespace, Atlas LAN service, and hosted provider endpoint.
@ -251,6 +257,12 @@ spec:
app: hermes-model-gate app: hermes-model-gate
ports: ports:
- {protocol: TCP, port: 8080} - {protocol: TCP, port: 8080}
- to:
- podSelector:
matchLabels:
app: hermes-switchyard
ports:
- {protocol: TCP, port: 9005}
- to: - to:
- podSelector: - podSelector:
matchLabels: matchLabels:
@ -286,6 +298,70 @@ spec:
--- ---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: NetworkPolicy kind: NetworkPolicy
metadata:
name: hermes-switchyard-isolation
namespace: hermes
spec:
podSelector:
matchLabels:
app: hermes-switchyard
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values: [hermes, hermes-agent, hermes-chat-tenant]
ports:
- {protocol: TCP, port: 9005}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ai
podSelector:
matchLabels:
app: ollama
ports:
- {protocol: TCP, port: 11434}
- to:
- podSelector:
matchLabels:
app: hermes-model-gate
ports:
- {protocol: TCP, port: 8080}
- to:
- podSelector:
matchLabels:
app: hermes-agent
ports:
- {protocol: TCP, port: 9003}
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 100.64.0.0/10
- 127.0.0.0/8
- 169.254.0.0/16
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- {protocol: TCP, port: 443}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: metadata:
name: hermes-chat-router-isolation name: hermes-chat-router-isolation
namespace: hermes namespace: hermes

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
# services/hermes/routing-catalog-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-routing-catalog
namespace: hermes
labels:
app: hermes-switchyard
spec:
accessModes:
- ReadWriteMany
storageClassName: astreae
resources:
requests:
storage: 1Gi

View File

@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Translate an internal relay key into the owner's Claude OAuth credential."""
from __future__ import annotations
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Final
import httpx
from routing_catalog import resolve_route
HOST: Final = os.environ.get("HERMES_CLAUDE_BROKER_HOST", "0.0.0.0")
PORT: Final = int(os.environ.get("HERMES_CLAUDE_BROKER_PORT", "9006"))
UPSTREAM: Final = os.environ.get(
"HERMES_CLAUDE_BROKER_UPSTREAM", "https://api.anthropic.com"
).rstrip("/")
MAX_BODY_BYTES: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20))
)
READ_TIMEOUT_SECONDS: Final = float(
os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "900")
)
ALLOWED_PATHS: Final = {
"/v1/messages",
"/v1/messages/count_tokens",
"/v1/models",
}
REQUIRED_BETAS: Final = (
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"claude-code-20250219",
"oauth-2025-04-20",
)
ROUTED_MODEL_PREFIX: Final = "route/claude/"
def _translate_model(body: bytes) -> bytes:
"""Translate a Switchyard effort-qualified target into a Claude model."""
if not body:
return body
try:
payload = json.loads(body)
except (TypeError, ValueError, json.JSONDecodeError):
return body
if not isinstance(payload, dict):
return body
model = payload.get("model")
if isinstance(model, str) and model.startswith(ROUTED_MODEL_PREFIX):
payload["model"] = resolve_route(model)
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
return body
def _read_secret(env_name: str, file_env_name: str) -> str:
"""Read a secret from an environment value or a mounted file."""
value = os.environ.get(env_name, "").strip()
if value:
return value
path = os.environ.get(file_env_name, "").strip()
if not path:
return ""
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError:
return ""
def _relay_key() -> str:
"""Return the shared internal key without caching rotated file contents."""
return _read_secret(
"HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE"
)
def _oauth_token() -> str:
"""Return the current owner OAuth token or fail closed."""
token = _read_secret(
"CLAUDE_CODE_OAUTH_TOKEN", "HERMES_CLAUDE_OAUTH_TOKEN_FILE"
)
if not token:
raise RuntimeError("owner Claude authentication is unavailable")
return token
def _authorized(authorization: str | None, api_key: str | None) -> bool:
"""Accept Switchyard's x-api-key or an internal Bearer relay key."""
expected = _relay_key()
if not expected:
return False
candidates = [api_key or ""]
if authorization and authorization.startswith("Bearer "):
candidates.append(authorization[7:].strip())
return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates)
def _merge_betas(incoming: str | None) -> str:
"""Preserve requested Anthropic betas while adding Claude Code OAuth betas."""
values: list[str] = []
for value in (*((incoming or "").split(",")), *REQUIRED_BETAS):
value = value.strip()
if value and value not in values:
values.append(value)
return ",".join(values)
class Handler(BaseHTTPRequestHandler):
"""Stream Anthropic responses while keeping the OAuth token server-side."""
server_version = "HermesClaudeOAuthBroker/1"
def log_message(self, format: str, *args: object) -> None:
"""Avoid logging paths or headers that could contain sensitive metadata."""
return
def _json(self, status: int, value: dict[str, object]) -> None:
body = json.dumps(value, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _check_auth(self) -> bool:
if _authorized(
self.headers.get("Authorization"), self.headers.get("x-api-key")
):
return True
self._json(401, {"error": {"type": "authentication_error", "message": "unauthorized"}})
return False
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/health":
try:
_oauth_token()
except RuntimeError as exc:
self._json(503, {"ok": False, "error": str(exc)})
return
self._json(200, {"ok": True, "provider": "anthropic-oauth"})
return
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": {"type": "not_found", "message": "not found"}})
return
if not self._check_auth():
return
self._proxy(b"")
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": {"type": "not_found", "message": "not found"}})
return
if not self._check_auth():
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._json(413, {"error": {"type": "request_too_large", "message": "request too large"}})
return
self._proxy(_translate_model(self.rfile.read(length)))
def _proxy(self, body: bytes) -> None:
"""Forward one bounded request and stream its response unchanged."""
response_started = False
try:
token = _oauth_token()
headers = {
"Accept": self.headers.get("Accept", "application/json"),
"Authorization": f"Bearer {token}",
"anthropic-version": self.headers.get(
"anthropic-version", "2023-06-01"
),
"anthropic-beta": _merge_betas(self.headers.get("anthropic-beta")),
"Content-Type": self.headers.get("Content-Type", "application/json"),
"User-Agent": "claude-code/2.1.226 (external, cli)",
"x-app": "cli",
}
timeout = httpx.Timeout(30.0, read=READ_TIMEOUT_SECONDS)
with httpx.Client(timeout=timeout) as client:
with client.stream(
self.command,
f"{UPSTREAM}{self.path}",
headers=headers,
content=body or None,
) as response:
self.send_response(response.status_code)
for name, value in response.headers.items():
if name.lower() in {
"content-type",
"cache-control",
"request-id",
"retry-after",
"anthropic-ratelimit-requests-limit",
"anthropic-ratelimit-requests-remaining",
"anthropic-ratelimit-requests-reset",
"anthropic-ratelimit-tokens-limit",
"anthropic-ratelimit-tokens-remaining",
"anthropic-ratelimit-tokens-reset",
}:
self.send_header(name, value)
self.send_header("Connection", "close")
self.end_headers()
response_started = True
for chunk in response.iter_bytes():
if chunk:
self.wfile.write(chunk)
self.wfile.flush()
except (RuntimeError, httpx.HTTPError, OSError) as exc:
# Once streaming headers have crossed the wire, an upstream failure
# can only terminate the stream. Sending a second HTTP response
# would corrupt the Anthropic event stream seen by Switchyard.
if not response_started and not self.wfile.closed:
self._json(
503,
{"error": {"type": "provider_unavailable", "message": str(exc)}},
)
finally:
self.close_connection = True
class Server(ThreadingHTTPServer):
"""Threaded server whose request workers do not block shutdown."""
daemon_threads = True
if __name__ == "__main__":
Server((HOST, PORT), Handler).serve_forever()

View File

@ -4,7 +4,6 @@
from __future__ import annotations from __future__ import annotations
import concurrent.futures import concurrent.futures
import importlib.util
import json import json
import os import os
import re import re
@ -14,6 +13,8 @@ import sys
import threading import threading
import time import time
import uuid import uuid
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -21,8 +22,10 @@ from typing import Any, Callable
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data")) DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
ROUTING_PATH = DATA_ROOT / "workspace/coordinator/model-routing.json" SWITCHYARD_URL = os.environ.get(
ROUTER_PATH = DATA_ROOT / "plugins/auto-router/__init__.py" "HERMES_SWITCHYARD_URL",
"http://hermes-switchyard.hermes.svc.cluster.local:9005/v1/chat/completions",
)
STATE_ROOT = DATA_ROOT / "cli-lanes" STATE_ROOT = DATA_ROOT / "cli-lanes"
CODEX_BIN = DATA_ROOT / "tools/bin/codex" CODEX_BIN = DATA_ROOT / "tools/bin/codex"
CLAUDE_BIN = DATA_ROOT / "tools/bin/claude" CLAUDE_BIN = DATA_ROOT / "tools/bin/claude"
@ -102,17 +105,6 @@ def load_json(path: Path) -> dict[str, Any]:
return value if isinstance(value, dict) else {} return value if isinstance(value, dict) else {}
def _load_router(path: Path = ROUTER_PATH) -> Any:
"""Load the same Jetson-first classifier used by interactive Hermes."""
spec = importlib.util.spec_from_file_location("hermes_cli_lane_router", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"AUTO router could not be loaded: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def parse_assignee(assignee: str) -> tuple[str | None, str | None]: def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
"""Parse external lane overrides while leaving cli-auto fully automatic.""" """Parse external lane overrides while leaving cli-auto fully automatic."""
value = str(assignee or "").strip().lower() value = str(assignee or "").strip().lower()
@ -124,63 +116,75 @@ def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
return match.group(1), match.group(2) return match.group(1), match.group(2)
def _split_route(value: str) -> tuple[str, str]: def _decode_worker_target(value: str) -> tuple[str, str, str]:
provider, separator, model = value.partition("/") """Decode the selected model header emitted by a worker decision route."""
if not separator or not provider or not model: parts = value.split("/", 3)
raise RuntimeError(f"invalid managed route: {value}") if len(parts) != 4 or parts[0] != "worker":
return provider, model raise RuntimeError(f"invalid Switchyard worker target: {value}")
provider, model, effort = parts[1:]
if provider not in {"codex", "claude"} or effort not in EFFORTS:
def _worker_provider(provider: str) -> str | None: raise RuntimeError(f"unsupported Switchyard worker target: {value}")
return {"openai-codex": "codex", "anthropic": "claude"}.get(provider) return provider, model, effort
def select_route( def select_route(
prompt: str, prompt: str,
assignee: str, assignee: str,
*, *,
routing_path: Path = ROUTING_PATH,
router_path: Path = ROUTER_PATH,
exclude_provider: str | None = None, exclude_provider: str | None = None,
switchyard_url: str = SWITCHYARD_URL,
open_request: Callable[..., Any] = urllib.request.urlopen,
) -> Route: ) -> Route:
"""Always consult the Jetson, then apply a deliberate lane override if set.""" """Ask Switchyard to select one native CLI worker at this boundary."""
router = _load_router(router_path) started = time.monotonic()
decision = router.classify_task(prompt)
manual_provider, manual_effort = parse_assignee(assignee) manual_provider, manual_effort = parse_assignee(assignee)
selected_provider = manual_provider or str(decision.provider) if manual_provider and manual_effort:
effort = manual_effort or str(decision.effort) route_id = f"atlas/worker/manual/{manual_provider}/{manual_effort}"
if effort not in EFFORTS: source = "switchyard-manual"
raise RuntimeError(f"classifier returned unsupported effort: {effort}") else:
if exclude_provider == selected_provider: route_id = "atlas/worker/auto/maximum"
selected_provider = "claude" if selected_provider == "codex" else "codex" source = "switchyard-classifier"
status = load_json(routing_path) context = prompt
profile = f"{selected_provider}-{effort}" if exclude_provider:
chain = (status.get("routes") or {}).get(profile) context += (
if not isinstance(chain, list) or not chain: f"\n\nRouting constraint: the {exclude_provider} provider failed or "
raise RuntimeError(f"managed route unavailable: {profile}") "exhausted capacity at this boundary. Do not select it."
hosted = [str(item) for item in chain if _worker_provider(_split_route(str(item))[0])] )
if not hosted: payload = json.dumps(
raise RuntimeError(f"no hosted CLI route available: {profile}") {
first = next( "model": route_id,
(item for item in hosted if _worker_provider(_split_route(item)[0]) == selected_provider), "messages": [{"role": "user", "content": context}],
hosted[0], "stream": False,
"max_tokens": 1,
}
).encode("utf-8")
request = urllib.request.Request(
switchyard_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
) )
provider_key, model = _split_route(first) try:
actual_provider = _worker_provider(provider_key) with open_request(request, timeout=60) as response:
if actual_provider is None: selected = str(response.headers.get("x-model-router-selected-model") or "")
raise RuntimeError(f"route is not a CLI provider: {first}") rationale = str(response.headers.get("x-model-router-rationale") or "")
response.read()
except (OSError, urllib.error.URLError) as exc:
raise RuntimeError(f"Switchyard worker routing failed: {exc}") from exc
provider, model, effort = _decode_worker_target(selected)
if exclude_provider and provider == exclude_provider:
raise RuntimeError(
f"Switchyard selected excluded provider {exclude_provider} for retry"
)
return Route( return Route(
provider=actual_provider, provider=provider,
model=model, model=model,
effort=effort, effort=effort,
profile=f"{actual_provider}-{effort}", profile=f"{provider}-{effort}",
classifier=str(decision.classifier), classifier=source,
reason=( reason=rationale or f"Switchyard selected {selected}",
str(decision.reason) latency_ms=int((time.monotonic() - started) * 1000),
+ ("; manual lane override applied after Jetson classification" if manual_provider else "") fallback_chain=(),
),
latency_ms=int(decision.latency_ms),
fallback_chain=tuple(item for item in hosted if item != first),
) )

View File

@ -15,6 +15,8 @@ from typing import Any
import httpx import httpx
from routing_catalog import resolve_route
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0") HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003")) PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
@ -25,7 +27,7 @@ UPSTREAM = os.environ.get(
).rstrip("/") ).rstrip("/")
MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20))) MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20)))
READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900")) READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900"))
ALLOWED_MODELS = { FALLBACK_ALLOWED_MODELS = {
value.strip() value.strip()
for value in os.environ.get( for value in os.environ.get(
"HERMES_CODEX_BROKER_MODELS", "HERMES_CODEX_BROKER_MODELS",
@ -33,6 +35,14 @@ ALLOWED_MODELS = {
).split(",") ).split(",")
if value.strip() if value.strip()
} }
ROUTED_MODEL_PREFIX = "route/codex/"
def _real_model(model: str) -> str:
"""Translate a Switchyard effort-qualified target into a Codex model."""
if model.startswith(ROUTED_MODEL_PREFIX):
model = resolve_route(model)
return model
def _authorized(header: str | None) -> bool: def _authorized(header: str | None) -> bool:
@ -89,8 +99,12 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ValueError("JSON object required") raise ValueError("JSON object required")
model = payload.get("model") model = payload.get("model")
if not isinstance(model, str) or model not in ALLOWED_MODELS: if not isinstance(model, str):
raise ValueError("unsupported Codex model") raise ValueError("unsupported Codex model")
model = _real_model(model)
if not model.startswith("gpt-"):
raise ValueError("unsupported Codex model")
payload["model"] = model
# Tenant conversations must not enter the owner's server-side history. # Tenant conversations must not enter the owner's server-side history.
payload["store"] = False payload["store"] = False
payload["stream"] = True payload["stream"] = True
@ -135,7 +149,7 @@ class Handler(BaseHTTPRequestHandler):
"object": "list", "object": "list",
"data": [ "data": [
{"id": model, "object": "model", "owned_by": "openai-codex"} {"id": model, "object": "model", "owned_by": "openai-codex"}
for model in sorted(ALLOWED_MODELS) for model in sorted(FALLBACK_ALLOWED_MODELS)
], ],
}, },
) )

View File

@ -4,10 +4,12 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import json
import os import os
import re import re
import shutil import shutil
import subprocess import subprocess
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -26,6 +28,10 @@ ATLAS_FALLBACK = {
} }
# Backwards-compatible name used by the focused unit tests and status tooling. # Backwards-compatible name used by the focused unit tests and status tooling.
LOCAL_FALLBACK = ATLAS_FALLBACK 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 = { MANAGED_ENV_KEYS = {
"CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN", "GITEA_TOKEN",
@ -215,6 +221,113 @@ def _write_yaml(path: Path, value: dict[str, Any], mode: int | None = None) -> b
return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode) 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]]:
"""Return last-known-good effort, tier, and catalog values for a provider."""
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", [])
return (
dict(resolved) if isinstance(resolved, dict) else {},
dict(tiers) if isinstance(tiers, dict) else {},
_unique_models(models if isinstance(models, list) else []),
)
def build_routing_catalog(
codex: Catalog, claude: Catalog, previous: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Build a current catalog while retaining working routes during outages."""
previous = previous or {}
providers: dict[str, Any] = {}
specifications = (
(
"codex",
codex,
choose_codex_for_effort,
{"luna": "low", "terra": "medium", "sol": "high"},
{
"low": "gpt-5.6-luna",
"medium": "gpt-5.6-terra",
"high": "gpt-5.6-sol",
"xhigh": "gpt-5.6-sol",
},
),
(
"claude",
claude,
choose_claude_for_effort,
{"haiku": "low", "sonnet": "medium", "opus": "xhigh"},
{
"low": "claude-haiku-4-5-20251001",
"medium": "claude-sonnet-5",
"high": "claude-opus-5",
"xhigh": "claude-opus-5",
},
),
)
for name, discovered, chooser, tier_efforts, defaults in specifications:
old_resolved, old_tiers, old_models = _previous_provider_models(previous, name)
source_models = discovered.models if discovered.live else old_models
resolved: dict[str, str] = {}
for effort in EFFORTS:
current = str(old_resolved.get(effort) or defaults[effort])
resolved[effort] = (
chooser(source_models, effort, current)
if source_models
else current
)
tiers: dict[str, str] = {}
for tier, effort in tier_efforts.items():
current = str(old_tiers.get(tier) or defaults[effort])
tier_matches = [model for model in source_models if tier in model.lower()]
tiers[tier] = (
max(tier_matches, key=lambda model: (model_version(model), model))
if tier_matches
else (resolved[effort] if discovered.live else current)
)
providers[name] = {
"state": discovered.state,
"connected": discovered.connected,
"live": discovered.live,
"models": _unique_models(
discovered.models
if discovered.live
else (old_models or discovered.models)
),
"resolved": resolved,
"tiers": tiers,
}
return {
"schema_version": 1,
"updated_at": int(time.time()),
"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."""
catalog = build_routing_catalog(codex, claude, _read_json(path))
_atomic_write(path, json.dumps(catalog, indent=2, sort_keys=True) + "\n", 0o644)
return catalog
def _read_env(path: Path) -> dict[str, str]: def _read_env(path: Path) -> dict[str, str]:
"""Read the small dotenv subset used by Hermes provider credentials.""" """Read the small dotenv subset used by Hermes provider credentials."""
values: dict[str, str] = {} values: dict[str, str] = {}
@ -381,6 +494,36 @@ def _profile_config(
return config return config
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( def _write_profile(
root: Path, root: Path,
name: str, name: str,
@ -402,50 +545,33 @@ def _write_profile(
_update_profile_env(profile / ".env", env_values) _update_profile_env(profile / ".env", env_values)
def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, Any]: def configure_routes(
"""Update the coordinator and managed worker profiles.""" 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" config_path = root / "config.yaml"
base = _read_yaml(config_path) base = _read_yaml(config_path)
old_coordinator = _existing_model(base, "openai-codex", CODEX_BASELINE) resolved_catalog_path = catalog_path or (
old_claude_root = _existing_model(base, "anthropic", CLAUDE_BASELINE) Path(ROUTING_CATALOG_PATH)
codex_models: dict[str, str] = {} if ROUTING_CATALOG_PATH
claude_models: dict[str, str] = {} else root / "routing-catalog.json"
for effort in EFFORTS: )
old_codex = _existing_model( catalog = write_routing_catalog(resolved_catalog_path, codex, claude)
_read_yaml(root / "profiles" / f"codex-{effort}" / "config.yaml"), providers = catalog["providers"]
"openai-codex", codex_models = providers["codex"]["resolved"]
old_coordinator, claude_models = providers["claude"]["resolved"]
)
old_claude = _existing_model(
_read_yaml(root / "profiles" / f"claude-{effort}" / "config.yaml"),
"anthropic",
old_claude_root,
)
codex_models[effort] = (
choose_codex_for_effort(codex.models, effort, old_codex)
if codex.live
else old_codex
)
claude_models[effort] = (
choose_claude_for_effort(claude.models, effort, old_claude)
if claude.live
else old_claude
)
codex_coordinator = codex_models["medium"] coordinator_toolsets = copy.deepcopy(base.get("toolsets"))
claude_coordinator = claude_models["medium"] base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high")
# The coordinator uses its configured toolsets. Worker profiles below are
base["model"] = { # deliberately toolset-empty so Hermes resolves their native defaults.
"provider": "openai-codex", if coordinator_toolsets is None:
"default": codex_coordinator, base.pop("toolsets", None)
"model": codex_coordinator, else:
"openai_runtime": "codex_app_server", base["toolsets"] = coordinator_toolsets
}
base["fallback_providers"] = [
{"provider": "anthropic", "model": claude_coordinator},
copy.deepcopy(ATLAS_FALLBACK),
]
base["model_catalog"] = {"enabled": True, "ttl_hours": 1}
_write_yaml(config_path, base) _write_yaml(config_path, base)
env_values = _read_env(root / ".env") env_values = _read_env(root / ".env")
@ -455,73 +581,56 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
claude_model = claude_models[effort] claude_model = claude_models[effort]
codex_name = f"codex-{effort}" codex_name = f"codex-{effort}"
claude_name = f"claude-{effort}" claude_name = f"claude-{effort}"
codex_route = {
"low": "atlas/manual/codex/luna",
"medium": "atlas/manual/codex/terra",
"high": "atlas/manual/codex/sol",
"xhigh": "atlas/manual/codex/sol",
}[effort]
claude_route = {
"low": "atlas/manual/claude/haiku",
"medium": "atlas/manual/claude/sonnet",
"high": "atlas/manual/claude/sonnet",
"xhigh": "atlas/manual/claude/opus",
}[effort]
_write_profile( _write_profile(
root, root,
codex_name, codex_name,
f"Codex implementation worker at {effort} effort, with Claude and local fallback.", 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.", "You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.",
_profile_config( _switchyard_profile_config(base, codex_route, effort),
base,
"openai-codex",
codex_model,
{"provider": "anthropic", "model": claude_model},
effort,
),
env_values, env_values,
) )
_write_profile( _write_profile(
root, root,
claude_name, claude_name,
f"Claude architecture and review worker at {effort} effort, with Codex and local fallback.", 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.", "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.",
_profile_config( _switchyard_profile_config(base, claude_route, effort),
base,
"anthropic",
claude_model,
{"provider": "openai-codex", "model": codex_model},
effort,
),
env_values, env_values,
) )
local = [] if effort == "xhigh" else ["custom/qwen2.5:14b-instruct-q4_0"] routes[codex_name] = [codex_route]
routes[codex_name] = [ routes[claude_name] = [claude_route]
f"openai-codex/{codex_model}",
f"anthropic/{claude_model}",
*local,
]
routes[claude_name] = [
f"anthropic/{claude_model}",
f"openai-codex/{codex_model}",
*local,
]
_write_profile( _write_profile(
root, root,
"synthesis-xhigh", "synthesis-xhigh",
"Cross-provider synthesis and critical review, capped at xhigh effort.", "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.", "Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
_profile_config( _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"),
base,
"anthropic",
claude_models["xhigh"],
{"provider": "openai-codex", "model": codex_models["xhigh"]},
"xhigh",
),
env_values, env_values,
) )
routes["synthesis-xhigh"] = [ routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE]
f"anthropic/{claude_models['xhigh']}",
f"openai-codex/{codex_models['xhigh']}",
]
_write_yaml( _write_yaml(
root / "profile.yaml", root / "profile.yaml",
{ {
"description": "Coordinator for project objectives, delegating implementation to Codex and architecture or review to Claude through isolated Kanban boards.", "description": "Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries.",
"description_auto": False, "description_auto": False,
}, },
) )
routes["coordinator"] = [ routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE]
f"openai-codex/{codex_coordinator}", routes["catalog"] = [
f"anthropic/{claude_coordinator}", *(f"openai-codex/{model}" for model in codex_models.values()),
*(f"anthropic/{model}" for model in claude_models.values()),
] ]
return routes return routes

View File

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Resolve stable Switchyard selectors through the stewarded model catalog."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
CATALOG_PATH = Path(
os.environ.get("HERMES_ROUTING_CATALOG_PATH", "/routing-catalog/catalog.json")
)
DEFAULTS = {
"codex": {
"low": "gpt-5.6-luna",
"medium": "gpt-5.6-terra",
"high": "gpt-5.6-sol",
"xhigh": "gpt-5.6-sol",
},
"claude": {
"low": "claude-haiku-4-5-20251001",
"medium": "claude-sonnet-5",
"high": "claude-opus-5",
"xhigh": "claude-opus-5",
},
}
PREFIXES = {"codex": "gpt-", "claude": "claude-"}
def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]:
"""Load a valid routing catalog, falling back to an empty mapping."""
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def resolve_model(
provider: str,
selector: str,
effort: str,
catalog: dict[str, Any] | None = None,
) -> str:
"""Resolve an automatic or capability-tier selector to an exact model ID."""
if provider not in DEFAULTS or effort not in DEFAULTS[provider]:
raise ValueError("unsupported provider or effort")
prefix = PREFIXES[provider]
if selector.startswith(prefix):
return selector
document = catalog if catalog is not None else load_catalog()
providers = document.get("providers", {}) if isinstance(document, dict) else {}
record = providers.get(provider, {}) if isinstance(providers, dict) else {}
if not isinstance(record, dict):
record = {}
mapping_name = "resolved" if selector == "auto" else "tiers"
mapping = record.get(mapping_name, {})
candidate = mapping.get(effort if selector == "auto" else selector, "") if isinstance(mapping, dict) else ""
if not isinstance(candidate, str) or not candidate.startswith(prefix):
candidate = DEFAULTS[provider][effort]
return candidate
def resolve_route(route: str, catalog: dict[str, Any] | None = None) -> str:
"""Resolve a route/<provider>/<selector>/<effort> target to a model ID."""
parts = route.split("/")
if len(parts) != 4 or parts[0] != "route":
return route
return resolve_model(parts[1], parts[2], parts[3], catalog)
def resolve_worker_route(route: str, catalog: dict[str, Any] | None = None) -> str:
"""Resolve a worker selector while preserving the worker route envelope."""
parts = route.split("/")
if len(parts) != 4 or parts[0] != "worker":
raise ValueError("unsupported worker route")
model = resolve_model(parts[1], parts[2], parts[3], catalog)
return f"worker/{parts[1]}/{model}/{parts[3]}"

View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Return deterministic OpenAI-compatible replies for Switchyard worker routes."""
from __future__ import annotations
import json
import os
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from routing_catalog import resolve_worker_route
PORT = int(os.environ.get("HERMES_WORKER_ROUTE_BROKER_PORT", "9007"))
PREFIX = "worker/"
def _reply(model: str) -> dict[str, Any]:
"""Build the minimal response used only to expose Switchyard's decision."""
return {
"id": f"worker-route-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": model},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
class Handler(BaseHTTPRequestHandler):
"""Serve a local-only target sink after Switchyard selects a worker."""
protocol_version = "HTTP/1.1"
def _send(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self._send(200, {"status": "ok"})
return
self._send(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
if self.path not in {"/v1/chat/completions", "/chat/completions"}:
self._send(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 1024 * 1024:
raise ValueError("invalid request length")
payload = json.loads(self.rfile.read(length))
model = str(payload.get("model") or "")
if not model.startswith(PREFIX):
raise ValueError("unsupported worker route")
model = resolve_worker_route(model)
except (ValueError, TypeError, json.JSONDecodeError) as exc:
self._send(400, {"error": str(exc)})
return
self._send(200, _reply(model))
def log_message(self, format: str, *args: Any) -> None:
print(f"worker-route-broker {self.address_string()} {format % args}", flush=True)
def main() -> None:
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,464 @@
# services/hermes/switchyard-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: hermes-switchyard
namespace: hermes
labels:
app: hermes-switchyard
data:
routes.toml: |
schema_version = 1
[llm_clients.classifier]
format = "openai_chat"
base_url = "http://ollama.ai.svc.cluster.local:11434/v1"
max_retries = 1
[llm_clients.local_low]
format = "openai_chat"
base_url = "http://hermes-model-gate.hermes.svc.cluster.local:11434/v1"
max_retries = 1
[llm_clients.local_medium]
format = "openai_chat"
base_url = "http://hermes-model-gate.hermes.svc.cluster.local:11434/v1"
max_retries = 1
[llm_clients.codex_low]
format = "openai_responses"
base_url = "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.codex_medium]
format = "openai_responses"
base_url = "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.codex_high]
format = "openai_responses"
base_url = "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.codex_xhigh]
format = "openai_responses"
base_url = "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.claude_low]
format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.claude_medium]
format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.claude_high]
format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.claude_xhigh]
format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1"
api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1
[llm_clients.worker_decision]
format = "openai_chat"
base_url = "http://127.0.0.1:9007/v1"
max_retries = 0
[targets.classifier]
id = "qwen2.5:3b-instruct-q4_0"
llm_client = "classifier"
[targets.local_qwen_low]
id = "route/local/qwen2.5-14b/low"
llm_client = "local_low"
extra_body = { reasoning_effort = "low" }
[targets.local_qwen_medium]
id = "route/local/qwen2.5-14b/medium"
llm_client = "local_medium"
extra_body = { reasoning_effort = "medium" }
[targets.codex_luna_low]
id = "route/codex/luna/low"
llm_client = "codex_low"
extra_body = { reasoning = { effort = "low" } }
[targets.codex_terra_low]
id = "route/codex/terra/low"
llm_client = "codex_low"
extra_body = { reasoning = { effort = "low" } }
[targets.codex_terra_medium]
id = "route/codex/terra/medium"
llm_client = "codex_medium"
extra_body = { reasoning = { effort = "medium" } }
[targets.codex_terra_high]
id = "route/codex/terra/high"
llm_client = "codex_high"
extra_body = { reasoning = { effort = "high" } }
[targets.codex_sol_medium]
id = "route/codex/sol/medium"
llm_client = "codex_medium"
extra_body = { reasoning = { effort = "medium" } }
[targets.codex_sol_high]
id = "route/codex/sol/high"
llm_client = "codex_high"
extra_body = { reasoning = { effort = "high" } }
[targets.codex_sol_xhigh]
id = "route/codex/sol/xhigh"
llm_client = "codex_xhigh"
extra_body = { reasoning = { effort = "xhigh" } }
[targets.claude_haiku_low]
id = "route/claude/haiku/low"
llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_sonnet_medium]
id = "route/claude/sonnet/medium"
llm_client = "claude_medium"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "medium" } }
[targets.claude_sonnet_high]
id = "route/claude/sonnet/high"
llm_client = "claude_high"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } }
[targets.claude_opus_high]
id = "route/claude/opus/high"
llm_client = "claude_high"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } }
[targets.claude_opus_xhigh]
id = "route/claude/opus/xhigh"
llm_client = "claude_xhigh"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "xhigh" } }
[targets.worker_codex_luna_low]
id = "worker/codex/luna/low"
llm_client = "worker_decision"
[targets.worker_codex_terra_medium]
id = "worker/codex/terra/medium"
llm_client = "worker_decision"
[targets.worker_codex_sol_high]
id = "worker/codex/sol/high"
llm_client = "worker_decision"
[targets.worker_codex_sol_xhigh]
id = "worker/codex/sol/xhigh"
llm_client = "worker_decision"
[targets.worker_claude_haiku_low]
id = "worker/claude/haiku/low"
llm_client = "worker_decision"
[targets.worker_claude_sonnet_medium]
id = "worker/claude/sonnet/medium"
llm_client = "worker_decision"
[targets.worker_claude_sonnet_high]
id = "worker/claude/sonnet/high"
llm_client = "worker_decision"
[targets.worker_claude_opus_xhigh]
id = "worker/claude/opus/xhigh"
llm_client = "worker_decision"
[routes.auto_fast]
id = "atlas/auto/fast"
type = "llm_classifier"
mode = "custom"
classifier_target = "classifier"
targets = ["codex_terra_medium", "claude_sonnet_medium", "codex_luna_low", "claude_haiku_low", "local_qwen_low", "local_qwen_medium", "codex_terra_low", "codex_terra_high", "codex_sol_medium", "codex_sol_high", "codex_sol_xhigh", "claude_sonnet_high", "claude_opus_high", "claude_opus_xhigh"]
default_target = "codex_terra_medium"
max_output_tokens = 96
session_affinity = false
recent_turn_window = 8
context_window = 272000
tool_calling = true
reasoning = true
prompt = """
You are the routing authority for a private family assistant. Choose exactly
one configured target for this model-call boundary. Mildly favor fast and
economical answers, but never trade away correctness for difficult,
ambiguous, safety-sensitive, tool-heavy, or consequential work. Use local
Qwen only for simple low-risk conversation, formatting, or continuity.
Prefer Codex for implementation, debugging, tests, and direct repository
work. Prefer Claude for architecture, ambiguity, long-context synthesis,
risk analysis, and independent review. Interpret requests such as "answer
quickly", "think hard", or "use Claude" semantically. An explicit user
provider, model, or depth instruction wins unless it would undercut a clear
safety floor. Every request is a fresh boundary; classify the actual current
objective and recent context, including referential instructions like
"continue" or "do it". Never select effort above xhigh.
"""
response_schema = '''
{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_terra_medium","claude_sonnet_medium","codex_luna_low","claude_haiku_low","local_qwen_low","local_qwen_medium","codex_terra_low","codex_terra_high","codex_sol_medium","codex_sol_high","codex_sol_xhigh","claude_sonnet_high","claude_opus_high","claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
'''
[routes.auto_fast.policy]
type = "target_selector"
selector = "/decision/target"
[routes.auto_balanced]
id = "atlas/auto/balanced"
type = "llm_classifier"
mode = "custom"
classifier_target = "classifier"
targets = ["codex_terra_medium", "claude_sonnet_medium", "codex_luna_low", "claude_haiku_low", "local_qwen_low", "local_qwen_medium", "codex_terra_low", "codex_terra_high", "codex_sol_medium", "codex_sol_high", "codex_sol_xhigh", "claude_sonnet_high", "claude_opus_high", "claude_opus_xhigh"]
default_target = "codex_terra_medium"
max_output_tokens = 96
session_affinity = false
recent_turn_window = 8
context_window = 272000
tool_calling = true
reasoning = true
prompt = """
You are the routing authority for a private general assistant. Choose
exactly one configured target for this model-call boundary. Balance latency,
cost, and intelligence while giving difficult, uncertain, tool-heavy, or
consequential work enough capability. Use local Qwen only for simple
low-risk conversation, formatting, or continuity. Prefer Codex for
implementation, debugging, tests, and direct repository work. Prefer Claude
for architecture, ambiguity, long-context synthesis, risk analysis, and
independent review. Interpret speed/depth and provider requests
semantically; explicit user intent wins unless it undercuts a clear safety
floor. Classify the actual current objective and recent context, including
referential instructions such as "continue" or "do it". Never select effort
above xhigh.
"""
response_schema = '''
{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_terra_medium","claude_sonnet_medium","codex_luna_low","claude_haiku_low","local_qwen_low","local_qwen_medium","codex_terra_low","codex_terra_high","codex_sol_medium","codex_sol_high","codex_sol_xhigh","claude_sonnet_high","claude_opus_high","claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
'''
[routes.auto_balanced.policy]
type = "target_selector"
selector = "/decision/target"
[routes.auto_deep]
id = "atlas/auto/deep"
type = "llm_classifier"
mode = "custom"
classifier_target = "classifier"
targets = ["claude_sonnet_high", "codex_sol_high", "codex_terra_medium", "claude_sonnet_medium", "codex_terra_high", "codex_sol_medium", "claude_opus_high", "codex_sol_xhigh", "claude_opus_xhigh", "codex_luna_low", "claude_haiku_low", "local_qwen_medium", "local_qwen_low", "codex_terra_low"]
default_target = "claude_sonnet_high"
max_output_tokens = 96
session_affinity = false
recent_turn_window = 12
context_window = 272000
tool_calling = true
reasoning = true
prompt = """
You are the routing authority for operations triage. Choose exactly one
configured target for this model-call boundary. Favor evidence, diagnosis,
uncertainty handling, and correctness over marginal latency savings. Cheap
routes are appropriate only for genuinely mechanical low-risk steps. Prefer
Codex for implementation, debugging, tests, and repository work; prefer
Claude for diagnosis, architecture, ambiguity, synthesis, risk analysis,
and independent review. Interpret requests for speed, depth, or a provider
semantically. Explicit user intent wins unless it violates a safety floor.
Re-evaluate every current boundary using recent tool evidence and resolve
referential instructions such as "continue" or "do it" from that context.
Never select effort above xhigh.
"""
response_schema = '''
{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["claude_sonnet_high","codex_sol_high","codex_terra_medium","claude_sonnet_medium","codex_terra_high","codex_sol_medium","claude_opus_high","codex_sol_xhigh","claude_opus_xhigh","codex_luna_low","claude_haiku_low","local_qwen_medium","local_qwen_low","codex_terra_low"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
'''
[routes.auto_deep.policy]
type = "target_selector"
selector = "/decision/target"
[routes.auto_maximum]
id = "atlas/auto/maximum"
type = "llm_classifier"
mode = "custom"
classifier_target = "classifier"
targets = ["codex_sol_high", "claude_opus_high", "claude_sonnet_high", "codex_terra_high", "codex_sol_xhigh", "claude_opus_xhigh", "codex_terra_medium", "claude_sonnet_medium", "codex_sol_medium", "codex_luna_low", "claude_haiku_low", "local_qwen_medium", "local_qwen_low", "codex_terra_low"]
default_target = "codex_sol_high"
max_output_tokens = 96
session_affinity = false
recent_turn_window = 16
context_window = 272000
tool_calling = true
reasoning = true
prompt = """
You are the routing authority for an owner-only engineering agent. Choose
exactly one configured target for this model-call boundary. Strongly favor
correctness, verification, and task completion; do not waste premium
capacity on truly mechanical steps. Prefer Codex for implementation,
debugging, tests, and direct repository changes. Prefer Claude for
architecture, difficult ambiguity, long-context synthesis, risk analysis,
and independent review. Production changes, security, migrations, data-loss
risk, destructive work, and critical final review require high or xhigh.
Interpret user requests for speed, deeper thought, or a specific provider
semantically. Explicit user intent wins unless it undercuts the safety
floor. Re-evaluate every current boundary from the objective and recent tool
evidence, including referential instructions such as "continue" or "do
it". Never select effort above xhigh.
"""
response_schema = '''
{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_sol_high","claude_opus_high","claude_sonnet_high","codex_terra_high","codex_sol_xhigh","claude_opus_xhigh","codex_terra_medium","claude_sonnet_medium","codex_sol_medium","codex_luna_low","claude_haiku_low","local_qwen_medium","local_qwen_low","codex_terra_low"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
'''
[routes.auto_maximum.policy]
type = "target_selector"
selector = "/decision/target"
[routes.worker_auto_maximum]
id = "atlas/worker/auto/maximum"
type = "llm_classifier"
mode = "custom"
classifier_target = "classifier"
targets = ["worker_codex_sol_high", "worker_claude_sonnet_high", "worker_codex_sol_xhigh", "worker_claude_opus_xhigh", "worker_codex_terra_medium", "worker_claude_sonnet_medium", "worker_codex_luna_low", "worker_claude_haiku_low"]
default_target = "worker_codex_sol_high"
max_output_tokens = 96
session_affinity = false
recent_turn_window = 16
context_window = 272000
tool_calling = false
reasoning = true
prompt = """
You are the sole routing authority for one durable engineering-worker
launch. Choose exactly one configured worker target from the current
objective, acceptance criteria, risk, and any prior-provider failure in the
request. Prefer Codex for implementation, debugging, tests, and repository
changes. Prefer Claude for architecture, ambiguity, synthesis, adversarial
analysis, and independent review. Use low only for mechanical bounded work,
medium for ordinary work, high for difficult or consequential work, and
xhigh for critical security, migration, destructive-risk, or independent
final review. Never exceed xhigh. If the request says a provider has failed
or exhausted capacity, do not select that provider.
"""
response_schema = '''
{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["worker_codex_sol_high","worker_claude_sonnet_high","worker_codex_sol_xhigh","worker_claude_opus_xhigh","worker_codex_terra_medium","worker_claude_sonnet_medium","worker_codex_luna_low","worker_claude_haiku_low"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
'''
[routes.worker_auto_maximum.policy]
type = "target_selector"
selector = "/decision/target"
[routes.worker_manual_codex_low]
id = "atlas/worker/manual/codex/low"
type = "random"
targets = ["worker_codex_luna_low"]
[routes.worker_manual_codex_medium]
id = "atlas/worker/manual/codex/medium"
type = "random"
targets = ["worker_codex_terra_medium"]
[routes.worker_manual_codex_high]
id = "atlas/worker/manual/codex/high"
type = "random"
targets = ["worker_codex_sol_high"]
[routes.worker_manual_codex_xhigh]
id = "atlas/worker/manual/codex/xhigh"
type = "random"
targets = ["worker_codex_sol_xhigh"]
[routes.worker_manual_claude_low]
id = "atlas/worker/manual/claude/low"
type = "random"
targets = ["worker_claude_haiku_low"]
[routes.worker_manual_claude_medium]
id = "atlas/worker/manual/claude/medium"
type = "random"
targets = ["worker_claude_sonnet_medium"]
[routes.worker_manual_claude_high]
id = "atlas/worker/manual/claude/high"
type = "random"
targets = ["worker_claude_sonnet_high"]
[routes.worker_manual_claude_xhigh]
id = "atlas/worker/manual/claude/xhigh"
type = "random"
targets = ["worker_claude_opus_xhigh"]
# A zero-weight target is not selected initially. Switchyard still walks
# this ordered list after 403/408/429/5xx, timeout, or transport failure.
[routes.manual_codex_luna]
id = "atlas/manual/codex/luna"
type = "random"
targets = ["codex_luna_low", "claude_haiku_low", "codex_terra_medium", "claude_sonnet_medium", "local_qwen_low"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_terra]
id = "atlas/manual/codex/terra"
type = "random"
targets = ["codex_terra_medium", "claude_sonnet_medium", "codex_sol_high", "claude_sonnet_high", "local_qwen_medium"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_sol]
id = "atlas/manual/codex/sol"
type = "random"
targets = ["codex_sol_high", "claude_opus_high", "claude_sonnet_high", "codex_terra_high", "local_qwen_medium"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_haiku]
id = "atlas/manual/claude/haiku"
type = "random"
targets = ["claude_haiku_low", "codex_luna_low", "claude_sonnet_medium", "codex_terra_medium", "local_qwen_low"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_sonnet]
id = "atlas/manual/claude/sonnet"
type = "random"
targets = ["claude_sonnet_high", "codex_sol_high", "claude_opus_high", "codex_terra_high", "local_qwen_medium"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_opus]
id = "atlas/manual/claude/opus"
type = "random"
targets = ["claude_opus_high", "codex_sol_high", "claude_sonnet_high", "codex_terra_high", "local_qwen_medium"]
weights = [1, 0, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_local_qwen]
id = "atlas/manual/local/qwen-14b"
type = "random"
targets = ["local_qwen_medium", "codex_terra_medium", "claude_sonnet_medium", "codex_luna_low", "claude_haiku_low"]
weights = [1, 0, 0, 0, 0]
context_window = 131072
tool_calling = true
reasoning = false

View File

@ -0,0 +1,249 @@
# services/hermes/switchyard-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes-switchyard
namespace: hermes
labels:
app: hermes-switchyard
spec:
replicas: 1
revisionHistoryLimit: 2
strategy:
type: Recreate
selector:
matchLabels:
app: hermes-switchyard
template:
metadata:
labels:
app: hermes-switchyard
annotations:
ai.bstein.dev/config-rev: "20260811-switchyard-authority-v1"
prometheus.io/scrape: "true"
prometheus.io/port: "9005"
prometheus.io/path: /metrics
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-template-anthropic-token: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.anthropic_oauth_token }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-relay-key: kv/data/atlas/hermes/chat-telegram
vault.hashicorp.com/agent-inject-template-relay-key: |
{{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
{{ .Data.data.relay_key }}
{{- end }}
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true"
vault.hashicorp.com/agent-requests-cpu: 25m
vault.hashicorp.com/agent-requests-mem: 32Mi
vault.hashicorp.com/agent-limits-cpu: 100m
vault.hashicorp.com/agent-limits-mem: 128Mi
spec:
serviceAccountName: hermes-switchyard
automountServiceAccountToken: true
terminationGracePeriodSeconds: 330
securityContext:
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values: [amd64]
- key: node-role.kubernetes.io/worker
operator: In
values: ["true"]
- key: kubernetes.io/hostname
operator: NotIn
values: [titan-18, titan-22, titan-24]
containers:
- name: switchyard
image: registry.bstein.dev/bstein/hermes-switchyard@sha256:dc050faf8ed16be9b6c6bdf0b9f6e033a37d29ce9a2b1b7eb6ca10dbae50b946
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
- |
ATLAS_BROKER_KEY="$(tr -d '\r\n' < /vault/secrets/relay-key)"
export ATLAS_BROKER_KEY
exec switchyard-server \
--config /etc/switchyard/routes.toml \
--host 0.0.0.0 \
--port 9005 \
--shutdown-timeout 5m \
--routing-log-file /var/lib/switchyard/routing.jsonl
env:
- name: RUST_LOG
value: switchyard_server=info,libsy=info
ports:
- name: http
containerPort: 9005
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "2"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /etc/switchyard
readOnly: true
- name: state
mountPath: /var/lib/switchyard
- name: tmp
mountPath: /tmp
- name: claude-oauth-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:693c4ee09052b75325fe6dc08a45ad24205e0f294dd608a197aa93d8a64c990d
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/claude_oauth_broker.py
env:
- name: HERMES_CLAUDE_OAUTH_TOKEN_FILE
value: /vault/secrets/anthropic-token
- name: HERMES_CLAUDE_BROKER_KEY_FILE
value: /vault/secrets/relay-key
- name: HERMES_CLAUDE_BROKER_READ_TIMEOUT
value: "1800"
- name: HERMES_ROUTING_CATALOG_PATH
value: /routing-catalog/catalog.json
ports:
- name: claude
containerPort: 9006
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: claude
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: claude
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 500m
memory: 384Mi
volumeMounts:
- name: coordinator
mountPath: /opt/coordinator
readOnly: true
- name: tmp
mountPath: /tmp
- name: routing-catalog
mountPath: /routing-catalog
readOnly: true
- name: worker-route-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:693c4ee09052b75325fe6dc08a45ad24205e0f294dd608a197aa93d8a64c990d
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/worker_route_broker.py
ports:
- name: worker-route
containerPort: 9007
protocol: TCP
env:
- name: HERMES_ROUTING_CATALOG_PATH
value: /routing-catalog/catalog.json
readinessProbe:
httpGet:
path: /health
port: worker-route
initialDelaySeconds: 2
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: worker-route
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
resources:
requests:
cpu: 10m
memory: 24Mi
limits:
cpu: 100m
memory: 96Mi
volumeMounts:
- name: coordinator
mountPath: /opt/coordinator
readOnly: true
- name: tmp
mountPath: /tmp
- name: routing-catalog
mountPath: /routing-catalog
readOnly: true
volumes:
- name: config
configMap:
name: hermes-switchyard
- name: coordinator
configMap:
name: hermes-coordinator
defaultMode: 0555
- name: state
persistentVolumeClaim:
claimName: hermes-switchyard-state
- name: routing-catalog
persistentVolumeClaim:
claimName: hermes-routing-catalog
- name: tmp
emptyDir: {}

View File

@ -0,0 +1,15 @@
# services/hermes/switchyard-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-switchyard-state
namespace: hermes
labels:
app: hermes-switchyard
spec:
accessModes:
- ReadWriteOnce
storageClassName: astreae
resources:
requests:
storage: 2Gi

View File

@ -0,0 +1,17 @@
# services/hermes/switchyard-service.yaml
apiVersion: v1
kind: Service
metadata:
name: hermes-switchyard
namespace: hermes
labels:
app: hermes-switchyard
spec:
type: ClusterIP
selector:
app: hermes-switchyard
ports:
- name: http
port: 9005
targetPort: http
protocol: TCP

View File

@ -13,6 +13,12 @@ metadata:
--- ---
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata:
name: hermes-switchyard
namespace: hermes
---
apiVersion: v1
kind: ServiceAccount
metadata: metadata:
name: hermes-chat name: hermes-chat
namespace: hermes namespace: hermes

View File

@ -3,7 +3,7 @@
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: vault-k8s-auth-hermes-5 name: vault-k8s-auth-hermes-6
namespace: vault namespace: vault
spec: spec:
backoffLimit: 2 backoffLimit: 2

View File

@ -255,7 +255,7 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
"game-stream/*" "" "game-stream/*" ""
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \ write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
"hermes/triage-oidc hermes/agent-tokens" "" "hermes/triage-oidc hermes/agent-tokens" ""
write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \ write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram" "" "hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram" ""
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \ write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
"hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" "" "hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" ""

View File

@ -1,18 +1,15 @@
"""Contracts for Agent Hermes automatic route selection.""" """Contracts for the thin Hermes-to-Switchyard boundary adapter."""
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import io
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
SOURCE = ( SOURCE = Path(__file__).parents[2] / "services/hermes/plugins/auto-router/__init__.py"
Path(__file__).parents[2]
/ "services/hermes/plugins/auto-router/__init__.py"
)
SPEC = importlib.util.spec_from_file_location("hermes_auto_router", SOURCE) SPEC = importlib.util.spec_from_file_location("hermes_auto_router", SOURCE)
assert SPEC and SPEC.loader assert SPEC and SPEC.loader
router = importlib.util.module_from_spec(SPEC) router = importlib.util.module_from_spec(SPEC)
@ -20,784 +17,139 @@ sys.modules[SPEC.name] = router
SPEC.loader.exec_module(router) SPEC.loader.exec_module(router)
def _status() -> dict: def _agent(**overrides):
return { values = {
"providers": { "provider": router.SWITCHYARD_PROVIDER,
"openai-codex": {"connected": True}, "model": "atlas/auto/maximum",
"anthropic": {"connected": True}, "base_url": "http://hermes-switchyard:9005/v1",
}, "api_key": "atlas-switchyard",
"routes": { "api_mode": "chat_completions",
"codex-low": [ "reasoning_config": {"effort": "high"},
"openai-codex/gpt-5.6-luna", "_fallback_chain": [{"provider": "anthropic"}],
"anthropic/claude-haiku-4-5-20251001", "_fallback_index": 1,
], "_fallback_activated": True,
"codex-medium": [ "_fallback_model": {"provider": "anthropic"},
"openai-codex/gpt-5.6-terra", "_hermes_explicit_model_pick": False,
"anthropic/claude-sonnet-5", "_hermes_explicit_reasoning_effort": "",
], "_hermes_routing_priority": "",
"claude-medium": [
"anthropic/claude-sonnet-5",
"openai-codex/gpt-5.6-terra",
],
"claude-xhigh": [
"anthropic/claude-opus-5",
"openai-codex/gpt-5.6-sol",
],
},
} }
values.update(overrides)
return SimpleNamespace(**values)
def test_heuristics_keep_simple_questions_cheap_and_risky_work_capped(): def test_profile_defaults_are_distinct_and_quality_ordered():
simple = router.heuristic_decision("Who is the current provider?") assert router.PROFILE_ROUTE == {
risky = router.heuristic_decision("Migrate production Vault credentials safely") "chat": "atlas/auto/fast",
"triage": "atlas/auto/deep",
assert (simple.shape, simple.effort, simple.provider) == ( "agent": "atlas/auto/maximum",
"question", }
"low", assert router.PROFILE_ROUTE[router.ROUTER_PROFILE] in router.AUTO_ROUTES
"codex",
)
assert (risky.shape, risky.effort, risky.provider) == (
"review",
"xhigh",
"claude",
)
def test_jetson_selects_provider_while_deterministic_policy_preserves_work_shape( def test_auto_boundary_selects_only_a_public_switchyard_route(tmp_path, monkeypatch):
monkeypatch, monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
agent = _agent()
route, effort, source = router._boundary_selection(agent)
assert route == router.PROFILE_ROUTE[router.ROUTER_PROFILE]
assert effort == ""
assert source == "auto"
def test_ui_priority_changes_auto_posture_without_selecting_a_target(
tmp_path, monkeypatch
): ):
monkeypatch.setattr( monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
router, agent = _agent(_hermes_routing_priority="deep")
"jetson_decision",
lambda text: router.Decision(
"question", "high", "claude", "jetson", "test", 42
),
)
decision = router.classify_task("Implement and test the new API handler") route, effort, source = router._boundary_selection(agent)
assert decision.shape == "implementation" assert (route, effort, source) == ("atlas/auto/deep", "", "ui-auto")
assert decision.provider == "claude" assert route in router.AUTO_ROUTES
assert decision.effort == "high"
assert decision.classifier == "jetson"
def test_route_uses_managed_models_and_connected_provider_fallback(): def test_ui_manual_model_and_effort_are_forwarded_as_constraints(
status = _status() tmp_path, monkeypatch
decision = router.Decision(
"question", "low", "codex", "heuristic", "short question"
)
plan = router.select_route(status, decision)
assert plan["profile"] == "codex-low"
assert plan["model"] == "gpt-5.6-luna"
status["providers"]["openai-codex"]["connected"] = False
status["routes"]["claude-low"] = [
"anthropic/claude-haiku-4-5-20251001",
"openai-codex/gpt-5.6-luna",
]
fallback = router.select_route(status, decision)
assert fallback["profile"] == "claude-low"
assert fallback["provider"] == "anthropic"
def test_route_circuit_breaker_skips_recently_failed_provider():
status = _status()
status["routes"]["codex-medium"] = [
"openai-codex/gpt-5.6-terra",
"anthropic/claude-sonnet-5",
]
decision = router.Decision(
"question", "medium", "claude", "jetson", "local vote"
)
policy = {
"mode": "auto",
"provider_cooldowns": {
"anthropic": {"until_epoch": router.time.time() + 300}
},
}
plan = router.select_route(status, decision, policy=policy)
assert plan["profile"] == "codex-medium"
assert plan["provider"] == "openai-codex"
def test_local_classifier_accepts_only_bounded_route_decisions():
assert router._validated_local_route("?", "?", "?", 1) is None
decision = router._validated_local_route("A", "H", "D", 1)
assert decision is not None
assert (decision.shape, decision.provider, decision.effort, decision.priority) == (
"question",
"claude",
"high",
"deep",
)
assert router._validated_local_route("C", "M", "?", 1) is None
def test_structured_vote_requires_a_complete_bounded_json_object():
assert router._parse_route_vote(
'{"provider":"C","effort":"M","priority":"F"}'
) == ("C", "M", "F")
assert router._parse_route_vote('"A"') is None
assert router._parse_route_vote(
'{"provider":"codex","effort":"M","priority":"F"}'
) is None
assert router._parse_route_vote(
'{"provider":"A","effort":"H"}'
) is None
def test_jetson_requests_one_structured_provider_effort_priority_vote(monkeypatch):
calls = []
def structured(text, timeout):
calls.append((text, timeout))
return (("A", "H", "D"), 12)
monkeypatch.setattr(router, "_jetson_route", structured)
decision = router.jetson_decision("Review the architecture")
assert (
decision.provider,
decision.effort,
decision.priority,
decision.latency_ms,
) == (
"claude",
"high",
"deep",
12,
)
assert calls == [("Review the architecture", 2.5)]
def test_ollama_requests_use_numeric_keep_alive(monkeypatch):
payloads = []
def urlopen(request, timeout):
payloads.append(json.loads(request.data))
if request.full_url.endswith("/chat"):
body = {
"message": {
"content": '{"provider":"C","effort":"L","priority":"F"}'
}
}
else:
body = {"response": "P"}
return io.BytesIO(json.dumps(body).encode())
monkeypatch.setattr(router.urllib.request, "urlopen", urlopen)
vote, _ = router._jetson_route("Answer quickly", 2.5)
assert vote == ("C", "L", "F")
assert payloads[0]["keep_alive"] == -1
router._classifier_warm_lock.acquire()
router._rewarm_classifier()
assert payloads[1]["keep_alive"] == -1
def test_every_auto_classification_consults_jetson_and_keeps_safety_floors(monkeypatch):
calls = []
def classify(text):
calls.append(text)
return router.Decision("question", "low", "codex", "jetson", "test", 5)
monkeypatch.setattr(router, "jetson_decision", classify)
simple = router.classify_task("Who is the provider?")
risky = router.classify_task("Migrate production Vault credentials")
assert len(calls) == 2
assert (simple.effort, simple.provider) == ("low", "codex")
assert (risky.shape, risky.effort, risky.provider) == (
"review",
"xhigh",
"claude",
)
def test_trivial_prompt_respects_balanced_jetson_effort(monkeypatch):
calls = []
def classify(text):
calls.append(text)
return router.Decision("question", "medium", "codex", "jetson", "test", 5)
monkeypatch.setattr(router, "jetson_decision", classify)
decision = router.classify_task(
"Reply with exactly ROUTE_SMOKE_OK. Do not call tools."
)
assert calls == ["Reply with exactly ROUTE_SMOKE_OK. Do not call tools."]
assert (decision.effort, decision.provider, decision.classifier) == (
"medium",
"codex",
"jetson",
)
def test_semantic_speed_priority_reduces_speculative_depth_but_not_safety(monkeypatch):
monkeypatch.setattr(
router,
"jetson_decision",
lambda text: router.Decision(
"question", "high", "codex", "jetson", "test", 5, "fast"
),
)
simple = router.classify_task("Give me a concise status summary.")
risky = router.classify_task("Quickly migrate production Vault credentials.")
assert simple.effort == "medium"
assert risky.effort == "xhigh"
def test_ui_priority_changes_quality_posture_without_crossing_safety_floor(monkeypatch):
monkeypatch.setattr(
router,
"jetson_decision",
lambda text: router.Decision(
"question", "low", "codex", "jetson", "test", 5, "balanced"
),
)
maximum = router.classify_task(
"Give me the current status.", priority_override="maximum"
)
fast_risky = router.classify_task(
"Delete production Vault credentials.", priority_override="fast"
)
assert (maximum.priority, maximum.effort, maximum.classifier) == (
"maximum",
"high",
"ui-jetson",
)
assert (fast_risky.priority, fast_risky.effort, fast_risky.provider) == (
"fast",
"xhigh",
"claude",
)
def test_service_fallback_postures_favor_chat_speed_and_agent_quality(monkeypatch):
monkeypatch.setattr(router, "jetson_decision", lambda text: None)
monkeypatch.setattr(router, "ROUTER_PROFILE", "chat")
monkeypatch.setattr(router, "CHAT_MODE", True)
chat = router.classify_task("What time is dinner?")
monkeypatch.setattr(router, "ROUTER_PROFILE", "triage")
monkeypatch.setattr(router, "CHAT_MODE", False)
triage = router.classify_task("Summarize the failed health check.")
monkeypatch.setattr(router, "ROUTER_PROFILE", "agent")
agent = router.classify_task("Explain this helper function.")
assert (chat.priority, chat.effort, chat.provider) == ("fast", "low", "local")
assert (triage.priority, triage.effort) == ("deep", "medium")
assert (agent.priority, agent.effort) == ("maximum", "high")
def test_trivial_prompt_can_still_escalate_on_strong_jetson_signal(monkeypatch):
monkeypatch.setattr(
router,
"jetson_decision",
lambda text: router.Decision(
"question", "high", "claude", "jetson", "test", 5
),
)
decision = router.classify_task("Check this.")
assert (decision.effort, decision.provider) == ("high", "claude")
def test_architecture_and_review_fail_upward_to_claude(monkeypatch):
monkeypatch.setattr(
router,
"jetson_decision",
lambda text: router.Decision(
"question", "low", "codex", "jetson", "test", 5
),
)
architecture = router.classify_task("Design the service architecture")
review = router.classify_task("Review this change for regressions")
assert (architecture.provider, architecture.effort) == ("claude", "medium")
assert (review.provider, review.effort) == ("claude", "medium")
def test_referential_outstanding_work_never_uses_low_route(monkeypatch):
monkeypatch.setattr(
router,
"jetson_decision",
lambda text: router.Decision(
"question", "low", "codex", "jetson", "test", 10
),
)
decision = router.classify_task(
"Look, loop through all of the still outstanding work that you identified. "
"Do it to the best of your abilities."
)
assert (decision.shape, decision.effort, decision.provider) == (
"implementation",
"high",
"codex",
)
def test_referential_followup_uses_recent_context_for_risk_floor(monkeypatch):
calls = []
def classify(text):
calls.append(text)
return router.Decision("question", "low", "codex", "jetson", "test", 5)
monkeypatch.setattr(router, "jetson_decision", classify)
history = [
{"role": "user", "content": "Is the work complete?"},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": (
"Still outstanding: production deployment retry, provider "
"switching, runtime experiments, and a full repository test pass."
),
}
],
},
]
decision = router.classify_task(
"Loop through all outstanding work and finish it.", history
)
assert (decision.shape, decision.effort, decision.provider) == (
"review",
"xhigh",
"claude",
)
assert decision.classifier == "jetson-context"
assert "recent assistant context" in decision.reason
assert len(calls) == 1
def test_internal_prompt_contains_objective_tools_and_results():
text = router._internal_task_text(
"Finish the production deployment safely",
[
{
"role": "assistant",
"content": "I will inspect the failed rollout.",
"tool_calls": [
{
"function": {
"name": "terminal",
"arguments": '{"command":"kubectl get pods"}',
}
}
],
},
{"role": "tool", "content": "deployment is degraded"},
],
)
assert "Finish the production deployment safely" in text
assert "planned tool terminal" in text
assert "deployment is degraded" in text
def test_every_internal_auto_prompt_is_reclassified_and_applied(monkeypatch):
calls = []
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
monkeypatch.setattr(router, "_load_json", lambda path: _status())
monkeypatch.setattr(
router,
"classify_task",
lambda text: calls.append(text)
or router.Decision(
"question", "medium", "claude", "jetson", "test", 7
),
)
applied = []
recorded = []
monkeypatch.setattr(
router, "_apply_route", lambda ctx, agent, plan: applied.append(plan)
)
monkeypatch.setattr(
router,
"_record_internal_plan",
lambda policy, plan, count: recorded.append((plan, count)),
)
class Agent:
provider = "openai-codex"
model = "gpt-5.6-luna"
reasoning_config = {"effort": "low"}
def _emit_status(self, message):
self.message = message
agent = Agent()
router._pre_internal_route(
object(),
agent=agent,
user_message="Continue",
conversation_history=[
{"role": "tool", "content": "The architecture review found a risk."}
],
api_call_count=3,
)
assert len(calls) == 1
assert applied[0]["profile"] == "claude-medium"
assert applied[0]["classifier"] == "jetson-internal"
assert recorded[0][1] == 3
assert agent.message.startswith("AUTO internal #3")
def test_manual_route_audits_internal_prompt_without_overriding_user_choice(monkeypatch):
monkeypatch.setattr(
router,
"_current_policy",
lambda: {
"mode": "manual",
"manual": {"provider": "claude", "effort": "medium", "model": ""},
},
)
monkeypatch.setattr(router, "_load_json", lambda path: _status())
calls = []
monkeypatch.setattr(
router,
"classify_task",
lambda text: calls.append(text)
or router.Decision("implementation", "xhigh", "codex", "jetson", "audit", 5),
)
plans = []
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
monkeypatch.setattr(router, "_record_internal_plan", lambda *args: None)
router._pre_internal_route(
object(),
agent=object(),
user_message="Continue",
conversation_history=[{"role": "tool", "content": "done"}],
api_call_count=2,
)
assert len(calls) == 1
assert plans[0]["profile"] == "claude-medium"
assert plans[0]["classifier"] == "manual-jetson-internal"
def test_every_native_subagent_is_classified_and_routed_independently(monkeypatch):
calls = []
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
monkeypatch.setattr(router, "_load_json", lambda path: _status())
monkeypatch.setattr(
router,
"classify_task",
lambda text: calls.append(text)
or router.Decision(
"implementation", "medium", "codex", "jetson", "test", 9
),
)
applied = []
recorded = []
monkeypatch.setattr(
router, "_apply_route", lambda ctx, agent, plan: applied.append((agent, plan))
)
monkeypatch.setattr(
router,
"_record_subagent_plan",
lambda policy, plan, goal, index: recorded.append((plan, goal, index)),
)
class Parent:
def _emit_status(self, message):
self.message = message
child = object()
parent = Parent()
router._pre_subagent_route(
object(),
agent=child,
parent_agent=parent,
goal="Implement the bounded parser fix",
context="Run the focused tests.",
task_index=2,
)
assert len(calls) == 1
assert "Run the focused tests" in calls[0]
assert applied[0][0] is child
assert applied[0][1]["profile"] == "codex-medium"
assert applied[0][1]["classifier"] == "jetson-subagent"
assert recorded[0][1:] == ("Implement the bounded parser fix", 2)
assert parent.message.startswith("AUTO child #3")
def test_manual_route_audits_and_applies_override_to_native_subagent(monkeypatch):
monkeypatch.setattr(
router,
"_current_policy",
lambda: {
"mode": "manual",
"manual": {"provider": "claude", "effort": "medium", "model": ""},
},
)
monkeypatch.setattr(router, "_load_json", lambda path: _status())
calls = []
monkeypatch.setattr(
router,
"classify_task",
lambda text: calls.append(text)
or router.Decision("implementation", "high", "codex", "jetson", "audit", 5),
)
plans = []
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
monkeypatch.setattr(router, "_record_subagent_plan", lambda *args: None)
router._pre_subagent_route(
object(), agent=object(), parent_agent=object(), goal="Review the diff"
)
assert len(calls) == 1
assert plans[0]["profile"] == "claude-medium"
assert plans[0]["classifier"] == "manual-jetson-subagent"
def test_manual_policy_is_reapplied_on_every_non_command_turn(monkeypatch):
monkeypatch.setattr(
router,
"_current_policy",
lambda: {
"mode": "manual",
"manual": {"provider": "claude", "effort": "medium", "model": ""},
},
)
monkeypatch.setattr(router, "_load_json", lambda path: _status())
monkeypatch.setattr(
router,
"classify_task",
lambda text, history=None: router.Decision(
"implementation", "high", "codex", "jetson", "audit", 5
),
)
plans = []
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
class Agent:
def _emit_status(self, message):
self.message = message
agent = Agent()
router._pre_turn_route(object(), agent=agent, user_message="Continue the task")
assert plans[0]["profile"] == "claude-medium"
assert plans[0]["model"] == "claude-sonnet-5"
assert agent.message.startswith("MANUAL target")
assert plans[0]["classifier"] == "manual-jetson"
def test_webui_exact_model_and_effort_remain_authoritative_after_jetson_audit(
monkeypatch,
): ):
monkeypatch.setattr(router, "CHAT_MODE", True) monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local")) agent = _agent(
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"}) model="atlas/manual/claude/opus",
monkeypatch.setattr(router, "_load_json", lambda path: {}) _hermes_explicit_model_pick=True,
audits = [] _hermes_explicit_reasoning_effort="xhigh",
def classify(text, history=None, priority_override=""):
audits.append((text, priority_override))
return router.Decision(
"question", "low", "local", "jetson", "audit", 8, "fast"
)
monkeypatch.setattr(router, "classify_task", classify)
plans = []
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
class Agent:
provider = "openai-codex"
model = "gpt-5.6-sol"
_hermes_routing_priority = "deep"
_hermes_explicit_model_pick = True
_hermes_explicit_reasoning_effort = "xhigh"
def _emit_status(self, message):
self.message = message
agent = Agent()
router._pre_turn_route(
object(), agent=agent, user_message="Review this answer carefully."
) )
assert audits == [("Review this answer carefully.", "deep")] assert router._boundary_selection(agent) == (
assert plans[0]["provider"] == "atlas-codex" "atlas/manual/claude/opus",
assert plans[0]["model"] == "gpt-5.6-sol" "xhigh",
assert plans[0]["effort"] == "xhigh" "ui-manual",
assert plans[0]["classifier"] == "manual-ui-jetson" )
assert agent.message.startswith("MANUAL target")
def test_chat_natural_language_override_is_one_turn_and_keeps_jetson_audit( def test_manual_command_persists_a_switchyard_route_not_a_direct_provider(
monkeypatch, tmp_path, monkeypatch
): ):
monkeypatch.setattr(router, "CHAT_MODE", True) path = tmp_path / "route-policy.json"
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local")) monkeypatch.setattr(router, "POLICY_PATH", path)
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"}) ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
monkeypatch.setattr(router, "_load_json", lambda path: {})
calls = []
monkeypatch.setattr(
router,
"classify_task",
lambda text, history=None: calls.append(text)
or router.Decision("question", "low", "local", "jetson", "audit", 8),
)
plans = []
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
class Agent: message = router._route_command(ctx, "manual codex xhigh sol")
def _emit_status(self, message): policy = json.loads(path.read_text(encoding="utf-8"))
self.message = message
agent = Agent() assert "Manual Switchyard constraint enabled" in message
router._pre_turn_route( assert policy["manual"] == {
object(), agent=agent, user_message="Use Claude at xhigh for this answer." "route": "atlas/manual/codex/sol",
) "effort": "xhigh",
assert calls == ["Use Claude at xhigh for this answer."]
assert plans[0]["provider"] == "anthropic"
assert plans[0]["model"] == "claude-opus-5"
assert plans[0]["effort"] == "xhigh"
assert plans[0]["classifier"] == "explicit-jetson"
assert agent.message.startswith("USER target")
def test_chat_text_overrides_do_not_steal_image_provider_instructions(monkeypatch):
monkeypatch.setattr(router, "CHAT_MODE", True)
assert router._explicit_text_override("Use local image generation for this photo") is None
assert router._explicit_text_override("Generate this image with OpenAI") is None
assert router._explicit_text_override("Answer locally with the Qwen model") == (
"local",
"",
)
assert router._explicit_text_override("Ask Codex with high reasoning") == (
"codex",
"high",
)
def test_post_turn_records_and_announces_capacity_fallback(monkeypatch):
policy = {
"mode": "auto",
"last_decision": {
"provider": "anthropic",
"model": "claude-sonnet-5",
"effort": "medium",
"classifier": "jetson",
},
} }
written = [] assert "provider" not in policy["manual"]
monkeypatch.setattr(router, "_current_policy", lambda: policy)
monkeypatch.setattr(router, "_write_policy", lambda value: written.append(value))
class Agent:
provider = "openai-codex"
model = "gpt-5.6-terra"
def _emit_status(self, message):
self.message = message
agent = Agent()
cli = type("CLI", (), {"agent": agent})()
manager = type("Manager", (), {"_cli_ref": cli})()
ctx = type("Context", (), {"_manager": manager})()
router._post_turn_route(ctx, model="gpt-5.6-terra")
outcome = written[-1]["last_decision"]
assert outcome["fallback_used"] is True
assert outcome["actual_provider"] == "openai-codex"
assert outcome["actual_model"] == "gpt-5.6-terra"
assert written[-1]["provider_cooldowns"]["anthropic"]["until_epoch"] > router.time.time()
assert agent.message.startswith("FALLBACK USED")
def test_post_turn_rewarms_classifier_after_local_chat(monkeypatch): def test_effort_above_xhigh_is_rejected(tmp_path, monkeypatch):
policy = { monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
"mode": "auto", ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
"last_decision": {
"provider": "custom", message = router._route_command(ctx, "manual claude max opus")
"model": "qwen2.5:14b-instruct-q4_0",
"effort": "low", assert "Effort must be" in message
"classifier": "jetson",
},
def test_switchyard_owns_fallbacks_and_auto_clears_static_effort():
agent = _agent()
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
router._switch_agent(ctx, agent, "atlas/auto/maximum", "")
assert agent.reasoning_config is None
assert agent._fallback_chain == []
assert agent._fallback_index == 0
assert agent._fallback_activated is False
assert agent._fallback_model is None
def test_registers_every_model_call_boundary_and_route_command():
hooks = {}
commands = {}
class Context:
def register_hook(self, name, callback):
hooks[name] = callback
def register_command(self, name, callback, **metadata):
commands[name] = (callback, metadata)
router.register(Context())
assert set(hooks) == {
"pre_turn_route",
"pre_internal_route",
"pre_subagent_route",
} }
warmed = [] assert "route" in commands
monkeypatch.setattr(router, "_current_policy", lambda: policy)
monkeypatch.setattr(router, "_write_policy", lambda value: None)
monkeypatch.setattr(
router,
"_rewarm_classifier_after_local",
lambda provider, model: warmed.append((provider, model)),
)
class Agent:
provider = "custom"
model = "qwen2.5:14b-instruct-q4_0"
def _emit_status(self, message):
self.message = message
agent = Agent()
cli = type("CLI", (), {"agent": agent})()
manager = type("Manager", (), {"_cli_ref": cli})()
ctx = type("Context", (), {"_manager": manager})()
router._post_turn_route(ctx, model="qwen2.5:14b-instruct-q4_0")
assert warmed == [("custom", "qwen2.5:14b-instruct-q4_0")]
assert agent.message.startswith("ROUTE USED")
def test_status_distinguishes_requested_route_from_actual_outcome(monkeypatch): def test_adapter_contains_no_content_classifier_or_direct_ollama_call():
monkeypatch.setattr( source = SOURCE.read_text(encoding="utf-8")
router,
"_current_policy",
lambda: {
"mode": "auto",
"last_decision": {
"provider": "anthropic",
"model": "claude-sonnet-5",
"effort": "medium",
"classifier": "jetson",
"actual_provider": "openai-codex",
"actual_model": "gpt-5.6-terra",
"fallback_used": True,
},
},
)
manager = type("Manager", (), {"_cli_ref": None})()
ctx = type("Context", (), {"_manager": manager})()
status = router._status_text(ctx) assert "jetson_decision" not in source
assert "urllib.request" not in source
assert "Last requested route: anthropic/claude-sonnet-5" in status assert "ollama.ai.svc" not in source
assert "Last actual outcome: fallback: openai-codex/gpt-5.6-terra" in status

View File

@ -27,7 +27,9 @@ def test_chat_config_enables_real_research_compute_and_delegation():
configmap = _documents(HERMES / "chat-configmap.yaml")[0] configmap = _documents(HERMES / "chat-configmap.yaml")[0]
config = yaml.safe_load(configmap["data"]["config.yaml"]) config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["agent"]["reasoning_effort"] == "high" # AUTO leaves effort unset so the target chosen by Switchyard owns it.
assert "reasoning_effort" not in config["agent"]
assert config["model"]["model"] == "atlas/auto/fast"
assert config["web"] == { assert config["web"] == {
"backend": "ddgs", "backend": "ddgs",
"search_backend": "ddgs", "search_backend": "ddgs",
@ -116,7 +118,7 @@ def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
def test_gateway_image_honors_ui_model_and_caps_reasoning(): def test_gateway_image_honors_ui_model_and_caps_reasoning():
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text() dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
assert "_resolve_request_route" in dockerfile assert "_resolve_request_route" in dockerfile
assert 'allowed_providers = {"openai-codex", "anthropic"}' in dockerfile assert 'allowed_providers = {"atlas-switchyard"}' in dockerfile
assert 'reasoning_effort=body.get("reasoning_effort")' in dockerfile assert 'reasoning_effort=body.get("reasoning_effort")' in dockerfile
assert 'reasoning_config = {"enabled": True, "effort": "xhigh"}' in dockerfile assert 'reasoning_config = {"enabled": True, "effort": "xhigh"}' in dockerfile
assert "ddgs==9.14.4" in dockerfile assert "ddgs==9.14.4" in dockerfile
@ -167,7 +169,14 @@ def test_webui_recovers_auth_and_labels_session_scoped_controls():
assert "res.status===401||res.status===403" in dockerfile assert "res.status===401||res.status===403" in dockerfile
assert "window.location.assign('/oauth2/start?rd='" in dockerfile assert "window.location.assign('/oauth2/start?rd='" in dockerfile
assert "childrenExpanded?'':''" in dockerfile assert "childrenExpanded?'':''" in dockerfile
assert "profile default: ' + p.model" in dockerfile assert "'atlas/auto/maximum': 'Automatic · Maximum'" in dockerfile
router = (ROOT / "dockerfiles" / "hermes-webui-router.js").read_text()
assert "'atlas/auto/fast':'AUTO · Fast'" in router
assert "'atlas/manual/codex/sol':'Codex · SOL'" in router
assert "'atlas/manual/claude/opus':'Claude · Opus'" in router
assert "watchModelOptions('modelSelect')" in router
assert "watchModelOptions('settingsModel')" in router
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route(): def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
@ -387,27 +396,37 @@ def test_chat_image_generation_uses_private_owner_broker():
) )
def test_chat_reasoning_uses_private_owner_codex_broker(): def test_chat_reasoning_uses_switchyard_without_owner_credentials():
"""Family pods receive Codex turns without mounting the owner's OAuth file.""" """Family pods use AUTO/manual routes without mounting owner credentials."""
configmap = _documents(HERMES / "chat-configmap.yaml")[0] configmap = _documents(HERMES / "chat-configmap.yaml")[0]
config = yaml.safe_load(configmap["data"]["config.yaml"]) config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["model"] == { assert config["model"] == {
"provider": "atlas-codex", "provider": "atlas-switchyard",
"default": "gpt-5.6-terra", "default": "atlas/auto/fast",
"model": "gpt-5.6-terra", "model": "atlas/auto/fast",
} }
assert config["providers"]["atlas-codex"] == { assert config["providers"]["atlas-switchyard"] == {
"name": "Atlas Codex", "name": "Automatic Router",
"api": "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1", "api": "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1",
"key_env": "HERMES_IMAGE_BROKER_KEY", "api_key": "atlas-switchyard",
"default_model": "gpt-5.6-terra", "default_model": "atlas/auto/fast",
"transport": "codex_responses", "transport": "chat_completions",
} }
assert config["platforms"]["api_server"]["extra"]["model_routes"] == { assert config["platforms"]["api_server"]["extra"]["model_routes"] == {
"gpt-5.6-terra": { route: {"provider": "atlas-switchyard", "model": route}
"provider": "atlas-codex", for route in [
"model": "gpt-5.6-terra", "atlas/auto/fast",
} "atlas/auto/balanced",
"atlas/auto/deep",
"atlas/auto/maximum",
"atlas/manual/codex/luna",
"atlas/manual/codex/terra",
"atlas/manual/codex/sol",
"atlas/manual/claude/haiku",
"atlas/manual/claude/sonnet",
"atlas/manual/claude/opus",
"atlas/manual/local/qwen-14b",
]
} }
agent = _documents(HERMES / "agent-deployment.yaml")[0] agent = _documents(HERMES / "agent-deployment.yaml")[0]
@ -418,10 +437,11 @@ def test_chat_reasoning_uses_private_owner_codex_broker():
] ]
assert broker["securityContext"]["readOnlyRootFilesystem"] is True assert broker["securityContext"]["readOnlyRootFilesystem"] is True
assert broker["securityContext"]["runAsNonRoot"] is True assert broker["securityContext"]["runAsNonRoot"] is True
assert broker["env"][-2:] == [ assert {item["name"]: item["value"] for item in broker["env"]}.items() >= {
{"name": "PYTHONPATH", "value": "/opt/hermes"}, "PYTHONPATH": "/opt/hermes",
{"name": "HERMES_CODEX_BROKER_LISTEN_PORT", "value": "9003"}, "HERMES_CODEX_BROKER_LISTEN_PORT": "9003",
] "HERMES_ROUTING_CATALOG_PATH": "/routing-catalog/catalog.json",
}.items()
services = _documents(HERMES / "service.yaml") services = _documents(HERMES / "service.yaml")
service = next( service = next(
@ -477,11 +497,16 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
assert module._authorized("Bearer relay-secret") is True assert module._authorized("Bearer relay-secret") is True
assert module._authorized("Bearer wrong") is False assert module._authorized("Bearer wrong") is False
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
payload = module._validate_payload( payload = module._validate_payload(
{"model": "gpt-5.6-terra", "store": True, "stream": False} {"model": "gpt-5.6-terra", "store": True, "stream": False}
) )
assert payload["store"] is False assert payload["store"] is False
assert payload["stream"] is True assert payload["stream"] is True
routed = module._validate_payload(
{"model": "route/codex/gpt-5.6-luna/low", "stream": False}
)
assert routed["model"] == "gpt-5.6-luna"
with pytest.raises(ValueError, match="unsupported Codex model"): with pytest.raises(ValueError, match="unsupported Codex model"):
module._validate_payload({"model": "unapproved-model"}) module._validate_payload({"model": "unapproved-model"})
@ -666,7 +691,15 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"): for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"):
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"] config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
assert "gpt-oss:20b" not in config assert "gpt-oss:20b" not in config
assert "qwen2.5:14b-instruct-q4_0" in config assert "atlas-switchyard" in config
switchyard = _documents(HERMES / "switchyard-configmap.yaml")[0]["data"][
"routes.toml"
]
assert "route/local/qwen2.5-14b/medium" in switchyard
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
"model_gate.py"
]
assert "qwen2.5:14b-instruct-q4_0" in model_gate
def test_titan20_serializes_classifier_and_local_chat_model_residency(): def test_titan20_serializes_classifier_and_local_chat_model_residency():

View File

@ -59,121 +59,87 @@ def _oauth_deployment(name: str) -> dict:
) )
def test_auto_lane_always_uses_the_jetson_decision(tmp_path: Path): class _SwitchyardResponse:
router = tmp_path / "router.py" """Minimal context-managed response used by routing contract tests."""
router.write_text(
"""
class Decision:
shape = "review"
effort = "xhigh"
provider = "claude"
classifier = "jetson"
reason = "local vote"
latency_ms = 17
def classify_task(prompt): def __init__(self, selected: str, rationale: str = "local classifier vote"):
assert "security review" in prompt self.headers = {
return Decision() "x-model-router-selected-model": selected,
""", "x-model-router-rationale": rationale,
encoding="utf-8", }
)
routes = tmp_path / "routes.json" def __enter__(self):
routes.write_text( return self
json.dumps(
{ def __exit__(self, *_args):
"routes": { return False
"claude-xhigh": [
"anthropic/claude-opus-5", def read(self):
"openai-codex/gpt-5.6-sol", return b"{}"
]
}
} def test_auto_lane_uses_switchyard_worker_decision():
), observed = {}
encoding="utf-8",
) def route(request, timeout):
observed["payload"] = json.loads(request.data)
observed["timeout"] = timeout
return _SwitchyardResponse("worker/claude/claude-opus-5/xhigh")
route = lanes.select_route( route = lanes.select_route(
"Perform the security review.", "Perform the security review.",
"cli-auto", "cli-auto",
routing_path=routes, open_request=route,
router_path=router,
) )
assert route.provider == "claude" assert route.provider == "claude"
assert route.model == "claude-opus-5" assert route.model == "claude-opus-5"
assert route.effort == "xhigh" assert route.effort == "xhigh"
assert route.classifier == "jetson" assert route.classifier == "switchyard-classifier"
assert route.latency_ms == 17 assert observed["payload"]["model"] == "atlas/worker/auto/maximum"
assert observed["timeout"] == 60
def test_manual_lane_still_calls_jetson_before_applying_override(tmp_path: Path): def test_manual_lane_is_still_enforced_by_switchyard():
router = tmp_path / "router.py" observed = {}
router.write_text(
""" def route(request, timeout):
called = 0 observed["payload"] = json.loads(request.data)
class Decision: return _SwitchyardResponse(
shape = "question" "worker/claude/claude-sonnet-5/high", "manual route"
effort = "low" )
provider = "codex"
classifier = "jetson"
reason = "local vote"
latency_ms = 8
def classify_task(prompt):
global called
called += 1
return Decision()
""",
encoding="utf-8",
)
routes = tmp_path / "routes.json"
routes.write_text(
json.dumps({"routes": {"claude-high": ["anthropic/claude-opus-5"]}}),
encoding="utf-8",
)
route = lanes.select_route( route = lanes.select_route(
"Implement it.", "Implement it.",
"cli-claude-high", "cli-claude-high",
routing_path=routes, open_request=route,
router_path=router,
) )
assert route.provider == "claude" assert route.provider == "claude"
assert route.effort == "high" assert route.effort == "high"
assert "manual lane override" in route.reason assert route.classifier == "switchyard-manual"
assert route.reason == "manual route"
assert observed["payload"]["model"] == "atlas/worker/manual/claude/high"
def test_cross_provider_retry_excludes_failed_provider(tmp_path: Path): def test_cross_provider_retry_passes_failed_provider_to_switchyard():
router = tmp_path / "router.py" observed = {}
router.write_text(
""" def route(request, timeout):
class Decision: observed["payload"] = json.loads(request.data)
shape = "implementation" return _SwitchyardResponse("worker/claude/claude-sonnet-5/high")
effort = "high"
provider = "codex"
classifier = "jetson"
reason = "retry vote"
latency_ms = 9
def classify_task(prompt): return Decision()
""",
encoding="utf-8",
)
routes = tmp_path / "routes.json"
routes.write_text(
json.dumps({"routes": {"claude-high": ["anthropic/claude-opus-5"]}}),
encoding="utf-8",
)
route = lanes.select_route( route = lanes.select_route(
"Retry after capacity exhaustion.", "Retry after capacity exhaustion.",
"cli-auto", "cli-auto",
exclude_provider="codex", exclude_provider="codex",
routing_path=routes, open_request=route,
router_path=router,
) )
assert route.provider == "claude" assert route.provider == "claude"
assert route.classifier == "jetson" assert route.classifier == "switchyard-classifier"
content = observed["payload"]["messages"][0]["content"]
assert "codex provider failed or exhausted capacity" in content
def test_claude_session_is_reserved_before_first_process(tmp_path: Path, monkeypatch): def test_claude_session_is_reserved_before_first_process(tmp_path: Path, monkeypatch):
@ -835,6 +801,16 @@ def test_agent_network_boundary_allows_only_authenticated_web_and_broker_surface
{"protocol": "TCP", "port": 9003}, {"protocol": "TCP", "port": 9003},
], ],
}, },
{
"from": [
{
"podSelector": {
"matchLabels": {"app": "hermes-switchyard"}
}
}
],
"ports": [{"protocol": "TCP", "port": 9003}],
},
] ]
assert isolation["spec"]["egress"] == [{}] assert isolation["spec"]["egress"] == [{}]
@ -856,6 +832,46 @@ def test_owner_agent_has_cluster_admin_kubernetes_context():
] ]
def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
"""Routing must not inherit the owner agent's cluster-admin capability."""
service_accounts = [
item
for item in yaml.safe_load_all(
(HERMES / "vault-serviceaccount.yaml").read_text()
)
if item
]
assert any(
item["kind"] == "ServiceAccount"
and item["metadata"]["name"] == "hermes-switchyard"
for item in service_accounts
)
deployment = yaml.safe_load((HERMES / "switchyard-deployment.yaml").read_text())
pod = deployment["spec"]["template"]["spec"]
assert pod["serviceAccountName"] == "hermes-switchyard"
for container_name in ("claude-oauth-broker", "worker-route-broker"):
container = next(
item for item in pod["containers"] if item["name"] == container_name
)
catalog = next(
item
for item in container["volumeMounts"]
if item["mountPath"] == "/routing-catalog"
)
assert catalog["readOnly"] is True
rbac = [
item
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
if item
]
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
assert binding["subjects"] == [
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
]
def test_owner_agent_installs_the_pinned_operator_toolchain(): def test_owner_agent_installs_the_pinned_operator_toolchain():
script = (SCRIPTS / "install_agent_tools.sh").read_text() script = (SCRIPTS / "install_agent_tools.sh").read_text()
for value in [ for value in [

View File

@ -6,6 +6,7 @@ import importlib.util
import json import json
import subprocess import subprocess
import sys import sys
import tomllib
from pathlib import Path from pathlib import Path
import yaml import yaml
@ -16,6 +17,7 @@ SCRIPT = (
) )
sys.path.insert(0, str(SCRIPT.parent)) sys.path.insert(0, str(SCRIPT.parent))
routing = importlib.import_module("hermes_model_routing") routing = importlib.import_module("hermes_model_routing")
catalog_resolver = importlib.import_module("routing_catalog")
SPEC = importlib.util.spec_from_file_location("hermes_coordinator", SCRIPT) SPEC = importlib.util.spec_from_file_location("hermes_coordinator", SCRIPT)
assert SPEC and SPEC.loader assert SPEC and SPEC.loader
coordinator = importlib.util.module_from_spec(SPEC) coordinator = importlib.util.module_from_spec(SPEC)
@ -65,6 +67,101 @@ def test_empty_catalog_retains_current_models():
assert routing.choose_claude_model([], "claude-opus-5") == "claude-opus-5" assert routing.choose_claude_model([], "claude-opus-5") == "claude-opus-5"
def test_dynamic_catalog_resolves_new_models_and_preserves_last_known_good():
"""Stable Switchyard aliases follow live releases and survive catalog outages."""
codex = routing.Catalog(
"openai-codex",
["gpt-5.7-luna", "gpt-5.7-terra", "gpt-5.7-sol"],
True,
True,
"connected",
)
claude = routing.Catalog(
"anthropic",
["claude-haiku-5", "claude-sonnet-6", "claude-opus-6"],
True,
True,
"connected",
)
current = routing.build_routing_catalog(codex, claude)
assert catalog_resolver.resolve_model("codex", "auto", "high", current) == "gpt-5.7-sol"
assert catalog_resolver.resolve_model("codex", "terra", "medium", current) == "gpt-5.7-terra"
assert catalog_resolver.resolve_model("claude", "auto", "xhigh", current) == "claude-opus-6"
assert catalog_resolver.resolve_model("claude", "sonnet", "high", current) == "claude-sonnet-6"
unavailable = routing.Catalog("openai-codex", [], False, True, "degraded")
preserved = routing.build_routing_catalog(unavailable, unavailable, current)
assert preserved["providers"]["codex"]["resolved"] == current["providers"]["codex"]["resolved"]
assert preserved["providers"]["claude"]["tiers"] == current["providers"]["claude"]["tiers"]
def test_live_catalog_never_routes_to_a_removed_model_tier():
"""A live provider catalog must replace a retired tier with a live model."""
previous = {
"providers": {
"codex": {
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
"resolved": {
"low": "gpt-5.6-luna",
"medium": "gpt-5.6-terra",
"high": "gpt-5.6-sol",
"xhigh": "gpt-5.6-sol",
},
"tiers": {
"luna": "gpt-5.6-luna",
"terra": "gpt-5.6-terra",
"sol": "gpt-5.6-sol",
},
}
}
}
codex = routing.Catalog(
"openai-codex", ["gpt-5.7-terra", "gpt-5.7-sol"], True, True, "connected"
)
claude = routing.Catalog("anthropic", ["claude-sonnet-6"], True, True, "connected")
current = routing.build_routing_catalog(codex, claude, previous)
assert current["providers"]["codex"]["tiers"]["luna"] == "gpt-5.7-terra"
assert current["providers"]["codex"]["tiers"]["luna"] in codex.models
assert current["providers"]["claude"]["tiers"]["opus"] == "claude-sonnet-6"
def test_switchyard_targets_have_unique_upstream_identities():
"""Switchyard drops duplicate client/model pairs, so reject them in Git."""
manifest = yaml.safe_load(
(SCRIPT.parents[1] / "switchyard-configmap.yaml").read_text(encoding="utf-8")
)
config = tomllib.loads(manifest["data"]["routes.toml"])
target_names = set(config["targets"])
identities: set[tuple[str, str]] = set()
for target in config["targets"].values():
identity = (target["llm_client"], target["id"])
assert identity not in identities
identities.add(identity)
for route in config["routes"].values():
for target_name in route.get("targets", []):
assert target_name in target_names
if route.get("target"):
assert route["target"] in target_names
def test_worker_alias_resolves_before_cli_launch():
document = {
"providers": {
"codex": {
"resolved": {"high": "gpt-5.8-sol"},
"tiers": {"sol": "gpt-5.8-sol"},
}
}
}
assert (
catalog_resolver.resolve_worker_route("worker/codex/auto/high", document)
== "worker/codex/gpt-5.8-sol/high"
)
def test_codex_cli_login_counts_as_connected_runtime(monkeypatch): def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
"""AUTO routing must recognize the authenticated app-server CLI lane.""" """AUTO routing must recognize the authenticated app-server CLI lane."""
monkeypatch.setattr(routing.shutil, "which", lambda name: "/usr/bin/codex") monkeypatch.setattr(routing.shutil, "which", lambda name: "/usr/bin/codex")
@ -82,7 +179,7 @@ def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
assert routing.codex_cli_authenticated() is True assert routing.codex_cli_authenticated() is True
def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path): def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
(tmp_path / "config.yaml").write_text( (tmp_path / "config.yaml").write_text(
yaml.safe_dump(_base_config()), encoding="utf-8" yaml.safe_dump(_base_config()), encoding="utf-8"
) )
@ -112,35 +209,31 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
claude_profile = yaml.safe_load( claude_profile = yaml.safe_load(
(tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8") (tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8")
) )
assert root["model"]["model"] == "gpt-5.6-terra" assert root["model"] == {
assert root["model"]["openai_runtime"] == "codex_app_server" "provider": "atlas-switchyard",
assert codex_profile["model"]["model"] == "gpt-5.6-sol" "default": "atlas/auto/maximum",
assert codex_profile["model"]["openai_runtime"] == "codex_app_server" "model": "atlas/auto/maximum",
assert codex_profile["fallback_providers"][0] == {
"provider": "anthropic",
"model": "claude-opus-5",
} }
assert claude_profile["model"]["model"] == "claude-opus-5" assert root["fallback_providers"] == []
assert claude_profile["fallback_providers"][0]["provider"] == "openai-codex" assert root["toolsets"] == ["kanban"]
assert codex_profile["model"]["model"] == "atlas/manual/codex/sol"
assert codex_profile["model"]["provider"] == "atlas-switchyard"
assert claude_profile["model"]["model"] == "atlas/manual/claude/sonnet"
assert claude_profile["model"]["provider"] == "atlas-switchyard"
assert codex_profile["fallback_providers"] == []
assert claude_profile["fallback_providers"] == []
assert codex_profile["toolsets"] == [] assert codex_profile["toolsets"] == []
assert codex_profile["agent"]["reasoning_effort"] == "high" assert codex_profile["agent"]["reasoning_effort"] == "high"
assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK assert codex_xhigh_profile["fallback_providers"] == []
assert len(codex_profile["fallback_providers"]) == 2 assert routes["codex-xhigh"] == ["atlas/manual/codex/sol"]
assert codex_xhigh_profile["fallback_providers"] == [ assert routes["claude-xhigh"] == ["atlas/manual/claude/opus"]
{"provider": "anthropic", "model": "claude-opus-5"} assert routes["synthesis-xhigh"] == ["atlas/auto/maximum"]
] assert routes["coordinator"] == ["atlas/auto/maximum"]
assert routes["codex-xhigh"] == [ assert all(
"openai-codex/gpt-5.6-sol", "max" not in profile_name
"anthropic/claude-opus-5", for profile_name in routes
] if profile_name != "catalog"
assert routes["claude-xhigh"] == [ )
"anthropic/claude-opus-5",
"openai-codex/gpt-5.6-sol",
]
assert all("custom/" not in route for route in routes["synthesis-xhigh"])
assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra"
assert "custom/qwen2.5:14b-instruct-q4_0" not in routes["coordinator"]
assert "max" not in json.dumps(routes)
profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8") profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")
assert "CLAUDE_CODE_OAUTH_TOKEN=claude-secret" in profile_env assert "CLAUDE_CODE_OAUTH_TOKEN=claude-secret" in profile_env
@ -150,7 +243,7 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600 assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600
def test_degraded_catalog_does_not_replace_known_working_route(tmp_path: Path): def test_degraded_catalog_does_not_replace_switchyard_authority(tmp_path: Path):
base = _base_config() base = _base_config()
base["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol" base["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol"
(tmp_path / "config.yaml").write_text(yaml.safe_dump(base), encoding="utf-8") (tmp_path / "config.yaml").write_text(yaml.safe_dump(base), encoding="utf-8")
@ -161,12 +254,13 @@ def test_degraded_catalog_does_not_replace_known_working_route(tmp_path: Path):
routing.configure_routes(tmp_path, codex, claude) routing.configure_routes(tmp_path, codex, claude)
current = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) current = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert current["model"]["model"] == "gpt-5.6-sol" assert current["model"]["model"] == "atlas/auto/maximum"
assert current["fallback_providers"][0]["model"] == "claude-opus-5" assert current["model"]["provider"] == "atlas-switchyard"
assert current["fallback_providers"] == []
def test_degraded_refresh_preserves_last_worker_specific_model(tmp_path: Path): def test_degraded_refresh_preserves_switchyard_worker_preference(tmp_path: Path):
"""A catalog outage must not collapse the Codex worker onto coordinator tier.""" """A catalog outage must not bypass a managed worker's Switchyard route."""
(tmp_path / "config.yaml").write_text( (tmp_path / "config.yaml").write_text(
yaml.safe_dump(_base_config()), encoding="utf-8" yaml.safe_dump(_base_config()), encoding="utf-8"
) )
@ -187,7 +281,8 @@ def test_degraded_refresh_preserves_last_worker_specific_model(tmp_path: Path):
worker = yaml.safe_load( worker = yaml.safe_load(
(tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8") (tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8")
) )
assert worker["model"]["model"] == "gpt-5.6-sol" assert worker["model"]["model"] == "atlas/manual/codex/sol"
assert worker["model"]["provider"] == "atlas-switchyard"
def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch): def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch):

View File

@ -39,6 +39,20 @@ def test_model_gate_preserves_supported_and_non_json_requests():
assert normalize(non_json) == non_json assert normalize(non_json) == non_json
def test_model_gate_translates_switchyard_local_target_alias():
normalize = _model_gate_namespace()["_normalize_reasoning"]
routed = json.loads(
normalize(
b'{"model":"route/local/qwen2.5-14b/medium",'
b'"reasoning_effort":"medium","messages":[]}'
)
)
assert routed["model"] == "qwen2.5:14b-instruct-q4_0"
assert routed["reasoning_effort"] == "medium"
def test_model_gate_runs_a_renderer_aware_ariadne_handoff(): def test_model_gate_runs_a_renderer_aware_ariadne_handoff():
"""Wolf cannot reach Ollama until the local image service reports idle.""" """Wolf cannot reach Ollama until the local image service reports idle."""
namespace = _model_gate_namespace() namespace = _model_gate_namespace()