hermes(agent): restore stock dashboard reliability
All checks were successful
Tests / Declarative: Post Actions passed: 220

This commit is contained in:
jenkins 2026-08-10 21:08:42 -03:00
parent aca9f1ec86
commit dfa12dd7f5
16 changed files with 525 additions and 197 deletions

View File

@ -5,6 +5,13 @@ This is the mental model and demonstration script for the operator instance at
consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the
place to perform infrastructure triage. place to perform infrastructure triage.
`agent.hermes.bstein.dev` is the owner-only engineering control plane. Its root
serves the stock Hermes dashboard, including embedded chat/TUI and the native
sessions, files, models, logs, Kanban, skills, plugins, MCP, profiles, and
configuration surfaces. `/terminal/` remains the raw full-screen TUI. The
community WebUI is intentionally reserved for isolated consumer chat, where
its conversation-first layout is a better fit.
## Consumer chat and Telegram ## Consumer chat and Telegram
`chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator `chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator

View File

@ -16,10 +16,6 @@ data:
fallback_providers: fallback_providers:
- provider: anthropic - provider: anthropic
model: claude-sonnet-5 model: claude-sonnet-5
- provider: custom
model: qwen2.5:14b-instruct-q4_0
base_url: http://ollama.ai.svc.cluster.local:11434/v1
api_key: ollama
- provider: custom - provider: custom
model: gpt-oss:20b model: gpt-oss:20b
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1 base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
@ -228,7 +224,7 @@ data:
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}`; they still call the Jetson for the audit
record, then apply the explicit override. Observe workers through Kanban and record, then apply the explicit override. Observe workers through Kanban and
the WebUI worker list, 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.
@ -248,9 +244,10 @@ data:
START-HERE.md: | START-HERE.md: |
# Agent Hermes # Agent Hermes
The authenticated root of agent.hermes.bstein.dev opens the persistent The authenticated root of agent.hermes.bstein.dev opens Hermes' stock
Hermes WebUI and its Project/Kanban controls. `/terminal/` opens the stock dashboard with embedded chat/TUI, sessions, files, models, logs, Kanban,
Hermes TUI. Give Hermes skills, plugins, MCP, profiles, and configuration. `/terminal/` opens the
raw full-screen Hermes TUI. Give Hermes
the outcome you want and it will decompose dependent work, classify every the outcome you want and it will decompose dependent work, classify every
delegated leaf on the Jetson, choose Codex or Claude, preserve the task on delegated leaf on the Jetson, choose Codex or Claude, preserve the task on
the Cassandra board, and synthesize the evidence. Persistent real Codex and the Cassandra board, and synthesize the evidence. Persistent real Codex and

View File

@ -24,7 +24,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260810-owner-union-toolbox" ai.bstein.dev/config-rev: "20260810-stock-dashboard-init-hardening"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent 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-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -39,6 +39,13 @@ spec:
{{ . }} {{ . }}
{{- end -}} {{- end -}}
{{- end }} {{- end }}
vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/agent-oidc
vault.hashicorp.com/agent-inject-template-oidc-config: |
{{- with secret "kv/data/atlas/hermes/agent-oidc" -}}
client_id = "{{ .Data.data.client_id }}"
client_secret = "{{ .Data.data.client_secret }}"
cookie_secret = "{{ .Data.data.cookie_secret }}"
{{- end -}}
vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true" vault.hashicorp.com/agent-init-first: "true"
vault.hashicorp.com/agent-requests-cpu: 25m vault.hashicorp.com/agent-requests-cpu: 25m
@ -211,6 +218,29 @@ spec:
resources: resources:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi} limits: {cpu: 100m, memory: 128Mi}
- name: patch-tui-gateway
image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/patch_tui_gateway.py
- /opt/hermes/tui_gateway/server.py
- /patched/server.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: coordinator
mountPath: /opt/coordinator
readOnly: true
- name: tui-gateway-patch
mountPath: /patched
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: bootstrap-coordinator - name: bootstrap-coordinator
image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5 image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
@ -343,48 +373,57 @@ spec:
resources: resources:
requests: {cpu: 250m, memory: 512Mi} requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "2", memory: 4Gi} limits: {cpu: "2", memory: 4Gi}
- name: webui - name: dashboard
image: registry.bstein.dev/bstein/hermes-webui@sha256:c109e6faec1d6b86859a182bc845a2e35d64260459dda3892c0510db1dc7272d image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
- | - |
api_key="$(sed -n 's/^API_SERVER_KEY=//p' /opt/data/.env | tail -n 1)" set -a
test -n "${api_key}" . /opt/data/.env
export API_SERVER_KEY="${api_key}" set +a
export HERMES_WEBUI_GATEWAY_API_KEY="${api_key}" exec /opt/hermes/.venv/bin/hermes dashboard \
exec /opt/hermes/.venv/bin/python /opt/hermes-webui/server.py --host 127.0.0.1 \
--port 9119 \
--skip-build \
--no-open
ports: ports:
- {name: dashboard, containerPort: 8787, protocol: TCP} - {name: dashboard, containerPort: 9119, protocol: TCP}
env: env:
- {name: HERMES_HOME, value: /opt/data} - {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json} - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HOME, value: /opt/data/home} - {name: HOME, value: /opt/data/home}
- {name: HERMES_WEBUI_AGENT_DIR, value: /opt/hermes} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: HERMES_WEBUI_HOST, value: 0.0.0.0} - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: HERMES_WEBUI_PORT, value: "8787"} - {name: KUBECONFIG, value: /opt/data/home/.kube/config}
- {name: HERMES_WEBUI_STATE_DIR, value: /opt/data/webui} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_WEBUI_DEFAULT_WORKSPACE, value: /opt/data/workspace} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
- {name: HERMES_WEBUI_CHAT_BACKEND, value: gateway} - {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright}
- {name: HERMES_WEBUI_GATEWAY_BASE_URL, value: http://127.0.0.1:8642} - {name: AGENT_BROWSER_EXECUTABLE_PATH, value: /opt/hermes/.playwright/chromium_headless_shell-1228/chrome-linux/headless_shell}
- {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"} - {name: AGENT_BROWSER_ARGS, value: "--no-sandbox,--disable-dev-shm-usage"}
- {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"} - {name: HERMES_DASHBOARD_FILES_ROOT, value: /opt/data/workspace}
- {name: HERMES_WEBUI_SECURE, value: "1"} - {name: HERMES_DASHBOARD_PUBLIC_URL, value: https://agent.hermes.bstein.dev}
- {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://agent.hermes.bstein.dev} - {name: HERMES_TUI_AGENT_INIT_TIMEOUT_S, value: "180"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true} - {name: provider-auth, mountPath: /shared-auth}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
workingDir: /opt/data/workspace
readinessProbe: readinessProbe:
httpGet: {path: /health, port: dashboard} exec:
initialDelaySeconds: 10 command: [curl, -fsS, http://127.0.0.1:9119/api/status]
initialDelaySeconds: 20
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
livenessProbe: livenessProbe:
httpGet: {path: /health, port: dashboard} exec:
initialDelaySeconds: 30 command: [curl, -fsS, http://127.0.0.1:9119/api/status]
initialDelaySeconds: 60
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
securityContext: securityContext:
@ -398,8 +437,67 @@ spec:
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 750m, memory: 1Gi} limits: {cpu: "2", memory: 4Gi}
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561
imagePullPolicy: IfNotPresent
args:
- --provider=oidc
- --config=/vault/secrets/oidc-config
- --redirect-url=https://agent.hermes.bstein.dev/oauth2/callback
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
- --user-id-claim=sub
- --oidc-groups-claim=groups
- --allowed-group=/hermes-owner
- --code-challenge-method=S256
- --scope=openid profile email
- --email-domain=*
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
- --set-xauthrequest=true
- --pass-user-headers=true
- --pass-basic-auth=false
- --pass-host-header=false
- --proxy-websockets=true
- --cookie-name=__Host-hermes_agent
- --cookie-path=/
- --cookie-secure=true
- --cookie-samesite=lax
- --cookie-csrf-expire=10m
- --cookie-csrf-per-request=true
- --cookie-csrf-per-request-limit=8
- --cookie-refresh=1h
- --cookie-expire=8h
- --upstream=http://127.0.0.1:7681/terminal/
- --upstream=http://127.0.0.1:9119/
- --http-address=0.0.0.0:4180
- --skip-provider-button=true
- --reverse-proxy=true
- --trusted-proxy-ip=10.42.0.0/16
ports:
- {name: auth-http, containerPort: 4180, protocol: TCP}
readinessProbe:
httpGet: {path: /ping, port: auth-http}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: {path: /ping, port: auth-http}
initialDelaySeconds: 20
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi}
volumeMounts:
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
- {name: oauth-tmp, mountPath: /tmp}
- name: terminal - name: terminal
image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5 image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
@ -411,7 +509,6 @@ spec:
set +a set +a
exec /opt/data/tools/bin/ttyd \ exec /opt/data/tools/bin/ttyd \
--writable \ --writable \
--check-origin \
--base-path /terminal \ --base-path /terminal \
--interface 0.0.0.0 \ --interface 0.0.0.0 \
--port 7681 \ --port 7681 \
@ -450,11 +547,13 @@ spec:
- {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright} - {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright}
- {name: AGENT_BROWSER_EXECUTABLE_PATH, value: /opt/hermes/.playwright/chromium_headless_shell-1228/chrome-linux/headless_shell} - {name: AGENT_BROWSER_EXECUTABLE_PATH, value: /opt/hermes/.playwright/chromium_headless_shell-1228/chrome-linux/headless_shell}
- {name: AGENT_BROWSER_ARGS, value: "--no-sandbox,--disable-dev-shm-usage"} - {name: AGENT_BROWSER_ARGS, value: "--no-sandbox,--disable-dev-shm-usage"}
- {name: HERMES_TUI_AGENT_INIT_TIMEOUT_S, value: "180"}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {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: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
@ -570,12 +669,20 @@ spec:
defaultMode: 0444 defaultMode: 0444
- name: auth-patch - name: auth-patch
emptyDir: {} emptyDir: {}
- name: tui-gateway-patch
emptyDir: {}
- name: auto-router-plugin - name: auto-router-plugin
configMap: configMap:
name: hermes-auto-router-plugin name: hermes-auto-router-plugin
- name: tmp - name: tmp
emptyDir: emptyDir:
sizeLimit: 4Gi sizeLimit: 4Gi
- name: oauth-tmp
emptyDir:
sizeLimit: 64Mi
- name: allowlist
configMap:
name: hermes-owner-allowlist
- name: ttyd-index - name: ttyd-index
emptyDir: emptyDir:
sizeLimit: 2Mi sizeLimit: 2Mi

View File

@ -10,23 +10,30 @@ spec:
replacement: https://agent.hermes.bstein.dev/terminal/ replacement: https://agent.hermes.bstein.dev/terminal/
permanent: false permanent: false
--- ---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: hermes-agent-stock-dashboard-headers
namespace: hermes
spec:
headers:
customRequestHeaders:
Origin: http://127.0.0.1:9119
---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: hermes-sites name: hermes-agent-dashboard
namespace: hermes namespace: hermes
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: hermes-hermes-agent-terminal-slash@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: hermes-hermes-agent-stock-dashboard-headers@kubernetescrd
traefik.ingress.kubernetes.io/router.tls: "true" traefik.ingress.kubernetes.io/router.tls: "true"
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- agent.hermes.bstein.dev - agent.hermes.bstein.dev
- chat.hermes.bstein.dev
- triage.hermes.bstein.dev
secretName: hermes-sites-tls secretName: hermes-sites-tls
rules: rules:
- host: agent.hermes.bstein.dev - host: agent.hermes.bstein.dev
@ -39,6 +46,52 @@ spec:
name: oauth2-proxy-hermes-agent name: oauth2-proxy-hermes-agent
port: port:
name: http name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes-agent-terminal
namespace: hermes
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: hermes-hermes-agent-terminal-slash@kubernetescrd
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- agent.hermes.bstein.dev
secretName: hermes-sites-tls
rules:
- host: agent.hermes.bstein.dev
http:
paths:
- path: /terminal
pathType: Prefix
backend:
service:
name: oauth2-proxy-hermes-agent
port:
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes-sites
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- agent.hermes.bstein.dev
- chat.hermes.bstein.dev
- triage.hermes.bstein.dev
secretName: hermes-sites-tls
rules:
- host: chat.hermes.bstein.dev - host: chat.hermes.bstein.dev
http: http:
paths: paths:

View File

@ -58,6 +58,7 @@ configMapGenerator:
- install_agent_tools.sh=scripts/install_agent_tools.sh - install_agent_tools.sh=scripts/install_agent_tools.sh
- migrate_herdr_state.py=scripts/migrate_herdr_state.py - migrate_herdr_state.py=scripts/migrate_herdr_state.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py - patch_hermes_auth.py=scripts/patch_hermes_auth.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
options: options:
disableNameSuffixHash: true disableNameSuffixHash: true

View File

@ -62,6 +62,34 @@ data:
return owner return owner
def _normalize_reasoning(body: bytes | None) -> bytes | None:
"""Clamp hosted-only effort names to the local server's supported tier."""
if not body:
return body
try:
payload = json.loads(body)
except (TypeError, ValueError, json.JSONDecodeError):
return body
if not isinstance(payload, dict):
return body
changed = False
for key in ("reasoning_effort", "reasoning"):
value = payload.get(key)
if isinstance(value, str) and value.lower() in {"xhigh", "max"}:
payload[key] = "high"
changed = True
elif isinstance(value, dict):
effort = value.get("effort")
if isinstance(effort, str) and effort.lower() in {"xhigh", "max"}:
value["effort"] = "high"
changed = True
if not changed:
return body
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
"""Proxy local model traffic while exposing health and ownership status.""" """Proxy local model traffic while exposing health and ownership status."""
@ -98,6 +126,7 @@ data:
length = int(self.headers.get("Content-Length", "0") or 0) length = int(self.headers.get("Content-Length", "0") or 0)
body = self.rfile.read(length) if length else None body = self.rfile.read(length) if length else None
body = _normalize_reasoning(body)
headers = {"Content-Type": self.headers.get("Content-Type", "application/json")} headers = {"Content-Type": self.headers.get("Content-Type", "application/json")}
if self.headers.get("Accept"): if self.headers.get("Accept"):
headers["Accept"] = self.headers["Accept"] headers["Accept"] = self.headers["Accept"]

View File

@ -68,12 +68,14 @@ spec:
policyTypes: [Ingress, Egress] policyTypes: [Ingress, Egress]
ingress: ingress:
- from: - from:
- podSelector: - namespaceSelector:
matchLabels: matchLabels:
app: oauth2-proxy-hermes-agent kubernetes.io/metadata.name: traefik
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports: ports:
- {protocol: TCP, port: 7681} - {protocol: TCP, port: 4180}
- {protocol: TCP, port: 8787}
# 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.
@ -316,7 +318,6 @@ spec:
- key: app - key: app
operator: In operator: In
values: values:
- oauth2-proxy-hermes-agent
- oauth2-proxy-hermes-chat - oauth2-proxy-hermes-chat
- oauth2-proxy-hermes-triage - oauth2-proxy-hermes-triage
policyTypes: [Ingress, Egress] policyTypes: [Ingress, Egress]

View File

@ -15,9 +15,9 @@ metadata:
namespace: hermes namespace: hermes
spec: spec:
selector: selector:
app: oauth2-proxy-hermes-agent app: hermes-agent
ports: ports:
- {name: http, port: 80, targetPort: http} - {name: http, port: 80, targetPort: auth-http}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
@ -43,103 +43,6 @@ spec:
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata:
name: oauth2-proxy-hermes-agent
namespace: hermes
labels:
app: oauth2-proxy-hermes-agent
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app: oauth2-proxy-hermes-agent
template:
metadata:
labels:
app: oauth2-proxy-hermes-agent
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/agent-oidc
vault.hashicorp.com/agent-inject-template-oidc-config: |
{{- with secret "kv/data/atlas/hermes/agent-oidc" -}}
client_id = "{{ .Data.data.client_id }}"
client_secret = "{{ .Data.data.client_secret }}"
cookie_secret = "{{ .Data.data.cookie_secret }}"
{{- end -}}
spec:
serviceAccountName: hermes-agent
automountServiceAccountToken: true
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561
imagePullPolicy: IfNotPresent
args:
- --provider=oidc
- --config=/vault/secrets/oidc-config
- --redirect-url=https://agent.hermes.bstein.dev/oauth2/callback
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
- --user-id-claim=sub
- --oidc-groups-claim=groups
- --allowed-group=/hermes-owner
- --code-challenge-method=S256
- --scope=openid profile email
- --email-domain=*
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
- --set-xauthrequest=true
- --pass-user-headers=true
- --pass-basic-auth=false
- --proxy-websockets=true
- --cookie-name=__Host-hermes_agent
- --cookie-path=/
- --cookie-secure=true
- --cookie-samesite=lax
- --cookie-csrf-expire=10m
- --cookie-csrf-per-request=true
- --cookie-csrf-per-request-limit=8
- --cookie-refresh=1h
- --cookie-expire=8h
- --upstream=http://hermes-agent.hermes.svc.cluster.local:7681/terminal/
- --upstream=http://hermes-agent.hermes.svc.cluster.local:8787/
- --http-address=0.0.0.0:4180
- --skip-provider-button=true
- --reverse-proxy=true
- --trusted-proxy-ip=10.42.0.0/16
ports:
- {name: http, containerPort: 4180}
readinessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 20
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi}
volumeMounts:
- {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
- {name: tmp, mountPath: /tmp}
volumes:
- name: allowlist
configMap:
name: hermes-owner-allowlist
- name: tmp
emptyDir: {sizeLimit: 64Mi}
---
apiVersion: apps/v1
kind: Deployment
metadata: metadata:
name: oauth2-proxy-hermes-triage name: oauth2-proxy-hermes-triage
namespace: hermes namespace: hermes

View File

@ -26,6 +26,12 @@ JETSON_MODEL = os.environ.get(
EFFORTS = ("low", "medium", "high", "xhigh") EFFORTS = ("low", "medium", "high", "xhigh")
PROVIDERS = ("codex", "claude") PROVIDERS = ("codex", "claude")
EFFORT_RANK = {effort: rank for rank, effort in enumerate(EFFORTS)} EFFORT_RANK = {effort: rank for rank, effort in enumerate(EFFORTS)}
try:
PROVIDER_COOLDOWN_S = float(
os.environ.get("HERMES_PROVIDER_COOLDOWN_S", "900")
)
except (TypeError, ValueError):
PROVIDER_COOLDOWN_S = 900.0
RISK_TERMS = { RISK_TERMS = {
"credential", "credential",
@ -409,6 +415,37 @@ def _write_policy(value: dict[str, Any]) -> None:
os.replace(temporary, POLICY_PATH) os.replace(temporary, POLICY_PATH)
def _provider_is_cooled_down(policy: dict[str, Any], provider: str) -> bool:
"""Return whether a recent runtime fallback temporarily suppresses a lane."""
cooldowns = policy.get("provider_cooldowns")
if not isinstance(cooldowns, dict):
return False
state = cooldowns.get(provider)
if not isinstance(state, dict):
return False
try:
return float(state.get("until_epoch") or 0) > time.time()
except (TypeError, ValueError):
return False
def _cool_down_provider(
policy: dict[str, Any], provider: str, actual_provider: str
) -> None:
"""Circuit-break a provider after Hermes had to cross-provider fallback."""
duration = max(60.0, min(PROVIDER_COOLDOWN_S, 3600.0))
cooldowns = policy.get("provider_cooldowns")
if not isinstance(cooldowns, dict):
cooldowns = {}
cooldowns[provider] = {
"until_epoch": time.time() + duration,
"reason": "cross-provider runtime fallback",
"actual_provider": actual_provider,
"recorded_at": datetime.now(timezone.utc).isoformat(),
}
policy["provider_cooldowns"] = cooldowns
def _split_route(route: str) -> tuple[str, str]: def _split_route(route: str) -> tuple[str, str]:
provider, separator, model = route.partition("/") provider, separator, model = route.partition("/")
if not separator or not provider or not model: if not separator or not provider or not model:
@ -417,14 +454,27 @@ def _split_route(route: str) -> tuple[str, str]:
def select_route( def select_route(
status: dict[str, Any], decision: Decision, model_override: str = "" status: dict[str, Any],
decision: Decision,
model_override: str = "",
policy: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Resolve a connected managed provider/model chain for a decision.""" """Resolve a connected managed provider/model chain for a decision."""
providers = status.get("providers") or {} providers = status.get("providers") or {}
policy = policy if isinstance(policy, dict) else _current_policy()
selected = decision.provider selected = decision.provider
provider_key = "openai-codex" if selected == "codex" else "anthropic" provider_key = "openai-codex" if selected == "codex" else "anthropic"
alternate = "claude" if selected == "codex" else "codex" alternate = "claude" if selected == "codex" else "codex"
if not bool((providers.get(provider_key) or {}).get("connected", True)): alternate_key = "anthropic" if alternate == "claude" else "openai-codex"
selected_unavailable = (
not bool((providers.get(provider_key) or {}).get("connected", True))
or _provider_is_cooled_down(policy, provider_key)
)
alternate_available = (
bool((providers.get(alternate_key) or {}).get("connected", True))
and not _provider_is_cooled_down(policy, alternate_key)
)
if selected_unavailable and alternate_available:
selected = alternate selected = alternate
profile = f"{selected}-{decision.effort}" profile = f"{selected}-{decision.effort}"
chain = (status.get("routes") or {}).get(profile) chain = (status.get("routes") or {}).get(profile)
@ -625,6 +675,8 @@ def _post_turn_route(ctx: Any, **kwargs: Any) -> None:
fallback_used = ( fallback_used = (
actual_provider != target_provider or actual_model != target_model actual_provider != target_provider or actual_model != target_model
) )
if target_provider and actual_provider != target_provider:
_cool_down_provider(policy, target_provider, actual_provider)
last.update( last.update(
{ {
"actual_provider": actual_provider, "actual_provider": actual_provider,

View File

@ -16,12 +16,6 @@ import yaml
CODEX_BASELINE = "gpt-5.6-terra" CODEX_BASELINE = "gpt-5.6-terra"
CLAUDE_BASELINE = "claude-opus-5" CLAUDE_BASELINE = "claude-opus-5"
EFFORTS = ("low", "medium", "high", "xhigh") EFFORTS = ("low", "medium", "high", "xhigh")
JETSON_FALLBACK = {
"provider": "custom",
"model": "qwen2.5:14b-instruct-q4_0",
"base_url": "http://ollama.ai.svc.cluster.local:11434/v1",
"api_key": "ollama",
}
ATLAS_FALLBACK = { ATLAS_FALLBACK = {
"provider": "custom", "provider": "custom",
"model": "gpt-oss:20b", "model": "gpt-oss:20b",
@ -337,7 +331,6 @@ def _profile_config(
} }
config["fallback_providers"] = [ config["fallback_providers"] = [
fallback, fallback,
copy.deepcopy(JETSON_FALLBACK),
copy.deepcopy(ATLAS_FALLBACK), copy.deepcopy(ATLAS_FALLBACK),
] ]
agent = config.setdefault("agent", {}) agent = config.setdefault("agent", {})
@ -408,7 +401,6 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
} }
base["fallback_providers"] = [ base["fallback_providers"] = [
{"provider": "anthropic", "model": claude_coordinator}, {"provider": "anthropic", "model": claude_coordinator},
copy.deepcopy(JETSON_FALLBACK),
copy.deepcopy(ATLAS_FALLBACK), copy.deepcopy(ATLAS_FALLBACK),
] ]
base["model_catalog"] = {"enabled": True, "ttl_hours": 1} base["model_catalog"] = {"enabled": True, "ttl_hours": 1}

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Make Hermes' deferred TUI agent startup deadline operator-configurable."""
from __future__ import annotations
import argparse
from pathlib import Path
BEFORE = '''def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
ready = session.get("agent_ready")
if ready is not None and not ready.wait(timeout=timeout):
return _err(rid, 5032, "agent initialization timed out")
'''
AFTER = '''def _agent_init_timeout() -> float:
"""Return the bounded startup allowance for tool-heavy TUI sessions."""
try:
configured = float(
os.environ.get("HERMES_TUI_AGENT_INIT_TIMEOUT_S", "180")
)
except (TypeError, ValueError):
configured = 180.0
return max(30.0, min(configured, 900.0))
def _wait_agent(
session: dict, rid: str, timeout: float | None = None
) -> dict | None:
ready = session.get("agent_ready")
wait_timeout = _agent_init_timeout() if timeout is None else timeout
if ready is not None and not ready.wait(timeout=wait_timeout):
return _err(rid, 5032, "agent initialization timed out")
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the narrow timeout override and fail on upstream drift."""
content = source.read_text(encoding="utf-8")
if BEFORE not in content:
raise RuntimeError("Hermes TUI gateway patch context changed")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content.replace(BEFORE, AFTER, 1), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
patch(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -41,25 +41,6 @@ spec:
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata:
name: hermes-agent
namespace: hermes
labels:
app: hermes-agent
spec:
type: ClusterIP
selector:
app: hermes-agent
ports:
- name: dashboard
port: 8787
targetPort: dashboard
- name: terminal
port: 7681
targetPort: terminal
---
apiVersion: v1
kind: Service
metadata: metadata:
name: hermes-chat-tenant name: hermes-chat-tenant
namespace: hermes namespace: hermes

View File

@ -99,6 +99,28 @@ def test_route_uses_managed_models_and_connected_provider_fallback():
assert fallback["provider"] == "anthropic" 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(): def test_local_classifier_accepts_only_bounded_route_decisions():
assert router._validated_local_route("?", "?", 1) is None assert router._validated_local_route("?", "?", 1) is None
decision = router._validated_local_route("A", "H", 1) decision = router._validated_local_route("A", "H", 1)
@ -477,6 +499,7 @@ def test_post_turn_records_and_announces_capacity_fallback(monkeypatch):
assert outcome["fallback_used"] is True assert outcome["fallback_used"] is True
assert outcome["actual_provider"] == "openai-codex" assert outcome["actual_provider"] == "openai-codex"
assert outcome["actual_model"] == "gpt-5.6-terra" assert outcome["actual_model"] == "gpt-5.6-terra"
assert written[-1]["provider_cooldowns"]["anthropic"]["until_epoch"] > router.time.time()
assert agent.message.startswith("FALLBACK USED") assert agent.message.startswith("FALLBACK USED")

View File

@ -32,6 +32,7 @@ lanes = _load("cli_lane_runner")
policy = _load("claude_command_policy") policy = _load("claude_command_policy")
migration = _load("migrate_herdr_state") migration = _load("migrate_herdr_state")
auth_patch = _load("patch_hermes_auth") auth_patch = _load("patch_hermes_auth")
tui_gateway_patch = _load("patch_tui_gateway")
ttyd_patch = _load("patch_ttyd_index") ttyd_patch = _load("patch_ttyd_index")
client_config = _load("configure_agent_clients") client_config = _load("configure_agent_clients")
@ -625,36 +626,89 @@ def test_agent_uses_one_native_kanban_control_plane():
assert "herdr-dispatch" not in rendered assert "herdr-dispatch" not in rendered
def test_agent_root_is_webui_and_terminal_is_a_separate_path(): def test_agent_root_is_stock_dashboard_and_terminal_is_a_separate_path():
deployment = _agent_deployment() deployment = _agent_deployment()
terminal = next( pod = deployment["spec"]["template"]["spec"]
item for item in deployment["spec"]["template"]["spec"]["containers"] containers = {item["name"]: item for item in pod["containers"]}
if item["name"] == "terminal" assert "webui" not in containers
)
dashboard = containers["dashboard"]
dashboard_command = dashboard["args"][0]
assert "/opt/hermes/.venv/bin/hermes dashboard" in dashboard_command
assert "--host 127.0.0.1" in dashboard_command
assert "--port 9119" in dashboard_command
assert "--skip-build" in dashboard_command
assert "--no-open" in dashboard_command
dashboard_env = {item["name"]: item["value"] for item in dashboard["env"]}
assert dashboard_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180"
terminal = containers["terminal"]
command = terminal["args"][0] command = terminal["args"][0]
assert "--base-path /terminal" in command assert "--base-path /terminal" in command
assert "--check-origin" in command assert "--check-origin" not in command
assert "/usr/bin/tmux new-session -A" in command assert "/usr/bin/tmux new-session -A" in command
assert "--continue" in command assert "--continue" in command
assert "--yolo" in command assert "--yolo" in command
terminal_env = {item["name"]: item["value"] for item in terminal["env"]}
assert terminal_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180"
args = containers["oauth2-proxy"]["args"]
terminal_upstream = "--upstream=http://127.0.0.1:7681/terminal/"
dashboard_upstream = "--upstream=http://127.0.0.1:9119/"
assert terminal_upstream in args
assert dashboard_upstream in args
assert args.index(terminal_upstream) < args.index(dashboard_upstream)
assert "--pass-host-header=false" in args
patch_init = next(
item for item in pod["initContainers"]
if item["name"] == "patch-tui-gateway"
)
assert patch_init["command"][-1] == "/patched/server.py"
for name in ("dashboard", "terminal"):
mounts = containers[name]["volumeMounts"]
assert {
"name": "tui-gateway-patch",
"mountPath": "/opt/hermes/tui_gateway/server.py",
"subPath": "server.py",
} in mounts
oauth = _oauth_deployment("oauth2-proxy-hermes-agent")
args = oauth["spec"]["template"]["spec"]["containers"][0]["args"]
assert "--upstream=http://hermes-agent.hermes.svc.cluster.local:7681/terminal/" in args
assert "--upstream=http://hermes-agent.hermes.svc.cluster.local:8787/" in args
assert args.index("--upstream=http://hermes-agent.hermes.svc.cluster.local:7681/terminal/") < args.index("--upstream=http://hermes-agent.hermes.svc.cluster.local:8787/")
ingress_documents = [ ingress_documents = [
item item
for item in yaml.safe_load_all((HERMES / "agent-ingress.yaml").read_text()) for item in yaml.safe_load_all((HERMES / "agent-ingress.yaml").read_text())
if item if item
] ]
middleware = next(item for item in ingress_documents if item["kind"] == "Middleware") middlewares = {
assert middleware["spec"]["redirectRegex"]["replacement"].endswith("/terminal/") item["metadata"]["name"]: item
for item in ingress_documents
if item["kind"] == "Middleware"
}
assert middlewares["hermes-agent-terminal-slash"]["spec"]["redirectRegex"][
"replacement"
].endswith("/terminal/")
assert middlewares["hermes-agent-stock-dashboard-headers"]["spec"]["headers"][
"customRequestHeaders"
]["Origin"] == "http://127.0.0.1:9119"
ingresses = {
item["metadata"]["name"]: item
for item in ingress_documents
if item["kind"] == "Ingress"
}
assert ingresses["hermes-agent-dashboard"]["metadata"]["annotations"][
"traefik.ingress.kubernetes.io/router.middlewares"
] == "hermes-hermes-agent-stock-dashboard-headers@kubernetescrd"
assert ingresses["hermes-agent-terminal"]["metadata"]["annotations"][
"traefik.ingress.kubernetes.io/router.middlewares"
] == "hermes-hermes-agent-terminal-slash@kubernetescrd"
def test_agent_auth_is_bstein_group_and_email_bounded(): def test_agent_auth_is_bstein_group_and_email_bounded():
oauth = _oauth_deployment("oauth2-proxy-hermes-agent") deployment = _agent_deployment()
args = oauth["spec"]["template"]["spec"]["containers"][0]["args"] oauth = next(
item for item in deployment["spec"]["template"]["spec"]["containers"]
if item["name"] == "oauth2-proxy"
)
args = oauth["args"]
assert "--user-id-claim=sub" in args assert "--user-id-claim=sub" in args
assert "--oidc-groups-claim=groups" in args assert "--oidc-groups-claim=groups" in args
assert "--allowed-group=/hermes-owner" in args assert "--allowed-group=/hermes-owner" in args
@ -674,11 +728,19 @@ def test_agent_network_boundary_allows_only_authenticated_web_surfaces():
isolation = next(item for item in documents if item.get("metadata", {}).get("name") == "hermes-agent-isolation") isolation = next(item for item in documents if item.get("metadata", {}).get("name") == "hermes-agent-isolation")
assert isolation["spec"]["ingress"] == [ assert isolation["spec"]["ingress"] == [
{ {
"from": [{"podSelector": {"matchLabels": {"app": "oauth2-proxy-hermes-agent"}}}], "from": [
"ports": [ {
{"protocol": "TCP", "port": 7681}, "namespaceSelector": {
{"protocol": "TCP", "port": 8787}, "matchLabels": {
"kubernetes.io/metadata.name": "traefik"
}
},
"podSelector": {
"matchLabels": {"app.kubernetes.io/name": "traefik"}
},
}
], ],
"ports": [{"protocol": "TCP", "port": 4180}],
} }
] ]
assert isolation["spec"]["egress"] == [{}] assert isolation["spec"]["egress"] == [{}]
@ -735,7 +797,6 @@ def test_owner_agent_uses_only_the_canonical_hostname():
HERMES / "agent-configmap.yaml", HERMES / "agent-configmap.yaml",
HERMES / "agent-deployment.yaml", HERMES / "agent-deployment.yaml",
HERMES / "agent-ingress.yaml", HERMES / "agent-ingress.yaml",
HERMES / "oauth2-proxy.yaml",
Path(__file__).parents[2] / "scripts/ops/hermes_triage_monitor.py", Path(__file__).parents[2] / "scripts/ops/hermes_triage_monitor.py",
] ]
for path in paths: for path in paths:
@ -774,6 +835,32 @@ def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path):
auth_patch.patch(source, tmp_path / "patched.py") auth_patch.patch(source, tmp_path / "patched.py")
def test_tui_gateway_patch_extends_and_bounds_agent_startup(tmp_path: Path):
source = tmp_path / "server.py"
destination = tmp_path / "patched/server.py"
source.write_text(
"import os\n\n" + tui_gateway_patch.BEFORE + "\ndef unchanged():\n pass\n",
encoding="utf-8",
)
tui_gateway_patch.patch(source, destination)
content = destination.read_text(encoding="utf-8")
assert "HERMES_TUI_AGENT_INIT_TIMEOUT_S" in content
assert 'configured = 180.0' in content
assert "return max(30.0, min(configured, 900.0))" in content
assert "timeout: float | None = None" in content
assert "ready.wait(timeout=wait_timeout)" in content
assert "def unchanged():" in content
def test_tui_gateway_patch_fails_closed_on_upstream_drift(tmp_path: Path):
source = tmp_path / "server.py"
source.write_text("def changed():\n pass\n", encoding="utf-8")
with pytest.raises(RuntimeError, match="context changed"):
tui_gateway_patch.patch(source, tmp_path / "patched.py")
def test_ttyd_clipboard_and_reconnect_patch_remain_enabled(): def test_ttyd_clipboard_and_reconnect_patch_remain_enabled():
source = '<html><body><script>document.execCommand("copy")</script></body></html>' source = '<html><body><script>document.execCommand("copy")</script></body></html>'
content = ttyd_patch.patch_html(source) content = ttyd_patch.patch_html(source)

View File

@ -101,7 +101,8 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
assert claude_profile["fallback_providers"][0]["provider"] == "openai-codex" assert claude_profile["fallback_providers"][0]["provider"] == "openai-codex"
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.JETSON_FALLBACK assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK
assert len(codex_profile["fallback_providers"]) == 2
assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra" assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra"
assert "max" not in json.dumps(routes) assert "max" not in json.dumps(routes)

View File

@ -0,0 +1,39 @@
"""Focused contracts for Hermes's local inference admission proxy."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
HERMES = Path(__file__).parents[2] / "services/hermes"
def _model_gate_namespace() -> dict:
document = yaml.safe_load((HERMES / "model-gate-configmap.yaml").read_text())
namespace = {"__name__": "hermes_model_gate_test"}
exec(compile(document["data"]["model_gate.py"], "model_gate.py", "exec"), namespace)
return namespace
def test_model_gate_clamps_hosted_only_reasoning_efforts():
normalize = _model_gate_namespace()["_normalize_reasoning"]
top_level = json.loads(normalize(b'{"reasoning_effort":"xhigh"}'))
string_reasoning = json.loads(normalize(b'{"reasoning":"max"}'))
nested = json.loads(normalize(b'{"reasoning":{"effort":"xhigh"}}'))
assert top_level["reasoning_effort"] == "high"
assert string_reasoning["reasoning"] == "high"
assert nested["reasoning"]["effort"] == "high"
def test_model_gate_preserves_supported_and_non_json_requests():
normalize = _model_gate_namespace()["_normalize_reasoning"]
supported = b'{"reasoning_effort":"medium","messages":[]}'
non_json = b"streamed-body"
assert normalize(supported) == supported
assert normalize(non_json) == non_json