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
|
kind: Deployment
|
||||||
name: hermes-model-gate
|
name: hermes-model-gate
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
|
- apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: hermes-switchyard
|
||||||
|
namespace: hermes
|
||||||
- apiVersion: apps/v1
|
- apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: hermes
|
name: hermes
|
||||||
|
|||||||
@ -476,7 +476,9 @@ route_after = route_before + ''' def _resolve_request_route(self, body: Dict[
|
|||||||
|
|
||||||
provider = body.get("provider")
|
provider = body.get("provider")
|
||||||
model = body.get("model")
|
model = body.get("model")
|
||||||
allowed_providers = {"openai-codex", "atlas-codex", "anthropic"}
|
# Every WebUI selection is a public Switchyard route. Direct provider
|
||||||
|
# picks would create a second routing control plane and bypass failover.
|
||||||
|
allowed_providers = {"atlas-switchyard"}
|
||||||
if provider not in allowed_providers or not isinstance(model, str):
|
if provider not in allowed_providers or not isinstance(model, str):
|
||||||
return None
|
return None
|
||||||
model = model.strip()
|
model = model.strip()
|
||||||
|
|||||||
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
|
# dockerfiles/Dockerfile.hermes-webui
|
||||||
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
|
FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
|
||||||
|
|
||||||
FROM registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
FROM registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
@ -72,7 +72,16 @@ sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
|
|||||||
panels = Path("/opt/hermes-webui/static/panels.js")
|
panels = Path("/opt/hermes-webui/static/panels.js")
|
||||||
source = panels.read_text(encoding="utf-8")
|
source = panels.read_text(encoding="utf-8")
|
||||||
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
|
||||||
after = " if (typeof p.model === 'string' && p.model) meta.push('profile default: ' + p.model.split('/').pop());\n"
|
after = ''' if (typeof p.model === 'string' && p.model) {
|
||||||
|
const routeLabels = {
|
||||||
|
'atlas/auto/fast': 'Automatic · Fast',
|
||||||
|
'atlas/auto/balanced': 'Automatic · Balanced',
|
||||||
|
'atlas/auto/deep': 'Automatic · Deep',
|
||||||
|
'atlas/auto/maximum': 'Automatic · Maximum',
|
||||||
|
};
|
||||||
|
meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
|
||||||
|
}
|
||||||
|
'''
|
||||||
if source.count(before) != 2:
|
if source.count(before) != 2:
|
||||||
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
raise SystemExit("Hermes WebUI profile-model label patch context changed")
|
||||||
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
panels.write_text(source.replace(before, after, 2), encoding="utf-8")
|
||||||
@ -93,12 +102,13 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
|
|||||||
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
|
||||||
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
|
||||||
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
|
||||||
&& grep -Fq "profile default: ' + p.model" /opt/hermes-webui/static/panels.js \
|
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
|
||||||
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
|
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
|
||||||
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
|
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
|
||||||
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
|
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
|
||||||
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
|
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
|
||||||
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
|
||||||
|
&& grep -Fq "'atlas/auto/fast':'AUTO · Fast'" /opt/hermes-webui/static/atlas-router.js \
|
||||||
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
&& grep -Fq 'explicit_reasoning_effort' /opt/hermes-webui/api/gateway_chat.py \
|
||||||
&& /opt/hermes/.venv/bin/python -m py_compile \
|
&& /opt/hermes/.venv/bin/python -m py_compile \
|
||||||
/opt/hermes-webui/api/routes.py \
|
/opt/hermes-webui/api/routes.py \
|
||||||
|
|||||||
@ -5,6 +5,51 @@
|
|||||||
const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'};
|
const LABELS={auto:'AUTO',fast:'FAST',balanced:'BALANCED',deep:'DEEP',maximum:'MAXIMUM'};
|
||||||
const STORAGE_KEY='atlas.hermes.routing-priority';
|
const STORAGE_KEY='atlas.hermes.routing-priority';
|
||||||
const EXPLICIT_EFFORT_KEY='atlas.hermes.explicit-reasoning';
|
const EXPLICIT_EFFORT_KEY='atlas.hermes.explicit-reasoning';
|
||||||
|
const ROUTE_LABELS={
|
||||||
|
'atlas/auto/fast':'AUTO · Fast',
|
||||||
|
'atlas/auto/balanced':'AUTO · Balanced',
|
||||||
|
'atlas/auto/deep':'AUTO · Deep',
|
||||||
|
'atlas/auto/maximum':'AUTO · Maximum',
|
||||||
|
'atlas/manual/codex/luna':'Codex · Luna',
|
||||||
|
'atlas/manual/codex/terra':'Codex · Terra',
|
||||||
|
'atlas/manual/codex/sol':'Codex · SOL',
|
||||||
|
'atlas/manual/claude/haiku':'Claude · Haiku',
|
||||||
|
'atlas/manual/claude/sonnet':'Claude · Sonnet',
|
||||||
|
'atlas/manual/claude/opus':'Claude · Opus',
|
||||||
|
'atlas/manual/local/qwen-14b':'Local · Qwen 14B'
|
||||||
|
};
|
||||||
|
|
||||||
|
function routeId(value){
|
||||||
|
const raw=String(value||'').trim();
|
||||||
|
if(ROUTE_LABELS[raw]) return raw;
|
||||||
|
const marker=raw.indexOf(':atlas/');
|
||||||
|
return marker>=0?raw.slice(marker+1):raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyRouteLabel(value){
|
||||||
|
return ROUTE_LABELS[routeId(value)]||'';
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelModelOptions(){
|
||||||
|
['modelSelect','settingsModel'].forEach(function(id){
|
||||||
|
const select=document.getElementById(id);
|
||||||
|
if(!select) return;
|
||||||
|
Array.from(select.options||[]).forEach(function(option){
|
||||||
|
const label=friendlyRouteLabel(option.value);
|
||||||
|
if(label&&option.textContent!==label) option.textContent=label;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(typeof window.syncModelChip==='function') window.syncModelChip();
|
||||||
|
if(typeof window.syncSettingsModelChip==='function') window.syncSettingsModelChip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchModelOptions(id){
|
||||||
|
const select=document.getElementById(id);
|
||||||
|
if(!select||select.dataset.atlasFriendlyRoutes==='1') return;
|
||||||
|
select.dataset.atlasFriendlyRoutes='1';
|
||||||
|
new MutationObserver(labelModelOptions).observe(select,{childList:true,subtree:true});
|
||||||
|
select.addEventListener('change',labelModelOptions);
|
||||||
|
}
|
||||||
|
|
||||||
function currentPriority(){
|
function currentPriority(){
|
||||||
let value='auto';
|
let value='auto';
|
||||||
@ -71,9 +116,13 @@
|
|||||||
explicit_reasoning_effort:explicitReasoning&&effort?effort:undefined
|
explicit_reasoning_effort:explicitReasoning&&effort?effort:undefined
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
window.hermesFriendlyRouteLabel=friendlyRouteLabel;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded',function(){
|
document.addEventListener('DOMContentLoaded',function(){
|
||||||
render();
|
render();
|
||||||
|
labelModelOptions();
|
||||||
|
watchModelOptions('modelSelect');
|
||||||
|
watchModelOptions('settingsModel');
|
||||||
const chip=document.getElementById('composerRoutingChip');
|
const chip=document.getElementById('composerRoutingChip');
|
||||||
if(chip) chip.addEventListener('click',toggle);
|
if(chip) chip.addEventListener('click',toggle);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -60,6 +60,10 @@ spec:
|
|||||||
value: "512"
|
value: "512"
|
||||||
- name: JETSON_JETPACK
|
- name: JETSON_JETPACK
|
||||||
value: "5"
|
value: "5"
|
||||||
|
- name: OLLAMA_LLM_LIBRARY
|
||||||
|
value: cuda_jetpack5
|
||||||
|
- name: LD_LIBRARY_PATH
|
||||||
|
value: /lib/ollama:/lib/ollama/cuda_jetpack5:/usr/lib/aarch64-linux-gnu/tegra
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
@ -120,6 +124,10 @@ spec:
|
|||||||
value: compute,utility
|
value: compute,utility
|
||||||
- name: JETSON_JETPACK
|
- name: JETSON_JETPACK
|
||||||
value: "5"
|
value: "5"
|
||||||
|
- name: OLLAMA_LLM_LIBRARY
|
||||||
|
value: cuda_jetpack5
|
||||||
|
- name: LD_LIBRARY_PATH
|
||||||
|
value: /lib/ollama:/lib/ollama/cuda_jetpack5:/usr/lib/aarch64-linux-gnu/tegra
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
@ -9,29 +9,28 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
model:
|
model:
|
||||||
provider: openai-codex
|
provider: atlas-switchyard
|
||||||
default: gpt-5.6-terra
|
default: atlas/auto/maximum
|
||||||
model: gpt-5.6-terra
|
model: atlas/auto/maximum
|
||||||
# Reuse the owner's authenticated Codex CLI instead of maintaining a
|
|
||||||
# second rotating OAuth token in Hermes' provider store.
|
|
||||||
openai_runtime: codex_app_server
|
|
||||||
|
|
||||||
fallback_providers:
|
providers:
|
||||||
- provider: anthropic
|
atlas-switchyard:
|
||||||
model: claude-sonnet-5
|
name: Atlas Switchyard
|
||||||
- provider: custom
|
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||||
model: qwen2.5:14b-instruct-q4_0
|
api_key: atlas-switchyard
|
||||||
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
|
default_model: atlas/auto/maximum
|
||||||
api_key: ollama
|
transport: chat_completions
|
||||||
|
|
||||||
|
fallback_providers: []
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
api_max_retries: 1
|
api_max_retries: 1
|
||||||
# The coordinator supervises native children and durable CLI workers;
|
# The coordinator supervises native children and durable CLI workers;
|
||||||
# give it enough room to inspect, steer, review, and synthesize.
|
# give it enough room to inspect, steer, review, and synthesize.
|
||||||
max_turns: 180
|
max_turns: 180
|
||||||
# This is the fail-safe when the router is unavailable. AUTO normally
|
# Switchyard independently classifies every user, internal, delegated,
|
||||||
# classifies every user, internal, delegated, and durable-worker turn.
|
# and durable-worker boundary. Explicit UI effort is forwarded as an
|
||||||
reasoning_effort: high
|
# override; AUTO leaves effort unset so the selected target owns it.
|
||||||
|
|
||||||
delegation:
|
delegation:
|
||||||
# Native Hermes owns decomposition and fan-out. Every child is routed
|
# Native Hermes owns decomposition and fan-out. Every child is routed
|
||||||
@ -160,8 +159,9 @@ data:
|
|||||||
the initial project. Use native Hermes delegation as the normal planning,
|
the initial project. Use native Hermes delegation as the normal planning,
|
||||||
fan-out, and synthesis path. Durable real Codex and Claude Code CLI work is
|
fan-out, and synthesis path. Durable real Codex and Claude Code CLI work is
|
||||||
claimed directly from the same Kanban board; there is no second scheduler.
|
claimed directly from the same Kanban board; there is no second scheduler.
|
||||||
You remain responsible for planning, routing, fallback, review, and the
|
You remain responsible for planning, decomposition, review, and the final
|
||||||
final synthesized answer.
|
synthesized answer. Switchyard is the sole authority for provider, model,
|
||||||
|
effort, and capacity failover at every model-call boundary.
|
||||||
|
|
||||||
Prefer Codex for implementation, debugging, test loops, and focused repo
|
Prefer Codex for implementation, debugging, test loops, and focused repo
|
||||||
changes. Prefer Claude Code for architecture, long-context investigation,
|
changes. Prefer Claude Code for architecture, long-context investigation,
|
||||||
@ -183,10 +183,11 @@ data:
|
|||||||
tasks when an objective benefits from persistent Codex or Claude Code CLI
|
tasks when an objective benefits from persistent Codex or Claude Code CLI
|
||||||
execution that survives browser disconnects and can resume after restarts.
|
execution that survives browser disconnects and can resume after restarts.
|
||||||
|
|
||||||
Local Jetson inference is the first provider-independent fallback. Use it
|
The Jetson classifier is mandatory for AUTO selection. Switchyard may use
|
||||||
for bounded classification, summaries, and continuity when hosted capacity
|
local Qwen for bounded low-risk responses and continuity, or spill to a
|
||||||
is constrained. Do not silently treat a local fallback as equivalent to a
|
hosted provider when local capability is insufficient. Do not describe a
|
||||||
high-risk xhigh review; disclose the downgrade and preserve the task.
|
local response as equivalent to a high-risk xhigh review; preserve the
|
||||||
|
task and make any downgrade visible in routing evidence.
|
||||||
AGENTS.md: |
|
AGENTS.md: |
|
||||||
# Hermes project coordinator
|
# Hermes project coordinator
|
||||||
|
|
||||||
@ -198,9 +199,9 @@ data:
|
|||||||
## Difficulty routing
|
## Difficulty routing
|
||||||
|
|
||||||
The coordinator starts in `/route auto`. AUTO classifies every user task
|
The coordinator starts in `/route auto`. AUTO classifies every user task
|
||||||
before inference, uses the Jetson routing model when it answers within the
|
before inference and always uses the Jetson routing model; it never skips
|
||||||
latency budget, and falls back to deterministic policy without delaying the
|
classification because the prompt looks simple. `/route status` explains
|
||||||
conversation. `/route status` explains the live selection. Use
|
the public route contract. Use
|
||||||
`/route manual <codex|claude> <low|medium|high|xhigh> [model]` for a
|
`/route manual <codex|claude> <low|medium|high|xhigh> [model]` for a
|
||||||
persistent manual override, and `/route auto` to return control to Hermes.
|
persistent manual override, and `/route auto` to return control to Hermes.
|
||||||
|
|
||||||
@ -211,11 +212,13 @@ data:
|
|||||||
critical final review. `xhigh` is the hard maximum; never request max or
|
critical final review. `xhigh` is the hard maximum; never request max or
|
||||||
ultracode.
|
ultracode.
|
||||||
|
|
||||||
Read `/opt/data/workspace/coordinator/model-routing.json` before naming a
|
`/opt/data/workspace/coordinator/model-routing.json` is catalog and health
|
||||||
model. The hourly steward discovers the models currently available to both
|
evidence, not a routing control plane. The hourly steward discovers the
|
||||||
accounts and preserves the last working route during catalog outages.
|
models currently available to both accounts, preserves its last known-good
|
||||||
Profiles are `codex-{low,medium,high,xhigh}` and
|
catalog during outages, and keeps every generated profile on a public
|
||||||
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`.
|
Switchyard route. Profiles are `codex-{low,medium,high,xhigh}` and
|
||||||
|
`claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`; selecting one is a
|
||||||
|
Switchyard constraint and never a direct provider bypass.
|
||||||
|
|
||||||
## Decomposition and delegation
|
## Decomposition and delegation
|
||||||
|
|
||||||
@ -224,20 +227,20 @@ data:
|
|||||||
referenced plan from recent context, identify bounded leaf tasks and their
|
referenced plan from recent context, identify bounded leaf tasks and their
|
||||||
dependencies, then use `delegate_task` for independent leaves. Run only
|
dependencies, then use `delegate_task` for independent leaves. Run only
|
||||||
dependency-free leaves in parallel. Each native child and every nested
|
dependency-free leaves in parallel. Each native child and every nested
|
||||||
child is independently classified by the Jetson before its first model
|
child and each subsequent internal continuation is independently routed by
|
||||||
request, so cheap leaves may use low effort while difficult or risky leaves
|
Switchyard using the Jetson classifier, so cheap leaves may use low effort
|
||||||
are raised to high or xhigh. Verify and synthesize all child evidence in the
|
while difficult or risky leaves are raised to high or xhigh. Verify and
|
||||||
foreground coordinator. Do not delegate a one-tool mechanical action merely
|
synthesize all child evidence in the foreground coordinator. Do not
|
||||||
to create an agent.
|
delegate a one-tool mechanical action merely to create an agent.
|
||||||
|
|
||||||
For persistent real Codex or Claude Code CLI work, create a bounded Kanban
|
For persistent real Codex or Claude Code CLI work, create a bounded Kanban
|
||||||
worktree task assigned to `cli-auto`. The direct lane reserves the task atomically,
|
worktree task assigned to `cli-auto`. The direct lane reserves the task atomically,
|
||||||
sends every start/retry/continuation boundary through the Jetson classifier,
|
sends every start/retry/continuation boundary through Switchyard and its
|
||||||
records provider/model/effort and session identifiers on the task, streams
|
Jetson classifier, records provider/model/effort and session identifiers on the task, streams
|
||||||
logs into the task worker log, and resumes the provider session after a pod
|
logs into the task worker log, and resumes the provider session after a pod
|
||||||
restart. Manual lanes are `cli-codex-{low,medium,high,xhigh}` and
|
restart. Manual lanes are `cli-codex-{low,medium,high,xhigh}` and
|
||||||
`cli-claude-{low,medium,high,xhigh}`; they still call the Jetson for the audit
|
`cli-claude-{low,medium,high,xhigh}`; those manual constraints are still
|
||||||
record, then apply the explicit override. Observe workers through Kanban and
|
enforced and recorded by Switchyard. Observe workers through Kanban and
|
||||||
the dashboard session/task lists, not terminal panes. If Codex reports its first-use
|
the dashboard session/task lists, not terminal panes. If Codex reports its first-use
|
||||||
login requirement, run `codex login --device-auth` once in `/terminal/` and
|
login requirement, run `codex login --device-auth` once in `/terminal/` and
|
||||||
ask Brad to complete the displayed code.
|
ask Brad to complete the displayed code.
|
||||||
@ -262,9 +265,10 @@ data:
|
|||||||
dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban,
|
dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban,
|
||||||
skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the
|
skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the
|
||||||
raw full-screen Hermes TUI. Give Hermes
|
raw full-screen Hermes TUI. Give Hermes
|
||||||
the outcome you want and it will decompose dependent work, classify every
|
the outcome you want and it will decompose dependent work, route every
|
||||||
delegated leaf on the Jetson, choose Codex or Claude, preserve the task on
|
model-call boundary through Switchyard and its Jetson classifier, choose
|
||||||
the Cassandra board, and synthesize the evidence. Persistent real Codex and
|
local Qwen, Codex, or Claude as appropriate, preserve the task on the
|
||||||
|
Cassandra board, and synthesize the evidence. Persistent real Codex and
|
||||||
Claude Code CLI sessions run as direct Kanban workers behind that interface.
|
Claude Code CLI sessions run as direct Kanban workers behind that interface.
|
||||||
Use `/route status` to inspect the current decision, `/route auto`
|
Use `/route status` to inspect the current decision, `/route auto`
|
||||||
for automatic routing, or `/route manual <codex|claude>
|
for automatic routing, or `/route manual <codex|claude>
|
||||||
|
|||||||
@ -180,7 +180,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 32Mi}
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
limits: {cpu: 100m, memory: 64Mi}
|
limits: {cpu: 100m, memory: 64Mi}
|
||||||
- name: install-agent-tools
|
- name: install-agent-tools
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
@ -228,7 +228,7 @@ spec:
|
|||||||
requests: {cpu: 100m, memory: 256Mi}
|
requests: {cpu: 100m, memory: 256Mi}
|
||||||
limits: {cpu: "1", memory: 1Gi}
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
- name: patch-auth
|
- name: patch-auth
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -251,7 +251,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 64Mi}
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
limits: {cpu: 100m, memory: 128Mi}
|
limits: {cpu: 100m, memory: 128Mi}
|
||||||
- name: patch-tui-gateway
|
- name: patch-tui-gateway
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -274,7 +274,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 64Mi}
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
limits: {cpu: 100m, memory: 128Mi}
|
limits: {cpu: 100m, memory: 128Mi}
|
||||||
- name: patch-codex-runtime
|
- name: patch-codex-runtime
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -307,7 +307,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 64Mi}
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
limits: {cpu: 100m, memory: 128Mi}
|
limits: {cpu: 100m, memory: 128Mi}
|
||||||
- name: bootstrap-coordinator
|
- name: bootstrap-coordinator
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -320,6 +320,7 @@ spec:
|
|||||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
|
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
||||||
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@ -332,11 +333,12 @@ spec:
|
|||||||
- {name: provider-auth, mountPath: /shared-auth}
|
- {name: provider-auth, mountPath: /shared-auth}
|
||||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||||
|
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 50m, memory: 128Mi}
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
limits: {cpu: 500m, memory: 512Mi}
|
limits: {cpu: 500m, memory: 512Mi}
|
||||||
- name: configure-agent-clients
|
- name: configure-agent-clients
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
@ -358,6 +360,7 @@ spec:
|
|||||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
|
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
||||||
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@ -370,11 +373,12 @@ spec:
|
|||||||
- {name: provider-auth, mountPath: /shared-auth}
|
- {name: provider-auth, mountPath: /shared-auth}
|
||||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||||
|
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 25m, memory: 32Mi}
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
limits: {cpu: 250m, memory: 128Mi}
|
limits: {cpu: 250m, memory: 128Mi}
|
||||||
- name: prepare-ttyd-index
|
- name: prepare-ttyd-index
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -396,7 +400,7 @@ spec:
|
|||||||
limits: {cpu: 250m, memory: 128Mi}
|
limits: {cpu: 250m, memory: 128Mi}
|
||||||
containers:
|
containers:
|
||||||
- name: hermes
|
- name: hermes
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/init, /opt/hermes/docker/main-wrapper.sh]
|
command: [/init, /opt/hermes/docker/main-wrapper.sh]
|
||||||
args: [gateway, run]
|
args: [gateway, run]
|
||||||
@ -532,7 +536,7 @@ spec:
|
|||||||
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
|
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
|
||||||
- {name: oauth-tmp, mountPath: /tmp}
|
- {name: oauth-tmp, mountPath: /tmp}
|
||||||
- name: terminal
|
- name: terminal
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
@ -626,7 +630,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 64Mi}
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
limits: {cpu: 500m, memory: 512Mi}
|
limits: {cpu: 500m, memory: 512Mi}
|
||||||
- name: cli-lane-runner
|
- name: cli-lane-runner
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
@ -668,7 +672,7 @@ spec:
|
|||||||
requests: {cpu: 100m, memory: 256Mi}
|
requests: {cpu: 100m, memory: 256Mi}
|
||||||
limits: {cpu: "3", memory: 6Gi}
|
limits: {cpu: "3", memory: 6Gi}
|
||||||
- name: model-steward
|
- name: model-steward
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"]
|
command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"]
|
||||||
env:
|
env:
|
||||||
@ -678,6 +682,7 @@ spec:
|
|||||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
|
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
||||||
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@ -690,11 +695,12 @@ spec:
|
|||||||
- {name: provider-auth, mountPath: /shared-auth}
|
- {name: provider-auth, mountPath: /shared-auth}
|
||||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||||
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
|
||||||
|
- {name: routing-catalog, mountPath: /routing-catalog}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 25m, memory: 64Mi}
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
limits: {cpu: 250m, memory: 512Mi}
|
limits: {cpu: 250m, memory: 512Mi}
|
||||||
- name: image-broker
|
- name: image-broker
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
@ -744,7 +750,7 @@ spec:
|
|||||||
requests: {cpu: 50m, memory: 128Mi}
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
limits: {cpu: "1", memory: 1Gi}
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
- name: codex-broker
|
- name: codex-broker
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
@ -762,6 +768,7 @@ spec:
|
|||||||
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
- {name: CODEX_HOME, value: /opt/data/home/.codex}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
|
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
|
||||||
|
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
tcpSocket: {port: codex-broker}
|
tcpSocket: {port: codex-broker}
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
@ -786,6 +793,7 @@ spec:
|
|||||||
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||||
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
|
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
|
||||||
resources:
|
resources:
|
||||||
requests: {cpu: 50m, memory: 128Mi}
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
limits: {cpu: "1", memory: 1Gi}
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
@ -796,6 +804,9 @@ spec:
|
|||||||
- name: provider-auth
|
- name: provider-auth
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: hermes-provider-auth
|
claimName: hermes-provider-auth
|
||||||
|
- name: routing-catalog
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: hermes-routing-catalog
|
||||||
- name: config
|
- name: config
|
||||||
configMap:
|
configMap:
|
||||||
name: hermes-agent-config
|
name: hermes-agent-config
|
||||||
|
|||||||
@ -9,27 +9,20 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
model:
|
model:
|
||||||
provider: atlas-codex
|
provider: atlas-switchyard
|
||||||
default: gpt-5.6-terra
|
default: atlas/auto/fast
|
||||||
model: gpt-5.6-terra
|
model: atlas/auto/fast
|
||||||
providers:
|
providers:
|
||||||
atlas-codex:
|
atlas-switchyard:
|
||||||
name: Atlas Codex
|
name: Automatic Router
|
||||||
api: http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1
|
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||||
key_env: HERMES_IMAGE_BROKER_KEY
|
api_key: atlas-switchyard
|
||||||
default_model: gpt-5.6-terra
|
default_model: atlas/auto/fast
|
||||||
transport: codex_responses
|
transport: chat_completions
|
||||||
fallback_providers:
|
fallback_providers: []
|
||||||
- provider: anthropic
|
|
||||||
model: claude-sonnet-5
|
|
||||||
- provider: custom
|
|
||||||
model: qwen2.5:14b-instruct-q4_0
|
|
||||||
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
|
|
||||||
api_key: ollama
|
|
||||||
agent:
|
agent:
|
||||||
api_max_retries: 2
|
api_max_retries: 2
|
||||||
max_turns: 120
|
max_turns: 120
|
||||||
reasoning_effort: high
|
|
||||||
delegation:
|
delegation:
|
||||||
max_concurrent_children: 2
|
max_concurrent_children: 2
|
||||||
max_iterations: 80
|
max_iterations: 80
|
||||||
@ -62,9 +55,17 @@ data:
|
|||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
model_routes:
|
model_routes:
|
||||||
gpt-5.6-terra:
|
atlas/auto/fast: {provider: atlas-switchyard, model: atlas/auto/fast}
|
||||||
provider: atlas-codex
|
atlas/auto/balanced: {provider: atlas-switchyard, model: atlas/auto/balanced}
|
||||||
model: gpt-5.6-terra
|
atlas/auto/deep: {provider: atlas-switchyard, model: atlas/auto/deep}
|
||||||
|
atlas/auto/maximum: {provider: atlas-switchyard, model: atlas/auto/maximum}
|
||||||
|
atlas/manual/codex/luna: {provider: atlas-switchyard, model: atlas/manual/codex/luna}
|
||||||
|
atlas/manual/codex/terra: {provider: atlas-switchyard, model: atlas/manual/codex/terra}
|
||||||
|
atlas/manual/codex/sol: {provider: atlas-switchyard, model: atlas/manual/codex/sol}
|
||||||
|
atlas/manual/claude/haiku: {provider: atlas-switchyard, model: atlas/manual/claude/haiku}
|
||||||
|
atlas/manual/claude/sonnet: {provider: atlas-switchyard, model: atlas/manual/claude/sonnet}
|
||||||
|
atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
|
||||||
|
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}
|
||||||
dashboard:
|
dashboard:
|
||||||
public_url: https://chat.hermes.bstein.dev
|
public_url: https://chat.hermes.bstein.dev
|
||||||
display:
|
display:
|
||||||
|
|||||||
@ -158,7 +158,7 @@ spec:
|
|||||||
requests: {cpu: 25m, memory: 32Mi}
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
limits: {cpu: 100m, memory: 64Mi}
|
limits: {cpu: 100m, memory: 64Mi}
|
||||||
- name: patch-auth
|
- name: patch-auth
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -179,7 +179,7 @@ spec:
|
|||||||
limits: {cpu: 100m, memory: 128Mi}
|
limits: {cpu: 100m, memory: 128Mi}
|
||||||
containers:
|
containers:
|
||||||
- name: hermes
|
- name: hermes
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
@ -239,7 +239,7 @@ spec:
|
|||||||
requests: {cpu: 250m, memory: 512Mi}
|
requests: {cpu: 250m, memory: 512Mi}
|
||||||
limits: {cpu: "1", memory: 2Gi}
|
limits: {cpu: "1", memory: 2Gi}
|
||||||
- name: webui
|
- name: webui
|
||||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5
|
image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
|
|||||||
@ -9,23 +9,23 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
model:
|
model:
|
||||||
provider: anthropic
|
provider: atlas-switchyard
|
||||||
default: claude-opus-5
|
default: atlas/auto/deep
|
||||||
model: claude-opus-5
|
model: atlas/auto/deep
|
||||||
|
|
||||||
fallback_providers:
|
providers:
|
||||||
- provider: openai-codex
|
atlas-switchyard:
|
||||||
model: gpt-5.6-terra
|
name: Atlas Switchyard
|
||||||
- provider: custom
|
api: http://hermes-switchyard.hermes.svc.cluster.local:9005/v1
|
||||||
model: qwen2.5:14b-instruct-q4_0
|
api_key: atlas-switchyard
|
||||||
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
|
default_model: atlas/auto/deep
|
||||||
api_key: ollama
|
transport: chat_completions
|
||||||
|
|
||||||
|
fallback_providers: []
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
api_max_retries: 1
|
api_max_retries: 1
|
||||||
# A static high default is the fail-safe if routing is unavailable. In
|
# AUTO leaves effort unset so Switchyard's selected target owns it.
|
||||||
# AUTO, the Jetson classifier independently selects every turn.
|
|
||||||
reasoning_effort: high
|
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
enabled:
|
enabled:
|
||||||
|
|||||||
@ -186,7 +186,7 @@ spec:
|
|||||||
cpu: 100m
|
cpu: 100m
|
||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
- name: patch-auth
|
- name: patch-auth
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /opt/hermes/.venv/bin/python
|
- /opt/hermes/.venv/bin/python
|
||||||
@ -238,7 +238,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
containers:
|
containers:
|
||||||
- name: hermes
|
- name: hermes
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:70e19a30a1d8a1e2d7bf29d41b0fae46cbdff7a7ce0b656d50b265d8497a1cf1
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/opt/hermes/.venv/bin/hermes]
|
command: [/opt/hermes/.venv/bin/hermes]
|
||||||
args:
|
args:
|
||||||
@ -351,7 +351,7 @@ spec:
|
|||||||
cpu: "2"
|
cpu: "2"
|
||||||
memory: 4Gi
|
memory: 4Gi
|
||||||
- name: webui
|
- name: webui
|
||||||
image: registry.bstein.dev/bstein/hermes-webui@sha256:8391f7545e953d354d6c093d5fec40233759cc7449f7a32597fcc2aaa76f30d5
|
image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: [/bin/sh, -ec]
|
command: [/bin/sh, -ec]
|
||||||
args:
|
args:
|
||||||
|
|||||||
@ -8,9 +8,12 @@ resources:
|
|||||||
- configmap.yaml
|
- configmap.yaml
|
||||||
- agent-configmap.yaml
|
- agent-configmap.yaml
|
||||||
- chat-configmap.yaml
|
- chat-configmap.yaml
|
||||||
|
- switchyard-configmap.yaml
|
||||||
- rbac.yaml
|
- rbac.yaml
|
||||||
- agent-rbac.yaml
|
- agent-rbac.yaml
|
||||||
- pvc.yaml
|
- pvc.yaml
|
||||||
|
- switchyard-pvc.yaml
|
||||||
|
- routing-catalog-pvc.yaml
|
||||||
- chat-pvcs.yaml
|
- chat-pvcs.yaml
|
||||||
- oauth-session-store.yaml
|
- oauth-session-store.yaml
|
||||||
- model-gate-rbac.yaml
|
- model-gate-rbac.yaml
|
||||||
@ -19,6 +22,7 @@ resources:
|
|||||||
- model-gate-state.yaml
|
- model-gate-state.yaml
|
||||||
- model-gate-configmap.yaml
|
- model-gate-configmap.yaml
|
||||||
- model-gate-deployment.yaml
|
- model-gate-deployment.yaml
|
||||||
|
- switchyard-deployment.yaml
|
||||||
- image-policy-configmap.yaml
|
- image-policy-configmap.yaml
|
||||||
- networkpolicy.yaml
|
- networkpolicy.yaml
|
||||||
- local-image-deployment.yaml
|
- local-image-deployment.yaml
|
||||||
@ -29,6 +33,7 @@ resources:
|
|||||||
- chat-sandbox.yaml
|
- chat-sandbox.yaml
|
||||||
- chat-router.yaml
|
- chat-router.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
|
- switchyard-service.yaml
|
||||||
- oauth2-proxy.yaml
|
- oauth2-proxy.yaml
|
||||||
- agent-certificate.yaml
|
- agent-certificate.yaml
|
||||||
- agent-ingress.yaml
|
- agent-ingress.yaml
|
||||||
@ -55,6 +60,8 @@ configMapGenerator:
|
|||||||
- codex=scripts/codex
|
- codex=scripts/codex
|
||||||
- configure_agent_clients.py=scripts/configure_agent_clients.py
|
- configure_agent_clients.py=scripts/configure_agent_clients.py
|
||||||
- codex_broker.py=scripts/codex_broker.py
|
- codex_broker.py=scripts/codex_broker.py
|
||||||
|
- claude_oauth_broker.py=scripts/claude_oauth_broker.py
|
||||||
|
- worker_route_broker.py=scripts/worker_route_broker.py
|
||||||
- gitea_askpass.sh=scripts/gitea_askpass.sh
|
- gitea_askpass.sh=scripts/gitea_askpass.sh
|
||||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||||
@ -66,6 +73,7 @@ configMapGenerator:
|
|||||||
- patch_codex_runtime.py=scripts/patch_codex_runtime.py
|
- patch_codex_runtime.py=scripts/patch_codex_runtime.py
|
||||||
- patch_tui_gateway.py=scripts/patch_tui_gateway.py
|
- patch_tui_gateway.py=scripts/patch_tui_gateway.py
|
||||||
- patch_ttyd_index.py=scripts/patch_ttyd_index.py
|
- patch_ttyd_index.py=scripts/patch_ttyd_index.py
|
||||||
|
- routing_catalog.py=scripts/routing_catalog.py
|
||||||
options:
|
options:
|
||||||
disableNameSuffixHash: true
|
disableNameSuffixHash: true
|
||||||
- name: hermes-agent-kubeconfig
|
- name: hermes-agent-kubeconfig
|
||||||
|
|||||||
@ -52,7 +52,7 @@ data:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_reasoning(body: bytes | None) -> bytes | None:
|
def _normalize_reasoning(body: bytes | None) -> bytes | None:
|
||||||
"""Clamp hosted-only effort names to the local server's supported tier."""
|
"""Translate routed aliases and clamp effort to the local server tier."""
|
||||||
|
|
||||||
if not body:
|
if not body:
|
||||||
return body
|
return body
|
||||||
@ -64,6 +64,10 @@ data:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
|
model = payload.get("model")
|
||||||
|
if isinstance(model, str) and model.startswith("route/local/qwen2.5-14b/"):
|
||||||
|
payload["model"] = "qwen2.5:14b-instruct-q4_0"
|
||||||
|
changed = True
|
||||||
for key in ("reasoning_effort", "reasoning"):
|
for key in ("reasoning_effort", "reasoning"):
|
||||||
value = payload.get(key)
|
value = payload.get(key)
|
||||||
if isinstance(value, str) and value.lower() in {"xhigh", "max"}:
|
if isinstance(value, str) and value.lower() in {"xhigh", "max"}:
|
||||||
|
|||||||
@ -36,7 +36,7 @@ spec:
|
|||||||
matchExpressions:
|
matchExpressions:
|
||||||
- key: app
|
- key: app
|
||||||
operator: In
|
operator: In
|
||||||
values: [hermes, hermes-agent, hermes-chat-tenant]
|
values: [hermes, hermes-agent, hermes-chat-tenant, hermes-switchyard]
|
||||||
ports:
|
ports:
|
||||||
- {protocol: TCP, port: 8080}
|
- {protocol: TCP, port: 8080}
|
||||||
- from:
|
- from:
|
||||||
@ -103,6 +103,12 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- {protocol: TCP, port: 9002}
|
- {protocol: TCP, port: 9002}
|
||||||
- {protocol: TCP, port: 9003}
|
- {protocol: TCP, port: 9003}
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-switchyard
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9003}
|
||||||
# agent.hermes.bstein.dev is an owner-only engineering workstation. The
|
# agent.hermes.bstein.dev is an owner-only engineering workstation. The
|
||||||
# browser boundary remains OAuth-protected, while its workers need to reach
|
# browser boundary remains OAuth-protected, while its workers need to reach
|
||||||
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
|
# every cluster namespace, Atlas LAN service, and hosted provider endpoint.
|
||||||
@ -251,6 +257,12 @@ spec:
|
|||||||
app: hermes-model-gate
|
app: hermes-model-gate
|
||||||
ports:
|
ports:
|
||||||
- {protocol: TCP, port: 8080}
|
- {protocol: TCP, port: 8080}
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-switchyard
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9005}
|
||||||
- to:
|
- to:
|
||||||
- podSelector:
|
- podSelector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
@ -286,6 +298,70 @@ spec:
|
|||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: NetworkPolicy
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: hermes-switchyard-isolation
|
||||||
|
namespace: hermes
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-switchyard
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app
|
||||||
|
operator: In
|
||||||
|
values: [hermes, hermes-agent, hermes-chat-tenant]
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9005}
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
k8s-app: kube-dns
|
||||||
|
ports:
|
||||||
|
- {protocol: UDP, port: 53}
|
||||||
|
- {protocol: TCP, port: 53}
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: ai
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: ollama
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 11434}
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-model-gate
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 8080}
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: hermes-agent
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 9003}
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 0.0.0.0/0
|
||||||
|
except:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 100.64.0.0/10
|
||||||
|
- 127.0.0.0/8
|
||||||
|
- 169.254.0.0/16
|
||||||
|
- 172.16.0.0/12
|
||||||
|
- 192.168.0.0/16
|
||||||
|
ports:
|
||||||
|
- {protocol: TCP, port: 443}
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
metadata:
|
metadata:
|
||||||
name: hermes-chat-router-isolation
|
name: hermes-chat-router-isolation
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -14,6 +13,8 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -21,8 +22,10 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
|
|
||||||
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||||
ROUTING_PATH = DATA_ROOT / "workspace/coordinator/model-routing.json"
|
SWITCHYARD_URL = os.environ.get(
|
||||||
ROUTER_PATH = DATA_ROOT / "plugins/auto-router/__init__.py"
|
"HERMES_SWITCHYARD_URL",
|
||||||
|
"http://hermes-switchyard.hermes.svc.cluster.local:9005/v1/chat/completions",
|
||||||
|
)
|
||||||
STATE_ROOT = DATA_ROOT / "cli-lanes"
|
STATE_ROOT = DATA_ROOT / "cli-lanes"
|
||||||
CODEX_BIN = DATA_ROOT / "tools/bin/codex"
|
CODEX_BIN = DATA_ROOT / "tools/bin/codex"
|
||||||
CLAUDE_BIN = DATA_ROOT / "tools/bin/claude"
|
CLAUDE_BIN = DATA_ROOT / "tools/bin/claude"
|
||||||
@ -102,17 +105,6 @@ def load_json(path: Path) -> dict[str, Any]:
|
|||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _load_router(path: Path = ROUTER_PATH) -> Any:
|
|
||||||
"""Load the same Jetson-first classifier used by interactive Hermes."""
|
|
||||||
spec = importlib.util.spec_from_file_location("hermes_cli_lane_router", path)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
raise RuntimeError(f"AUTO router could not be loaded: {path}")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
|
def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
|
||||||
"""Parse external lane overrides while leaving cli-auto fully automatic."""
|
"""Parse external lane overrides while leaving cli-auto fully automatic."""
|
||||||
value = str(assignee or "").strip().lower()
|
value = str(assignee or "").strip().lower()
|
||||||
@ -124,63 +116,75 @@ def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
|
|||||||
return match.group(1), match.group(2)
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
|
||||||
def _split_route(value: str) -> tuple[str, str]:
|
def _decode_worker_target(value: str) -> tuple[str, str, str]:
|
||||||
provider, separator, model = value.partition("/")
|
"""Decode the selected model header emitted by a worker decision route."""
|
||||||
if not separator or not provider or not model:
|
parts = value.split("/", 3)
|
||||||
raise RuntimeError(f"invalid managed route: {value}")
|
if len(parts) != 4 or parts[0] != "worker":
|
||||||
return provider, model
|
raise RuntimeError(f"invalid Switchyard worker target: {value}")
|
||||||
|
provider, model, effort = parts[1:]
|
||||||
|
if provider not in {"codex", "claude"} or effort not in EFFORTS:
|
||||||
def _worker_provider(provider: str) -> str | None:
|
raise RuntimeError(f"unsupported Switchyard worker target: {value}")
|
||||||
return {"openai-codex": "codex", "anthropic": "claude"}.get(provider)
|
return provider, model, effort
|
||||||
|
|
||||||
|
|
||||||
def select_route(
|
def select_route(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
assignee: str,
|
assignee: str,
|
||||||
*,
|
*,
|
||||||
routing_path: Path = ROUTING_PATH,
|
|
||||||
router_path: Path = ROUTER_PATH,
|
|
||||||
exclude_provider: str | None = None,
|
exclude_provider: str | None = None,
|
||||||
|
switchyard_url: str = SWITCHYARD_URL,
|
||||||
|
open_request: Callable[..., Any] = urllib.request.urlopen,
|
||||||
) -> Route:
|
) -> Route:
|
||||||
"""Always consult the Jetson, then apply a deliberate lane override if set."""
|
"""Ask Switchyard to select one native CLI worker at this boundary."""
|
||||||
router = _load_router(router_path)
|
started = time.monotonic()
|
||||||
decision = router.classify_task(prompt)
|
|
||||||
manual_provider, manual_effort = parse_assignee(assignee)
|
manual_provider, manual_effort = parse_assignee(assignee)
|
||||||
selected_provider = manual_provider or str(decision.provider)
|
if manual_provider and manual_effort:
|
||||||
effort = manual_effort or str(decision.effort)
|
route_id = f"atlas/worker/manual/{manual_provider}/{manual_effort}"
|
||||||
if effort not in EFFORTS:
|
source = "switchyard-manual"
|
||||||
raise RuntimeError(f"classifier returned unsupported effort: {effort}")
|
else:
|
||||||
if exclude_provider == selected_provider:
|
route_id = "atlas/worker/auto/maximum"
|
||||||
selected_provider = "claude" if selected_provider == "codex" else "codex"
|
source = "switchyard-classifier"
|
||||||
status = load_json(routing_path)
|
context = prompt
|
||||||
profile = f"{selected_provider}-{effort}"
|
if exclude_provider:
|
||||||
chain = (status.get("routes") or {}).get(profile)
|
context += (
|
||||||
if not isinstance(chain, list) or not chain:
|
f"\n\nRouting constraint: the {exclude_provider} provider failed or "
|
||||||
raise RuntimeError(f"managed route unavailable: {profile}")
|
"exhausted capacity at this boundary. Do not select it."
|
||||||
hosted = [str(item) for item in chain if _worker_provider(_split_route(str(item))[0])]
|
)
|
||||||
if not hosted:
|
payload = json.dumps(
|
||||||
raise RuntimeError(f"no hosted CLI route available: {profile}")
|
{
|
||||||
first = next(
|
"model": route_id,
|
||||||
(item for item in hosted if _worker_provider(_split_route(item)[0]) == selected_provider),
|
"messages": [{"role": "user", "content": context}],
|
||||||
hosted[0],
|
"stream": False,
|
||||||
|
"max_tokens": 1,
|
||||||
|
}
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
switchyard_url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
)
|
)
|
||||||
provider_key, model = _split_route(first)
|
try:
|
||||||
actual_provider = _worker_provider(provider_key)
|
with open_request(request, timeout=60) as response:
|
||||||
if actual_provider is None:
|
selected = str(response.headers.get("x-model-router-selected-model") or "")
|
||||||
raise RuntimeError(f"route is not a CLI provider: {first}")
|
rationale = str(response.headers.get("x-model-router-rationale") or "")
|
||||||
|
response.read()
|
||||||
|
except (OSError, urllib.error.URLError) as exc:
|
||||||
|
raise RuntimeError(f"Switchyard worker routing failed: {exc}") from exc
|
||||||
|
provider, model, effort = _decode_worker_target(selected)
|
||||||
|
if exclude_provider and provider == exclude_provider:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Switchyard selected excluded provider {exclude_provider} for retry"
|
||||||
|
)
|
||||||
return Route(
|
return Route(
|
||||||
provider=actual_provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
effort=effort,
|
effort=effort,
|
||||||
profile=f"{actual_provider}-{effort}",
|
profile=f"{provider}-{effort}",
|
||||||
classifier=str(decision.classifier),
|
classifier=source,
|
||||||
reason=(
|
reason=rationale or f"Switchyard selected {selected}",
|
||||||
str(decision.reason)
|
latency_ms=int((time.monotonic() - started) * 1000),
|
||||||
+ ("; manual lane override applied after Jetson classification" if manual_provider else "")
|
fallback_chain=(),
|
||||||
),
|
|
||||||
latency_ms=int(decision.latency_ms),
|
|
||||||
fallback_chain=tuple(item for item in hosted if item != first),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,8 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from routing_catalog import resolve_route
|
||||||
|
|
||||||
|
|
||||||
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
|
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
|
||||||
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
|
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
|
||||||
@ -25,7 +27,7 @@ UPSTREAM = os.environ.get(
|
|||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20)))
|
MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20)))
|
||||||
READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900"))
|
READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900"))
|
||||||
ALLOWED_MODELS = {
|
FALLBACK_ALLOWED_MODELS = {
|
||||||
value.strip()
|
value.strip()
|
||||||
for value in os.environ.get(
|
for value in os.environ.get(
|
||||||
"HERMES_CODEX_BROKER_MODELS",
|
"HERMES_CODEX_BROKER_MODELS",
|
||||||
@ -33,6 +35,14 @@ ALLOWED_MODELS = {
|
|||||||
).split(",")
|
).split(",")
|
||||||
if value.strip()
|
if value.strip()
|
||||||
}
|
}
|
||||||
|
ROUTED_MODEL_PREFIX = "route/codex/"
|
||||||
|
|
||||||
|
|
||||||
|
def _real_model(model: str) -> str:
|
||||||
|
"""Translate a Switchyard effort-qualified target into a Codex model."""
|
||||||
|
if model.startswith(ROUTED_MODEL_PREFIX):
|
||||||
|
model = resolve_route(model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _authorized(header: str | None) -> bool:
|
def _authorized(header: str | None) -> bool:
|
||||||
@ -89,8 +99,12 @@ def _validate_payload(payload: Any) -> dict[str, Any]:
|
|||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("JSON object required")
|
raise ValueError("JSON object required")
|
||||||
model = payload.get("model")
|
model = payload.get("model")
|
||||||
if not isinstance(model, str) or model not in ALLOWED_MODELS:
|
if not isinstance(model, str):
|
||||||
raise ValueError("unsupported Codex model")
|
raise ValueError("unsupported Codex model")
|
||||||
|
model = _real_model(model)
|
||||||
|
if not model.startswith("gpt-"):
|
||||||
|
raise ValueError("unsupported Codex model")
|
||||||
|
payload["model"] = model
|
||||||
# Tenant conversations must not enter the owner's server-side history.
|
# Tenant conversations must not enter the owner's server-side history.
|
||||||
payload["store"] = False
|
payload["store"] = False
|
||||||
payload["stream"] = True
|
payload["stream"] = True
|
||||||
@ -135,7 +149,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"object": "list",
|
"object": "list",
|
||||||
"data": [
|
"data": [
|
||||||
{"id": model, "object": "model", "owned_by": "openai-codex"}
|
{"id": model, "object": "model", "owned_by": "openai-codex"}
|
||||||
for model in sorted(ALLOWED_MODELS)
|
for model in sorted(FALLBACK_ALLOWED_MODELS)
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,10 +4,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
@ -26,6 +28,10 @@ ATLAS_FALLBACK = {
|
|||||||
}
|
}
|
||||||
# Backwards-compatible name used by the focused unit tests and status tooling.
|
# Backwards-compatible name used by the focused unit tests and status tooling.
|
||||||
LOCAL_FALLBACK = ATLAS_FALLBACK
|
LOCAL_FALLBACK = ATLAS_FALLBACK
|
||||||
|
SWITCHYARD_PROVIDER = "atlas-switchyard"
|
||||||
|
SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1"
|
||||||
|
SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum"
|
||||||
|
ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip()
|
||||||
MANAGED_ENV_KEYS = {
|
MANAGED_ENV_KEYS = {
|
||||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
"GITEA_TOKEN",
|
"GITEA_TOKEN",
|
||||||
@ -215,6 +221,113 @@ def _write_yaml(path: Path, value: dict[str, Any], mode: int | None = None) -> b
|
|||||||
return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode)
|
return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
"""Read a JSON mapping without treating a partial write as valid state."""
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_provider_models(
|
||||||
|
previous: dict[str, Any], provider: str
|
||||||
|
) -> tuple[dict[str, str], dict[str, str], list[str]]:
|
||||||
|
"""Return last-known-good effort, tier, and catalog values for a provider."""
|
||||||
|
providers = previous.get("providers", {})
|
||||||
|
record = providers.get(provider, {}) if isinstance(providers, dict) else {}
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return {}, {}, []
|
||||||
|
resolved = record.get("resolved", {})
|
||||||
|
tiers = record.get("tiers", {})
|
||||||
|
models = record.get("models", [])
|
||||||
|
return (
|
||||||
|
dict(resolved) if isinstance(resolved, dict) else {},
|
||||||
|
dict(tiers) if isinstance(tiers, dict) else {},
|
||||||
|
_unique_models(models if isinstance(models, list) else []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_routing_catalog(
|
||||||
|
codex: Catalog, claude: Catalog, previous: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a current catalog while retaining working routes during outages."""
|
||||||
|
previous = previous or {}
|
||||||
|
providers: dict[str, Any] = {}
|
||||||
|
specifications = (
|
||||||
|
(
|
||||||
|
"codex",
|
||||||
|
codex,
|
||||||
|
choose_codex_for_effort,
|
||||||
|
{"luna": "low", "terra": "medium", "sol": "high"},
|
||||||
|
{
|
||||||
|
"low": "gpt-5.6-luna",
|
||||||
|
"medium": "gpt-5.6-terra",
|
||||||
|
"high": "gpt-5.6-sol",
|
||||||
|
"xhigh": "gpt-5.6-sol",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude",
|
||||||
|
claude,
|
||||||
|
choose_claude_for_effort,
|
||||||
|
{"haiku": "low", "sonnet": "medium", "opus": "xhigh"},
|
||||||
|
{
|
||||||
|
"low": "claude-haiku-4-5-20251001",
|
||||||
|
"medium": "claude-sonnet-5",
|
||||||
|
"high": "claude-opus-5",
|
||||||
|
"xhigh": "claude-opus-5",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, discovered, chooser, tier_efforts, defaults in specifications:
|
||||||
|
old_resolved, old_tiers, old_models = _previous_provider_models(previous, name)
|
||||||
|
source_models = discovered.models if discovered.live else old_models
|
||||||
|
resolved: dict[str, str] = {}
|
||||||
|
for effort in EFFORTS:
|
||||||
|
current = str(old_resolved.get(effort) or defaults[effort])
|
||||||
|
resolved[effort] = (
|
||||||
|
chooser(source_models, effort, current)
|
||||||
|
if source_models
|
||||||
|
else current
|
||||||
|
)
|
||||||
|
tiers: dict[str, str] = {}
|
||||||
|
for tier, effort in tier_efforts.items():
|
||||||
|
current = str(old_tiers.get(tier) or defaults[effort])
|
||||||
|
tier_matches = [model for model in source_models if tier in model.lower()]
|
||||||
|
tiers[tier] = (
|
||||||
|
max(tier_matches, key=lambda model: (model_version(model), model))
|
||||||
|
if tier_matches
|
||||||
|
else (resolved[effort] if discovered.live else current)
|
||||||
|
)
|
||||||
|
providers[name] = {
|
||||||
|
"state": discovered.state,
|
||||||
|
"connected": discovered.connected,
|
||||||
|
"live": discovered.live,
|
||||||
|
"models": _unique_models(
|
||||||
|
discovered.models
|
||||||
|
if discovered.live
|
||||||
|
else (old_models or discovered.models)
|
||||||
|
),
|
||||||
|
"resolved": resolved,
|
||||||
|
"tiers": tiers,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"updated_at": int(time.time()),
|
||||||
|
"providers": providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_routing_catalog(
|
||||||
|
path: Path, codex: Catalog, claude: Catalog
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically publish the catalog consumed by hosted and worker brokers."""
|
||||||
|
catalog = build_routing_catalog(codex, claude, _read_json(path))
|
||||||
|
_atomic_write(path, json.dumps(catalog, indent=2, sort_keys=True) + "\n", 0o644)
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
def _read_env(path: Path) -> dict[str, str]:
|
def _read_env(path: Path) -> dict[str, str]:
|
||||||
"""Read the small dotenv subset used by Hermes provider credentials."""
|
"""Read the small dotenv subset used by Hermes provider credentials."""
|
||||||
values: dict[str, str] = {}
|
values: dict[str, str] = {}
|
||||||
@ -381,6 +494,36 @@ def _profile_config(
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _switchyard_profile_config(
|
||||||
|
base: dict[str, Any], route: str, effort: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Derive a profile that cannot bypass the Switchyard authority."""
|
||||||
|
config = copy.deepcopy(base)
|
||||||
|
providers = config.setdefault("providers", {})
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
providers = {}
|
||||||
|
config["providers"] = providers
|
||||||
|
providers[SWITCHYARD_PROVIDER] = {
|
||||||
|
"name": "Atlas Switchyard",
|
||||||
|
"api": SWITCHYARD_API,
|
||||||
|
"api_key": "atlas-switchyard",
|
||||||
|
"default_model": route,
|
||||||
|
"transport": "chat_completions",
|
||||||
|
}
|
||||||
|
config["model"] = {
|
||||||
|
"provider": SWITCHYARD_PROVIDER,
|
||||||
|
"default": route,
|
||||||
|
"model": route,
|
||||||
|
}
|
||||||
|
config["fallback_providers"] = []
|
||||||
|
config["model_catalog"] = {"enabled": True, "ttl_hours": 1}
|
||||||
|
agent = config.setdefault("agent", {})
|
||||||
|
if isinstance(agent, dict):
|
||||||
|
agent["reasoning_effort"] = effort
|
||||||
|
config["toolsets"] = []
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _write_profile(
|
def _write_profile(
|
||||||
root: Path,
|
root: Path,
|
||||||
name: str,
|
name: str,
|
||||||
@ -402,50 +545,33 @@ def _write_profile(
|
|||||||
_update_profile_env(profile / ".env", env_values)
|
_update_profile_env(profile / ".env", env_values)
|
||||||
|
|
||||||
|
|
||||||
def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, Any]:
|
def configure_routes(
|
||||||
"""Update the coordinator and managed worker profiles."""
|
root: Path,
|
||||||
|
codex: Catalog,
|
||||||
|
claude: Catalog,
|
||||||
|
catalog_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Refresh catalogs while keeping every Hermes profile on Switchyard."""
|
||||||
config_path = root / "config.yaml"
|
config_path = root / "config.yaml"
|
||||||
base = _read_yaml(config_path)
|
base = _read_yaml(config_path)
|
||||||
old_coordinator = _existing_model(base, "openai-codex", CODEX_BASELINE)
|
resolved_catalog_path = catalog_path or (
|
||||||
old_claude_root = _existing_model(base, "anthropic", CLAUDE_BASELINE)
|
Path(ROUTING_CATALOG_PATH)
|
||||||
codex_models: dict[str, str] = {}
|
if ROUTING_CATALOG_PATH
|
||||||
claude_models: dict[str, str] = {}
|
else root / "routing-catalog.json"
|
||||||
for effort in EFFORTS:
|
)
|
||||||
old_codex = _existing_model(
|
catalog = write_routing_catalog(resolved_catalog_path, codex, claude)
|
||||||
_read_yaml(root / "profiles" / f"codex-{effort}" / "config.yaml"),
|
providers = catalog["providers"]
|
||||||
"openai-codex",
|
codex_models = providers["codex"]["resolved"]
|
||||||
old_coordinator,
|
claude_models = providers["claude"]["resolved"]
|
||||||
)
|
|
||||||
old_claude = _existing_model(
|
|
||||||
_read_yaml(root / "profiles" / f"claude-{effort}" / "config.yaml"),
|
|
||||||
"anthropic",
|
|
||||||
old_claude_root,
|
|
||||||
)
|
|
||||||
codex_models[effort] = (
|
|
||||||
choose_codex_for_effort(codex.models, effort, old_codex)
|
|
||||||
if codex.live
|
|
||||||
else old_codex
|
|
||||||
)
|
|
||||||
claude_models[effort] = (
|
|
||||||
choose_claude_for_effort(claude.models, effort, old_claude)
|
|
||||||
if claude.live
|
|
||||||
else old_claude
|
|
||||||
)
|
|
||||||
|
|
||||||
codex_coordinator = codex_models["medium"]
|
coordinator_toolsets = copy.deepcopy(base.get("toolsets"))
|
||||||
claude_coordinator = claude_models["medium"]
|
base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high")
|
||||||
|
# The coordinator uses its configured toolsets. Worker profiles below are
|
||||||
base["model"] = {
|
# deliberately toolset-empty so Hermes resolves their native defaults.
|
||||||
"provider": "openai-codex",
|
if coordinator_toolsets is None:
|
||||||
"default": codex_coordinator,
|
base.pop("toolsets", None)
|
||||||
"model": codex_coordinator,
|
else:
|
||||||
"openai_runtime": "codex_app_server",
|
base["toolsets"] = coordinator_toolsets
|
||||||
}
|
|
||||||
base["fallback_providers"] = [
|
|
||||||
{"provider": "anthropic", "model": claude_coordinator},
|
|
||||||
copy.deepcopy(ATLAS_FALLBACK),
|
|
||||||
]
|
|
||||||
base["model_catalog"] = {"enabled": True, "ttl_hours": 1}
|
|
||||||
_write_yaml(config_path, base)
|
_write_yaml(config_path, base)
|
||||||
|
|
||||||
env_values = _read_env(root / ".env")
|
env_values = _read_env(root / ".env")
|
||||||
@ -455,73 +581,56 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
|
|||||||
claude_model = claude_models[effort]
|
claude_model = claude_models[effort]
|
||||||
codex_name = f"codex-{effort}"
|
codex_name = f"codex-{effort}"
|
||||||
claude_name = f"claude-{effort}"
|
claude_name = f"claude-{effort}"
|
||||||
|
codex_route = {
|
||||||
|
"low": "atlas/manual/codex/luna",
|
||||||
|
"medium": "atlas/manual/codex/terra",
|
||||||
|
"high": "atlas/manual/codex/sol",
|
||||||
|
"xhigh": "atlas/manual/codex/sol",
|
||||||
|
}[effort]
|
||||||
|
claude_route = {
|
||||||
|
"low": "atlas/manual/claude/haiku",
|
||||||
|
"medium": "atlas/manual/claude/sonnet",
|
||||||
|
"high": "atlas/manual/claude/sonnet",
|
||||||
|
"xhigh": "atlas/manual/claude/opus",
|
||||||
|
}[effort]
|
||||||
_write_profile(
|
_write_profile(
|
||||||
root,
|
root,
|
||||||
codex_name,
|
codex_name,
|
||||||
f"Codex implementation worker at {effort} effort, with Claude and local fallback.",
|
f"Codex implementation preference at {effort} effort, enforced by Switchyard.",
|
||||||
"You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.",
|
"You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.",
|
||||||
_profile_config(
|
_switchyard_profile_config(base, codex_route, effort),
|
||||||
base,
|
|
||||||
"openai-codex",
|
|
||||||
codex_model,
|
|
||||||
{"provider": "anthropic", "model": claude_model},
|
|
||||||
effort,
|
|
||||||
),
|
|
||||||
env_values,
|
env_values,
|
||||||
)
|
)
|
||||||
_write_profile(
|
_write_profile(
|
||||||
root,
|
root,
|
||||||
claude_name,
|
claude_name,
|
||||||
f"Claude architecture and review worker at {effort} effort, with Codex and local fallback.",
|
f"Claude analysis preference at {effort} effort, enforced by Switchyard.",
|
||||||
"You are an architecture and review worker. Analyze the assigned task deeply, change files only when asked, and return concise conclusions, evidence, and risks to the coordinator.",
|
"You are an architecture and review worker. Analyze the assigned task deeply, change files only when asked, and return concise conclusions, evidence, and risks to the coordinator.",
|
||||||
_profile_config(
|
_switchyard_profile_config(base, claude_route, effort),
|
||||||
base,
|
|
||||||
"anthropic",
|
|
||||||
claude_model,
|
|
||||||
{"provider": "openai-codex", "model": codex_model},
|
|
||||||
effort,
|
|
||||||
),
|
|
||||||
env_values,
|
env_values,
|
||||||
)
|
)
|
||||||
local = [] if effort == "xhigh" else ["custom/qwen2.5:14b-instruct-q4_0"]
|
routes[codex_name] = [codex_route]
|
||||||
routes[codex_name] = [
|
routes[claude_name] = [claude_route]
|
||||||
f"openai-codex/{codex_model}",
|
|
||||||
f"anthropic/{claude_model}",
|
|
||||||
*local,
|
|
||||||
]
|
|
||||||
routes[claude_name] = [
|
|
||||||
f"anthropic/{claude_model}",
|
|
||||||
f"openai-codex/{codex_model}",
|
|
||||||
*local,
|
|
||||||
]
|
|
||||||
|
|
||||||
_write_profile(
|
_write_profile(
|
||||||
root,
|
root,
|
||||||
"synthesis-xhigh",
|
"synthesis-xhigh",
|
||||||
"Cross-provider synthesis and critical review, capped at xhigh effort.",
|
"Cross-provider synthesis and critical review, capped at xhigh effort.",
|
||||||
"Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
|
"Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.",
|
||||||
_profile_config(
|
_switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"),
|
||||||
base,
|
|
||||||
"anthropic",
|
|
||||||
claude_models["xhigh"],
|
|
||||||
{"provider": "openai-codex", "model": codex_models["xhigh"]},
|
|
||||||
"xhigh",
|
|
||||||
),
|
|
||||||
env_values,
|
env_values,
|
||||||
)
|
)
|
||||||
routes["synthesis-xhigh"] = [
|
routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE]
|
||||||
f"anthropic/{claude_models['xhigh']}",
|
|
||||||
f"openai-codex/{codex_models['xhigh']}",
|
|
||||||
]
|
|
||||||
_write_yaml(
|
_write_yaml(
|
||||||
root / "profile.yaml",
|
root / "profile.yaml",
|
||||||
{
|
{
|
||||||
"description": "Coordinator for project objectives, delegating implementation to Codex and architecture or review to Claude through isolated Kanban boards.",
|
"description": "Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries.",
|
||||||
"description_auto": False,
|
"description_auto": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
routes["coordinator"] = [
|
routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE]
|
||||||
f"openai-codex/{codex_coordinator}",
|
routes["catalog"] = [
|
||||||
f"anthropic/{claude_coordinator}",
|
*(f"openai-codex/{model}" for model in codex_models.values()),
|
||||||
|
*(f"anthropic/{model}" for model in claude_models.values()),
|
||||||
]
|
]
|
||||||
return routes
|
return routes
|
||||||
|
|||||||
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
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: hermes-switchyard
|
||||||
|
namespace: hermes
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: hermes-chat
|
name: hermes-chat
|
||||||
namespace: hermes
|
namespace: hermes
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: vault-k8s-auth-hermes-5
|
name: vault-k8s-auth-hermes-6
|
||||||
namespace: vault
|
namespace: vault
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
|
|||||||
@ -255,7 +255,7 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
|
|||||||
"game-stream/*" ""
|
"game-stream/*" ""
|
||||||
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
|
||||||
"hermes/triage-oidc hermes/agent-tokens" ""
|
"hermes/triage-oidc hermes/agent-tokens" ""
|
||||||
write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \
|
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
|
||||||
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram" ""
|
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram" ""
|
||||||
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
|
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
|
||||||
"hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" ""
|
"hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" ""
|
||||||
|
|||||||
@ -1,18 +1,15 @@
|
|||||||
"""Contracts for Agent Hermes automatic route selection."""
|
"""Contracts for the thin Hermes-to-Switchyard boundary adapter."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
SOURCE = (
|
SOURCE = Path(__file__).parents[2] / "services/hermes/plugins/auto-router/__init__.py"
|
||||||
Path(__file__).parents[2]
|
|
||||||
/ "services/hermes/plugins/auto-router/__init__.py"
|
|
||||||
)
|
|
||||||
SPEC = importlib.util.spec_from_file_location("hermes_auto_router", SOURCE)
|
SPEC = importlib.util.spec_from_file_location("hermes_auto_router", SOURCE)
|
||||||
assert SPEC and SPEC.loader
|
assert SPEC and SPEC.loader
|
||||||
router = importlib.util.module_from_spec(SPEC)
|
router = importlib.util.module_from_spec(SPEC)
|
||||||
@ -20,784 +17,139 @@ sys.modules[SPEC.name] = router
|
|||||||
SPEC.loader.exec_module(router)
|
SPEC.loader.exec_module(router)
|
||||||
|
|
||||||
|
|
||||||
def _status() -> dict:
|
def _agent(**overrides):
|
||||||
return {
|
values = {
|
||||||
"providers": {
|
"provider": router.SWITCHYARD_PROVIDER,
|
||||||
"openai-codex": {"connected": True},
|
"model": "atlas/auto/maximum",
|
||||||
"anthropic": {"connected": True},
|
"base_url": "http://hermes-switchyard:9005/v1",
|
||||||
},
|
"api_key": "atlas-switchyard",
|
||||||
"routes": {
|
"api_mode": "chat_completions",
|
||||||
"codex-low": [
|
"reasoning_config": {"effort": "high"},
|
||||||
"openai-codex/gpt-5.6-luna",
|
"_fallback_chain": [{"provider": "anthropic"}],
|
||||||
"anthropic/claude-haiku-4-5-20251001",
|
"_fallback_index": 1,
|
||||||
],
|
"_fallback_activated": True,
|
||||||
"codex-medium": [
|
"_fallback_model": {"provider": "anthropic"},
|
||||||
"openai-codex/gpt-5.6-terra",
|
"_hermes_explicit_model_pick": False,
|
||||||
"anthropic/claude-sonnet-5",
|
"_hermes_explicit_reasoning_effort": "",
|
||||||
],
|
"_hermes_routing_priority": "",
|
||||||
"claude-medium": [
|
|
||||||
"anthropic/claude-sonnet-5",
|
|
||||||
"openai-codex/gpt-5.6-terra",
|
|
||||||
],
|
|
||||||
"claude-xhigh": [
|
|
||||||
"anthropic/claude-opus-5",
|
|
||||||
"openai-codex/gpt-5.6-sol",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
def test_heuristics_keep_simple_questions_cheap_and_risky_work_capped():
|
def test_profile_defaults_are_distinct_and_quality_ordered():
|
||||||
simple = router.heuristic_decision("Who is the current provider?")
|
assert router.PROFILE_ROUTE == {
|
||||||
risky = router.heuristic_decision("Migrate production Vault credentials safely")
|
"chat": "atlas/auto/fast",
|
||||||
|
"triage": "atlas/auto/deep",
|
||||||
assert (simple.shape, simple.effort, simple.provider) == (
|
"agent": "atlas/auto/maximum",
|
||||||
"question",
|
}
|
||||||
"low",
|
assert router.PROFILE_ROUTE[router.ROUTER_PROFILE] in router.AUTO_ROUTES
|
||||||
"codex",
|
|
||||||
)
|
|
||||||
assert (risky.shape, risky.effort, risky.provider) == (
|
|
||||||
"review",
|
|
||||||
"xhigh",
|
|
||||||
"claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_jetson_selects_provider_while_deterministic_policy_preserves_work_shape(
|
def test_auto_boundary_selects_only_a_public_switchyard_route(tmp_path, monkeypatch):
|
||||||
monkeypatch,
|
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||||
|
agent = _agent()
|
||||||
|
|
||||||
|
route, effort, source = router._boundary_selection(agent)
|
||||||
|
|
||||||
|
assert route == router.PROFILE_ROUTE[router.ROUTER_PROFILE]
|
||||||
|
assert effort == ""
|
||||||
|
assert source == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_priority_changes_auto_posture_without_selecting_a_target(
|
||||||
|
tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||||
router,
|
agent = _agent(_hermes_routing_priority="deep")
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "high", "claude", "jetson", "test", 42
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
decision = router.classify_task("Implement and test the new API handler")
|
route, effort, source = router._boundary_selection(agent)
|
||||||
|
|
||||||
assert decision.shape == "implementation"
|
assert (route, effort, source) == ("atlas/auto/deep", "", "ui-auto")
|
||||||
assert decision.provider == "claude"
|
assert route in router.AUTO_ROUTES
|
||||||
assert decision.effort == "high"
|
|
||||||
assert decision.classifier == "jetson"
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_uses_managed_models_and_connected_provider_fallback():
|
def test_ui_manual_model_and_effort_are_forwarded_as_constraints(
|
||||||
status = _status()
|
tmp_path, monkeypatch
|
||||||
decision = router.Decision(
|
|
||||||
"question", "low", "codex", "heuristic", "short question"
|
|
||||||
)
|
|
||||||
plan = router.select_route(status, decision)
|
|
||||||
assert plan["profile"] == "codex-low"
|
|
||||||
assert plan["model"] == "gpt-5.6-luna"
|
|
||||||
|
|
||||||
status["providers"]["openai-codex"]["connected"] = False
|
|
||||||
status["routes"]["claude-low"] = [
|
|
||||||
"anthropic/claude-haiku-4-5-20251001",
|
|
||||||
"openai-codex/gpt-5.6-luna",
|
|
||||||
]
|
|
||||||
fallback = router.select_route(status, decision)
|
|
||||||
assert fallback["profile"] == "claude-low"
|
|
||||||
assert fallback["provider"] == "anthropic"
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_circuit_breaker_skips_recently_failed_provider():
|
|
||||||
status = _status()
|
|
||||||
status["routes"]["codex-medium"] = [
|
|
||||||
"openai-codex/gpt-5.6-terra",
|
|
||||||
"anthropic/claude-sonnet-5",
|
|
||||||
]
|
|
||||||
decision = router.Decision(
|
|
||||||
"question", "medium", "claude", "jetson", "local vote"
|
|
||||||
)
|
|
||||||
policy = {
|
|
||||||
"mode": "auto",
|
|
||||||
"provider_cooldowns": {
|
|
||||||
"anthropic": {"until_epoch": router.time.time() + 300}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
plan = router.select_route(status, decision, policy=policy)
|
|
||||||
|
|
||||||
assert plan["profile"] == "codex-medium"
|
|
||||||
assert plan["provider"] == "openai-codex"
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_classifier_accepts_only_bounded_route_decisions():
|
|
||||||
assert router._validated_local_route("?", "?", "?", 1) is None
|
|
||||||
decision = router._validated_local_route("A", "H", "D", 1)
|
|
||||||
assert decision is not None
|
|
||||||
assert (decision.shape, decision.provider, decision.effort, decision.priority) == (
|
|
||||||
"question",
|
|
||||||
"claude",
|
|
||||||
"high",
|
|
||||||
"deep",
|
|
||||||
)
|
|
||||||
assert router._validated_local_route("C", "M", "?", 1) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_structured_vote_requires_a_complete_bounded_json_object():
|
|
||||||
assert router._parse_route_vote(
|
|
||||||
'{"provider":"C","effort":"M","priority":"F"}'
|
|
||||||
) == ("C", "M", "F")
|
|
||||||
assert router._parse_route_vote('"A"') is None
|
|
||||||
assert router._parse_route_vote(
|
|
||||||
'{"provider":"codex","effort":"M","priority":"F"}'
|
|
||||||
) is None
|
|
||||||
assert router._parse_route_vote(
|
|
||||||
'{"provider":"A","effort":"H"}'
|
|
||||||
) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_jetson_requests_one_structured_provider_effort_priority_vote(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def structured(text, timeout):
|
|
||||||
calls.append((text, timeout))
|
|
||||||
return (("A", "H", "D"), 12)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "_jetson_route", structured)
|
|
||||||
|
|
||||||
decision = router.jetson_decision("Review the architecture")
|
|
||||||
|
|
||||||
assert (
|
|
||||||
decision.provider,
|
|
||||||
decision.effort,
|
|
||||||
decision.priority,
|
|
||||||
decision.latency_ms,
|
|
||||||
) == (
|
|
||||||
"claude",
|
|
||||||
"high",
|
|
||||||
"deep",
|
|
||||||
12,
|
|
||||||
)
|
|
||||||
assert calls == [("Review the architecture", 2.5)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_requests_use_numeric_keep_alive(monkeypatch):
|
|
||||||
payloads = []
|
|
||||||
|
|
||||||
def urlopen(request, timeout):
|
|
||||||
payloads.append(json.loads(request.data))
|
|
||||||
if request.full_url.endswith("/chat"):
|
|
||||||
body = {
|
|
||||||
"message": {
|
|
||||||
"content": '{"provider":"C","effort":"L","priority":"F"}'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
body = {"response": "P"}
|
|
||||||
return io.BytesIO(json.dumps(body).encode())
|
|
||||||
|
|
||||||
monkeypatch.setattr(router.urllib.request, "urlopen", urlopen)
|
|
||||||
|
|
||||||
vote, _ = router._jetson_route("Answer quickly", 2.5)
|
|
||||||
assert vote == ("C", "L", "F")
|
|
||||||
assert payloads[0]["keep_alive"] == -1
|
|
||||||
|
|
||||||
router._classifier_warm_lock.acquire()
|
|
||||||
router._rewarm_classifier()
|
|
||||||
assert payloads[1]["keep_alive"] == -1
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_auto_classification_consults_jetson_and_keeps_safety_floors(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def classify(text):
|
|
||||||
calls.append(text)
|
|
||||||
return router.Decision("question", "low", "codex", "jetson", "test", 5)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "jetson_decision", classify)
|
|
||||||
|
|
||||||
simple = router.classify_task("Who is the provider?")
|
|
||||||
risky = router.classify_task("Migrate production Vault credentials")
|
|
||||||
|
|
||||||
assert len(calls) == 2
|
|
||||||
assert (simple.effort, simple.provider) == ("low", "codex")
|
|
||||||
assert (risky.shape, risky.effort, risky.provider) == (
|
|
||||||
"review",
|
|
||||||
"xhigh",
|
|
||||||
"claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_trivial_prompt_respects_balanced_jetson_effort(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def classify(text):
|
|
||||||
calls.append(text)
|
|
||||||
return router.Decision("question", "medium", "codex", "jetson", "test", 5)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "jetson_decision", classify)
|
|
||||||
|
|
||||||
decision = router.classify_task(
|
|
||||||
"Reply with exactly ROUTE_SMOKE_OK. Do not call tools."
|
|
||||||
)
|
|
||||||
|
|
||||||
assert calls == ["Reply with exactly ROUTE_SMOKE_OK. Do not call tools."]
|
|
||||||
assert (decision.effort, decision.provider, decision.classifier) == (
|
|
||||||
"medium",
|
|
||||||
"codex",
|
|
||||||
"jetson",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_speed_priority_reduces_speculative_depth_but_not_safety(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "high", "codex", "jetson", "test", 5, "fast"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
simple = router.classify_task("Give me a concise status summary.")
|
|
||||||
risky = router.classify_task("Quickly migrate production Vault credentials.")
|
|
||||||
|
|
||||||
assert simple.effort == "medium"
|
|
||||||
assert risky.effort == "xhigh"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ui_priority_changes_quality_posture_without_crossing_safety_floor(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "low", "codex", "jetson", "test", 5, "balanced"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
maximum = router.classify_task(
|
|
||||||
"Give me the current status.", priority_override="maximum"
|
|
||||||
)
|
|
||||||
fast_risky = router.classify_task(
|
|
||||||
"Delete production Vault credentials.", priority_override="fast"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (maximum.priority, maximum.effort, maximum.classifier) == (
|
|
||||||
"maximum",
|
|
||||||
"high",
|
|
||||||
"ui-jetson",
|
|
||||||
)
|
|
||||||
assert (fast_risky.priority, fast_risky.effort, fast_risky.provider) == (
|
|
||||||
"fast",
|
|
||||||
"xhigh",
|
|
||||||
"claude",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_service_fallback_postures_favor_chat_speed_and_agent_quality(monkeypatch):
|
|
||||||
monkeypatch.setattr(router, "jetson_decision", lambda text: None)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "ROUTER_PROFILE", "chat")
|
|
||||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
|
||||||
chat = router.classify_task("What time is dinner?")
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "ROUTER_PROFILE", "triage")
|
|
||||||
monkeypatch.setattr(router, "CHAT_MODE", False)
|
|
||||||
triage = router.classify_task("Summarize the failed health check.")
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "ROUTER_PROFILE", "agent")
|
|
||||||
agent = router.classify_task("Explain this helper function.")
|
|
||||||
|
|
||||||
assert (chat.priority, chat.effort, chat.provider) == ("fast", "low", "local")
|
|
||||||
assert (triage.priority, triage.effort) == ("deep", "medium")
|
|
||||||
assert (agent.priority, agent.effort) == ("maximum", "high")
|
|
||||||
|
|
||||||
|
|
||||||
def test_trivial_prompt_can_still_escalate_on_strong_jetson_signal(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "high", "claude", "jetson", "test", 5
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
decision = router.classify_task("Check this.")
|
|
||||||
|
|
||||||
assert (decision.effort, decision.provider) == ("high", "claude")
|
|
||||||
|
|
||||||
|
|
||||||
def test_architecture_and_review_fail_upward_to_claude(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "low", "codex", "jetson", "test", 5
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
architecture = router.classify_task("Design the service architecture")
|
|
||||||
review = router.classify_task("Review this change for regressions")
|
|
||||||
|
|
||||||
assert (architecture.provider, architecture.effort) == ("claude", "medium")
|
|
||||||
assert (review.provider, review.effort) == ("claude", "medium")
|
|
||||||
|
|
||||||
|
|
||||||
def test_referential_outstanding_work_never_uses_low_route(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"jetson_decision",
|
|
||||||
lambda text: router.Decision(
|
|
||||||
"question", "low", "codex", "jetson", "test", 10
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
decision = router.classify_task(
|
|
||||||
"Look, loop through all of the still outstanding work that you identified. "
|
|
||||||
"Do it to the best of your abilities."
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (decision.shape, decision.effort, decision.provider) == (
|
|
||||||
"implementation",
|
|
||||||
"high",
|
|
||||||
"codex",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_referential_followup_uses_recent_context_for_risk_floor(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def classify(text):
|
|
||||||
calls.append(text)
|
|
||||||
return router.Decision("question", "low", "codex", "jetson", "test", 5)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "jetson_decision", classify)
|
|
||||||
history = [
|
|
||||||
{"role": "user", "content": "Is the work complete?"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": (
|
|
||||||
"Still outstanding: production deployment retry, provider "
|
|
||||||
"switching, runtime experiments, and a full repository test pass."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
decision = router.classify_task(
|
|
||||||
"Loop through all outstanding work and finish it.", history
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (decision.shape, decision.effort, decision.provider) == (
|
|
||||||
"review",
|
|
||||||
"xhigh",
|
|
||||||
"claude",
|
|
||||||
)
|
|
||||||
assert decision.classifier == "jetson-context"
|
|
||||||
assert "recent assistant context" in decision.reason
|
|
||||||
assert len(calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_internal_prompt_contains_objective_tools_and_results():
|
|
||||||
text = router._internal_task_text(
|
|
||||||
"Finish the production deployment safely",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "I will inspect the failed rollout.",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"function": {
|
|
||||||
"name": "terminal",
|
|
||||||
"arguments": '{"command":"kubectl get pods"}',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "content": "deployment is degraded"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "Finish the production deployment safely" in text
|
|
||||||
assert "planned tool terminal" in text
|
|
||||||
assert "deployment is degraded" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_internal_auto_prompt_is_reclassified_and_applied(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: _status())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text: calls.append(text)
|
|
||||||
or router.Decision(
|
|
||||||
"question", "medium", "claude", "jetson", "test", 7
|
|
||||||
),
|
|
||||||
)
|
|
||||||
applied = []
|
|
||||||
recorded = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router, "_apply_route", lambda ctx, agent, plan: applied.append(plan)
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_record_internal_plan",
|
|
||||||
lambda policy, plan, count: recorded.append((plan, count)),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
provider = "openai-codex"
|
|
||||||
model = "gpt-5.6-luna"
|
|
||||||
reasoning_config = {"effort": "low"}
|
|
||||||
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
|
||||||
router._pre_internal_route(
|
|
||||||
object(),
|
|
||||||
agent=agent,
|
|
||||||
user_message="Continue",
|
|
||||||
conversation_history=[
|
|
||||||
{"role": "tool", "content": "The architecture review found a risk."}
|
|
||||||
],
|
|
||||||
api_call_count=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(calls) == 1
|
|
||||||
assert applied[0]["profile"] == "claude-medium"
|
|
||||||
assert applied[0]["classifier"] == "jetson-internal"
|
|
||||||
assert recorded[0][1] == 3
|
|
||||||
assert agent.message.startswith("AUTO internal #3")
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_route_audits_internal_prompt_without_overriding_user_choice(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_current_policy",
|
|
||||||
lambda: {
|
|
||||||
"mode": "manual",
|
|
||||||
"manual": {"provider": "claude", "effort": "medium", "model": ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: _status())
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text: calls.append(text)
|
|
||||||
or router.Decision("implementation", "xhigh", "codex", "jetson", "audit", 5),
|
|
||||||
)
|
|
||||||
plans = []
|
|
||||||
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
|
|
||||||
monkeypatch.setattr(router, "_record_internal_plan", lambda *args: None)
|
|
||||||
|
|
||||||
router._pre_internal_route(
|
|
||||||
object(),
|
|
||||||
agent=object(),
|
|
||||||
user_message="Continue",
|
|
||||||
conversation_history=[{"role": "tool", "content": "done"}],
|
|
||||||
api_call_count=2,
|
|
||||||
)
|
|
||||||
assert len(calls) == 1
|
|
||||||
assert plans[0]["profile"] == "claude-medium"
|
|
||||||
assert plans[0]["classifier"] == "manual-jetson-internal"
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_native_subagent_is_classified_and_routed_independently(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: _status())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text: calls.append(text)
|
|
||||||
or router.Decision(
|
|
||||||
"implementation", "medium", "codex", "jetson", "test", 9
|
|
||||||
),
|
|
||||||
)
|
|
||||||
applied = []
|
|
||||||
recorded = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router, "_apply_route", lambda ctx, agent, plan: applied.append((agent, plan))
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_record_subagent_plan",
|
|
||||||
lambda policy, plan, goal, index: recorded.append((plan, goal, index)),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Parent:
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
child = object()
|
|
||||||
parent = Parent()
|
|
||||||
router._pre_subagent_route(
|
|
||||||
object(),
|
|
||||||
agent=child,
|
|
||||||
parent_agent=parent,
|
|
||||||
goal="Implement the bounded parser fix",
|
|
||||||
context="Run the focused tests.",
|
|
||||||
task_index=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(calls) == 1
|
|
||||||
assert "Run the focused tests" in calls[0]
|
|
||||||
assert applied[0][0] is child
|
|
||||||
assert applied[0][1]["profile"] == "codex-medium"
|
|
||||||
assert applied[0][1]["classifier"] == "jetson-subagent"
|
|
||||||
assert recorded[0][1:] == ("Implement the bounded parser fix", 2)
|
|
||||||
assert parent.message.startswith("AUTO child #3")
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_route_audits_and_applies_override_to_native_subagent(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_current_policy",
|
|
||||||
lambda: {
|
|
||||||
"mode": "manual",
|
|
||||||
"manual": {"provider": "claude", "effort": "medium", "model": ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: _status())
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text: calls.append(text)
|
|
||||||
or router.Decision("implementation", "high", "codex", "jetson", "audit", 5),
|
|
||||||
)
|
|
||||||
plans = []
|
|
||||||
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
|
|
||||||
monkeypatch.setattr(router, "_record_subagent_plan", lambda *args: None)
|
|
||||||
|
|
||||||
router._pre_subagent_route(
|
|
||||||
object(), agent=object(), parent_agent=object(), goal="Review the diff"
|
|
||||||
)
|
|
||||||
assert len(calls) == 1
|
|
||||||
assert plans[0]["profile"] == "claude-medium"
|
|
||||||
assert plans[0]["classifier"] == "manual-jetson-subagent"
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_policy_is_reapplied_on_every_non_command_turn(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_current_policy",
|
|
||||||
lambda: {
|
|
||||||
"mode": "manual",
|
|
||||||
"manual": {"provider": "claude", "effort": "medium", "model": ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: _status())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text, history=None: router.Decision(
|
|
||||||
"implementation", "high", "codex", "jetson", "audit", 5
|
|
||||||
),
|
|
||||||
)
|
|
||||||
plans = []
|
|
||||||
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
|
|
||||||
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
|
||||||
router._pre_turn_route(object(), agent=agent, user_message="Continue the task")
|
|
||||||
|
|
||||||
assert plans[0]["profile"] == "claude-medium"
|
|
||||||
assert plans[0]["model"] == "claude-sonnet-5"
|
|
||||||
assert agent.message.startswith("MANUAL target")
|
|
||||||
assert plans[0]["classifier"] == "manual-jetson"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_exact_model_and_effort_remain_authoritative_after_jetson_audit(
|
|
||||||
monkeypatch,
|
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||||
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local"))
|
agent = _agent(
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
model="atlas/manual/claude/opus",
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: {})
|
_hermes_explicit_model_pick=True,
|
||||||
audits = []
|
_hermes_explicit_reasoning_effort="xhigh",
|
||||||
|
|
||||||
def classify(text, history=None, priority_override=""):
|
|
||||||
audits.append((text, priority_override))
|
|
||||||
return router.Decision(
|
|
||||||
"question", "low", "local", "jetson", "audit", 8, "fast"
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(router, "classify_task", classify)
|
|
||||||
plans = []
|
|
||||||
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
|
|
||||||
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
provider = "openai-codex"
|
|
||||||
model = "gpt-5.6-sol"
|
|
||||||
_hermes_routing_priority = "deep"
|
|
||||||
_hermes_explicit_model_pick = True
|
|
||||||
_hermes_explicit_reasoning_effort = "xhigh"
|
|
||||||
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
|
||||||
router._pre_turn_route(
|
|
||||||
object(), agent=agent, user_message="Review this answer carefully."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert audits == [("Review this answer carefully.", "deep")]
|
assert router._boundary_selection(agent) == (
|
||||||
assert plans[0]["provider"] == "atlas-codex"
|
"atlas/manual/claude/opus",
|
||||||
assert plans[0]["model"] == "gpt-5.6-sol"
|
"xhigh",
|
||||||
assert plans[0]["effort"] == "xhigh"
|
"ui-manual",
|
||||||
assert plans[0]["classifier"] == "manual-ui-jetson"
|
)
|
||||||
assert agent.message.startswith("MANUAL target")
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_natural_language_override_is_one_turn_and_keeps_jetson_audit(
|
def test_manual_command_persists_a_switchyard_route_not_a_direct_provider(
|
||||||
monkeypatch,
|
tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
path = tmp_path / "route-policy.json"
|
||||||
monkeypatch.setattr(router, "PROVIDERS", ("codex", "claude", "local"))
|
monkeypatch.setattr(router, "POLICY_PATH", path)
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: {"mode": "auto"})
|
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
|
||||||
monkeypatch.setattr(router, "_load_json", lambda path: {})
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"classify_task",
|
|
||||||
lambda text, history=None: calls.append(text)
|
|
||||||
or router.Decision("question", "low", "local", "jetson", "audit", 8),
|
|
||||||
)
|
|
||||||
plans = []
|
|
||||||
monkeypatch.setattr(router, "_apply_route", lambda ctx, agent, plan: plans.append(plan))
|
|
||||||
monkeypatch.setattr(router, "_record_plan", lambda policy, plan: None)
|
|
||||||
|
|
||||||
class Agent:
|
message = router._route_command(ctx, "manual codex xhigh sol")
|
||||||
def _emit_status(self, message):
|
policy = json.loads(path.read_text(encoding="utf-8"))
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
assert "Manual Switchyard constraint enabled" in message
|
||||||
router._pre_turn_route(
|
assert policy["manual"] == {
|
||||||
object(), agent=agent, user_message="Use Claude at xhigh for this answer."
|
"route": "atlas/manual/codex/sol",
|
||||||
)
|
"effort": "xhigh",
|
||||||
|
|
||||||
assert calls == ["Use Claude at xhigh for this answer."]
|
|
||||||
assert plans[0]["provider"] == "anthropic"
|
|
||||||
assert plans[0]["model"] == "claude-opus-5"
|
|
||||||
assert plans[0]["effort"] == "xhigh"
|
|
||||||
assert plans[0]["classifier"] == "explicit-jetson"
|
|
||||||
assert agent.message.startswith("USER target")
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_text_overrides_do_not_steal_image_provider_instructions(monkeypatch):
|
|
||||||
monkeypatch.setattr(router, "CHAT_MODE", True)
|
|
||||||
|
|
||||||
assert router._explicit_text_override("Use local image generation for this photo") is None
|
|
||||||
assert router._explicit_text_override("Generate this image with OpenAI") is None
|
|
||||||
assert router._explicit_text_override("Answer locally with the Qwen model") == (
|
|
||||||
"local",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
assert router._explicit_text_override("Ask Codex with high reasoning") == (
|
|
||||||
"codex",
|
|
||||||
"high",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_turn_records_and_announces_capacity_fallback(monkeypatch):
|
|
||||||
policy = {
|
|
||||||
"mode": "auto",
|
|
||||||
"last_decision": {
|
|
||||||
"provider": "anthropic",
|
|
||||||
"model": "claude-sonnet-5",
|
|
||||||
"effort": "medium",
|
|
||||||
"classifier": "jetson",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
written = []
|
assert "provider" not in policy["manual"]
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: policy)
|
|
||||||
monkeypatch.setattr(router, "_write_policy", lambda value: written.append(value))
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
provider = "openai-codex"
|
|
||||||
model = "gpt-5.6-terra"
|
|
||||||
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
|
||||||
cli = type("CLI", (), {"agent": agent})()
|
|
||||||
manager = type("Manager", (), {"_cli_ref": cli})()
|
|
||||||
ctx = type("Context", (), {"_manager": manager})()
|
|
||||||
|
|
||||||
router._post_turn_route(ctx, model="gpt-5.6-terra")
|
|
||||||
|
|
||||||
outcome = written[-1]["last_decision"]
|
|
||||||
assert outcome["fallback_used"] is True
|
|
||||||
assert outcome["actual_provider"] == "openai-codex"
|
|
||||||
assert outcome["actual_model"] == "gpt-5.6-terra"
|
|
||||||
assert written[-1]["provider_cooldowns"]["anthropic"]["until_epoch"] > router.time.time()
|
|
||||||
assert agent.message.startswith("FALLBACK USED")
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_turn_rewarms_classifier_after_local_chat(monkeypatch):
|
def test_effort_above_xhigh_is_rejected(tmp_path, monkeypatch):
|
||||||
policy = {
|
monkeypatch.setattr(router, "POLICY_PATH", tmp_path / "route-policy.json")
|
||||||
"mode": "auto",
|
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
|
||||||
"last_decision": {
|
|
||||||
"provider": "custom",
|
message = router._route_command(ctx, "manual claude max opus")
|
||||||
"model": "qwen2.5:14b-instruct-q4_0",
|
|
||||||
"effort": "low",
|
assert "Effort must be" in message
|
||||||
"classifier": "jetson",
|
|
||||||
},
|
|
||||||
|
def test_switchyard_owns_fallbacks_and_auto_clears_static_effort():
|
||||||
|
agent = _agent()
|
||||||
|
ctx = SimpleNamespace(_manager=SimpleNamespace(_cli_ref=None))
|
||||||
|
|
||||||
|
router._switch_agent(ctx, agent, "atlas/auto/maximum", "")
|
||||||
|
|
||||||
|
assert agent.reasoning_config is None
|
||||||
|
assert agent._fallback_chain == []
|
||||||
|
assert agent._fallback_index == 0
|
||||||
|
assert agent._fallback_activated is False
|
||||||
|
assert agent._fallback_model is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_registers_every_model_call_boundary_and_route_command():
|
||||||
|
hooks = {}
|
||||||
|
commands = {}
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
def register_hook(self, name, callback):
|
||||||
|
hooks[name] = callback
|
||||||
|
|
||||||
|
def register_command(self, name, callback, **metadata):
|
||||||
|
commands[name] = (callback, metadata)
|
||||||
|
|
||||||
|
router.register(Context())
|
||||||
|
|
||||||
|
assert set(hooks) == {
|
||||||
|
"pre_turn_route",
|
||||||
|
"pre_internal_route",
|
||||||
|
"pre_subagent_route",
|
||||||
}
|
}
|
||||||
warmed = []
|
assert "route" in commands
|
||||||
monkeypatch.setattr(router, "_current_policy", lambda: policy)
|
|
||||||
monkeypatch.setattr(router, "_write_policy", lambda value: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
router,
|
|
||||||
"_rewarm_classifier_after_local",
|
|
||||||
lambda provider, model: warmed.append((provider, model)),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
provider = "custom"
|
|
||||||
model = "qwen2.5:14b-instruct-q4_0"
|
|
||||||
|
|
||||||
def _emit_status(self, message):
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
agent = Agent()
|
|
||||||
cli = type("CLI", (), {"agent": agent})()
|
|
||||||
manager = type("Manager", (), {"_cli_ref": cli})()
|
|
||||||
ctx = type("Context", (), {"_manager": manager})()
|
|
||||||
|
|
||||||
router._post_turn_route(ctx, model="qwen2.5:14b-instruct-q4_0")
|
|
||||||
|
|
||||||
assert warmed == [("custom", "qwen2.5:14b-instruct-q4_0")]
|
|
||||||
assert agent.message.startswith("ROUTE USED")
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_distinguishes_requested_route_from_actual_outcome(monkeypatch):
|
def test_adapter_contains_no_content_classifier_or_direct_ollama_call():
|
||||||
monkeypatch.setattr(
|
source = SOURCE.read_text(encoding="utf-8")
|
||||||
router,
|
|
||||||
"_current_policy",
|
|
||||||
lambda: {
|
|
||||||
"mode": "auto",
|
|
||||||
"last_decision": {
|
|
||||||
"provider": "anthropic",
|
|
||||||
"model": "claude-sonnet-5",
|
|
||||||
"effort": "medium",
|
|
||||||
"classifier": "jetson",
|
|
||||||
"actual_provider": "openai-codex",
|
|
||||||
"actual_model": "gpt-5.6-terra",
|
|
||||||
"fallback_used": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
manager = type("Manager", (), {"_cli_ref": None})()
|
|
||||||
ctx = type("Context", (), {"_manager": manager})()
|
|
||||||
|
|
||||||
status = router._status_text(ctx)
|
assert "jetson_decision" not in source
|
||||||
|
assert "urllib.request" not in source
|
||||||
assert "Last requested route: anthropic/claude-sonnet-5" in status
|
assert "ollama.ai.svc" not in source
|
||||||
assert "Last actual outcome: fallback: openai-codex/gpt-5.6-terra" in status
|
|
||||||
|
|||||||
@ -27,7 +27,9 @@ def test_chat_config_enables_real_research_compute_and_delegation():
|
|||||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||||
|
|
||||||
assert config["agent"]["reasoning_effort"] == "high"
|
# AUTO leaves effort unset so the target chosen by Switchyard owns it.
|
||||||
|
assert "reasoning_effort" not in config["agent"]
|
||||||
|
assert config["model"]["model"] == "atlas/auto/fast"
|
||||||
assert config["web"] == {
|
assert config["web"] == {
|
||||||
"backend": "ddgs",
|
"backend": "ddgs",
|
||||||
"search_backend": "ddgs",
|
"search_backend": "ddgs",
|
||||||
@ -116,7 +118,7 @@ def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
|||||||
def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||||
assert "_resolve_request_route" in dockerfile
|
assert "_resolve_request_route" in dockerfile
|
||||||
assert 'allowed_providers = {"openai-codex", "anthropic"}' in dockerfile
|
assert 'allowed_providers = {"atlas-switchyard"}' in dockerfile
|
||||||
assert 'reasoning_effort=body.get("reasoning_effort")' in dockerfile
|
assert 'reasoning_effort=body.get("reasoning_effort")' in dockerfile
|
||||||
assert 'reasoning_config = {"enabled": True, "effort": "xhigh"}' in dockerfile
|
assert 'reasoning_config = {"enabled": True, "effort": "xhigh"}' in dockerfile
|
||||||
assert "ddgs==9.14.4" in dockerfile
|
assert "ddgs==9.14.4" in dockerfile
|
||||||
@ -167,7 +169,14 @@ def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
|||||||
assert "res.status===401||res.status===403" in dockerfile
|
assert "res.status===401||res.status===403" in dockerfile
|
||||||
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
||||||
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
||||||
assert "profile default: ' + p.model" in dockerfile
|
assert "'atlas/auto/maximum': 'Automatic · Maximum'" in dockerfile
|
||||||
|
|
||||||
|
router = (ROOT / "dockerfiles" / "hermes-webui-router.js").read_text()
|
||||||
|
assert "'atlas/auto/fast':'AUTO · Fast'" in router
|
||||||
|
assert "'atlas/manual/codex/sol':'Codex · SOL'" in router
|
||||||
|
assert "'atlas/manual/claude/opus':'Claude · Opus'" in router
|
||||||
|
assert "watchModelOptions('modelSelect')" in router
|
||||||
|
assert "watchModelOptions('settingsModel')" in router
|
||||||
|
|
||||||
|
|
||||||
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||||
@ -387,27 +396,37 @@ def test_chat_image_generation_uses_private_owner_broker():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_chat_reasoning_uses_private_owner_codex_broker():
|
def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||||
"""Family pods receive Codex turns without mounting the owner's OAuth file."""
|
"""Family pods use AUTO/manual routes without mounting owner credentials."""
|
||||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||||
assert config["model"] == {
|
assert config["model"] == {
|
||||||
"provider": "atlas-codex",
|
"provider": "atlas-switchyard",
|
||||||
"default": "gpt-5.6-terra",
|
"default": "atlas/auto/fast",
|
||||||
"model": "gpt-5.6-terra",
|
"model": "atlas/auto/fast",
|
||||||
}
|
}
|
||||||
assert config["providers"]["atlas-codex"] == {
|
assert config["providers"]["atlas-switchyard"] == {
|
||||||
"name": "Atlas Codex",
|
"name": "Automatic Router",
|
||||||
"api": "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1",
|
"api": "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1",
|
||||||
"key_env": "HERMES_IMAGE_BROKER_KEY",
|
"api_key": "atlas-switchyard",
|
||||||
"default_model": "gpt-5.6-terra",
|
"default_model": "atlas/auto/fast",
|
||||||
"transport": "codex_responses",
|
"transport": "chat_completions",
|
||||||
}
|
}
|
||||||
assert config["platforms"]["api_server"]["extra"]["model_routes"] == {
|
assert config["platforms"]["api_server"]["extra"]["model_routes"] == {
|
||||||
"gpt-5.6-terra": {
|
route: {"provider": "atlas-switchyard", "model": route}
|
||||||
"provider": "atlas-codex",
|
for route in [
|
||||||
"model": "gpt-5.6-terra",
|
"atlas/auto/fast",
|
||||||
}
|
"atlas/auto/balanced",
|
||||||
|
"atlas/auto/deep",
|
||||||
|
"atlas/auto/maximum",
|
||||||
|
"atlas/manual/codex/luna",
|
||||||
|
"atlas/manual/codex/terra",
|
||||||
|
"atlas/manual/codex/sol",
|
||||||
|
"atlas/manual/claude/haiku",
|
||||||
|
"atlas/manual/claude/sonnet",
|
||||||
|
"atlas/manual/claude/opus",
|
||||||
|
"atlas/manual/local/qwen-14b",
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||||
@ -418,10 +437,11 @@ def test_chat_reasoning_uses_private_owner_codex_broker():
|
|||||||
]
|
]
|
||||||
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
|
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
|
||||||
assert broker["securityContext"]["runAsNonRoot"] is True
|
assert broker["securityContext"]["runAsNonRoot"] is True
|
||||||
assert broker["env"][-2:] == [
|
assert {item["name"]: item["value"] for item in broker["env"]}.items() >= {
|
||||||
{"name": "PYTHONPATH", "value": "/opt/hermes"},
|
"PYTHONPATH": "/opt/hermes",
|
||||||
{"name": "HERMES_CODEX_BROKER_LISTEN_PORT", "value": "9003"},
|
"HERMES_CODEX_BROKER_LISTEN_PORT": "9003",
|
||||||
]
|
"HERMES_ROUTING_CATALOG_PATH": "/routing-catalog/catalog.json",
|
||||||
|
}.items()
|
||||||
|
|
||||||
services = _documents(HERMES / "service.yaml")
|
services = _documents(HERMES / "service.yaml")
|
||||||
service = next(
|
service = next(
|
||||||
@ -477,11 +497,16 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
|||||||
|
|
||||||
assert module._authorized("Bearer relay-secret") is True
|
assert module._authorized("Bearer relay-secret") is True
|
||||||
assert module._authorized("Bearer wrong") is False
|
assert module._authorized("Bearer wrong") is False
|
||||||
|
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
|
||||||
payload = module._validate_payload(
|
payload = module._validate_payload(
|
||||||
{"model": "gpt-5.6-terra", "store": True, "stream": False}
|
{"model": "gpt-5.6-terra", "store": True, "stream": False}
|
||||||
)
|
)
|
||||||
assert payload["store"] is False
|
assert payload["store"] is False
|
||||||
assert payload["stream"] is True
|
assert payload["stream"] is True
|
||||||
|
routed = module._validate_payload(
|
||||||
|
{"model": "route/codex/gpt-5.6-luna/low", "stream": False}
|
||||||
|
)
|
||||||
|
assert routed["model"] == "gpt-5.6-luna"
|
||||||
with pytest.raises(ValueError, match="unsupported Codex model"):
|
with pytest.raises(ValueError, match="unsupported Codex model"):
|
||||||
module._validate_payload({"model": "unapproved-model"})
|
module._validate_payload({"model": "unapproved-model"})
|
||||||
|
|
||||||
@ -666,7 +691,15 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
|
|||||||
for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"):
|
for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"):
|
||||||
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
||||||
assert "gpt-oss:20b" not in config
|
assert "gpt-oss:20b" not in config
|
||||||
assert "qwen2.5:14b-instruct-q4_0" in config
|
assert "atlas-switchyard" in config
|
||||||
|
switchyard = _documents(HERMES / "switchyard-configmap.yaml")[0]["data"][
|
||||||
|
"routes.toml"
|
||||||
|
]
|
||||||
|
assert "route/local/qwen2.5-14b/medium" in switchyard
|
||||||
|
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
||||||
|
"model_gate.py"
|
||||||
|
]
|
||||||
|
assert "qwen2.5:14b-instruct-q4_0" in model_gate
|
||||||
|
|
||||||
|
|
||||||
def test_titan20_serializes_classifier_and_local_chat_model_residency():
|
def test_titan20_serializes_classifier_and_local_chat_model_residency():
|
||||||
|
|||||||
@ -59,121 +59,87 @@ def _oauth_deployment(name: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_auto_lane_always_uses_the_jetson_decision(tmp_path: Path):
|
class _SwitchyardResponse:
|
||||||
router = tmp_path / "router.py"
|
"""Minimal context-managed response used by routing contract tests."""
|
||||||
router.write_text(
|
|
||||||
"""
|
|
||||||
class Decision:
|
|
||||||
shape = "review"
|
|
||||||
effort = "xhigh"
|
|
||||||
provider = "claude"
|
|
||||||
classifier = "jetson"
|
|
||||||
reason = "local vote"
|
|
||||||
latency_ms = 17
|
|
||||||
|
|
||||||
def classify_task(prompt):
|
def __init__(self, selected: str, rationale: str = "local classifier vote"):
|
||||||
assert "security review" in prompt
|
self.headers = {
|
||||||
return Decision()
|
"x-model-router-selected-model": selected,
|
||||||
""",
|
"x-model-router-rationale": rationale,
|
||||||
encoding="utf-8",
|
}
|
||||||
)
|
|
||||||
routes = tmp_path / "routes.json"
|
def __enter__(self):
|
||||||
routes.write_text(
|
return self
|
||||||
json.dumps(
|
|
||||||
{
|
def __exit__(self, *_args):
|
||||||
"routes": {
|
return False
|
||||||
"claude-xhigh": [
|
|
||||||
"anthropic/claude-opus-5",
|
def read(self):
|
||||||
"openai-codex/gpt-5.6-sol",
|
return b"{}"
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
def test_auto_lane_uses_switchyard_worker_decision():
|
||||||
),
|
observed = {}
|
||||||
encoding="utf-8",
|
|
||||||
)
|
def route(request, timeout):
|
||||||
|
observed["payload"] = json.loads(request.data)
|
||||||
|
observed["timeout"] = timeout
|
||||||
|
return _SwitchyardResponse("worker/claude/claude-opus-5/xhigh")
|
||||||
|
|
||||||
route = lanes.select_route(
|
route = lanes.select_route(
|
||||||
"Perform the security review.",
|
"Perform the security review.",
|
||||||
"cli-auto",
|
"cli-auto",
|
||||||
routing_path=routes,
|
open_request=route,
|
||||||
router_path=router,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert route.provider == "claude"
|
assert route.provider == "claude"
|
||||||
assert route.model == "claude-opus-5"
|
assert route.model == "claude-opus-5"
|
||||||
assert route.effort == "xhigh"
|
assert route.effort == "xhigh"
|
||||||
assert route.classifier == "jetson"
|
assert route.classifier == "switchyard-classifier"
|
||||||
assert route.latency_ms == 17
|
assert observed["payload"]["model"] == "atlas/worker/auto/maximum"
|
||||||
|
assert observed["timeout"] == 60
|
||||||
|
|
||||||
|
|
||||||
def test_manual_lane_still_calls_jetson_before_applying_override(tmp_path: Path):
|
def test_manual_lane_is_still_enforced_by_switchyard():
|
||||||
router = tmp_path / "router.py"
|
observed = {}
|
||||||
router.write_text(
|
|
||||||
"""
|
def route(request, timeout):
|
||||||
called = 0
|
observed["payload"] = json.loads(request.data)
|
||||||
class Decision:
|
return _SwitchyardResponse(
|
||||||
shape = "question"
|
"worker/claude/claude-sonnet-5/high", "manual route"
|
||||||
effort = "low"
|
)
|
||||||
provider = "codex"
|
|
||||||
classifier = "jetson"
|
|
||||||
reason = "local vote"
|
|
||||||
latency_ms = 8
|
|
||||||
def classify_task(prompt):
|
|
||||||
global called
|
|
||||||
called += 1
|
|
||||||
return Decision()
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
routes = tmp_path / "routes.json"
|
|
||||||
routes.write_text(
|
|
||||||
json.dumps({"routes": {"claude-high": ["anthropic/claude-opus-5"]}}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
route = lanes.select_route(
|
route = lanes.select_route(
|
||||||
"Implement it.",
|
"Implement it.",
|
||||||
"cli-claude-high",
|
"cli-claude-high",
|
||||||
routing_path=routes,
|
open_request=route,
|
||||||
router_path=router,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert route.provider == "claude"
|
assert route.provider == "claude"
|
||||||
assert route.effort == "high"
|
assert route.effort == "high"
|
||||||
assert "manual lane override" in route.reason
|
assert route.classifier == "switchyard-manual"
|
||||||
|
assert route.reason == "manual route"
|
||||||
|
assert observed["payload"]["model"] == "atlas/worker/manual/claude/high"
|
||||||
|
|
||||||
|
|
||||||
def test_cross_provider_retry_excludes_failed_provider(tmp_path: Path):
|
def test_cross_provider_retry_passes_failed_provider_to_switchyard():
|
||||||
router = tmp_path / "router.py"
|
observed = {}
|
||||||
router.write_text(
|
|
||||||
"""
|
def route(request, timeout):
|
||||||
class Decision:
|
observed["payload"] = json.loads(request.data)
|
||||||
shape = "implementation"
|
return _SwitchyardResponse("worker/claude/claude-sonnet-5/high")
|
||||||
effort = "high"
|
|
||||||
provider = "codex"
|
|
||||||
classifier = "jetson"
|
|
||||||
reason = "retry vote"
|
|
||||||
latency_ms = 9
|
|
||||||
def classify_task(prompt): return Decision()
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
routes = tmp_path / "routes.json"
|
|
||||||
routes.write_text(
|
|
||||||
json.dumps({"routes": {"claude-high": ["anthropic/claude-opus-5"]}}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
route = lanes.select_route(
|
route = lanes.select_route(
|
||||||
"Retry after capacity exhaustion.",
|
"Retry after capacity exhaustion.",
|
||||||
"cli-auto",
|
"cli-auto",
|
||||||
exclude_provider="codex",
|
exclude_provider="codex",
|
||||||
routing_path=routes,
|
open_request=route,
|
||||||
router_path=router,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert route.provider == "claude"
|
assert route.provider == "claude"
|
||||||
assert route.classifier == "jetson"
|
assert route.classifier == "switchyard-classifier"
|
||||||
|
content = observed["payload"]["messages"][0]["content"]
|
||||||
|
assert "codex provider failed or exhausted capacity" in content
|
||||||
|
|
||||||
|
|
||||||
def test_claude_session_is_reserved_before_first_process(tmp_path: Path, monkeypatch):
|
def test_claude_session_is_reserved_before_first_process(tmp_path: Path, monkeypatch):
|
||||||
@ -835,6 +801,16 @@ def test_agent_network_boundary_allows_only_authenticated_web_and_broker_surface
|
|||||||
{"protocol": "TCP", "port": 9003},
|
{"protocol": "TCP", "port": 9003},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": [
|
||||||
|
{
|
||||||
|
"podSelector": {
|
||||||
|
"matchLabels": {"app": "hermes-switchyard"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [{"protocol": "TCP", "port": 9003}],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
assert isolation["spec"]["egress"] == [{}]
|
assert isolation["spec"]["egress"] == [{}]
|
||||||
|
|
||||||
@ -856,6 +832,46 @@ def test_owner_agent_has_cluster_admin_kubernetes_context():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
|
||||||
|
"""Routing must not inherit the owner agent's cluster-admin capability."""
|
||||||
|
service_accounts = [
|
||||||
|
item
|
||||||
|
for item in yaml.safe_load_all(
|
||||||
|
(HERMES / "vault-serviceaccount.yaml").read_text()
|
||||||
|
)
|
||||||
|
if item
|
||||||
|
]
|
||||||
|
assert any(
|
||||||
|
item["kind"] == "ServiceAccount"
|
||||||
|
and item["metadata"]["name"] == "hermes-switchyard"
|
||||||
|
for item in service_accounts
|
||||||
|
)
|
||||||
|
|
||||||
|
deployment = yaml.safe_load((HERMES / "switchyard-deployment.yaml").read_text())
|
||||||
|
pod = deployment["spec"]["template"]["spec"]
|
||||||
|
assert pod["serviceAccountName"] == "hermes-switchyard"
|
||||||
|
for container_name in ("claude-oauth-broker", "worker-route-broker"):
|
||||||
|
container = next(
|
||||||
|
item for item in pod["containers"] if item["name"] == container_name
|
||||||
|
)
|
||||||
|
catalog = next(
|
||||||
|
item
|
||||||
|
for item in container["volumeMounts"]
|
||||||
|
if item["mountPath"] == "/routing-catalog"
|
||||||
|
)
|
||||||
|
assert catalog["readOnly"] is True
|
||||||
|
|
||||||
|
rbac = [
|
||||||
|
item
|
||||||
|
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
|
||||||
|
if item
|
||||||
|
]
|
||||||
|
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
||||||
|
assert binding["subjects"] == [
|
||||||
|
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_owner_agent_installs_the_pinned_operator_toolchain():
|
def test_owner_agent_installs_the_pinned_operator_toolchain():
|
||||||
script = (SCRIPTS / "install_agent_tools.sh").read_text()
|
script = (SCRIPTS / "install_agent_tools.sh").read_text()
|
||||||
for value in [
|
for value in [
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@ -16,6 +17,7 @@ SCRIPT = (
|
|||||||
)
|
)
|
||||||
sys.path.insert(0, str(SCRIPT.parent))
|
sys.path.insert(0, str(SCRIPT.parent))
|
||||||
routing = importlib.import_module("hermes_model_routing")
|
routing = importlib.import_module("hermes_model_routing")
|
||||||
|
catalog_resolver = importlib.import_module("routing_catalog")
|
||||||
SPEC = importlib.util.spec_from_file_location("hermes_coordinator", SCRIPT)
|
SPEC = importlib.util.spec_from_file_location("hermes_coordinator", SCRIPT)
|
||||||
assert SPEC and SPEC.loader
|
assert SPEC and SPEC.loader
|
||||||
coordinator = importlib.util.module_from_spec(SPEC)
|
coordinator = importlib.util.module_from_spec(SPEC)
|
||||||
@ -65,6 +67,101 @@ def test_empty_catalog_retains_current_models():
|
|||||||
assert routing.choose_claude_model([], "claude-opus-5") == "claude-opus-5"
|
assert routing.choose_claude_model([], "claude-opus-5") == "claude-opus-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_catalog_resolves_new_models_and_preserves_last_known_good():
|
||||||
|
"""Stable Switchyard aliases follow live releases and survive catalog outages."""
|
||||||
|
codex = routing.Catalog(
|
||||||
|
"openai-codex",
|
||||||
|
["gpt-5.7-luna", "gpt-5.7-terra", "gpt-5.7-sol"],
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"connected",
|
||||||
|
)
|
||||||
|
claude = routing.Catalog(
|
||||||
|
"anthropic",
|
||||||
|
["claude-haiku-5", "claude-sonnet-6", "claude-opus-6"],
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
"connected",
|
||||||
|
)
|
||||||
|
current = routing.build_routing_catalog(codex, claude)
|
||||||
|
|
||||||
|
assert catalog_resolver.resolve_model("codex", "auto", "high", current) == "gpt-5.7-sol"
|
||||||
|
assert catalog_resolver.resolve_model("codex", "terra", "medium", current) == "gpt-5.7-terra"
|
||||||
|
assert catalog_resolver.resolve_model("claude", "auto", "xhigh", current) == "claude-opus-6"
|
||||||
|
assert catalog_resolver.resolve_model("claude", "sonnet", "high", current) == "claude-sonnet-6"
|
||||||
|
|
||||||
|
unavailable = routing.Catalog("openai-codex", [], False, True, "degraded")
|
||||||
|
preserved = routing.build_routing_catalog(unavailable, unavailable, current)
|
||||||
|
assert preserved["providers"]["codex"]["resolved"] == current["providers"]["codex"]["resolved"]
|
||||||
|
assert preserved["providers"]["claude"]["tiers"] == current["providers"]["claude"]["tiers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_catalog_never_routes_to_a_removed_model_tier():
|
||||||
|
"""A live provider catalog must replace a retired tier with a live model."""
|
||||||
|
previous = {
|
||||||
|
"providers": {
|
||||||
|
"codex": {
|
||||||
|
"models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
|
||||||
|
"resolved": {
|
||||||
|
"low": "gpt-5.6-luna",
|
||||||
|
"medium": "gpt-5.6-terra",
|
||||||
|
"high": "gpt-5.6-sol",
|
||||||
|
"xhigh": "gpt-5.6-sol",
|
||||||
|
},
|
||||||
|
"tiers": {
|
||||||
|
"luna": "gpt-5.6-luna",
|
||||||
|
"terra": "gpt-5.6-terra",
|
||||||
|
"sol": "gpt-5.6-sol",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
codex = routing.Catalog(
|
||||||
|
"openai-codex", ["gpt-5.7-terra", "gpt-5.7-sol"], True, True, "connected"
|
||||||
|
)
|
||||||
|
claude = routing.Catalog("anthropic", ["claude-sonnet-6"], True, True, "connected")
|
||||||
|
|
||||||
|
current = routing.build_routing_catalog(codex, claude, previous)
|
||||||
|
|
||||||
|
assert current["providers"]["codex"]["tiers"]["luna"] == "gpt-5.7-terra"
|
||||||
|
assert current["providers"]["codex"]["tiers"]["luna"] in codex.models
|
||||||
|
assert current["providers"]["claude"]["tiers"]["opus"] == "claude-sonnet-6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switchyard_targets_have_unique_upstream_identities():
|
||||||
|
"""Switchyard drops duplicate client/model pairs, so reject them in Git."""
|
||||||
|
manifest = yaml.safe_load(
|
||||||
|
(SCRIPT.parents[1] / "switchyard-configmap.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
config = tomllib.loads(manifest["data"]["routes.toml"])
|
||||||
|
target_names = set(config["targets"])
|
||||||
|
identities: set[tuple[str, str]] = set()
|
||||||
|
for target in config["targets"].values():
|
||||||
|
identity = (target["llm_client"], target["id"])
|
||||||
|
assert identity not in identities
|
||||||
|
identities.add(identity)
|
||||||
|
for route in config["routes"].values():
|
||||||
|
for target_name in route.get("targets", []):
|
||||||
|
assert target_name in target_names
|
||||||
|
if route.get("target"):
|
||||||
|
assert route["target"] in target_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_alias_resolves_before_cli_launch():
|
||||||
|
document = {
|
||||||
|
"providers": {
|
||||||
|
"codex": {
|
||||||
|
"resolved": {"high": "gpt-5.8-sol"},
|
||||||
|
"tiers": {"sol": "gpt-5.8-sol"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
catalog_resolver.resolve_worker_route("worker/codex/auto/high", document)
|
||||||
|
== "worker/codex/gpt-5.8-sol/high"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
|
def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
|
||||||
"""AUTO routing must recognize the authenticated app-server CLI lane."""
|
"""AUTO routing must recognize the authenticated app-server CLI lane."""
|
||||||
monkeypatch.setattr(routing.shutil, "which", lambda name: "/usr/bin/codex")
|
monkeypatch.setattr(routing.shutil, "which", lambda name: "/usr/bin/codex")
|
||||||
@ -82,7 +179,7 @@ def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
|
|||||||
assert routing.codex_cli_authenticated() is True
|
assert routing.codex_cli_authenticated() is True
|
||||||
|
|
||||||
|
|
||||||
def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path):
|
def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
|
||||||
(tmp_path / "config.yaml").write_text(
|
(tmp_path / "config.yaml").write_text(
|
||||||
yaml.safe_dump(_base_config()), encoding="utf-8"
|
yaml.safe_dump(_base_config()), encoding="utf-8"
|
||||||
)
|
)
|
||||||
@ -112,35 +209,31 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
|
|||||||
claude_profile = yaml.safe_load(
|
claude_profile = yaml.safe_load(
|
||||||
(tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8")
|
(tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
assert root["model"]["model"] == "gpt-5.6-terra"
|
assert root["model"] == {
|
||||||
assert root["model"]["openai_runtime"] == "codex_app_server"
|
"provider": "atlas-switchyard",
|
||||||
assert codex_profile["model"]["model"] == "gpt-5.6-sol"
|
"default": "atlas/auto/maximum",
|
||||||
assert codex_profile["model"]["openai_runtime"] == "codex_app_server"
|
"model": "atlas/auto/maximum",
|
||||||
assert codex_profile["fallback_providers"][0] == {
|
|
||||||
"provider": "anthropic",
|
|
||||||
"model": "claude-opus-5",
|
|
||||||
}
|
}
|
||||||
assert claude_profile["model"]["model"] == "claude-opus-5"
|
assert root["fallback_providers"] == []
|
||||||
assert claude_profile["fallback_providers"][0]["provider"] == "openai-codex"
|
assert root["toolsets"] == ["kanban"]
|
||||||
|
assert codex_profile["model"]["model"] == "atlas/manual/codex/sol"
|
||||||
|
assert codex_profile["model"]["provider"] == "atlas-switchyard"
|
||||||
|
assert claude_profile["model"]["model"] == "atlas/manual/claude/sonnet"
|
||||||
|
assert claude_profile["model"]["provider"] == "atlas-switchyard"
|
||||||
|
assert codex_profile["fallback_providers"] == []
|
||||||
|
assert claude_profile["fallback_providers"] == []
|
||||||
assert codex_profile["toolsets"] == []
|
assert codex_profile["toolsets"] == []
|
||||||
assert codex_profile["agent"]["reasoning_effort"] == "high"
|
assert codex_profile["agent"]["reasoning_effort"] == "high"
|
||||||
assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK
|
assert codex_xhigh_profile["fallback_providers"] == []
|
||||||
assert len(codex_profile["fallback_providers"]) == 2
|
assert routes["codex-xhigh"] == ["atlas/manual/codex/sol"]
|
||||||
assert codex_xhigh_profile["fallback_providers"] == [
|
assert routes["claude-xhigh"] == ["atlas/manual/claude/opus"]
|
||||||
{"provider": "anthropic", "model": "claude-opus-5"}
|
assert routes["synthesis-xhigh"] == ["atlas/auto/maximum"]
|
||||||
]
|
assert routes["coordinator"] == ["atlas/auto/maximum"]
|
||||||
assert routes["codex-xhigh"] == [
|
assert all(
|
||||||
"openai-codex/gpt-5.6-sol",
|
"max" not in profile_name
|
||||||
"anthropic/claude-opus-5",
|
for profile_name in routes
|
||||||
]
|
if profile_name != "catalog"
|
||||||
assert routes["claude-xhigh"] == [
|
)
|
||||||
"anthropic/claude-opus-5",
|
|
||||||
"openai-codex/gpt-5.6-sol",
|
|
||||||
]
|
|
||||||
assert all("custom/" not in route for route in routes["synthesis-xhigh"])
|
|
||||||
assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra"
|
|
||||||
assert "custom/qwen2.5:14b-instruct-q4_0" not in routes["coordinator"]
|
|
||||||
assert "max" not in json.dumps(routes)
|
|
||||||
|
|
||||||
profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")
|
profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")
|
||||||
assert "CLAUDE_CODE_OAUTH_TOKEN=claude-secret" in profile_env
|
assert "CLAUDE_CODE_OAUTH_TOKEN=claude-secret" in profile_env
|
||||||
@ -150,7 +243,7 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
|
|||||||
assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600
|
assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
|
||||||
def test_degraded_catalog_does_not_replace_known_working_route(tmp_path: Path):
|
def test_degraded_catalog_does_not_replace_switchyard_authority(tmp_path: Path):
|
||||||
base = _base_config()
|
base = _base_config()
|
||||||
base["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol"
|
base["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol"
|
||||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump(base), encoding="utf-8")
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump(base), encoding="utf-8")
|
||||||
@ -161,12 +254,13 @@ def test_degraded_catalog_does_not_replace_known_working_route(tmp_path: Path):
|
|||||||
routing.configure_routes(tmp_path, codex, claude)
|
routing.configure_routes(tmp_path, codex, claude)
|
||||||
|
|
||||||
current = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
current = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||||
assert current["model"]["model"] == "gpt-5.6-sol"
|
assert current["model"]["model"] == "atlas/auto/maximum"
|
||||||
assert current["fallback_providers"][0]["model"] == "claude-opus-5"
|
assert current["model"]["provider"] == "atlas-switchyard"
|
||||||
|
assert current["fallback_providers"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_degraded_refresh_preserves_last_worker_specific_model(tmp_path: Path):
|
def test_degraded_refresh_preserves_switchyard_worker_preference(tmp_path: Path):
|
||||||
"""A catalog outage must not collapse the Codex worker onto coordinator tier."""
|
"""A catalog outage must not bypass a managed worker's Switchyard route."""
|
||||||
(tmp_path / "config.yaml").write_text(
|
(tmp_path / "config.yaml").write_text(
|
||||||
yaml.safe_dump(_base_config()), encoding="utf-8"
|
yaml.safe_dump(_base_config()), encoding="utf-8"
|
||||||
)
|
)
|
||||||
@ -187,7 +281,8 @@ def test_degraded_refresh_preserves_last_worker_specific_model(tmp_path: Path):
|
|||||||
worker = yaml.safe_load(
|
worker = yaml.safe_load(
|
||||||
(tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8")
|
(tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
assert worker["model"]["model"] == "gpt-5.6-sol"
|
assert worker["model"]["model"] == "atlas/manual/codex/sol"
|
||||||
|
assert worker["model"]["provider"] == "atlas-switchyard"
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch):
|
def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch):
|
||||||
|
|||||||
@ -39,6 +39,20 @@ def test_model_gate_preserves_supported_and_non_json_requests():
|
|||||||
assert normalize(non_json) == non_json
|
assert normalize(non_json) == non_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_gate_translates_switchyard_local_target_alias():
|
||||||
|
normalize = _model_gate_namespace()["_normalize_reasoning"]
|
||||||
|
|
||||||
|
routed = json.loads(
|
||||||
|
normalize(
|
||||||
|
b'{"model":"route/local/qwen2.5-14b/medium",'
|
||||||
|
b'"reasoning_effort":"medium","messages":[]}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert routed["model"] == "qwen2.5:14b-instruct-q4_0"
|
||||||
|
assert routed["reasoning_effort"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
def test_model_gate_runs_a_renderer_aware_ariadne_handoff():
|
def test_model_gate_runs_a_renderer_aware_ariadne_handoff():
|
||||||
"""Wolf cannot reach Ollama until the local image service reports idle."""
|
"""Wolf cannot reach Ollama until the local image service reports idle."""
|
||||||
namespace = _model_gate_namespace()
|
namespace = _model_gate_namespace()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user