hermes: make Switchyard the routing authority
All checks were successful
Tests / Declarative: Post Actions passed: 240
All checks were successful
Tests / Declarative: Post Actions passed: 240
This commit is contained in:
parent
597e9c1c2e
commit
cef66ef0ca
@ -22,6 +22,10 @@ spec:
|
||||
kind: Deployment
|
||||
name: hermes-model-gate
|
||||
namespace: hermes
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: hermes-switchyard
|
||||
namespace: hermes
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: hermes
|
||||
|
||||
@ -476,7 +476,9 @@ route_after = route_before + ''' def _resolve_request_route(self, body: Dict[
|
||||
|
||||
provider = body.get("provider")
|
||||
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):
|
||||
return None
|
||||
model = model.strip()
|
||||
|
||||
25
dockerfiles/Dockerfile.hermes-switchyard
Normal file
25
dockerfiles/Dockerfile.hermes-switchyard
Normal 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"]
|
||||
@ -2,7 +2,7 @@
|
||||
# dockerfiles/Dockerfile.hermes-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
|
||||
|
||||
@ -72,7 +72,16 @@ sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
||||
panels = Path("/opt/hermes-webui/static/panels.js")
|
||||
source = panels.read_text(encoding="utf-8")
|
||||
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:
|
||||
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
||||
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 "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.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 '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 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
|
||||
&& 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 \
|
||||
&& /opt/hermes/.venv/bin/python -m py_compile \
|
||||
/opt/hermes-webui/api/routes.py \
|
||||
|
||||
@ -5,6 +5,51 @@
|
||||
const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'};
|
||||
const STORAGE_KEY='atlas.hermes.routing-priority';
|
||||
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(){
|
||||
let value='auto';
|
||||
@ -71,9 +116,13 @@
|
||||
explicit_reasoning_effort:explicitReasoning&&effort?effort:undefined
|
||||
};
|
||||
};
|
||||
window.hermesFriendlyRouteLabel=friendlyRouteLabel;
|
||||
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
render();
|
||||
labelModelOptions();
|
||||
watchModelOptions('modelSelect');
|
||||
watchModelOptions('settingsModel');
|
||||
const chip=document.getElementById('composerRoutingChip');
|
||||
if(chip) chip.addEventListener('click',toggle);
|
||||
});
|
||||
|
||||
@ -60,6 +60,10 @@ spec:
|
||||
value: "512"
|
||||
- name: JETSON_JETPACK
|
||||
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:
|
||||
- /bin/sh
|
||||
- -c
|
||||
@ -120,6 +124,10 @@ spec:
|
||||
value: compute,utility
|
||||
- name: JETSON_JETPACK
|
||||
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:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
||||
@ -9,29 +9,28 @@ metadata:
|
||||
data:
|
||||
config.yaml: |
|
||||
model:
|
||||
provider: openai-codex
|
||||
default: gpt-5.6-terra
|
||||
model: gpt-5.6-terra
|
||||
# Reuse the owner's authenticated Codex CLI instead of maintaining a
|
||||
# second rotating OAuth token in Hermes' provider store.
|
||||
openai_runtime: codex_app_server
|
||||
provider: atlas-switchyard
|
||||
default: atlas/auto/maximum
|
||||
model: atlas/auto/maximum
|
||||
|
||||
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
|
||||
providers:
|
||||
atlas-switchyard:
|
||||
name: Atlas Switchyard
|
||||
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||
api_key: atlas-switchyard
|
||||
default_model: atlas/auto/maximum
|
||||
transport: chat_completions
|
||||
|
||||
fallback_providers: []
|
||||
|
||||
agent:
|
||||
api_max_retries: 1
|
||||
# The coordinator supervises native children and durable CLI workers;
|
||||
# give it enough room to inspect, steer, review, and synthesize.
|
||||
max_turns: 180
|
||||
# This is the fail-safe when the router is unavailable. AUTO normally
|
||||
# classifies every user, internal, delegated, and durable-worker turn.
|
||||
reasoning_effort: high
|
||||
# Switchyard independently classifies every user, internal, delegated,
|
||||
# and durable-worker boundary. Explicit UI effort is forwarded as an
|
||||
# override; AUTO leaves effort unset so the selected target owns it.
|
||||
|
||||
delegation:
|
||||
# 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,
|
||||
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.
|
||||
You remain responsible for planning, routing, fallback, review, and the
|
||||
final synthesized answer.
|
||||
You remain responsible for planning, decomposition, review, and the final
|
||||
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
|
||||
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
|
||||
execution that survives browser disconnects and can resume after restarts.
|
||||
|
||||
Local Jetson inference is the first provider-independent fallback. Use it
|
||||
for bounded classification, summaries, and continuity when hosted capacity
|
||||
is constrained. Do not silently treat a local fallback as equivalent to a
|
||||
high-risk xhigh review; disclose the downgrade and preserve the task.
|
||||
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
||||
local Qwen for bounded low-risk responses and continuity, or spill to a
|
||||
hosted provider when local capability is insufficient. Do not describe a
|
||||
local response as equivalent to a high-risk xhigh review; preserve the
|
||||
task and make any downgrade visible in routing evidence.
|
||||
AGENTS.md: |
|
||||
# Hermes project coordinator
|
||||
|
||||
@ -198,9 +199,9 @@ data:
|
||||
## Difficulty routing
|
||||
|
||||
The coordinator starts in `/route auto`. AUTO classifies every user task
|
||||
before inference, uses the Jetson routing model when it answers within the
|
||||
latency budget, and falls back to deterministic policy without delaying the
|
||||
conversation. `/route status` explains the live selection. Use
|
||||
before inference and always uses the Jetson routing model; it never skips
|
||||
classification because the prompt looks simple. `/route status` explains
|
||||
the public route contract. Use
|
||||
`/route manual <codex|claude> <low|medium|high|xhigh> [model]` for a
|
||||
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
|
||||
ultracode.
|
||||
|
||||
Read `/opt/data/workspace/coordinator/model-routing.json` before naming a
|
||||
model. The hourly steward discovers the models currently available to both
|
||||
accounts and preserves the last working route during catalog outages.
|
||||
Profiles are `codex-{low,medium,high,xhigh}` and
|
||||
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`.
|
||||
`/opt/data/workspace/coordinator/model-routing.json` is catalog and health
|
||||
evidence, not a routing control plane. The hourly steward discovers the
|
||||
models currently available to both accounts, preserves its last known-good
|
||||
catalog during outages, and keeps every generated profile on a public
|
||||
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
|
||||
|
||||
@ -224,20 +227,20 @@ data:
|
||||
referenced plan from recent context, identify bounded leaf tasks and their
|
||||
dependencies, then use `delegate_task` for independent leaves. Run only
|
||||
dependency-free leaves in parallel. Each native child and every nested
|
||||
child is independently classified by the Jetson before its first model
|
||||
request, so cheap leaves may use low effort while difficult or risky leaves
|
||||
are raised to high or xhigh. Verify and synthesize all child evidence in the
|
||||
foreground coordinator. Do not delegate a one-tool mechanical action merely
|
||||
to create an agent.
|
||||
child and each subsequent internal continuation is independently routed by
|
||||
Switchyard using the Jetson classifier, so cheap leaves may use low effort
|
||||
while difficult or risky leaves are raised to high or xhigh. Verify and
|
||||
synthesize all child evidence in the foreground coordinator. Do not
|
||||
delegate a one-tool mechanical action merely to create an agent.
|
||||
|
||||
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,
|
||||
sends every start/retry/continuation boundary through the Jetson classifier,
|
||||
records provider/model/effort and session identifiers on the task, streams
|
||||
sends every start/retry/continuation boundary through Switchyard and its
|
||||
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
|
||||
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
|
||||
record, then apply the explicit override. Observe workers through Kanban and
|
||||
`cli-claude-{low,medium,high,xhigh}`; those manual constraints are still
|
||||
enforced and recorded by Switchyard. Observe workers through Kanban and
|
||||
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
|
||||
ask Brad to complete the displayed code.
|
||||
@ -262,9 +265,10 @@ data:
|
||||
dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban,
|
||||
skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the
|
||||
raw full-screen Hermes TUI. Give Hermes
|
||||
the outcome you want and it will decompose dependent work, classify every
|
||||
delegated leaf on the Jetson, choose Codex or Claude, preserve the task on
|
||||
the Cassandra board, and synthesize the evidence. Persistent real Codex and
|
||||
the outcome you want and it will decompose dependent work, route every
|
||||
model-call boundary through Switchyard and its Jetson classifier, choose
|
||||
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.
|
||||
Use `/route status` to inspect the current decision, `/route auto`
|
||||
for automatic routing, or `/route manual <codex|claude>
|
||||
|
||||
@ -180,7 +180,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
limits: {cpu: 100m, memory: 64Mi}
|
||||
- name: install-agent-tools
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- sh
|
||||
@ -228,7 +228,7 @@ spec:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
- name: patch-auth
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -251,7 +251,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 100m, memory: 128Mi}
|
||||
- name: patch-tui-gateway
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -274,7 +274,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 100m, memory: 128Mi}
|
||||
- name: patch-codex-runtime
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -307,7 +307,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 100m, memory: 128Mi}
|
||||
- name: bootstrap-coordinator
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -320,6 +320,7 @@ spec:
|
||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||
- {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}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@ -332,11 +333,12 @@ spec:
|
||||
- {name: provider-auth, mountPath: /shared-auth}
|
||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
- name: configure-agent-clients
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- sh
|
||||
@ -358,6 +360,7 @@ spec:
|
||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||
- {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}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@ -370,11 +373,12 @@ spec:
|
||||
- {name: provider-auth, mountPath: /shared-auth}
|
||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
limits: {cpu: 250m, memory: 128Mi}
|
||||
- name: prepare-ttyd-index
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -396,7 +400,7 @@ spec:
|
||||
limits: {cpu: 250m, memory: 128Mi}
|
||||
containers:
|
||||
- name: hermes
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/init, /opt/hermes/docker/main-wrapper.sh]
|
||||
args: [gateway, run]
|
||||
@ -532,7 +536,7 @@ spec:
|
||||
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
|
||||
- {name: oauth-tmp, mountPath: /tmp}
|
||||
- name: terminal
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -626,7 +630,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
- name: cli-lane-runner
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -668,7 +672,7 @@ spec:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: "3", memory: 6Gi}
|
||||
- name: model-steward
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"]
|
||||
env:
|
||||
@ -678,6 +682,7 @@ spec:
|
||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||
- {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}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@ -690,11 +695,12 @@ spec:
|
||||
- {name: provider-auth, mountPath: /shared-auth}
|
||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 64Mi}
|
||||
limits: {cpu: 250m, memory: 512Mi}
|
||||
- name: image-broker
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -744,7 +750,7 @@ spec:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
- name: codex-broker
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -762,6 +768,7 @@ spec:
|
||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||
- {name: PYTHONPATH, value: /opt/hermes}
|
||||
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
|
||||
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
||||
readinessProbe:
|
||||
tcpSocket: {port: codex-broker}
|
||||
initialDelaySeconds: 5
|
||||
@ -786,6 +793,7 @@ spec:
|
||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {cpu: "1", memory: 1Gi}
|
||||
@ -796,6 +804,9 @@ spec:
|
||||
- name: provider-auth
|
||||
persistentVolumeClaim:
|
||||
claimName: hermes-provider-auth
|
||||
- name: routing-catalog
|
||||
persistentVolumeClaim:
|
||||
claimName: hermes-routing-catalog
|
||||
- name: config
|
||||
configMap:
|
||||
name: hermes-agent-config
|
||||
|
||||
@ -9,27 +9,20 @@ metadata:
|
||||
data:
|
||||
config.yaml: |
|
||||
model:
|
||||
provider: atlas-codex
|
||||
default: gpt-5.6-terra
|
||||
model: gpt-5.6-terra
|
||||
provider: atlas-switchyard
|
||||
default: atlas/auto/fast
|
||||
model: atlas/auto/fast
|
||||
providers:
|
||||
atlas-codex:
|
||||
name: Atlas Codex
|
||||
api: http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1
|
||||
key_env: HERMES_IMAGE_BROKER_KEY
|
||||
default_model: gpt-5.6-terra
|
||||
transport: codex_responses
|
||||
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
|
||||
atlas-switchyard:
|
||||
name: Automatic Router
|
||||
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||
api_key: atlas-switchyard
|
||||
default_model: atlas/auto/fast
|
||||
transport: chat_completions
|
||||
fallback_providers: []
|
||||
agent:
|
||||
api_max_retries: 2
|
||||
max_turns: 120
|
||||
reasoning_effort: high
|
||||
delegation:
|
||||
max_concurrent_children: 2
|
||||
max_iterations: 80
|
||||
@ -62,9 +55,17 @@ data:
|
||||
enabled: true
|
||||
extra:
|
||||
model_routes:
|
||||
gpt-5.6-terra:
|
||||
provider: atlas-codex
|
||||
model: gpt-5.6-terra
|
||||
atlas/auto/fast: {provider: atlas-switchyard, model: atlas/auto/fast}
|
||||
atlas/auto/balanced: {provider: atlas-switchyard, model: atlas/auto/balanced}
|
||||
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:
|
||||
public_url: https://chat.hermes.bstein.dev
|
||||
display:
|
||||
|
||||
@ -158,7 +158,7 @@ spec:
|
||||
requests: {cpu: 25m, memory: 32Mi}
|
||||
limits: {cpu: 100m, memory: 64Mi}
|
||||
- name: patch-auth
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -179,7 +179,7 @@ spec:
|
||||
limits: {cpu: 100m, memory: 128Mi}
|
||||
containers:
|
||||
- name: hermes
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
@ -239,7 +239,7 @@ spec:
|
||||
requests: {cpu: 250m, memory: 512Mi}
|
||||
limits: {cpu: "1", memory: 2Gi}
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -9,23 +9,23 @@ metadata:
|
||||
data:
|
||||
config.yaml: |
|
||||
model:
|
||||
provider: anthropic
|
||||
default: claude-opus-5
|
||||
model: claude-opus-5
|
||||
provider: atlas-switchyard
|
||||
default: atlas/auto/deep
|
||||
model: atlas/auto/deep
|
||||
|
||||
fallback_providers:
|
||||
- provider: openai-codex
|
||||
model: gpt-5.6-terra
|
||||
- provider: custom
|
||||
model: qwen2.5:14b-instruct-q4_0
|
||||
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
|
||||
api_key: ollama
|
||||
providers:
|
||||
atlas-switchyard:
|
||||
name: Atlas Switchyard
|
||||
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||
api_key: atlas-switchyard
|
||||
default_model: atlas/auto/deep
|
||||
transport: chat_completions
|
||||
|
||||
fallback_providers: []
|
||||
|
||||
agent:
|
||||
api_max_retries: 1
|
||||
# A static high default is the fail-safe if routing is unavailable. In
|
||||
# AUTO, the Jetson classifier independently selects every turn.
|
||||
reasoning_effort: high
|
||||
# AUTO leaves effort unset so Switchyard's selected target owns it.
|
||||
|
||||
plugins:
|
||||
enabled:
|
||||
|
||||
@ -186,7 +186,7 @@ spec:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
- name: patch-auth
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/hermes/.venv/bin/python
|
||||
@ -238,7 +238,7 @@ spec:
|
||||
memory: 64Mi
|
||||
containers:
|
||||
- name: hermes
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/opt/hermes/.venv/bin/hermes]
|
||||
args:
|
||||
@ -351,7 +351,7 @@ spec:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
- name: webui
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5
|
||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
|
||||
@ -8,9 +8,12 @@ resources:
|
||||
- configmap.yaml
|
||||
- agent-configmap.yaml
|
||||
- chat-configmap.yaml
|
||||
- switchyard-configmap.yaml
|
||||
- rbac.yaml
|
||||
- agent-rbac.yaml
|
||||
- pvc.yaml
|
||||
- switchyard-pvc.yaml
|
||||
- routing-catalog-pvc.yaml
|
||||
- chat-pvcs.yaml
|
||||
- oauth-session-store.yaml
|
||||
- model-gate-rbac.yaml
|
||||
@ -19,6 +22,7 @@ resources:
|
||||
- model-gate-state.yaml
|
||||
- model-gate-configmap.yaml
|
||||
- model-gate-deployment.yaml
|
||||
- switchyard-deployment.yaml
|
||||
- image-policy-configmap.yaml
|
||||
- networkpolicy.yaml
|
||||
- local-image-deployment.yaml
|
||||
@ -29,6 +33,7 @@ resources:
|
||||
- chat-sandbox.yaml
|
||||
- chat-router.yaml
|
||||
- service.yaml
|
||||
- switchyard-service.yaml
|
||||
- oauth2-proxy.yaml
|
||||
- agent-certificate.yaml
|
||||
- agent-ingress.yaml
|
||||
@ -55,6 +60,8 @@ configMapGenerator:
|
||||
- codex=scripts/codex
|
||||
- configure_agent_clients.py=scripts/configure_agent_clients.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
|
||||
- hermes_coordinator.py=scripts/hermes_coordinator.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_tui_gateway.py=scripts/patch_tui_gateway.py
|
||||
- patch_ttyd_index.py=scripts/patch_ttyd_index.py
|
||||
- routing_catalog.py=scripts/routing_catalog.py
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
- name: hermes-agent-kubeconfig
|
||||
|
||||
@ -52,7 +52,7 @@ data:
|
||||
|
||||
|
||||
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:
|
||||
return body
|
||||
@ -64,6 +64,10 @@ data:
|
||||
return body
|
||||
|
||||
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"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.lower() in {"xhigh", "max"}:
|
||||
|
||||
@ -36,7 +36,7 @@ spec:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values: [hermes, hermes-agent, hermes-chat-tenant]
|
||||
values: [hermes, hermes-agent, hermes-chat-tenant, hermes-switchyard]
|
||||
ports:
|
||||
- {protocol: TCP, port: 8080}
|
||||
- from:
|
||||
@ -103,6 +103,12 @@ spec:
|
||||
ports:
|
||||
- {protocol: TCP, port: 9002}
|
||||
- {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
|
||||
# browser boundary remains OAuth-protected, while its workers need to reach
|
||||
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
|
||||
@ -251,6 +257,12 @@ spec:
|
||||
app: hermes-model-gate
|
||||
ports:
|
||||
- {protocol: TCP, port: 8080}
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app: hermes-switchyard
|
||||
ports:
|
||||
- {protocol: TCP, port: 9005}
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
@ -286,6 +298,70 @@ spec:
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
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:
|
||||
name: hermes-chat-router-isolation
|
||||
namespace: hermes
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
15
services/hermes/routing-catalog-pvc.yaml
Normal file
15
services/hermes/routing-catalog-pvc.yaml
Normal 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
|
||||
235
services/hermes/scripts/claude_oauth_broker.py
Normal file
235
services/hermes/scripts/claude_oauth_broker.py
Normal 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()
|
||||
@ -4,7 +4,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@ -14,6 +13,8 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@ -21,8 +22,10 @@ from typing import Any, Callable
|
||||
|
||||
|
||||
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
ROUTING_PATH = DATA_ROOT / "workspace/coordinator/model-routing.json"
|
||||
ROUTER_PATH = DATA_ROOT / "plugins/auto-router/__init__.py"
|
||||
SWITCHYARD_URL = os.environ.get(
|
||||
"HERMES_SWITCHYARD_URL",
|
||||
"http://hermes-switchyard.hermes.svc.cluster.local:9005/v1/chat/completions",
|
||||
)
|
||||
STATE_ROOT = DATA_ROOT / "cli-lanes"
|
||||
CODEX_BIN = DATA_ROOT / "tools/bin/codex"
|
||||
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 {}
|
||||
|
||||
|
||||
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]:
|
||||
"""Parse external lane overrides while leaving cli-auto fully automatic."""
|
||||
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)
|
||||
|
||||
|
||||
def _split_route(value: str) -> tuple[str, str]:
|
||||
provider, separator, model = value.partition("/")
|
||||
if not separator or not provider or not model:
|
||||
raise RuntimeError(f"invalid managed route: {value}")
|
||||
return provider, model
|
||||
|
||||
|
||||
def _worker_provider(provider: str) -> str | None:
|
||||
return {"openai-codex": "codex", "anthropic": "claude"}.get(provider)
|
||||
def _decode_worker_target(value: str) -> tuple[str, str, str]:
|
||||
"""Decode the selected model header emitted by a worker decision route."""
|
||||
parts = value.split("/", 3)
|
||||
if len(parts) != 4 or parts[0] != "worker":
|
||||
raise RuntimeError(f"invalid Switchyard worker target: {value}")
|
||||
provider, model, effort = parts[1:]
|
||||
if provider not in {"codex", "claude"} or effort not in EFFORTS:
|
||||
raise RuntimeError(f"unsupported Switchyard worker target: {value}")
|
||||
return provider, model, effort
|
||||
|
||||
|
||||
def select_route(
|
||||
prompt: str,
|
||||
assignee: str,
|
||||
*,
|
||||
routing_path: Path = ROUTING_PATH,
|
||||
router_path: Path = ROUTER_PATH,
|
||||
exclude_provider: str | None = None,
|
||||
switchyard_url: str = SWITCHYARD_URL,
|
||||
open_request: Callable[..., Any] = urllib.request.urlopen,
|
||||
) -> Route:
|
||||
"""Always consult the Jetson, then apply a deliberate lane override if set."""
|
||||
router = _load_router(router_path)
|
||||
decision = router.classify_task(prompt)
|
||||
"""Ask Switchyard to select one native CLI worker at this boundary."""
|
||||
started = time.monotonic()
|
||||
manual_provider, manual_effort = parse_assignee(assignee)
|
||||
selected_provider = manual_provider or str(decision.provider)
|
||||
effort = manual_effort or str(decision.effort)
|
||||
if effort not in EFFORTS:
|
||||
raise RuntimeError(f"classifier returned unsupported effort: {effort}")
|
||||
if exclude_provider == selected_provider:
|
||||
selected_provider = "claude" if selected_provider == "codex" else "codex"
|
||||
status = load_json(routing_path)
|
||||
profile = f"{selected_provider}-{effort}"
|
||||
chain = (status.get("routes") or {}).get(profile)
|
||||
if not isinstance(chain, list) or not chain:
|
||||
raise RuntimeError(f"managed route unavailable: {profile}")
|
||||
hosted = [str(item) for item in chain if _worker_provider(_split_route(str(item))[0])]
|
||||
if not hosted:
|
||||
raise RuntimeError(f"no hosted CLI route available: {profile}")
|
||||
first = next(
|
||||
(item for item in hosted if _worker_provider(_split_route(item)[0]) == selected_provider),
|
||||
hosted[0],
|
||||
if manual_provider and manual_effort:
|
||||
route_id = f"atlas/worker/manual/{manual_provider}/{manual_effort}"
|
||||
source = "switchyard-manual"
|
||||
else:
|
||||
route_id = "atlas/worker/auto/maximum"
|
||||
source = "switchyard-classifier"
|
||||
context = prompt
|
||||
if exclude_provider:
|
||||
context += (
|
||||
f"\n\nRouting constraint: the {exclude_provider} provider failed or "
|
||||
"exhausted capacity at this boundary. Do not select it."
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": route_id,
|
||||
"messages": [{"role": "user", "content": context}],
|
||||
"stream": False,
|
||||
"max_tokens": 1,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
switchyard_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with open_request(request, timeout=60) as response:
|
||||
selected = str(response.headers.get("x-model-router-selected-model") or "")
|
||||
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"
|
||||
)
|
||||
provider_key, model = _split_route(first)
|
||||
actual_provider = _worker_provider(provider_key)
|
||||
if actual_provider is None:
|
||||
raise RuntimeError(f"route is not a CLI provider: {first}")
|
||||
return Route(
|
||||
provider=actual_provider,
|
||||
provider=provider,
|
||||
model=model,
|
||||
effort=effort,
|
||||
profile=f"{actual_provider}-{effort}",
|
||||
classifier=str(decision.classifier),
|
||||
reason=(
|
||||
str(decision.reason)
|
||||
+ ("; manual lane override applied after Jetson classification" if manual_provider else "")
|
||||
),
|
||||
latency_ms=int(decision.latency_ms),
|
||||
fallback_chain=tuple(item for item in hosted if item != first),
|
||||
profile=f"{provider}-{effort}",
|
||||
classifier=source,
|
||||
reason=rationale or f"Switchyard selected {selected}",
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
fallback_chain=(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from routing_catalog import resolve_route
|
||||
|
||||
|
||||
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
|
||||
@ -25,7 +27,7 @@ UPSTREAM = os.environ.get(
|
||||
).rstrip("/")
|
||||
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"))
|
||||
ALLOWED_MODELS = {
|
||||
FALLBACK_ALLOWED_MODELS = {
|
||||
value.strip()
|
||||
for value in os.environ.get(
|
||||
"HERMES_CODEX_BROKER_MODELS",
|
||||
@ -33,6 +35,14 @@ ALLOWED_MODELS = {
|
||||
).split(",")
|
||||
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:
|
||||
@ -89,8 +99,12 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("JSON object required")
|
||||
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")
|
||||
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.
|
||||
payload["store"] = False
|
||||
payload["stream"] = True
|
||||
@ -135,7 +149,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": model, "object": "model", "owned_by": "openai-codex"}
|
||||
for model in sorted(ALLOWED_MODELS)
|
||||
for model in sorted(FALLBACK_ALLOWED_MODELS)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@ -4,10 +4,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@ -26,6 +28,10 @@ ATLAS_FALLBACK = {
|
||||
}
|
||||
# Backwards-compatible name used by the focused unit tests and status tooling.
|
||||
LOCAL_FALLBACK = ATLAS_FALLBACK
|
||||
SWITCHYARD_PROVIDER = "atlas-switchyard"
|
||||
SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1"
|
||||
SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum"
|
||||
ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip()
|
||||
MANAGED_ENV_KEYS = {
|
||||
"CLAUDE_CODE_OAUTH_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)
|
||||
|
||||
|
||||
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]:
|
||||
"""Read the small dotenv subset used by Hermes provider credentials."""
|
||||
values: dict[str, str] = {}
|
||||
@ -381,6 +494,36 @@ def _profile_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(
|
||||
root: Path,
|
||||
name: str,
|
||||
@ -402,50 +545,33 @@ def _write_profile(
|
||||
_update_profile_env(profile / ".env", env_values)
|
||||
|
||||
|
||||
def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, Any]:
|
||||
"""Update the coordinator and managed worker profiles."""
|
||||
def configure_routes(
|
||||
root: Path,
|
||||
codex: Catalog,
|
||||
claude: Catalog,
|
||||
catalog_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresh catalogs while keeping every Hermes profile on Switchyard."""
|
||||
config_path = root / "config.yaml"
|
||||
base = _read_yaml(config_path)
|
||||
old_coordinator = _existing_model(base, "openai-codex", CODEX_BASELINE)
|
||||
old_claude_root = _existing_model(base, "anthropic", CLAUDE_BASELINE)
|
||||
codex_models: dict[str, str] = {}
|
||||
claude_models: dict[str, str] = {}
|
||||
for effort in EFFORTS:
|
||||
old_codex = _existing_model(
|
||||
_read_yaml(root / "profiles" / f"codex-{effort}" / "config.yaml"),
|
||||
"openai-codex",
|
||||
old_coordinator,
|
||||
)
|
||||
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
|
||||
resolved_catalog_path = catalog_path or (
|
||||
Path(ROUTING_CATALOG_PATH)
|
||||
if ROUTING_CATALOG_PATH
|
||||
else root / "routing-catalog.json"
|
||||
)
|
||||
catalog = write_routing_catalog(resolved_catalog_path, codex, claude)
|
||||
providers = catalog["providers"]
|
||||
codex_models = providers["codex"]["resolved"]
|
||||
claude_models = providers["claude"]["resolved"]
|
||||
|
||||
codex_coordinator = codex_models["medium"]
|
||||
claude_coordinator = claude_models["medium"]
|
||||
|
||||
base["model"] = {
|
||||
"provider": "openai-codex",
|
||||
"default": codex_coordinator,
|
||||
"model": codex_coordinator,
|
||||
"openai_runtime": "codex_app_server",
|
||||
}
|
||||
base["fallback_providers"] = [
|
||||
{"provider": "anthropic", "model": claude_coordinator},
|
||||
copy.deepcopy(ATLAS_FALLBACK),
|
||||
]
|
||||
base["model_catalog"] = {"enabled": True, "ttl_hours": 1}
|
||||
coordinator_toolsets = copy.deepcopy(base.get("toolsets"))
|
||||
base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high")
|
||||
# The coordinator uses its configured toolsets. Worker profiles below are
|
||||
# deliberately toolset-empty so Hermes resolves their native defaults.
|
||||
if coordinator_toolsets is None:
|
||||
base.pop("toolsets", None)
|
||||
else:
|
||||
base["toolsets"] = coordinator_toolsets
|
||||
_write_yaml(config_path, base)
|
||||
|
||||
env_values = _read_env(root / ".env")
|
||||
@ -455,73 +581,56 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
|
||||
claude_model = claude_models[effort]
|
||||
codex_name = f"codex-{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(
|
||||
root,
|
||||
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.",
|
||||
_profile_config(
|
||||
base,
|
||||
"openai-codex",
|
||||
codex_model,
|
||||
{"provider": "anthropic", "model": claude_model},
|
||||
effort,
|
||||
),
|
||||
_switchyard_profile_config(base, codex_route, effort),
|
||||
env_values,
|
||||
)
|
||||
_write_profile(
|
||||
root,
|
||||
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.",
|
||||
_profile_config(
|
||||
base,
|
||||
"anthropic",
|
||||
claude_model,
|
||||
{"provider": "openai-codex", "model": codex_model},
|
||||
effort,
|
||||
),
|
||||
_switchyard_profile_config(base, claude_route, effort),
|
||||
env_values,
|
||||
)
|
||||
local = [] if effort == "xhigh" else ["custom/qwen2.5:14b-instruct-q4_0"]
|
||||
routes[codex_name] = [
|
||||
f"openai-codex/{codex_model}",
|
||||
f"anthropic/{claude_model}",
|
||||
*local,
|
||||
]
|
||||
routes[claude_name] = [
|
||||
f"anthropic/{claude_model}",
|
||||
f"openai-codex/{codex_model}",
|
||||
*local,
|
||||
]
|
||||
routes[codex_name] = [codex_route]
|
||||
routes[claude_name] = [claude_route]
|
||||
|
||||
_write_profile(
|
||||
root,
|
||||
"synthesis-xhigh",
|
||||
"Cross-provider synthesis and critical review, capped at xhigh effort.",
|
||||
"Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
|
||||
_profile_config(
|
||||
base,
|
||||
"anthropic",
|
||||
claude_models["xhigh"],
|
||||
{"provider": "openai-codex", "model": codex_models["xhigh"]},
|
||||
"xhigh",
|
||||
),
|
||||
_switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"),
|
||||
env_values,
|
||||
)
|
||||
routes["synthesis-xhigh"] = [
|
||||
f"anthropic/{claude_models['xhigh']}",
|
||||
f"openai-codex/{codex_models['xhigh']}",
|
||||
]
|
||||
routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE]
|
||||
_write_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,
|
||||
},
|
||||
)
|
||||
routes["coordinator"] = [
|
||||
f"openai-codex/{codex_coordinator}",
|
||||
f"anthropic/{claude_coordinator}",
|
||||
routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE]
|
||||
routes["catalog"] = [
|
||||
*(f"openai-codex/{model}" for model in codex_models.values()),
|
||||
*(f"anthropic/{model}" for model in claude_models.values()),
|
||||
]
|
||||
return routes
|
||||
|
||||
81
services/hermes/scripts/routing_catalog.py
Normal file
81
services/hermes/scripts/routing_catalog.py
Normal 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]}"
|
||||
85
services/hermes/scripts/worker_route_broker.py
Normal file
85
services/hermes/scripts/worker_route_broker.py
Normal 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()
|
||||
464
services/hermes/switchyard-configmap.yaml
Normal file
464
services/hermes/switchyard-configmap.yaml
Normal 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
|
||||
249
services/hermes/switchyard-deployment.yaml
Normal file
249
services/hermes/switchyard-deployment.yaml
Normal 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: {}
|
||||
15
services/hermes/switchyard-pvc.yaml
Normal file
15
services/hermes/switchyard-pvc.yaml
Normal 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
|
||||
17
services/hermes/switchyard-service.yaml
Normal file
17
services/hermes/switchyard-service.yaml
Normal 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
|
||||
@ -13,6 +13,12 @@ metadata:
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-switchyard
|
||||
namespace: hermes
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: hermes-chat
|
||||
namespace: hermes
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: vault-k8s-auth-hermes-5
|
||||
name: vault-k8s-auth-hermes-6
|
||||
namespace: vault
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
|
||||
@ -255,7 +255,7 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
|
||||
"game-stream/*" ""
|
||||
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
||||
"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" ""
|
||||
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
|
||||
"hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" ""
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
"""Contracts for Agent Hermes automatic route selection."""
|
||||
"""Contracts for the thin Hermes-to-Switchyard boundary adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
SOURCE = (
|
||||
Path(__file__).parents[2]
|
||||
/ "services/hermes/plugins/auto-router/__init__.py"
|
||||
)
|
||||
SOURCE = Path(__file__).parents[2] / "services/hermes/plugins/auto-router/__init__.py"
|
||||
SPEC = importlib.util.spec_from_file_location("hermes_auto_router", SOURCE)
|
||||
assert SPEC and SPEC.loader
|
||||
router = importlib.util.module_from_spec(SPEC)
|
||||
@ -20,784 +17,139 @@ sys.modules[SPEC.name] = router
|
||||
SPEC.loader.exec_module(router)
|
||||
|
||||
|
||||
def _status() -> dict:
|
||||
return {
|
||||
"providers": {
|
||||
"openai-codex": {"connected": True},
|
||||
"anthropic": {"connected": True},
|
||||
},
|
||||
"routes": {
|
||||
"codex-low": [
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
"anthropic/claude-haiku-4-5-20251001",
|
||||
],
|
||||
"codex-medium": [
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
"anthropic/claude-sonnet-5",
|
||||
],
|
||||
"claude-medium": [
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
],
|
||||
"claude-xhigh": [
|
||||
"anthropic/claude-opus-5",
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
],
|
||||
},
|
||||
def _agent(**overrides):
|
||||
values = {
|
||||
"provider": router.SWITCHYARD_PROVIDER,
|
||||
"model": "atlas/auto/maximum",
|
||||
"base_url": "http://hermes-switchyard:9005/v1",
|
||||
"api_key": "atlas-switchyard",
|
||||
"api_mode": "chat_completions",
|
||||
"reasoning_config": {"effort": "high"},
|
||||
"_fallback_chain": [{"provider": "anthropic"}],
|
||||
"_fallback_index": 1,
|
||||
"_fallback_activated": True,
|
||||
"_fallback_model": {"provider": "anthropic"},
|
||||
"_hermes_explicit_model_pick": False,
|
||||
"_hermes_explicit_reasoning_effort": "",
|
||||
"_hermes_routing_priority": "",
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_heuristics_keep_simple_questions_cheap_and_risky_work_capped():
|
||||
simple = router.heuristic_decision("Who is the current provider?")
|
||||
risky = router.heuristic_decision("Migrate production Vault credentials safely")
|
||||
|
||||
assert (simple.shape, simple.effort, simple.provider) == (
|
||||
"question",
|
||||
"low",
|
||||
"codex",
|
||||
)
|
||||
assert (risky.shape, risky.effort, risky.provider) == (
|
||||
"review",
|
||||
"xhigh",
|
||||
"claude",
|
||||
)
|
||||
def test_profile_defaults_are_distinct_and_quality_ordered():
|
||||
assert router.PROFILE_ROUTE == {
|
||||
"chat": "atlas/auto/fast",
|
||||
"triage": "atlas/auto/deep",
|
||||
"agent": "atlas/auto/maximum",
|
||||
}
|
||||
assert router.PROFILE_ROUTE[router.ROUTER_PROFILE] in router.AUTO_ROUTES
|
||||
|
||||
|
||||
def test_jetson_selects_provider_while_deterministic_policy_preserves_work_shape(
|
||||
monkeypatch,
|
||||
def test_auto_boundary_selects_only_a_public_switchyard_route(tmp_path, 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(
|
||||
router,
|
||||
"jetson_decision",
|
||||
lambda text: router.Decision(
|
||||
"question", "high", "claude", "jetson", "test", 42
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||
agent = _agent(_hermes_routing_priority="deep")
|
||||
|
||||
decision = router.classify_task("Implement and test the new API handler")
|
||||
route, effort, source = router._boundary_selection(agent)
|
||||
|
||||
assert decision.shape == "implementation"
|
||||
assert decision.provider == "claude"
|
||||
assert decision.effort == "high"
|
||||
assert decision.classifier == "jetson"
|
||||
assert (route, effort, source) == ("atlas/auto/deep", "", "ui-auto")
|
||||
assert route in router.AUTO_ROUTES
|
||||
|
||||
|
||||
def test_route_uses_managed_models_and_connected_provider_fallback():
|
||||
status = _status()
|
||||
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,
|
||||
def test_ui_manual_model_and_effort_are_forwarded_as_constraints(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
||||
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local"))
|
||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
||||
monkeypatch.setattr(router, "_load_json", lambda path: {})
|
||||
audits = []
|
||||
|
||||
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, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||
agent = _agent(
|
||||
model="atlas/manual/claude/opus",
|
||||
_hermes_explicit_model_pick=True,
|
||||
_hermes_explicit_reasoning_effort="xhigh",
|
||||
)
|
||||
|
||||
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 router._boundary_selection(agent) == (
|
||||
"atlas/manual/claude/opus",
|
||||
"xhigh",
|
||||
"ui-manual",
|
||||
)
|
||||
|
||||
assert audits == [("Review this answer carefully.", "deep")]
|
||||
assert plans[0]["provider"] == "atlas-codex"
|
||||
assert plans[0]["model"] == "gpt-5.6-sol"
|
||||
assert plans[0]["effort"] == "xhigh"
|
||||
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(
|
||||
monkeypatch,
|
||||
def test_manual_command_persists_a_switchyard_route_not_a_direct_provider(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
||||
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local"))
|
||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
||||
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)
|
||||
path = tmp_path / "route-policy.json"
|
||||
monkeypatch.setattr(router, "POLICY_PATH", path)
|
||||
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
|
||||
|
||||
class Agent:
|
||||
def _emit_status(self, message):
|
||||
self.message = message
|
||||
message = router._route_command(ctx, "manual codex xhigh sol")
|
||||
policy = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
agent = Agent()
|
||||
router._pre_turn_route(
|
||||
object(), agent=agent, user_message="Use Claude at xhigh for this answer."
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
assert "Manual Switchyard constraint enabled" in message
|
||||
assert policy["manual"] == {
|
||||
"route": "atlas/manual/codex/sol",
|
||||
"effort": "xhigh",
|
||||
}
|
||||
written = []
|
||||
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")
|
||||
assert "provider" not in policy["manual"]
|
||||
|
||||
|
||||
def test_post_turn_rewarms_classifier_after_local_chat(monkeypatch):
|
||||
policy = {
|
||||
"mode": "auto",
|
||||
"last_decision": {
|
||||
"provider": "custom",
|
||||
"model": "qwen2.5:14b-instruct-q4_0",
|
||||
"effort": "low",
|
||||
"classifier": "jetson",
|
||||
},
|
||||
def test_effort_above_xhigh_is_rejected(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
|
||||
|
||||
message = router._route_command(ctx, "manual claude max opus")
|
||||
|
||||
assert "Effort must be" in message
|
||||
|
||||
|
||||
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 = []
|
||||
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")
|
||||
assert "route" in commands
|
||||
|
||||
|
||||
def test_status_distinguishes_requested_route_from_actual_outcome(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
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})()
|
||||
def test_adapter_contains_no_content_classifier_or_direct_ollama_call():
|
||||
source = SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
status = router._status_text(ctx)
|
||||
|
||||
assert "Last requested route: anthropic/claude-sonnet-5" in status
|
||||
assert "Last actual outcome: fallback: openai-codex/gpt-5.6-terra" in status
|
||||
assert "jetson_decision" not in source
|
||||
assert "urllib.request" not in source
|
||||
assert "ollama.ai.svc" not in source
|
||||
|
||||
@ -27,7 +27,9 @@ def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
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"] == {
|
||||
"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():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||
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_config = {"enabled": True, "effort": "xhigh"}' 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 "window.location.assign('/oauth2/start?rd='" 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():
|
||||
@ -387,27 +396,37 @@ def test_chat_image_generation_uses_private_owner_broker():
|
||||
)
|
||||
|
||||
|
||||
def test_chat_reasoning_uses_private_owner_codex_broker():
|
||||
"""Family pods receive Codex turns without mounting the owner's OAuth file."""
|
||||
def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
"""Family pods use AUTO/manual routes without mounting owner credentials."""
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["model"] == {
|
||||
"provider": "atlas-codex",
|
||||
"default": "gpt-5.6-terra",
|
||||
"model": "gpt-5.6-terra",
|
||||
"provider": "atlas-switchyard",
|
||||
"default": "atlas/auto/fast",
|
||||
"model": "atlas/auto/fast",
|
||||
}
|
||||
assert config["providers"]["atlas-codex"] == {
|
||||
"name": "Atlas Codex",
|
||||
"api": "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1",
|
||||
"key_env": "HERMES_IMAGE_BROKER_KEY",
|
||||
"default_model": "gpt-5.6-terra",
|
||||
"transport": "codex_responses",
|
||||
assert config["providers"]["atlas-switchyard"] == {
|
||||
"name": "Automatic Router",
|
||||
"api": "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1",
|
||||
"api_key": "atlas-switchyard",
|
||||
"default_model": "atlas/auto/fast",
|
||||
"transport": "chat_completions",
|
||||
}
|
||||
assert config["platforms"]["api_server"]["extra"]["model_routes"] == {
|
||||
"gpt-5.6-terra": {
|
||||
"provider": "atlas-codex",
|
||||
"model": "gpt-5.6-terra",
|
||||
}
|
||||
route: {"provider": "atlas-switchyard", "model": route}
|
||||
for route in [
|
||||
"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]
|
||||
@ -418,10 +437,11 @@ def test_chat_reasoning_uses_private_owner_codex_broker():
|
||||
]
|
||||
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
|
||||
assert broker["securityContext"]["runAsNonRoot"] is True
|
||||
assert broker["env"][-2:] == [
|
||||
{"name": "PYTHONPATH", "value": "/opt/hermes"},
|
||||
{"name": "HERMES_CODEX_BROKER_LISTEN_PORT", "value": "9003"},
|
||||
]
|
||||
assert {item["name"]: item["value"] for item in broker["env"]}.items() >= {
|
||||
"PYTHONPATH": "/opt/hermes",
|
||||
"HERMES_CODEX_BROKER_LISTEN_PORT": "9003",
|
||||
"HERMES_ROUTING_CATALOG_PATH": "/routing-catalog/catalog.json",
|
||||
}.items()
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
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 wrong") is False
|
||||
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
|
||||
payload = module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "store": True, "stream": False}
|
||||
)
|
||||
assert payload["store"] is False
|
||||
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"):
|
||||
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"):
|
||||
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
||||
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():
|
||||
|
||||
@ -59,121 +59,87 @@ def _oauth_deployment(name: str) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def test_auto_lane_always_uses_the_jetson_decision(tmp_path: Path):
|
||||
router = tmp_path / "router.py"
|
||||
router.write_text(
|
||||
"""
|
||||
class Decision:
|
||||
shape = "review"
|
||||
effort = "xhigh"
|
||||
provider = "claude"
|
||||
classifier = "jetson"
|
||||
reason = "local vote"
|
||||
latency_ms = 17
|
||||
class _SwitchyardResponse:
|
||||
"""Minimal context-managed response used by routing contract tests."""
|
||||
|
||||
def classify_task(prompt):
|
||||
assert "security review" in prompt
|
||||
return Decision()
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
routes = tmp_path / "routes.json"
|
||||
routes.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"routes": {
|
||||
"claude-xhigh": [
|
||||
"anthropic/claude-opus-5",
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
]
|
||||
def __init__(self, selected: str, rationale: str = "local classifier vote"):
|
||||
self.headers = {
|
||||
"x-model-router-selected-model": selected,
|
||||
"x-model-router-rationale": rationale,
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"{}"
|
||||
|
||||
|
||||
def test_auto_lane_uses_switchyard_worker_decision():
|
||||
observed = {}
|
||||
|
||||
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(
|
||||
"Perform the security review.",
|
||||
"cli-auto",
|
||||
routing_path=routes,
|
||||
router_path=router,
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.model == "claude-opus-5"
|
||||
assert route.effort == "xhigh"
|
||||
assert route.classifier == "jetson"
|
||||
assert route.latency_ms == 17
|
||||
assert route.classifier == "switchyard-classifier"
|
||||
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):
|
||||
router = tmp_path / "router.py"
|
||||
router.write_text(
|
||||
"""
|
||||
called = 0
|
||||
class Decision:
|
||||
shape = "question"
|
||||
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",
|
||||
def test_manual_lane_is_still_enforced_by_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse(
|
||||
"worker/claude/claude-sonnet-5/high", "manual route"
|
||||
)
|
||||
|
||||
route = lanes.select_route(
|
||||
"Implement it.",
|
||||
"cli-claude-high",
|
||||
routing_path=routes,
|
||||
router_path=router,
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
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):
|
||||
router = tmp_path / "router.py"
|
||||
router.write_text(
|
||||
"""
|
||||
class Decision:
|
||||
shape = "implementation"
|
||||
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",
|
||||
)
|
||||
def test_cross_provider_retry_passes_failed_provider_to_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse("worker/claude/claude-sonnet-5/high")
|
||||
|
||||
route = lanes.select_route(
|
||||
"Retry after capacity exhaustion.",
|
||||
"cli-auto",
|
||||
exclude_provider="codex",
|
||||
routing_path=routes,
|
||||
router_path=router,
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
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):
|
||||
@ -835,6 +801,16 @@ def test_agent_network_boundary_allows_only_authenticated_web_and_broker_surface
|
||||
{"protocol": "TCP", "port": 9003},
|
||||
],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"podSelector": {
|
||||
"matchLabels": {"app": "hermes-switchyard"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [{"protocol": "TCP", "port": 9003}],
|
||||
},
|
||||
]
|
||||
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():
|
||||
script = (SCRIPTS / "install_agent_tools.sh").read_text()
|
||||
for value in [
|
||||
|
||||
@ -6,6 +6,7 @@ import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@ -16,6 +17,7 @@ SCRIPT = (
|
||||
)
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
routing = importlib.import_module("hermes_model_routing")
|
||||
catalog_resolver = importlib.import_module("routing_catalog")
|
||||
SPEC = importlib.util.spec_from_file_location("hermes_coordinator", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
"""AUTO routing must recognize the authenticated app-server CLI lane."""
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
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(
|
||||
(tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert root["model"]["model"] == "gpt-5.6-terra"
|
||||
assert root["model"]["openai_runtime"] == "codex_app_server"
|
||||
assert codex_profile["model"]["model"] == "gpt-5.6-sol"
|
||||
assert codex_profile["model"]["openai_runtime"] == "codex_app_server"
|
||||
assert codex_profile["fallback_providers"][0] == {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5",
|
||||
assert root["model"] == {
|
||||
"provider": "atlas-switchyard",
|
||||
"default": "atlas/auto/maximum",
|
||||
"model": "atlas/auto/maximum",
|
||||
}
|
||||
assert claude_profile["model"]["model"] == "claude-opus-5"
|
||||
assert claude_profile["fallback_providers"][0]["provider"] == "openai-codex"
|
||||
assert root["fallback_providers"] == []
|
||||
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["agent"]["reasoning_effort"] == "high"
|
||||
assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK
|
||||
assert len(codex_profile["fallback_providers"]) == 2
|
||||
assert codex_xhigh_profile["fallback_providers"] == [
|
||||
{"provider": "anthropic", "model": "claude-opus-5"}
|
||||
]
|
||||
assert routes["codex-xhigh"] == [
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"anthropic/claude-opus-5",
|
||||
]
|
||||
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)
|
||||
assert codex_xhigh_profile["fallback_providers"] == []
|
||||
assert routes["codex-xhigh"] == ["atlas/manual/codex/sol"]
|
||||
assert routes["claude-xhigh"] == ["atlas/manual/claude/opus"]
|
||||
assert routes["synthesis-xhigh"] == ["atlas/auto/maximum"]
|
||||
assert routes["coordinator"] == ["atlas/auto/maximum"]
|
||||
assert all(
|
||||
"max" not in profile_name
|
||||
for profile_name in routes
|
||||
if profile_name != "catalog"
|
||||
)
|
||||
|
||||
profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")
|
||||
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
|
||||
|
||||
|
||||
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["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol"
|
||||
(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)
|
||||
|
||||
current = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert current["model"]["model"] == "gpt-5.6-sol"
|
||||
assert current["fallback_providers"][0]["model"] == "claude-opus-5"
|
||||
assert current["model"]["model"] == "atlas/auto/maximum"
|
||||
assert current["model"]["provider"] == "atlas-switchyard"
|
||||
assert current["fallback_providers"] == []
|
||||
|
||||
|
||||
def test_degraded_refresh_preserves_last_worker_specific_model(tmp_path: Path):
|
||||
"""A catalog outage must not collapse the Codex worker onto coordinator tier."""
|
||||
def test_degraded_refresh_preserves_switchyard_worker_preference(tmp_path: Path):
|
||||
"""A catalog outage must not bypass a managed worker's Switchyard route."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
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(
|
||||
(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):
|
||||
|
||||
@ -39,6 +39,20 @@ def test_model_gate_preserves_supported_and_non_json_requests():
|
||||
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():
|
||||
"""Wolf cannot reach Ollama until the local image service reports idle."""
|
||||
namespace = _model_gate_namespace()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user