hermes: restore native provider routing
Some checks failed
Tests / Declarative: Post Actions failed: 3, passed: 260

This commit is contained in:
jenkins 2026-08-12 23:08:21 -03:00
parent b01680ad65
commit a1cd4969ad
25 changed files with 1783 additions and 372 deletions

View File

@ -14,6 +14,7 @@
'atlas/manual/codex/terra':'Codex · Terra', 'atlas/manual/codex/terra':'Codex · Terra',
'atlas/manual/codex/sol':'Codex · SOL', 'atlas/manual/codex/sol':'Codex · SOL',
'atlas/manual/claude/haiku':'Claude · Haiku', 'atlas/manual/claude/haiku':'Claude · Haiku',
'atlas/manual/claude/fable':'Claude · Fable',
'atlas/manual/claude/sonnet':'Claude · Sonnet', 'atlas/manual/claude/sonnet':'Claude · Sonnet',
'atlas/manual/claude/opus':'Claude · Opus', 'atlas/manual/claude/opus':'Claude · Opus',
'atlas/manual/local/qwen-14b':'Local · Qwen 14B' 'atlas/manual/local/qwen-14b':'Local · Qwen 14B'

View File

@ -25,7 +25,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: "20260812-stream-recovery" ai.bstein.dev/config-rev: "20260812-native-claude-subscription"
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
@ -103,6 +103,7 @@ spec:
/opt/data/home/.kube \ /opt/data/home/.kube \
/opt/data/cli-lanes \ /opt/data/cli-lanes \
/opt/data/logs \ /opt/data/logs \
/opt/data/provider-health \
/opt/data/tools/bin \ /opt/data/tools/bin \
/opt/data/workspace/coordinator \ /opt/data/workspace/coordinator \
/opt/data/workspace/projects \ /opt/data/workspace/projects \
@ -153,6 +154,7 @@ spec:
/opt/data/home/.kube \ /opt/data/home/.kube \
/opt/data/cli-lanes \ /opt/data/cli-lanes \
/opt/data/logs \ /opt/data/logs \
/opt/data/provider-health \
/opt/data/tools \ /opt/data/tools \
/opt/data/tools/bin \ /opt/data/tools/bin \
/opt/data/workspace \ /opt/data/workspace \
@ -273,6 +275,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-api-server-sessions
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/patch_api_server_sessions.py
- /opt/hermes/gateway/platforms/api_server.py
- /patched/api_server.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: coordinator
mountPath: /opt/coordinator
readOnly: true
- name: api-server-patch
mountPath: /patched
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-codex-runtime - name: patch-codex-runtime
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
@ -350,6 +375,7 @@ spec:
set +a set +a
/opt/hermes/.venv/bin/python /opt/coordinator/configure_agent_clients.py /opt/hermes/.venv/bin/python /opt/coordinator/configure_agent_clients.py
/opt/hermes/.venv/bin/python /opt/coordinator/migrate_herdr_state.py /opt/hermes/.venv/bin/python /opt/coordinator/migrate_herdr_state.py
/opt/hermes/.venv/bin/python /opt/coordinator/migrate_api_session_lineage.py
# Client configuration restores the persisted Codex CLI login. # Client configuration restores the persisted Codex CLI login.
# Refresh routing afterwards so AUTO sees the app-server lane on # Refresh routing afterwards so AUTO sees the app-server lane on
# the first request instead of waiting for the hourly steward. # the first request instead of waiting for the hourly steward.
@ -445,6 +471,7 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {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: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py} - {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_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}
@ -800,6 +827,60 @@ spec:
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi} limits: {cpu: "1", memory: 1Gi}
- name: claude-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
- |
set -a
. /opt/data/.env
set +a
unset ANTHROPIC_API_KEY CLAUDE_API_KEY
exec /opt/hermes/.venv/bin/python /opt/coordinator/claude_oauth_broker.py
ports:
- {name: claude-broker, containerPort: 9006, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HOME, value: /opt/data/home}
- {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: PYTHONPATH, value: /opt/hermes}
- {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_CLAUDE_BIN, value: /opt/coordinator/claude}
- {name: HERMES_CLAUDE_BROKER_PORT, value: "9006"}
- {name: HERMES_CLAUDE_BROKER_READ_TIMEOUT, value: "1800"}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
readinessProbe:
httpGet: {path: /health, port: claude-broker}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: {path: /health, port: claude-broker}
initialDelaySeconds: 30
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {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: tmp, mountPath: /tmp}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "3", memory: 3Gi}
volumes: volumes:
- name: home - name: home
persistentVolumeClaim: persistentVolumeClaim:
@ -825,6 +906,8 @@ spec:
emptyDir: {} emptyDir: {}
- name: tui-gateway-patch - name: tui-gateway-patch
emptyDir: {} emptyDir: {}
- name: api-server-patch
emptyDir: {}
- name: codex-runtime-patch - name: codex-runtime-patch
emptyDir: {} emptyDir: {}
- name: auto-router-plugin - name: auto-router-plugin

View File

@ -63,6 +63,7 @@ data:
atlas/manual/codex/terra: {provider: atlas-switchyard, model: atlas/manual/codex/terra} 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/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/haiku: {provider: atlas-switchyard, model: atlas/manual/claude/haiku}
atlas/manual/claude/fable: {provider: atlas-switchyard, model: atlas/manual/claude/fable}
atlas/manual/claude/sonnet: {provider: atlas-switchyard, model: atlas/manual/claude/sonnet} 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/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus}
atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b} atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b}

View File

@ -20,7 +20,7 @@ spec:
app: hermes-chat-router app: hermes-chat-router
annotations: annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router ai.bstein.dev/role: privacy-preserving-chat-tenant-router
ai.bstein.dev/config-rev: "20260812-keycloak-image-continuation" ai.bstein.dev/config-rev: "20260812-session-sidebar"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
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"
@ -62,7 +62,7 @@ spec:
values: [rpi5] values: [rpi5]
containers: containers:
- name: router - name: router
image: registry.bstein.dev/bstein/hermes-chat-router@sha256:72fbba10d108b1086e5620137f7549a838438ba510abb8cc8884bc592e7fd6bf image: registry.bstein.dev/bstein/hermes-chat-router@sha256:4e318a35353772cf16e39b2038209abd8b774cd065f39d7a99b6dcc6e28e2474
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- {name: http, containerPort: 8080, protocol: TCP} - {name: http, containerPort: 8080, protocol: TCP}

View File

@ -260,7 +260,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:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72 image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -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:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72 image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:

View File

@ -70,6 +70,8 @@ configMapGenerator:
- image_broker.py=scripts/image_broker.py - image_broker.py=scripts/image_broker.py
- 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
- migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
- patch_api_server_sessions.py=scripts/patch_api_server_sessions.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py
- patch_codex_runtime.py=scripts/patch_codex_runtime.py - patch_codex_runtime.py=scripts/patch_codex_runtime.py
- patch_stream_recovery.py=scripts/patch_stream_recovery.py - patch_stream_recovery.py=scripts/patch_stream_recovery.py

View File

@ -109,6 +109,7 @@ spec:
app: hermes-switchyard app: hermes-switchyard
ports: ports:
- {protocol: TCP, port: 9003} - {protocol: TCP, port: 9003}
- {protocol: TCP, port: 9006}
# 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.
@ -356,6 +357,7 @@ spec:
app: hermes-agent app: hermes-agent
ports: ports:
- {protocol: TCP, port: 9003} - {protocol: TCP, port: 9003}
- {protocol: TCP, port: 9006}
- to: - to:
- ipBlock: - ipBlock:
cidr: 0.0.0.0/0 cidr: 0.0.0.0/0

View File

@ -42,6 +42,7 @@ MANUAL_ROUTES = frozenset(
"atlas/manual/codex/terra", "atlas/manual/codex/terra",
"atlas/manual/codex/sol", "atlas/manual/codex/sol",
"atlas/manual/claude/haiku", "atlas/manual/claude/haiku",
"atlas/manual/claude/fable",
"atlas/manual/claude/sonnet", "atlas/manual/claude/sonnet",
"atlas/manual/claude/opus", "atlas/manual/claude/opus",
"atlas/manual/local/qwen-14b", "atlas/manual/local/qwen-14b",
@ -110,6 +111,8 @@ def _normalise_manual_route(provider: str, model: str = "") -> str:
"claude": { "claude": {
"haiku": "atlas/manual/claude/haiku", "haiku": "atlas/manual/claude/haiku",
"claude-haiku-4-5-20251001": "atlas/manual/claude/haiku", "claude-haiku-4-5-20251001": "atlas/manual/claude/haiku",
"fable": "atlas/manual/claude/fable",
"claude-fable-5": "atlas/manual/claude/fable",
"sonnet": "atlas/manual/claude/sonnet", "sonnet": "atlas/manual/claude/sonnet",
"claude-sonnet-5": "atlas/manual/claude/sonnet", "claude-sonnet-5": "atlas/manual/claude/sonnet",
"opus": "atlas/manual/claude/opus", "opus": "atlas/manual/claude/opus",
@ -166,6 +169,14 @@ def _boundary_selection(agent: Any) -> tuple[str, str, str]:
return str(policy["auto_route"]), ui_effort, "auto" return str(policy["auto_route"]), ui_effort, "auto"
def _resolved_route(route: str, effort: str) -> str:
"""Bind a manual family and UI effort to an exact Switchyard route."""
if route not in MANUAL_ROUTES or route.endswith("/local/qwen-14b"):
return route
provider_effort = effort if effort in {"low", "medium", "high", "xhigh"} else "low"
return f"{route}/{provider_effort}"
def _switch_agent(ctx: Any, agent: Any, route: str, effort: str) -> None: def _switch_agent(ctx: Any, agent: Any, route: str, effort: str) -> None:
"""Point one live Hermes agent at Switchyard and remove local failover.""" """Point one live Hermes agent at Switchyard and remove local failover."""
runtime_agent = _runtime_agent(ctx) runtime_agent = _runtime_agent(ctx)
@ -268,7 +279,8 @@ def _route_boundary(ctx: Any, scope: str, **kwargs: Any) -> None:
agent = kwargs.get("agent") or kwargs.get("child") or _runtime_agent(ctx) agent = kwargs.get("agent") or kwargs.get("child") or _runtime_agent(ctx)
if agent is None: if agent is None:
return return
route, effort, source = _boundary_selection(agent) requested_route, effort, source = _boundary_selection(agent)
route = _resolved_route(requested_route, effort)
_switch_agent(ctx, agent, route, effort) _switch_agent(ctx, agent, route, effort)
policy = _load_policy() policy = _load_policy()
_record_boundary(policy, route, effort, source, scope) _record_boundary(policy, route, effort, source, scope)

View File

@ -29,6 +29,7 @@
function ProviderCard(props) { function ProviderCard(props) {
const item = props.item || {}; const item = props.item || {};
const account = item.account || null; const account = item.account || null;
const nativeHealth = item.native_health || null;
const authLabel = account && account.access_token_live === false && account.refreshable const authLabel = account && account.access_token_live === false && account.refreshable
? "Authentication refreshable" ? "Authentication refreshable"
: account && account.authenticated : account && account.authenticated
@ -47,6 +48,10 @@
h("span", { className: account.authenticated ? "is-good" : "is-bad" }, authLabel), h("span", { className: account.authenticated ? "is-good" : "is-bad" }, authLabel),
account.rate_limit_tier && account.rate_limit_tier !== "unknown" ? h("span", null, "Tier: " + account.rate_limit_tier) : null, account.rate_limit_tier && account.rate_limit_tier !== "unknown" ? h("span", null, "Tier: " + account.rate_limit_tier) : null,
h("span", null, "Access token: " + when(account.token_expires_at)), h("span", null, "Access token: " + when(account.token_expires_at)),
nativeHealth && nativeHealth.transport ? h("span", null, "Transport: native Claude Code subscription") : null,
nativeHealth && nativeHealth.rate_limit && nativeHealth.rate_limit.utilization != null
? h("span", null, "Observed utilization: " + Math.round(Number(nativeHealth.rate_limit.utilization) * 100) + "%")
: null,
account.usage_url ? h("a", { account.usage_url ? h("a", {
className: "provider-status-usage-link", className: "provider-status-usage-link",
href: account.usage_url, href: account.usage_url,
@ -60,7 +65,20 @@
h(Stat, { label: "Tokens", value: number(item.total_tokens) }), h(Stat, { label: "Tokens", value: number(item.total_tokens) }),
h(Stat, { label: "Avg latency", value: item.avg_latency_ms ? number(item.avg_latency_ms) + " ms" : "—" }) h(Stat, { label: "Avg latency", value: item.avg_latency_ms ? number(item.avg_latency_ms) + " ms" : "—" })
), ),
item.configured_models && item.configured_models.length ? h("div", { className: "provider-status-models" },
h("strong", null, "Available models"),
item.supported_efforts && item.supported_efforts.length
? h("p", null, "Supported effort: " + item.supported_efforts.join(" · "))
: null,
item.configured_models.map(function (model) {
return h("div", { className: "provider-status-model", key: "configured-" + model },
h("code", null, model),
h("span", null, "selectable through AUTO or a manual override")
);
})
) : null,
item.models && item.models.length ? h("div", { className: "provider-status-models" }, item.models && item.models.length ? h("div", { className: "provider-status-models" },
h("strong", null, "Observed routes"),
item.models.map(function (model) { item.models.map(function (model) {
return h("div", { className: "provider-status-model", key: model.id }, return h("div", { className: "provider-status-model", key: model.id },
h("code", null, model.id), h("code", null, model.id),

View File

@ -25,6 +25,14 @@ CODEX_AUTH_PATH = Path(
CLAUDE_AUTH_PATH = Path( CLAUDE_AUTH_PATH = Path(
os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude") os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude")
) / ".credentials.json" ) / ".credentials.json"
ROUTING_CATALOG_PATH = Path(
os.environ.get("HERMES_ROUTING_CATALOG_PATH", "/routing-catalog/catalog.json")
)
CLAUDE_HEALTH_PATH = Path(
os.environ.get(
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
)
)
def _read_json(path: Path) -> dict[str, Any]: def _read_json(path: Path) -> dict[str, Any]:
@ -104,9 +112,14 @@ def _codex_account() -> dict[str, Any]:
subscription_until, subscription_live = _timestamp( subscription_until, subscription_live = _timestamp(
claims.get("chatgpt_subscription_active_until") claims.get("chatgpt_subscription_active_until")
) )
authenticated = bool(tokens.get("access_token")) and token_live is not False refreshable = bool(tokens.get("refresh_token"))
authenticated = (
bool(tokens.get("access_token")) and token_live is not False
) or refreshable
return { return {
"authenticated": authenticated, "authenticated": authenticated,
"access_token_live": token_live,
"refreshable": refreshable,
"auth_mode": auth.get("auth_mode") or "unknown", "auth_mode": auth.get("auth_mode") or "unknown",
"plan": claims.get("chatgpt_plan_type") or "unknown", "plan": claims.get("chatgpt_plan_type") or "unknown",
"token_expires_at": expires_at, "token_expires_at": expires_at,
@ -193,7 +206,11 @@ def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]:
calls = totals["calls"] calls = totals["calls"]
errors = totals["errors"] errors = totals["errors"]
if calls and errors: # Switchyard counters span the router process lifetime. One old transient
# failure must not leave an otherwise healthy provider permanently yellow.
total_boundaries = calls + errors
error_ratio = errors / total_boundaries if total_boundaries else 0.0
if calls and error_ratio >= 0.05:
state = "degraded" state = "degraded"
elif calls: elif calls:
state = "available" state = "available"
@ -211,6 +228,29 @@ def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]:
} }
def _configured_models(provider: str) -> list[str]:
"""Return the stewarded model catalog independently of observed traffic."""
catalog = _read_json(ROUTING_CATALOG_PATH)
providers = catalog.get("providers")
providers = providers if isinstance(providers, dict) else {}
record = providers.get(provider)
record = record if isinstance(record, dict) else {}
models = record.get("models")
if not isinstance(models, list):
return []
return sorted({str(model) for model in models if isinstance(model, str)})
def _fresh_health(path: Path, maximum_age: float = 86400.0) -> dict[str, Any]:
"""Read recent broker health without treating stale state as authoritative."""
value = _read_json(path)
try:
age = time.time() - path.stat().st_mtime
except OSError:
return {}
return value if age <= maximum_age else {}
def provider_status_payload() -> dict[str, Any]: def provider_status_payload() -> dict[str, Any]:
"""Build the owner-safe status document shared by dashboard and TUI.""" """Build the owner-safe status document shared by dashboard and TUI."""
health = _get_json(f"{SWITCHYARD_ROOT}/health") health = _get_json(f"{SWITCHYARD_ROOT}/health")
@ -221,8 +261,24 @@ def provider_status_payload() -> dict[str, Any]:
name: _provider_summary(name, models) name: _provider_summary(name, models)
for name in ("codex", "claude", "local") for name in ("codex", "claude", "local")
} }
for name, item in providers.items():
item["configured_models"] = _configured_models(name)
item["supported_efforts"] = (
["low", "medium", "high", "xhigh"]
if name in {"codex", "claude"}
else ["medium"]
)
providers["codex"]["account"] = _codex_account() providers["codex"]["account"] = _codex_account()
providers["claude"]["account"] = _claude_account() providers["claude"]["account"] = _claude_account()
claude_health = _fresh_health(CLAUDE_HEALTH_PATH)
providers["claude"]["native_health"] = claude_health
native_state = claude_health.get("state")
if native_state == "available":
providers["claude"]["state"] = "available"
elif native_state == "capacity-limited":
providers["claude"]["state"] = "degraded"
elif native_state == "unavailable":
providers["claude"]["state"] = "unavailable"
classifier = stats.get("classifier") classifier = stats.get("classifier")
classifier = classifier if isinstance(classifier, dict) else {} classifier = classifier if isinstance(classifier, dict) else {}
fallbacks = stats.get("routing_fallbacks") fallbacks = stats.get("routing_fallbacks")
@ -231,9 +287,11 @@ def provider_status_payload() -> dict[str, Any]:
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
"window": "Since the last Switchyard restart", "window": "Since the last Switchyard restart",
"quota_note": ( "quota_note": (
"Codex and Claude subscription balances are not exposed to this " "Codex uses the owner's ChatGPT Codex OAuth and Claude uses the "
"router. Open the provider usage page for authoritative remaining " "owner's native first-party Claude Code subscription; the Claude "
"capacity; the counters here show actual work observed by Switchyard." "lane does not use the metered Anthropic API key. Open each official "
"usage page for authoritative remaining capacity. The counters here "
"show actual work observed by Switchyard."
), ),
"router": { "router": {
"state": "available" if router_ok else "unavailable", "state": "available" if router_ok else "unavailable",

View File

@ -126,6 +126,17 @@ func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") { if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
t.Fatal("Telegram shortcut assets were not injected") t.Fatal("Telegram shortcut assets were not injected")
} }
assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil)
assetRequest.Header.Set("X-Forwarded-User", "subject")
assetResponse := httptest.NewRecorder()
router.ServeHTTP(assetResponse, assetRequest)
asset := assetResponse.Body.String()
if !strings.Contains(asset, "hermes-files-sidebar") || !strings.Contains(asset, "hermes-telegram-sidebar") {
t.Fatal("Files and Telegram were not integrated into the existing sidebar")
}
if strings.Contains(asset, "position:fixed") || strings.Contains(asset, "hermes-chat-tools") {
t.Fatal("legacy floating chat controls remain in the mobile bridge")
}
} }
func TestRouterRedirectsNativeLoginToSafeChatDestination(t *testing.T) { func TestRouterRedirectsNativeLoginToSafeChatDestination(t *testing.T) {

View File

@ -17,13 +17,13 @@ const telegramPage = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes on Telegram</title> <title>Hermes on Telegram</title>
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3"> <link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260812-1">
</head> </head>
<body class="hermes-link-page"> <body class="hermes-link-page">
<main class="hermes-link-card" data-telegram-page> <main class="hermes-link-card" data-telegram-page>
<a class="hermes-back" href="/"> Back to Hermes</a> <a class="hermes-back" href="/"> Back to Hermes</a>
<h1>Hermes on Telegram</h1> <h1>Hermes on Telegram</h1>
<p>Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.</p> <p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p>
<p id="telegram-status">Checking Telegram</p> <p id="telegram-status">Checking Telegram</p>
<div class="hermes-link-actions"> <div class="hermes-link-actions">
<button id="telegram-link" type="button">Create one-time link</button> <button id="telegram-link" type="button">Create one-time link</button>
@ -32,7 +32,7 @@ const telegramPage = `<!doctype html>
<section id="telegram-result" hidden></section> <section id="telegram-result" hidden></section>
<p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p> <p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p>
</main> </main>
<script src="/hermes-chat-bridge.js?v=20260809-3" defer></script> <script src="/hermes-chat-bridge.js?v=20260812-1" defer></script>
</body> </body>
</html>` </html>`
@ -42,7 +42,7 @@ const privateFilesPage = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes Private Files</title> <title>Hermes Private Files</title>
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3"> <link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260812-1">
</head> </head>
<body class="hermes-files-page"> <body class="hermes-files-page">
<main class="hermes-files-shell" data-files-page> <main class="hermes-files-shell" data-files-page>
@ -73,19 +73,18 @@ const privateFilesPage = `<!doctype html>
</section> </section>
</div> </div>
</main> </main>
<script src="/hermes-chat-bridge.js?v=20260809-3" defer></script> <script src="/hermes-chat-bridge.js?v=20260812-1" defer></script>
</body> </body>
</html>` </html>`
const bridgeCSS = ` const bridgeCSS = `
#hermes-chat-shortcuts{position:fixed;right:18px;top:82px;z-index:9999;display:flex;gap:8px;align-items:center;font:600 13px system-ui,sans-serif} #hermes-files-sidebar,#hermes-telegram-sidebar{display:flex;align-items:center}
#hermes-chat-shortcuts a{padding:8px 12px;border-radius:999px;color:#fff;text-decoration:none;box-shadow:0 5px 20px #0005}#hermes-files-shortcut{background:#475569}#hermes-telegram-shortcut{background:#229ed9}
.hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif} .hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif}
.hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006} .hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006}
.hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0} .hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}
.hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}.hermes-link-card button:disabled{cursor:not-allowed;opacity:.45} .hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}.hermes-link-card button:disabled{cursor:not-allowed;opacity:.45}
#telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px} #telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px}
.hermes-files-page{margin:0;min-height:100vh;background:#0b1020;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}.hermes-files-shell{width:min(1500px,calc(100% - 36px));margin:auto;padding:28px 0}.hermes-files-header{display:flex;justify-content:space-between;gap:28px;align-items:end;border-bottom:1px solid #293249;padding-bottom:18px}.hermes-files-header h1{margin:.4rem 0 0}.hermes-files-header p{margin:.25rem 0;color:#9ca3af}.hermes-files-header label{display:grid;gap:6px;color:#9ca3af}.hermes-files-header select{min-width:260px;background:#151b2e;color:#e5e7eb;border:1px solid #39445f;border-radius:8px;padding:9px}.hermes-breadcrumbs{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.hermes-breadcrumbs button{border:0;background:transparent;color:#7dd3fc;cursor:pointer;padding:4px}.hermes-files-grid{display:grid;grid-template-columns:minmax(280px,38%) 1fr;gap:18px}.hermes-files-grid>section{border:1px solid #293249;border-radius:12px;background:#11172a;min-height:65vh;overflow:hidden}.hermes-files-toolbar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #293249}.hermes-button{padding:6px 10px;border-radius:7px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-file-list{list-style:none;margin:0;padding:8px}.hermes-file-list button{width:100%;display:grid;grid-template-columns:1fr auto;gap:14px;text-align:left;border:0;border-radius:7px;padding:9px 10px;background:transparent;color:#e5e7eb;cursor:pointer}.hermes-file-list button:hover,.hermes-file-list button:focus{background:#202941}.hermes-file-meta{color:#8d98ad;font-size:12px}.hermes-file-viewer pre{box-sizing:border-box;margin:0;padding:18px;max-height:calc(65vh - 64px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#d9e2f1;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}#files-status{color:#9ca3af}@media(max-width:800px){#hermes-chat-shortcuts{top:auto;bottom:112px}.hermes-files-header{display:block}.hermes-files-header label{margin-top:14px}.hermes-files-header select{width:100%;min-width:0}.hermes-files-grid{grid-template-columns:1fr}.hermes-files-grid>section{min-height:38vh}} .hermes-files-page{margin:0;min-height:100vh;background:#0b1020;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}.hermes-files-shell{width:min(1500px,calc(100% - 36px));margin:auto;padding:28px 0}.hermes-files-header{display:flex;justify-content:space-between;gap:28px;align-items:end;border-bottom:1px solid #293249;padding-bottom:18px}.hermes-files-header h1{margin:.4rem 0 0}.hermes-files-header p{margin:.25rem 0;color:#9ca3af}.hermes-files-header label{display:grid;gap:6px;color:#9ca3af}.hermes-files-header select{min-width:260px;background:#151b2e;color:#e5e7eb;border:1px solid #39445f;border-radius:8px;padding:9px}.hermes-breadcrumbs{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.hermes-breadcrumbs button{border:0;background:transparent;color:#7dd3fc;cursor:pointer;padding:4px}.hermes-files-grid{display:grid;grid-template-columns:minmax(280px,38%) 1fr;gap:18px}.hermes-files-grid>section{border:1px solid #293249;border-radius:12px;background:#11172a;min-height:65vh;overflow:hidden}.hermes-files-toolbar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #293249}.hermes-button{padding:6px 10px;border-radius:7px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-file-list{list-style:none;margin:0;padding:8px}.hermes-file-list button{width:100%;display:grid;grid-template-columns:1fr auto;gap:14px;text-align:left;border:0;border-radius:7px;padding:9px 10px;background:transparent;color:#e5e7eb;cursor:pointer}.hermes-file-list button:hover,.hermes-file-list button:focus{background:#202941}.hermes-file-meta{color:#8d98ad;font-size:12px}.hermes-file-viewer pre{box-sizing:border-box;margin:0;padding:18px;max-height:calc(65vh - 64px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#d9e2f1;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}#files-status{color:#9ca3af}@media(max-width:800px){.hermes-files-header{display:block}.hermes-files-header label{margin-top:14px}.hermes-files-header select{width:100%;min-width:0}.hermes-files-grid{grid-template-columns:1fr}.hermes-files-grid>section{min-height:38vh}}
` `
const bridgeJS = `(() => { const bridgeJS = `(() => {
@ -96,23 +95,51 @@ const bridgeJS = `(() => {
}; };
hideChatAdministration(); hideChatAdministration();
if (!page && !filesPage) { if (!page && !filesPage) {
if (!document.getElementById('hermes-chat-shortcuts')) { const labelLink = (link, id, href, label, ariaLabel) => {
const shortcuts = document.createElement('nav'); if (link.id !== id) link.id = id;
shortcuts.id = 'hermes-chat-shortcuts'; if (link.getAttribute('href') !== href) link.setAttribute('href', href);
const match = location.pathname.match(/^\/session\/([^/]+)/); if (link.getAttribute('aria-label') !== ariaLabel) link.setAttribute('aria-label', ariaLabel);
const files = document.createElement('a'); const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT);
files.id = 'hermes-files-shortcut'; let textNode = walker.nextNode();
files.href = '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''); let replaced = false;
files.textContent = 'Files'; while (textNode) {
files.setAttribute('aria-label', 'Browse private Hermes files'); if (textNode.textContent.trim()) {
const telegram = document.createElement('a'); const current = textNode.textContent.trim();
telegram.id = 'hermes-telegram-shortcut'; if (current.toLowerCase() !== label.toLowerCase()) {
telegram.href = '/telegram'; textNode.textContent = textNode.textContent.replace(current, label);
telegram.textContent = 'Telegram';
telegram.setAttribute('aria-label', 'Connect Hermes to Telegram');
shortcuts.append(files, telegram);
document.body.appendChild(shortcuts);
} }
replaced = true;
break;
}
textNode = walker.nextNode();
}
if (!replaced) link.append(document.createTextNode(label));
};
const installSidebarLinks = () => {
const match = location.pathname.match(/^\/session\/([^/]+)/);
let files = document.getElementById('hermes-files-sidebar');
if (!files) {
files = Array.from(document.querySelectorAll('a[href]')).find((link) => {
try { return new URL(link.href, location.href).pathname === '/files'; } catch (_) { return false; }
});
}
if (!files) return;
labelLink(files, 'hermes-files-sidebar', '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''), 'Files', 'Browse private Hermes files');
let telegram = document.getElementById('hermes-telegram-sidebar');
if (!telegram) {
telegram = files.cloneNode(true);
files.after(telegram);
}
labelLink(telegram, 'hermes-telegram-sidebar', '/telegram', 'Telegram', 'Connect this account to the shared Hermes Telegram bot');
};
let scheduled = false;
const observer = new MutationObserver(() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; installSidebarLinks(); });
});
installSidebarLinks();
observer.observe(document.body, {childList:true, subtree:true});
return; return;
} }
if (filesPage) { if (filesPage) {
@ -383,8 +410,8 @@ func injectChatBridge(response *http.Response) error {
_ = response.Body.Close() _ = response.Body.Close()
content := string(body) content := string(body)
if !strings.Contains(content, "hermes-chat-bridge.js") { if !strings.Contains(content, "hermes-chat-bridge.js") {
content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3"></head>`, 1) content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260812-1"></head>`, 1)
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260809-3" defer></script></body>`, 1) content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260812-1" defer></script></body>`, 1)
} }
response.Body = io.NopCloser(strings.NewReader(content)) response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content)) response.ContentLength = int64(len(content))

View File

@ -1,72 +1,77 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Translate an internal relay key into the owner's Claude OAuth credential.""" """Expose the owner's native Claude Code subscription as an Anthropic lane."""
from __future__ import annotations from __future__ import annotations
import hmac import hmac
import json import json
import os import os
import re
import shutil
import subprocess
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Final from typing import Any, Final
import httpx from routing_catalog import load_catalog, resolve_route
from routing_catalog import resolve_route
HOST: Final = os.environ.get("HERMES_CLAUDE_BROKER_HOST", "0.0.0.0") HOST: Final = os.environ.get("HERMES_CLAUDE_BROKER_HOST", "0.0.0.0")
PORT: Final = int(os.environ.get("HERMES_CLAUDE_BROKER_PORT", "9006")) PORT: Final = int(os.environ.get("HERMES_CLAUDE_BROKER_PORT", "9006"))
UPSTREAM: Final = os.environ.get( CLAUDE_BIN: Final = os.environ.get("HERMES_CLAUDE_BIN", "/opt/coordinator/claude")
"HERMES_CLAUDE_BROKER_UPSTREAM", "https://api.anthropic.com"
).rstrip("/")
MAX_BODY_BYTES: Final = int( MAX_BODY_BYTES: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20)) os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20))
) )
READ_TIMEOUT_SECONDS: Final = float( TIMEOUT_SECONDS: Final = float(
os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "900") os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "1800")
)
MAX_CONCURRENCY: Final = int(
os.environ.get("HERMES_CLAUDE_BROKER_CONCURRENCY", "4")
)
HEALTH_PATH: Final = Path(
os.environ.get(
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
) )
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/" ROUTED_MODEL_PREFIX: Final = "route/claude/"
CAPACITY_ERROR_MARKERS: Final = ( EFFORTS: Final = {"low", "medium", "high", "xhigh"}
"extra usage", CAPACITY_PATTERN: Final = re.compile(
"plan limits", r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529)",
"usage limit", re.I,
"rate limit",
"credit balance",
"claude.ai/settings/usage",
) )
STRUCTURED_SCHEMA: Final = {
"type": "object",
def _translate_model(body: bytes) -> bytes: "additionalProperties": False,
"""Translate a Switchyard effort-qualified target into a Claude model.""" "required": ["type", "text", "tool_calls"],
if not body: "properties": {
return body "type": {"type": "string", "enum": ["final", "tool_calls"]},
try: "text": {"type": "string"},
payload = json.loads(body) "tool_calls": {
except (TypeError, ValueError, json.JSONDecodeError): "type": "array",
return body "items": {
if not isinstance(payload, dict): "type": "object",
return body "additionalProperties": False,
model = payload.get("model") "required": ["name", "input"],
if isinstance(model, str) and model.startswith(ROUTED_MODEL_PREFIX): "properties": {
payload["model"] = resolve_route(model) "name": {"type": "string"},
return json.dumps(payload, separators=(",", ":")).encode("utf-8") "input": {"type": "object"},
return body },
},
},
},
}
_slots = threading.BoundedSemaphore(MAX_CONCURRENCY)
_auth_probe_lock = threading.Lock()
_auth_probe_at = 0.0
_auth_probe_value: dict[str, Any] = {}
def _read_secret(env_name: str, file_env_name: str) -> str: def _read_secret(env_name: str, file_env_name: str) -> str:
"""Read a secret from an environment value or a mounted file.""" """Read a secret from an environment value or mounted file."""
value = os.environ.get(env_name, "").strip() value = os.environ.get(env_name, "").strip()
if value: if value:
return value return value
@ -80,63 +85,375 @@ def _read_secret(env_name: str, file_env_name: str) -> str:
def _relay_key() -> str: def _relay_key() -> str:
"""Return the shared internal key without caching rotated file contents.""" """Return the internal relay key shared with Switchyard."""
return _read_secret( return (
"HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE" _read_secret("HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE")
or os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
) )
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: def _authorized(authorization: str | None, api_key: str | None) -> bool:
"""Accept Switchyard's x-api-key or an internal Bearer relay key.""" """Accept Switchyard's API-key or bearer-key header."""
expected = _relay_key() expected = _relay_key()
if not expected: if not expected:
return False return False
candidates = [api_key or ""] candidates = [api_key or ""]
if authorization and authorization.startswith("Bearer "): if authorization and authorization.startswith("Bearer "):
candidates.append(authorization[7:].strip()) candidates.append(authorization[7:].strip())
return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates) return any(
candidate and hmac.compare_digest(candidate, expected)
for candidate in candidates
)
def _merge_betas(incoming: str | None) -> str: def _route(model: str, payload: dict[str, Any]) -> tuple[str, str]:
"""Preserve requested Anthropic betas while adding Claude Code OAuth betas.""" """Resolve one Switchyard model and effort into native Claude CLI values."""
values: list[str] = [] effort = "medium"
for value in (*((incoming or "").split(",")), *REQUIRED_BETAS): if model.startswith(ROUTED_MODEL_PREFIX):
value = value.strip() parts = model.split("/")
if value and value not in values: if parts[-1] in EFFORTS:
values.append(value) effort = parts[-1]
return ",".join(values) model = resolve_route(model)
output_config = payload.get("output_config")
if isinstance(output_config, dict) and output_config.get("effort") in EFFORTS:
effort = str(output_config["effort"])
if not model.startswith("claude-"):
raise ValueError("unsupported Claude model")
return model, effort
def _normalized_upstream_status(status: int, body: bytes) -> int: def _prompt(payload: dict[str, Any]) -> str:
"""Expose provider capacity exhaustion using Switchyard's retryable status.""" """Describe one Anthropic boundary without letting Claude run local tools."""
if status != 400: tools = payload.get("tools")
return status tools = tools if isinstance(tools, list) else []
text = body.decode("utf-8", errors="replace").lower() contract = {
if any(marker in text for marker in CAPACITY_ERROR_MARKERS): "system": payload.get("system") or "",
return 429 "messages": payload.get("messages") or [],
return status "tools": tools,
"tool_choice": payload.get("tool_choice") or {"type": "auto"},
}
return (
"You are serving one model boundary for Hermes. The JSON below is the "
"complete conversation and the only source of task context. Do not run "
"Claude Code tools or modify files yourself. If a listed external tool "
"is needed, return type=tool_calls with its exact name and a valid input "
"object. Otherwise return type=final and place the complete user-facing "
"answer in text. Do not describe this envelope.\n\n"
+ json.dumps(contract, ensure_ascii=False, separators=(",", ":"))
)
def _usage(event: dict[str, Any]) -> dict[str, int]:
"""Translate Claude Code's result accounting into Anthropic token fields."""
raw = event.get("usage")
raw = raw if isinstance(raw, dict) else {}
return {
"input_tokens": max(0, int(raw.get("input_tokens") or 0)),
"output_tokens": max(0, int(raw.get("output_tokens") or 0)),
"cache_creation_input_tokens": max(
0, int(raw.get("cache_creation_input_tokens") or 0)
),
"cache_read_input_tokens": max(
0, int(raw.get("cache_read_input_tokens") or 0)
),
}
def _atomic_health(value: dict[str, Any]) -> None:
"""Persist non-secret subscription/transport health for the owner dashboard."""
try:
HEALTH_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = HEALTH_PATH.with_name(f".{HEALTH_PATH.name}.{os.getpid()}.tmp")
temporary.write_text(
json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
os.replace(temporary, HEALTH_PATH)
except OSError:
pass
def _previous_health() -> dict[str, Any]:
"""Read the prior non-secret health snapshot when it is still valid JSON."""
try:
value = json.loads(HEALTH_PATH.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _claude_environment() -> dict[str, str]:
"""Return an environment that cannot silently select metered API billing."""
environment = os.environ.copy()
environment.pop("ANTHROPIC_API_KEY", None)
environment.pop("CLAUDE_API_KEY", None)
return environment
def _subscription_health(force: bool = False) -> dict[str, Any]:
"""Probe the native CLI login and cache the non-secret account result."""
global _auth_probe_at, _auth_probe_value
now = time.monotonic()
with _auth_probe_lock:
if not force and _auth_probe_value and now - _auth_probe_at < 60:
return dict(_auth_probe_value)
checked_at = datetime.now(timezone.utc).isoformat()
try:
completed = subprocess.run(
[CLAUDE_BIN, "auth", "status"],
text=True,
capture_output=True,
timeout=15,
env=_claude_environment(),
check=False,
)
raw = json.loads(completed.stdout) if completed.stdout.strip() else {}
except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
completed = None
raw = {}
authenticated = bool(
completed
and completed.returncode == 0
and isinstance(raw, dict)
and raw.get("loggedIn") is True
and raw.get("apiProvider") == "firstParty"
)
previous = _previous_health()
value = {
"transport": "claude-code-cli-subscription",
"state": "available" if authenticated else "unavailable",
"checked_at": checked_at,
"authenticated": authenticated,
"api_provider": raw.get("apiProvider") if isinstance(raw, dict) else None,
"auth_method": raw.get("authMethod") if isinstance(raw, dict) else None,
"subscription_type": raw.get("subscriptionType")
if isinstance(raw, dict)
else None,
}
# Readiness probes must not erase the most recently observed native
# usage window or successful route metadata. They only refresh auth.
for key in (
"rate_limit",
"last_success_at",
"last_error_at",
"latency_ms",
"model",
"effort",
):
if key in previous:
value[key] = previous[key]
_auth_probe_at = now
_auth_probe_value = value
_atomic_health(value)
return dict(value)
def _invoke(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], str]:
"""Run one native, first-party Claude Code subscription request."""
requested = payload.get("model")
if not isinstance(requested, str):
raise ValueError("Claude model is required")
model, effort = _route(requested, payload)
command = [
CLAUDE_BIN,
"-p",
"--output-format",
"stream-json",
"--verbose",
"--no-session-persistence",
"--tools",
"",
"--model",
model,
"--effort",
effort,
"--json-schema",
json.dumps(STRUCTURED_SCHEMA, separators=(",", ":")),
]
environment = _claude_environment()
started = time.monotonic()
with _slots:
completed = subprocess.run(
command,
input=_prompt(payload),
text=True,
capture_output=True,
timeout=TIMEOUT_SECONDS,
env=environment,
check=False,
)
result_event: dict[str, Any] = {}
rate_limit: dict[str, Any] = {}
for line in completed.stdout.splitlines():
try:
event = json.loads(line)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if not isinstance(event, dict):
continue
if event.get("type") == "rate_limit_event":
raw = event.get("rate_limit_info")
if isinstance(raw, dict):
rate_limit = raw
if event.get("type") == "result":
result_event = event
error_text = "\n".join(
value for value in (completed.stderr.strip(), completed.stdout[-8000:]) if value
)
if completed.returncode or result_event.get("is_error"):
health = _subscription_health()
health.update(
{
"state": "capacity-limited"
if CAPACITY_PATTERN.search(error_text)
else "unavailable",
"last_error_at": datetime.now(timezone.utc).isoformat(),
"model": model,
"effort": effort,
"rate_limit": rate_limit,
}
)
_atomic_health(
health
)
kind = "capacity" if CAPACITY_PATTERN.search(error_text) else "provider"
raise RuntimeError(f"{kind}: {error_text[-1200:] or 'Claude CLI failed'}")
structured = result_event.get("structured_output")
if not isinstance(structured, dict):
raw_result = result_event.get("result")
try:
structured = json.loads(raw_result) if isinstance(raw_result, str) else None
except (TypeError, ValueError, json.JSONDecodeError):
structured = None
if not isinstance(structured, dict):
raise RuntimeError("provider: Claude CLI returned no structured result")
usage = _usage(result_event)
actual_model = str(
next(iter(result_event.get("modelUsage") or {}), model)
if isinstance(result_event.get("modelUsage"), dict)
else model
)
health = _subscription_health()
health.update(
{
"state": "available",
"last_success_at": datetime.now(timezone.utc).isoformat(),
"latency_ms": int((time.monotonic() - started) * 1000),
"model": actual_model,
"effort": effort,
"rate_limit": rate_limit,
}
)
_atomic_health(health)
return structured, usage, actual_model
def _message(
structured: dict[str, Any], usage: dict[str, int], model: str
) -> dict[str, Any]:
"""Build one Anthropic Messages response from the structured CLI result."""
content: list[dict[str, Any]] = []
text = structured.get("text")
if isinstance(text, str) and text:
content.append({"type": "text", "text": text})
tool_calls = structured.get("tool_calls")
if isinstance(tool_calls, list):
for raw in tool_calls:
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
continue
tool_input = raw.get("input")
content.append(
{
"type": "tool_use",
"id": f"toolu_{uuid.uuid4().hex}",
"name": raw["name"],
"input": tool_input if isinstance(tool_input, dict) else {},
}
)
if not content:
content.append({"type": "text", "text": ""})
return {
"id": f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": "tool_use"
if any(item["type"] == "tool_use" for item in content)
else "end_turn",
"stop_sequence": None,
"usage": usage,
}
def _sse(message: dict[str, Any]) -> bytes:
"""Encode a completed message as a standards-compliant Anthropic SSE stream."""
events: list[tuple[str, dict[str, Any]]] = []
opening = dict(message)
opening["content"] = []
opening["stop_reason"] = None
opening["usage"] = {
"input_tokens": message["usage"]["input_tokens"],
"output_tokens": 0,
}
events.append(("message_start", {"type": "message_start", "message": opening}))
for index, block in enumerate(message["content"]):
if block["type"] == "text":
start = {"type": "text", "text": ""}
delta = {"type": "text_delta", "text": block["text"]}
else:
start = {key: block[key] for key in ("type", "id", "name")}
start["input"] = {}
delta = {
"type": "input_json_delta",
"partial_json": json.dumps(block["input"], separators=(",", ":")),
}
events.extend(
[
(
"content_block_start",
{
"type": "content_block_start",
"index": index,
"content_block": start,
},
),
(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": delta,
},
),
("content_block_stop", {"type": "content_block_stop", "index": index}),
]
)
events.append(
(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": message["stop_reason"], "stop_sequence": None},
"usage": {"output_tokens": message["usage"]["output_tokens"]},
},
)
)
events.append(("message_stop", {"type": "message_stop"}))
return "".join(
f"event: {name}\ndata: {json.dumps(value, separators=(',', ':'))}\n\n"
for name, value in events
).encode("utf-8")
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
"""Stream Anthropic responses while keeping the OAuth token server-side.""" """Serve the subset of Anthropic Messages used by Switchyard."""
server_version = "HermesClaudeOAuthBroker/1" server_version = "HermesClaudeCodeBroker/2"
def log_message(self, format: str, *args: object) -> None: def log_message(self, format: str, *args: object) -> None:
"""Avoid logging paths or headers that could contain sensitive metadata."""
return return
def _json(self, status: int, value: dict[str, object]) -> None: def _json(self, status: int, value: dict[str, Any]) -> None:
body = json.dumps(value, separators=(",", ":")).encode("utf-8") body = json.dumps(value, separators=(",", ":")).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
@ -145,33 +462,54 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _error(self, status: int, error_type: str, message: str) -> None:
self._json(
status,
{"type": "error", "error": {"type": error_type, "message": message}},
)
def _check_auth(self) -> bool: def _check_auth(self) -> bool:
if _authorized( if _authorized(self.headers.get("Authorization"), self.headers.get("x-api-key")):
self.headers.get("Authorization"), self.headers.get("x-api-key")
):
return True return True
self._json(401, {"error": {"type": "authentication_error", "message": "unauthorized"}}) self._error(401, "authentication_error", "unauthorized")
return False return False
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_GET(self) -> None: # noqa: N802
if self.path == "/health": if self.path == "/health":
try: executable = shutil.which(CLAUDE_BIN) or (
_oauth_token() CLAUDE_BIN if Path(CLAUDE_BIN).is_file() else ""
except RuntimeError as exc: )
self._json(503, {"ok": False, "error": str(exc)}) health = _subscription_health()
status = 200 if executable and health.get("authenticated") else 503
self._json(
status,
{
"ok": status == 200,
"provider": "claude-code-subscription",
"subscription_type": health.get("subscription_type"),
"auth_method": health.get("auth_method"),
},
)
return return
self._json(200, {"ok": True, "provider": "anthropic-oauth"}) if self.path != "/v1/models":
return self._error(404, "not_found", "not found")
if self.path not in ALLOWED_PATHS:
self._json(404, {"error": {"type": "not_found", "message": "not found"}})
return return
if not self._check_auth(): if not self._check_auth():
return return
self._proxy(b"") catalog = load_catalog()
providers = catalog.get("providers", {})
claude = providers.get("claude", {}) if isinstance(providers, dict) else {}
raw_models = claude.get("models", []) if isinstance(claude, dict) else []
models = sorted(
model
for model in raw_models
if isinstance(model, str) and model.startswith("claude-")
)
self._json(200, {"data": [{"id": model, "type": "model"} for model in models]})
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API def do_POST(self) -> None: # noqa: N802
if self.path not in ALLOWED_PATHS: if self.path not in {"/v1/messages", "/v1/messages/count_tokens"}:
self._json(404, {"error": {"type": "not_found", "message": "not found"}}) self._error(404, "not_found", "not found")
return return
if not self._check_auth(): if not self._check_auth():
return return
@ -180,93 +518,55 @@ class Handler(BaseHTTPRequestHandler):
except ValueError: except ValueError:
length = -1 length = -1
if length < 0 or length > MAX_BODY_BYTES: if length < 0 or length > MAX_BODY_BYTES:
self._json(413, {"error": {"type": "request_too_large", "message": "request too large"}}) self._error(413, "request_too_large", "request too large")
return 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: try:
token = _oauth_token() payload = json.loads(self.rfile.read(length))
headers = { except (TypeError, ValueError, json.JSONDecodeError):
"Accept": self.headers.get("Accept", "application/json"), self._error(400, "invalid_request_error", "JSON object required")
"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:
if response.status_code >= 400:
error_body = response.read()
self.send_response(
_normalized_upstream_status(
response.status_code, error_body
)
)
self.send_header(
"Content-Type",
response.headers.get("Content-Type", "application/json"),
)
self.send_header("Content-Length", str(len(error_body)))
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
response_started = True
self.wfile.write(error_body)
return return
if not isinstance(payload, dict):
self.send_response(response.status_code) self._error(400, "invalid_request_error", "JSON object required")
for name, value in response.headers.items(): return
if name.lower() in { if self.path.endswith("count_tokens"):
"content-type", self._json(200, {"input_tokens": max(1, len(_prompt(payload)) // 4)})
"cache-control", return
"request-id", try:
"retry-after", structured, usage, model = _invoke(payload)
"anthropic-ratelimit-requests-limit", message = _message(structured, usage, model)
"anthropic-ratelimit-requests-remaining", except ValueError as exc:
"anthropic-ratelimit-requests-reset", self._error(400, "invalid_request_error", str(exc))
"anthropic-ratelimit-tokens-limit", return
"anthropic-ratelimit-tokens-remaining", except subprocess.TimeoutExpired:
"anthropic-ratelimit-tokens-reset", self._error(504, "timeout_error", "Claude Code request timed out")
}: return
self.send_header(name, value) except (OSError, RuntimeError) as exc:
self.send_header("Connection", "close") detail = str(exc)
self.end_headers() status = 429 if detail.startswith("capacity:") else 503
response_started = True self._error(
for chunk in response.iter_bytes(): status,
if chunk: "rate_limit_error" if status == 429 else "api_error",
self.wfile.write(chunk) detail.partition(": ")[2] or detail,
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: return
self.close_connection = True if payload.get("stream"):
body = _sse(message)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self._json(200, message)
class Server(ThreadingHTTPServer): class Server(ThreadingHTTPServer):
"""Threaded server whose request workers do not block shutdown.""" """Threaded broker with bounded provider-side concurrency."""
daemon_threads = True daemon_threads = True
if __name__ == "__main__": if __name__ == "__main__":
_subscription_health(force=True)
Server((HOST, PORT), Handler).serve_forever() Server((HOST, PORT), Handler).serve_forever()

View File

@ -5,9 +5,11 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import fcntl
import hmac import hmac
import json import json
import os import os
import tempfile
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@ -39,6 +41,9 @@ FALLBACK_ALLOWED_MODELS = {
if value.strip() if value.strip()
} }
ROUTED_MODEL_PREFIX = "route/codex/" ROUTED_MODEL_PREFIX = "route/codex/"
TOKEN_REFRESH_SKEW_SECONDS = int(
os.environ.get("HERMES_CODEX_BROKER_REFRESH_SKEW_SECONDS", "300")
)
def _real_model(model: str) -> str: def _real_model(model: str) -> str:
@ -55,31 +60,86 @@ def _authorized(header: str | None) -> bool:
return hmac.compare_digest(header[7:].strip(), TOKEN) return hmac.compare_digest(header[7:].strip(), TOKEN)
def _access_token() -> str: def _token_expiry(token: str) -> float:
"""Read the current owner token; the Codex CLI remains refresh owner.""" """Return a JWT expiry timestamp, or zero for an opaque token."""
codex_home = Path(
os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
).expanduser()
try:
payload = json.loads((codex_home / "auth.json").read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise RuntimeError("owner Codex authentication is unavailable") from exc
if not isinstance(payload, dict):
raise RuntimeError("owner Codex authentication is invalid")
tokens = payload.get("tokens") or {}
token = tokens.get("access_token") if isinstance(tokens, dict) else None
if not isinstance(token, str) or not token.strip():
raise RuntimeError("owner Codex access token is unavailable")
token = token.strip()
try: try:
encoded = token.split(".")[1] encoded = token.split(".")[1]
encoded += "=" * (-len(encoded) % 4) encoded += "=" * (-len(encoded) % 4)
expires_at = json.loads(base64.urlsafe_b64decode(encoded)).get("exp", 0) return float(json.loads(base64.urlsafe_b64decode(encoded)).get("exp", 0))
except (IndexError, ValueError, TypeError, json.JSONDecodeError, binascii.Error): except (IndexError, ValueError, TypeError, json.JSONDecodeError, binascii.Error):
expires_at = 0 return 0.0
if expires_at and time.time() >= float(expires_at):
raise RuntimeError("owner Codex access token is expired")
def _write_codex_auth(path: Path, payload: dict[str, Any]) -> None:
"""Atomically persist refreshed first-party credentials for every CLI lane."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".auth.", suffix=".json", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(payload, stream, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def _access_token(*, force_refresh: bool = False) -> str:
"""Return a live ChatGPT OAuth token, refreshing the canonical CLI store."""
codex_home = Path(
os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))
).expanduser()
auth_path = codex_home / "auth.json"
lock_path = codex_home / "hermes-codex-broker.lock"
codex_home.mkdir(parents=True, exist_ok=True)
try:
with lock_path.open("a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
payload = json.loads(auth_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("owner Codex authentication is invalid")
tokens = payload.get("tokens") or {}
if not isinstance(tokens, dict):
raise RuntimeError("owner Codex authentication is invalid")
token = str(tokens.get("access_token") or "").strip()
if not token:
raise RuntimeError("owner Codex access token is unavailable")
expires_at = _token_expiry(token)
should_refresh = force_refresh or (
bool(expires_at)
and expires_at <= time.time() + TOKEN_REFRESH_SKEW_SECONDS
)
if should_refresh:
refresh_token = str(tokens.get("refresh_token") or "").strip()
if not refresh_token:
raise RuntimeError("owner Codex refresh token is unavailable")
# Use the same first-party ChatGPT OAuth refresh as Codex CLI.
# This never introduces an OpenAI API key or metered billing.
from hermes_cli.auth import refresh_codex_oauth_pure
refreshed = refresh_codex_oauth_pure(
token,
refresh_token,
timeout_seconds=30.0,
)
tokens["access_token"] = refreshed["access_token"]
tokens["refresh_token"] = refreshed.get(
"refresh_token", refresh_token
)
payload["tokens"] = tokens
payload["last_refresh"] = refreshed.get("last_refresh")
_write_codex_auth(auth_path, payload)
token = str(tokens["access_token"]).strip()
return token return token
except RuntimeError:
raise
except (OSError, ValueError) as exc:
raise RuntimeError("owner Codex authentication is unavailable") from exc
def _upstream_headers(token: str) -> dict[str, str]: def _upstream_headers(token: str) -> dict[str, str]:

View File

@ -19,6 +19,12 @@ import yaml
CODEX_BASELINE = "gpt-5.6-terra" CODEX_BASELINE = "gpt-5.6-terra"
CLAUDE_BASELINE = "claude-opus-5" CLAUDE_BASELINE = "claude-opus-5"
CLAUDE_SUBSCRIPTION_MODELS = (
"claude-haiku-4-5-20251001",
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-5",
)
EFFORTS = ("low", "medium", "high", "xhigh") EFFORTS = ("low", "medium", "high", "xhigh")
ATLAS_FALLBACK = { ATLAS_FALLBACK = {
"provider": "custom", "provider": "custom",
@ -271,7 +277,12 @@ def build_routing_catalog(
"claude", "claude",
claude, claude,
choose_claude_for_effort, choose_claude_for_effort,
{"haiku": "low", "sonnet": "medium", "opus": "xhigh"}, {
"haiku": "low",
"fable": "medium",
"sonnet": "high",
"opus": "xhigh",
},
{ {
"low": "claude-haiku-4-5-20251001", "low": "claude-haiku-4-5-20251001",
"medium": "claude-sonnet-5", "medium": "claude-sonnet-5",
@ -444,7 +455,45 @@ def discover_codex_models() -> Catalog:
def discover_claude_models() -> Catalog: def discover_claude_models() -> Catalog:
"""Use Anthropic's authenticated model endpoint when configured.""" """Use the native subscription login before any API-key catalog fallback."""
claude = shutil.which("claude") or os.environ.get("HERMES_CLAUDE_BIN", "")
if claude:
try:
environment = os.environ.copy()
environment.pop("ANTHROPIC_API_KEY", None)
environment.pop("CLAUDE_API_KEY", None)
status = subprocess.run(
[claude, "auth", "status"],
capture_output=True,
check=False,
text=True,
timeout=15,
env=environment,
)
detail = json.loads(status.stdout) if status.stdout.strip() else {}
if (
status.returncode == 0
and isinstance(detail, dict)
and detail.get("loggedIn") is True
and detail.get("apiProvider") == "firstParty"
):
try:
from hermes_cli.models import provider_model_ids
known = _unique_models(
provider_model_ids("anthropic", force_refresh=True)
)
except Exception:
known = []
return Catalog(
"anthropic",
_unique_models((*CLAUDE_SUBSCRIPTION_MODELS, *known)),
True,
True,
"connected-subscription",
)
except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
pass
token = str(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip() token = str(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip()
live: list[str] = [] live: list[str] = []
if token: if token:

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Nest known legacy agent API workers under their originating objective."""
from __future__ import annotations
import sqlite3
from pathlib import Path
STATE_DB = Path("/opt/data/state.db")
LEGACY_CASSANDRA_PARENT = "20260812_042739_d6ee37"
LEGACY_CASSANDRA_WORKERS = {
"api-d947ebb51d09eb97": "Cassandra handoff existence check",
"api-bd98c6145593e9e9": "Cassandra handoff heading check",
"api-cb00bdf2f7cd3889": "Cassandra worktree file check",
"api-91b5c2de1759380b": "Agent hosted route check",
"api-0d1d15e79c619f7b": "Cassandra file-tool check",
"api-5550b3556424fbdc": "Cassandra handoff summary",
"api-c288b9f3024a91b8": "Cassandra stale-worktree check",
"api-058e51802b58ee3f": "Cassandra verification worker",
}
def migrate(path: Path = STATE_DB) -> int:
"""Apply idempotent, transcript-preserving lineage corrections."""
if not path.is_file():
return 0
changed = 0
with sqlite3.connect(path) as connection:
parent = connection.execute(
"SELECT id FROM sessions WHERE id = ?", (LEGACY_CASSANDRA_PARENT,)
).fetchone()
if not parent:
return 0
for session_id, title in LEGACY_CASSANDRA_WORKERS.items():
cursor = connection.execute(
"""
UPDATE sessions
SET parent_session_id = ?,
title = ?
WHERE id = ?
AND source = 'api_server'
AND parent_session_id IS NULL
""",
(LEGACY_CASSANDRA_PARENT, title, session_id),
)
changed += cursor.rowcount
return changed
if __name__ == "__main__":
print(f"migrated {migrate()} legacy API sessions")

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Add explicit parent lineage to Hermes API-created sessions."""
from __future__ import annotations
import argparse
from pathlib import Path
BEFORE = ''' model = body.get("model") or self._model_name
system_prompt = body.get("system_prompt")
if system_prompt is not None and not isinstance(system_prompt, str):
return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400)
db.create_session(session_id, "api_server", model=str(model) if model else None, system_prompt=system_prompt)
'''
AFTER = ''' model = body.get("model") or self._model_name
system_prompt = body.get("system_prompt")
if system_prompt is not None and not isinstance(system_prompt, str):
return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400)
# API workers are first-class children of the objective that launched
# them. Accept a JSON field for normal clients and a header for thin
# relays that cannot extend their request schema.
metadata = body.get("metadata")
metadata_parent = metadata.get("parent_session_id") if isinstance(metadata, dict) else None
raw_parent = body.get("parent_session_id") or metadata_parent or request.headers.get(
"X-Hermes-Parent-Session-Id"
)
parent_session_id = str(raw_parent).strip() if raw_parent else None
if parent_session_id:
if (
len(parent_session_id) > self._MAX_SESSION_HEADER_LEN
or re.search(r'[\\r\\n\\x00]', parent_session_id)
or _is_path_unsafe(parent_session_id)
or parent_session_id == session_id
):
return web.json_response(_openai_error("Invalid parent session ID", code="invalid_parent_session_id"), status=400)
if not db.get_session(parent_session_id):
return web.json_response(_openai_error(f"Parent session not found: {parent_session_id}", code="parent_session_not_found"), status=404)
db.create_session(
session_id,
"api_server",
model=str(model) if model else None,
system_prompt=system_prompt,
parent_session_id=parent_session_id,
)
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the narrow session-lineage extension and fail on upstream drift."""
content = source.read_text(encoding="utf-8")
if BEFORE not in content:
raise RuntimeError("Hermes API session 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

@ -347,8 +347,9 @@ AUXILIARY_TOKEN_AFTER = ''' except Exception as exc:
# app-server and does not duplicate those credentials into Hermes' # app-server and does not duplicate those credentials into Hermes'
# provider auth store. Auxiliary tasks still use Hermes' native Codex # provider auth store. Auxiliary tasks still use Hermes' native Codex
# Responses adapter, so read the current CLI access token without # Responses adapter, so read the current CLI access token without
# copying or refreshing it here. The Codex CLI remains the sole owner # copying or refreshing it here. The authenticated Codex broker and
# of refresh-token rotation. # Codex CLI coordinate through this canonical file; this auxiliary
# path remains read-only and never creates a metered API-key lane.
try: try:
codex_home = os.environ.get("CODEX_HOME", "").strip() codex_home = os.environ.get("CODEX_HOME", "").strip()
if not codex_home: if not codex_home:
@ -361,8 +362,8 @@ AUXILIARY_TOKEN_AFTER = ''' except Exception as exc:
return None return None
# Match the native expiry check above. An expired CLI token is not # Match the native expiry check above. An expired CLI token is not
# refreshed from this side channel because that would race the # refreshed from this side channel because the broker owns the
# app-server's canonical refresh-token owner. # serialized, atomic refresh operation for chat boundaries.
try: try:
import base64 import base64
jwt_payload = access_token.split(".")[1] jwt_payload = access_token.split(".")[1]

View File

@ -27,6 +27,19 @@ DEFAULTS = {
}, },
} }
PREFIXES = {"codex": "gpt-", "claude": "claude-"} PREFIXES = {"codex": "gpt-", "claude": "claude-"}
TIER_DEFAULTS = {
"codex": {
"luna": "gpt-5.6-luna",
"terra": "gpt-5.6-terra",
"sol": "gpt-5.6-sol",
},
"claude": {
"haiku": "claude-haiku-4-5-20251001",
"fable": "claude-fable-5",
"sonnet": "claude-sonnet-5",
"opus": "claude-opus-5",
},
}
def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]:
@ -60,7 +73,11 @@ def resolve_model(
mapping = record.get(mapping_name, {}) mapping = record.get(mapping_name, {})
candidate = mapping.get(effort if selector == "auto" else selector, "") if isinstance(mapping, dict) else "" candidate = mapping.get(effort if selector == "auto" else selector, "") if isinstance(mapping, dict) else ""
if not isinstance(candidate, str) or not candidate.startswith(prefix): if not isinstance(candidate, str) or not candidate.startswith(prefix):
candidate = DEFAULTS[provider][effort] candidate = (
DEFAULTS[provider][effort]
if selector == "auto"
else TIER_DEFAULTS[provider].get(selector, DEFAULTS[provider][effort])
)
return candidate return candidate

View File

@ -125,3 +125,20 @@ spec:
port: 9004 port: 9004
targetPort: local-image targetPort: local-image
protocol: TCP protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: hermes-claude-broker
namespace: hermes
labels:
app: hermes-agent
spec:
type: ClusterIP
selector:
app: hermes-agent
ports:
- name: http
port: 9006
targetPort: claude-broker
protocol: TCP

View File

@ -54,25 +54,25 @@ data:
[llm_clients.claude_low] [llm_clients.claude_low]
format = "anthropic_messages" format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1" base_url = "http://hermes-claude-broker.hermes.svc.cluster.local:9006/v1"
api_key_env = "ATLAS_BROKER_KEY" api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1 max_retries = 1
[llm_clients.claude_medium] [llm_clients.claude_medium]
format = "anthropic_messages" format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1" base_url = "http://hermes-claude-broker.hermes.svc.cluster.local:9006/v1"
api_key_env = "ATLAS_BROKER_KEY" api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1 max_retries = 1
[llm_clients.claude_high] [llm_clients.claude_high]
format = "anthropic_messages" format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1" base_url = "http://hermes-claude-broker.hermes.svc.cluster.local:9006/v1"
api_key_env = "ATLAS_BROKER_KEY" api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1 max_retries = 1
[llm_clients.claude_xhigh] [llm_clients.claude_xhigh]
format = "anthropic_messages" format = "anthropic_messages"
base_url = "http://127.0.0.1:9006/v1" base_url = "http://hermes-claude-broker.hermes.svc.cluster.local:9006/v1"
api_key_env = "ATLAS_BROKER_KEY" api_key_env = "ATLAS_BROKER_KEY"
max_retries = 1 max_retries = 1
@ -98,6 +98,21 @@ data:
llm_client = "codex_low" llm_client = "codex_low"
extra_body = { reasoning = { effort = "low" } } extra_body = { reasoning = { effort = "low" } }
[targets.codex_luna_medium]
id = "route/codex/luna/medium"
llm_client = "codex_medium"
extra_body = { reasoning = { effort = "medium" } }
[targets.codex_luna_high]
id = "route/codex/luna/high"
llm_client = "codex_high"
extra_body = { reasoning = { effort = "high" } }
[targets.codex_luna_xhigh]
id = "route/codex/luna/xhigh"
llm_client = "codex_xhigh"
extra_body = { reasoning = { effort = "xhigh" } }
[targets.codex_terra_low] [targets.codex_terra_low]
id = "route/codex/terra/low" id = "route/codex/terra/low"
llm_client = "codex_low" llm_client = "codex_low"
@ -113,6 +128,16 @@ data:
llm_client = "codex_high" llm_client = "codex_high"
extra_body = { reasoning = { effort = "high" } } extra_body = { reasoning = { effort = "high" } }
[targets.codex_terra_xhigh]
id = "route/codex/terra/xhigh"
llm_client = "codex_xhigh"
extra_body = { reasoning = { effort = "xhigh" } }
[targets.codex_sol_low]
id = "route/codex/sol/low"
llm_client = "codex_low"
extra_body = { reasoning = { effort = "low" } }
[targets.codex_sol_medium] [targets.codex_sol_medium]
id = "route/codex/sol/medium" id = "route/codex/sol/medium"
llm_client = "codex_medium" llm_client = "codex_medium"
@ -131,6 +156,47 @@ data:
[targets.claude_haiku_low] [targets.claude_haiku_low]
id = "route/claude/haiku/low" id = "route/claude/haiku/low"
llm_client = "claude_low" llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_haiku_medium]
id = "route/claude/haiku/medium"
llm_client = "claude_medium"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "medium" } }
[targets.claude_haiku_high]
id = "route/claude/haiku/high"
llm_client = "claude_high"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } }
[targets.claude_haiku_xhigh]
id = "route/claude/haiku/xhigh"
llm_client = "claude_xhigh"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "xhigh" } }
[targets.claude_fable_low]
id = "route/claude/fable/low"
llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_fable_medium]
id = "route/claude/fable/medium"
llm_client = "claude_medium"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "medium" } }
[targets.claude_fable_high]
id = "route/claude/fable/high"
llm_client = "claude_high"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } }
[targets.claude_fable_xhigh]
id = "route/claude/fable/xhigh"
llm_client = "claude_xhigh"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "xhigh" } }
[targets.claude_sonnet_low]
id = "route/claude/sonnet/low"
llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_sonnet_medium] [targets.claude_sonnet_medium]
id = "route/claude/sonnet/medium" id = "route/claude/sonnet/medium"
@ -142,6 +208,21 @@ data:
llm_client = "claude_high" llm_client = "claude_high"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } } extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "high" } }
[targets.claude_sonnet_xhigh]
id = "route/claude/sonnet/xhigh"
llm_client = "claude_xhigh"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "xhigh" } }
[targets.claude_opus_low]
id = "route/claude/opus/low"
llm_client = "claude_low"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "low" } }
[targets.claude_opus_medium]
id = "route/claude/opus/medium"
llm_client = "claude_medium"
extra_body = { thinking = { type = "adaptive" }, output_config = { effort = "medium" } }
[targets.claude_opus_high] [targets.claude_opus_high]
id = "route/claude/opus/high" id = "route/claude/opus/high"
llm_client = "claude_high" llm_client = "claude_high"
@ -156,10 +237,42 @@ data:
id = "worker/codex/luna/low" id = "worker/codex/luna/low"
llm_client = "worker_decision" llm_client = "worker_decision"
[targets.worker_codex_luna_medium]
id = "worker/codex/luna/medium"
llm_client = "worker_decision"
[targets.worker_codex_luna_high]
id = "worker/codex/luna/high"
llm_client = "worker_decision"
[targets.worker_codex_luna_xhigh]
id = "worker/codex/luna/xhigh"
llm_client = "worker_decision"
[targets.worker_codex_terra_low]
id = "worker/codex/terra/low"
llm_client = "worker_decision"
[targets.worker_codex_terra_medium] [targets.worker_codex_terra_medium]
id = "worker/codex/terra/medium" id = "worker/codex/terra/medium"
llm_client = "worker_decision" llm_client = "worker_decision"
[targets.worker_codex_terra_high]
id = "worker/codex/terra/high"
llm_client = "worker_decision"
[targets.worker_codex_terra_xhigh]
id = "worker/codex/terra/xhigh"
llm_client = "worker_decision"
[targets.worker_codex_sol_low]
id = "worker/codex/sol/low"
llm_client = "worker_decision"
[targets.worker_codex_sol_medium]
id = "worker/codex/sol/medium"
llm_client = "worker_decision"
[targets.worker_codex_sol_high] [targets.worker_codex_sol_high]
id = "worker/codex/sol/high" id = "worker/codex/sol/high"
llm_client = "worker_decision" llm_client = "worker_decision"
@ -172,6 +285,38 @@ data:
id = "worker/claude/haiku/low" id = "worker/claude/haiku/low"
llm_client = "worker_decision" llm_client = "worker_decision"
[targets.worker_claude_haiku_medium]
id = "worker/claude/haiku/medium"
llm_client = "worker_decision"
[targets.worker_claude_haiku_high]
id = "worker/claude/haiku/high"
llm_client = "worker_decision"
[targets.worker_claude_haiku_xhigh]
id = "worker/claude/haiku/xhigh"
llm_client = "worker_decision"
[targets.worker_claude_fable_low]
id = "worker/claude/fable/low"
llm_client = "worker_decision"
[targets.worker_claude_fable_medium]
id = "worker/claude/fable/medium"
llm_client = "worker_decision"
[targets.worker_claude_fable_high]
id = "worker/claude/fable/high"
llm_client = "worker_decision"
[targets.worker_claude_fable_xhigh]
id = "worker/claude/fable/xhigh"
llm_client = "worker_decision"
[targets.worker_claude_sonnet_low]
id = "worker/claude/sonnet/low"
llm_client = "worker_decision"
[targets.worker_claude_sonnet_medium] [targets.worker_claude_sonnet_medium]
id = "worker/claude/sonnet/medium" id = "worker/claude/sonnet/medium"
llm_client = "worker_decision" llm_client = "worker_decision"
@ -180,6 +325,22 @@ data:
id = "worker/claude/sonnet/high" id = "worker/claude/sonnet/high"
llm_client = "worker_decision" llm_client = "worker_decision"
[targets.worker_claude_sonnet_xhigh]
id = "worker/claude/sonnet/xhigh"
llm_client = "worker_decision"
[targets.worker_claude_opus_low]
id = "worker/claude/opus/low"
llm_client = "worker_decision"
[targets.worker_claude_opus_medium]
id = "worker/claude/opus/medium"
llm_client = "worker_decision"
[targets.worker_claude_opus_high]
id = "worker/claude/opus/high"
llm_client = "worker_decision"
[targets.worker_claude_opus_xhigh] [targets.worker_claude_opus_xhigh]
id = "worker/claude/opus/xhigh" id = "worker/claude/opus/xhigh"
llm_client = "worker_decision" llm_client = "worker_decision"
@ -191,7 +352,7 @@ data:
classifier_target = "classifier" classifier_target = "classifier"
# Switchyard falls through this list after a request-local target failure. # Switchyard falls through this list after a request-local target failure.
# Keep both xhigh providers first so recovery can escalate, never downgrade. # Keep both xhigh providers first so recovery can escalate, never downgrade.
targets = ["codex_sol_xhigh", "claude_opus_xhigh", "codex_sol_high", "claude_sonnet_high", "claude_opus_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "codex_terra_medium", "codex_terra_low", "codex_luna_low", "claude_haiku_low"] targets = ["codex_sol_xhigh", "claude_opus_xhigh", "claude_fable_xhigh", "codex_sol_high", "claude_sonnet_high", "claude_opus_high", "claude_fable_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "claude_fable_medium", "codex_terra_medium", "codex_terra_low", "codex_luna_low", "claude_haiku_low", "claude_fable_low"]
default_target = "codex_terra_medium" default_target = "codex_terra_medium"
session_affinity = false session_affinity = false
recent_turn_window = 4 recent_turn_window = 4
@ -240,15 +401,17 @@ data:
to be unavailable, failed, exhausted, rate-limited, or out of capacity; use to be unavailable, failed, exhausted, rate-limited, or out of capacity; use
the other hosted provider at the same floor. the other hosted provider at the same floor.
5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, 5. Choose across the complete family catalog. Codex options are Luna,
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, Terra, and SOL at low through xhigh. Claude options are Haiku, Fable,
medium=claude_sonnet_medium, high=claude_sonnet_high, Sonnet, and Opus at low through xhigh. Prefer Fable for concise writing,
xhigh=claude_opus_xhigh. Re-evaluate every boundary and resolve "continue" synthesis, and instruction-following where its capability fits the effort
floor; use Sonnet or Opus for deeper analysis and review. Re-evaluate every
boundary and resolve "continue"
or "do it" from recent context. The manual local route remains available or "do it" from recent context. The manual local route remains available
only when the user explicitly selects it. only when the user explicitly selects it.
""" """
response_schema = ''' 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","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} {"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_terra_medium","claude_sonnet_medium","claude_fable_medium","codex_luna_low","claude_haiku_low","claude_fable_low","codex_terra_low","codex_terra_high","claude_fable_high","codex_sol_medium","codex_sol_high","codex_sol_xhigh","claude_sonnet_high","claude_opus_high","claude_fable_xhigh","claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
''' '''
[routes.auto_fast.policy] [routes.auto_fast.policy]
@ -262,7 +425,7 @@ data:
classifier_target = "classifier" classifier_target = "classifier"
# Switchyard falls through this list after a request-local target failure. # Switchyard falls through this list after a request-local target failure.
# Keep both xhigh providers first so recovery can escalate, never downgrade. # Keep both xhigh providers first so recovery can escalate, never downgrade.
targets = ["codex_sol_xhigh", "claude_opus_xhigh", "codex_sol_high", "claude_sonnet_high", "claude_opus_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "codex_terra_medium", "codex_terra_low", "codex_luna_low", "claude_haiku_low"] targets = ["codex_sol_xhigh", "claude_opus_xhigh", "claude_fable_xhigh", "codex_sol_high", "claude_sonnet_high", "claude_opus_high", "claude_fable_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "claude_fable_medium", "codex_terra_medium", "codex_terra_low", "codex_luna_low", "claude_haiku_low", "claude_fable_low"]
default_target = "codex_terra_medium" default_target = "codex_terra_medium"
session_affinity = false session_affinity = false
recent_turn_window = 4 recent_turn_window = 4
@ -312,15 +475,17 @@ data:
to be unavailable, failed, exhausted, rate-limited, or out of capacity; use to be unavailable, failed, exhausted, rate-limited, or out of capacity; use
the other hosted provider at the same floor. the other hosted provider at the same floor.
5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, 5. Choose across the complete family catalog. Codex options are Luna,
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, Terra, and SOL at low through xhigh. Claude options are Haiku, Fable,
medium=claude_sonnet_medium, high=claude_sonnet_high, Sonnet, and Opus at low through xhigh. Prefer Fable for concise writing,
xhigh=claude_opus_xhigh. Re-evaluate every boundary and resolve "continue" synthesis, and instruction-following where its capability fits the effort
floor; use Sonnet or Opus for deeper analysis and review. Re-evaluate every
boundary and resolve "continue"
or "do it" from recent context. The manual local route remains available or "do it" from recent context. The manual local route remains available
only when the user explicitly selects it. only when the user explicitly selects it.
""" """
response_schema = ''' 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","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} {"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_terra_medium","claude_sonnet_medium","claude_fable_medium","codex_luna_low","claude_haiku_low","claude_fable_low","codex_terra_low","codex_terra_high","claude_fable_high","codex_sol_medium","codex_sol_high","codex_sol_xhigh","claude_sonnet_high","claude_opus_high","claude_fable_xhigh","claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
''' '''
[routes.auto_balanced.policy] [routes.auto_balanced.policy]
@ -334,7 +499,7 @@ data:
classifier_target = "classifier" classifier_target = "classifier"
# Switchyard falls through this list after a request-local target failure. # Switchyard falls through this list after a request-local target failure.
# Keep both xhigh providers first so recovery can escalate, never downgrade. # Keep both xhigh providers first so recovery can escalate, never downgrade.
targets = ["claude_opus_xhigh", "codex_sol_xhigh", "claude_sonnet_high", "codex_sol_high", "claude_opus_high", "codex_terra_high", "claude_sonnet_medium", "codex_sol_medium", "codex_terra_medium"] targets = ["claude_opus_xhigh", "codex_sol_xhigh", "claude_fable_xhigh", "claude_sonnet_high", "codex_sol_high", "claude_opus_high", "claude_fable_high", "codex_terra_high", "claude_sonnet_medium", "claude_fable_medium", "codex_sol_medium", "codex_terra_medium"]
default_target = "claude_sonnet_high" default_target = "claude_sonnet_high"
session_affinity = false session_affinity = false
recent_turn_window = 6 recent_turn_window = 6
@ -376,15 +541,15 @@ data:
unavailable, failed, exhausted, rate-limited, or out of capacity; use the unavailable, failed, exhausted, rate-limited, or out of capacity; use the
other provider at the same floor. other provider at the same floor.
4. Map exactly: Codex medium=codex_terra_medium, 4. Choose across Codex Terra/SOL and Claude Fable/Sonnet/Opus at medium
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude through xhigh. Prefer Fable for writing and compact synthesis where it
medium=claude_sonnet_medium, high=claude_sonnet_high, clears the quality floor; use Sonnet or Opus for deeper diagnosis and
xhigh=claude_opus_xhigh. Low-tier targets are intentionally unavailable on review. Low-tier targets are intentionally unavailable on
this route. Re-evaluate every boundary and resolve "continue" or "do it" this route. Re-evaluate every boundary and resolve "continue" or "do it"
from recent context. from recent context.
""" """
response_schema = ''' 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"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false} {"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["claude_sonnet_high","claude_fable_high","codex_sol_high","codex_terra_medium","claude_sonnet_medium","claude_fable_medium","codex_terra_high","codex_sol_medium","claude_opus_high","codex_sol_xhigh","claude_fable_xhigh","claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
''' '''
[routes.auto_deep.policy] [routes.auto_deep.policy]
@ -398,7 +563,7 @@ data:
classifier_target = "classifier" classifier_target = "classifier"
# Switchyard falls through this list after a request-local target failure. # Switchyard falls through this list after a request-local target failure.
# Keep both xhigh providers first so recovery can escalate, never downgrade. # Keep both xhigh providers first so recovery can escalate, never downgrade.
targets = ["codex_sol_xhigh", "claude_opus_xhigh", "codex_sol_high", "claude_opus_high", "claude_sonnet_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "codex_terra_medium"] targets = ["codex_sol_xhigh", "claude_opus_xhigh", "claude_fable_xhigh", "codex_sol_high", "claude_opus_high", "claude_sonnet_high", "claude_fable_high", "codex_terra_high", "codex_sol_medium", "claude_sonnet_medium", "claude_fable_medium", "codex_terra_medium"]
default_target = "codex_sol_high" default_target = "codex_sol_high"
session_affinity = false session_affinity = false
recent_turn_window = 6 recent_turn_window = 6
@ -440,15 +605,15 @@ data:
unavailable, failed, exhausted, rate-limited, or out of capacity; use the unavailable, failed, exhausted, rate-limited, or out of capacity; use the
other provider at the same floor. other provider at the same floor.
4. Map exactly: Codex medium=codex_terra_medium, 4. Choose across Codex Terra/SOL and Claude Fable/Sonnet/Opus at medium
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude through xhigh. Prefer Fable for writing and compact synthesis where it
medium=claude_sonnet_medium, high=claude_sonnet_high, clears the quality floor; use Sonnet or Opus for deeper analysis and
xhigh=claude_opus_xhigh. Low-tier targets are intentionally unavailable on independent review. Low-tier targets are intentionally unavailable on
this route. Re-evaluate every boundary and resolve "continue" or "do it" this route. Re-evaluate every boundary and resolve "continue" or "do it"
from recent context. from recent context.
""" """
response_schema = ''' 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"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false} {"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["codex_sol_high","claude_opus_high","claude_sonnet_high","claude_fable_high","codex_terra_high","codex_sol_xhigh","claude_opus_xhigh","claude_fable_xhigh","codex_terra_medium","claude_sonnet_medium","claude_fable_medium","codex_sol_medium"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
''' '''
[routes.auto_maximum.policy] [routes.auto_maximum.policy]
@ -460,7 +625,7 @@ data:
type = "llm_classifier" type = "llm_classifier"
mode = "custom" mode = "custom"
classifier_target = "classifier" 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"] targets = ["worker_codex_luna_low", "worker_codex_luna_medium", "worker_codex_luna_high", "worker_codex_luna_xhigh", "worker_codex_terra_low", "worker_codex_terra_medium", "worker_codex_terra_high", "worker_codex_terra_xhigh", "worker_codex_sol_low", "worker_codex_sol_medium", "worker_codex_sol_high", "worker_codex_sol_xhigh", "worker_claude_haiku_low", "worker_claude_haiku_medium", "worker_claude_haiku_high", "worker_claude_haiku_xhigh", "worker_claude_fable_low", "worker_claude_fable_medium", "worker_claude_fable_high", "worker_claude_fable_xhigh", "worker_claude_sonnet_low", "worker_claude_sonnet_medium", "worker_claude_sonnet_high", "worker_claude_sonnet_xhigh", "worker_claude_opus_low", "worker_claude_opus_medium", "worker_claude_opus_high", "worker_claude_opus_xhigh"]
default_target = "worker_codex_sol_high" default_target = "worker_codex_sol_high"
session_affinity = false session_affinity = false
recent_turn_window = 6 recent_turn_window = 6
@ -499,15 +664,13 @@ data:
analysis, and independent review. analysis, and independent review.
A final independent review is Claude; implementing review findings is Codex. A final independent review is Claude; implementing review findings is Codex.
4. Map provider and effort exactly: 4. Choose across every configured Codex and Claude family at the exact
Codex low=worker_codex_luna_low; effort floor. Codex Luna is the economical tier, Terra is balanced, and
Codex medium=worker_codex_terra_medium; SOL is the deepest implementation tier. Claude Haiku is the economical
Codex high=worker_codex_sol_high; tier, Fable is preferred for concise writing and synthesis, Sonnet is
Codex xhigh=worker_codex_sol_xhigh. balanced, and Opus is the deepest analysis and review tier. Every family
Claude low=worker_claude_haiku_low; supports low, medium, high, and xhigh; choose the cheapest family that
Claude medium=worker_claude_sonnet_medium; clears the objective's quality floor without lowering its effort.
Claude high=worker_claude_sonnet_high;
Claude xhigh=worker_claude_opus_xhigh.
Examples: Examples:
Critical security migration final review -> worker_claude_opus_xhigh. Critical security migration final review -> worker_claude_opus_xhigh.
@ -524,7 +687,7 @@ data:
the floor. Return only the required decision object. the floor. Return only the required decision object.
""" """
response_schema = ''' 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} {"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["worker_codex_luna_low","worker_codex_luna_medium","worker_codex_luna_high","worker_codex_luna_xhigh","worker_codex_terra_low","worker_codex_terra_medium","worker_codex_terra_high","worker_codex_terra_xhigh","worker_codex_sol_low","worker_codex_sol_medium","worker_codex_sol_high","worker_codex_sol_xhigh","worker_claude_haiku_low","worker_claude_haiku_medium","worker_claude_haiku_high","worker_claude_haiku_xhigh","worker_claude_fable_low","worker_claude_fable_medium","worker_claude_fable_high","worker_claude_fable_xhigh","worker_claude_sonnet_low","worker_claude_sonnet_medium","worker_claude_sonnet_high","worker_claude_sonnet_xhigh","worker_claude_opus_low","worker_claude_opus_medium","worker_claude_opus_high","worker_claude_opus_xhigh"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}
''' '''
[routes.worker_auto_maximum.policy] [routes.worker_auto_maximum.policy]
@ -627,6 +790,268 @@ data:
tool_calling = true tool_calling = true
reasoning = true reasoning = true
[routes.manual_claude_fable]
id = "atlas/manual/claude/fable"
type = "random"
targets = ["claude_fable_medium", "codex_terra_medium", "claude_sonnet_medium", "codex_luna_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_luna_low]
id = "atlas/manual/codex/luna/low"
type = "random"
targets = ["codex_luna_low", "claude_haiku_low", "codex_terra_low", "claude_fable_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_luna_medium]
id = "atlas/manual/codex/luna/medium"
type = "random"
targets = ["codex_luna_medium", "claude_haiku_medium", "codex_terra_medium", "claude_fable_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_luna_high]
id = "atlas/manual/codex/luna/high"
type = "random"
targets = ["codex_luna_high", "claude_haiku_high", "codex_terra_high", "claude_fable_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_luna_xhigh]
id = "atlas/manual/codex/luna/xhigh"
type = "random"
targets = ["codex_luna_xhigh", "claude_haiku_xhigh", "codex_terra_xhigh", "claude_fable_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_terra_low]
id = "atlas/manual/codex/terra/low"
type = "random"
targets = ["codex_terra_low", "claude_fable_low", "claude_sonnet_low", "codex_sol_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_terra_medium]
id = "atlas/manual/codex/terra/medium"
type = "random"
targets = ["codex_terra_medium", "claude_fable_medium", "claude_sonnet_medium", "codex_sol_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_terra_high]
id = "atlas/manual/codex/terra/high"
type = "random"
targets = ["codex_terra_high", "claude_fable_high", "claude_sonnet_high", "codex_sol_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_terra_xhigh]
id = "atlas/manual/codex/terra/xhigh"
type = "random"
targets = ["codex_terra_xhigh", "claude_fable_xhigh", "claude_sonnet_xhigh", "codex_sol_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_sol_low]
id = "atlas/manual/codex/sol/low"
type = "random"
targets = ["codex_sol_low", "claude_sonnet_low", "claude_opus_low", "codex_terra_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_sol_medium]
id = "atlas/manual/codex/sol/medium"
type = "random"
targets = ["codex_sol_medium", "claude_sonnet_medium", "claude_opus_medium", "codex_terra_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_sol_high]
id = "atlas/manual/codex/sol/high"
type = "random"
targets = ["codex_sol_high", "claude_sonnet_high", "claude_opus_high", "codex_terra_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_codex_sol_xhigh]
id = "atlas/manual/codex/sol/xhigh"
type = "random"
targets = ["codex_sol_xhigh", "claude_sonnet_xhigh", "claude_opus_xhigh", "codex_terra_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_haiku_low]
id = "atlas/manual/claude/haiku/low"
type = "random"
targets = ["claude_haiku_low", "codex_luna_low", "claude_fable_low", "codex_terra_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_haiku_medium]
id = "atlas/manual/claude/haiku/medium"
type = "random"
targets = ["claude_haiku_medium", "codex_luna_medium", "claude_fable_medium", "codex_terra_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_haiku_high]
id = "atlas/manual/claude/haiku/high"
type = "random"
targets = ["claude_haiku_high", "codex_luna_high", "claude_fable_high", "codex_terra_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_haiku_xhigh]
id = "atlas/manual/claude/haiku/xhigh"
type = "random"
targets = ["claude_haiku_xhigh", "codex_luna_xhigh", "claude_fable_xhigh", "codex_terra_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_fable_low]
id = "atlas/manual/claude/fable/low"
type = "random"
targets = ["claude_fable_low", "codex_terra_low", "claude_sonnet_low", "codex_luna_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_fable_medium]
id = "atlas/manual/claude/fable/medium"
type = "random"
targets = ["claude_fable_medium", "codex_terra_medium", "claude_sonnet_medium", "codex_luna_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_fable_high]
id = "atlas/manual/claude/fable/high"
type = "random"
targets = ["claude_fable_high", "codex_terra_high", "claude_sonnet_high", "codex_luna_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_fable_xhigh]
id = "atlas/manual/claude/fable/xhigh"
type = "random"
targets = ["claude_fable_xhigh", "codex_terra_xhigh", "claude_sonnet_xhigh", "codex_luna_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_sonnet_low]
id = "atlas/manual/claude/sonnet/low"
type = "random"
targets = ["claude_sonnet_low", "codex_sol_low", "codex_terra_low", "claude_fable_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_sonnet_medium]
id = "atlas/manual/claude/sonnet/medium"
type = "random"
targets = ["claude_sonnet_medium", "codex_sol_medium", "codex_terra_medium", "claude_fable_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_sonnet_high]
id = "atlas/manual/claude/sonnet/high"
type = "random"
targets = ["claude_sonnet_high", "codex_sol_high", "codex_terra_high", "claude_fable_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_sonnet_xhigh]
id = "atlas/manual/claude/sonnet/xhigh"
type = "random"
targets = ["claude_sonnet_xhigh", "codex_sol_xhigh", "codex_terra_xhigh", "claude_fable_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_opus_low]
id = "atlas/manual/claude/opus/low"
type = "random"
targets = ["claude_opus_low", "codex_sol_low", "claude_sonnet_low", "codex_terra_low"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_opus_medium]
id = "atlas/manual/claude/opus/medium"
type = "random"
targets = ["claude_opus_medium", "codex_sol_medium", "claude_sonnet_medium", "codex_terra_medium"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_opus_high]
id = "atlas/manual/claude/opus/high"
type = "random"
targets = ["claude_opus_high", "codex_sol_high", "claude_sonnet_high", "codex_terra_high"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_claude_opus_xhigh]
id = "atlas/manual/claude/opus/xhigh"
type = "random"
targets = ["claude_opus_xhigh", "codex_sol_xhigh", "claude_sonnet_xhigh", "codex_terra_xhigh"]
weights = [1, 0, 0, 0]
context_window = 272000
tool_calling = true
reasoning = true
[routes.manual_local_qwen] [routes.manual_local_qwen]
id = "atlas/manual/local/qwen-14b" id = "atlas/manual/local/qwen-14b"
type = "random" type = "random"

View File

@ -22,17 +22,12 @@ spec:
labels: labels:
app: hermes-switchyard app: hermes-switchyard
annotations: annotations:
ai.bstein.dev/config-rev: "20260812-hosted-foreground" ai.bstein.dev/config-rev: "20260812-native-claude-subscription"
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "9005" prometheus.io/port: "9005"
prometheus.io/path: /metrics prometheus.io/path: /metrics
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-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-secret-relay-key: kv/data/atlas/hermes/chat-telegram
vault.hashicorp.com/agent-inject-template-relay-key: | vault.hashicorp.com/agent-inject-template-relay-key: |
{{- with secret "kv/data/atlas/hermes/chat-telegram" -}} {{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
@ -126,63 +121,6 @@ spec:
mountPath: /var/lib/switchyard mountPath: /var/lib/switchyard
- name: tmp - name: tmp
mountPath: /tmp mountPath: /tmp
- name: claude-oauth-broker
image: registry.bstein.dev/bstein/hermes-switchyard-brokers@sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083
imagePullPolicy: IfNotPresent
command:
- 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 - name: worker-route-broker
image: registry.bstein.dev/bstein/hermes-switchyard-brokers@sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083 image: registry.bstein.dev/bstein/hermes-switchyard-brokers@sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent

View File

@ -88,6 +88,30 @@ def test_ui_manual_model_and_effort_are_forwarded_as_constraints(
) )
def test_manual_family_and_effort_resolve_to_one_exact_switchyard_route():
assert router._resolved_route("atlas/manual/codex/terra", "xhigh") == (
"atlas/manual/codex/terra/xhigh"
)
assert router._resolved_route("atlas/manual/claude/fable", "high") == (
"atlas/manual/claude/fable/high"
)
assert router._resolved_route("atlas/manual/claude/opus", "") == (
"atlas/manual/claude/opus/low"
)
assert router._resolved_route("atlas/manual/local/qwen-14b", "xhigh") == (
"atlas/manual/local/qwen-14b"
)
def test_fable_is_a_supported_manual_claude_family():
assert router._normalise_manual_route("claude", "fable") == (
"atlas/manual/claude/fable"
)
assert router._normalise_manual_route("claude", "claude-fable-5") == (
"atlas/manual/claude/fable"
)
def test_manual_command_persists_a_switchyard_route_not_a_direct_provider( def test_manual_command_persists_a_switchyard_route_not_a_direct_provider(
tmp_path, monkeypatch tmp_path, monkeypatch
): ):
@ -201,7 +225,81 @@ def test_provider_status_separates_observed_activity_from_plan_quota(monkeypatch
assert payload["providers"]["claude"]["state"] == "unavailable" assert payload["providers"]["claude"]["state"] == "unavailable"
assert payload["providers"]["local"]["total_tokens"] == 300 assert payload["providers"]["local"]["total_tokens"] == 300
assert payload["providers"]["codex"]["account"]["quota_reported"] is False assert payload["providers"]["codex"]["account"]["quota_reported"] is False
assert "not exposed" in payload["quota_note"] assert "native first-party Claude Code subscription" in payload["quota_note"]
assert "does not use the metered Anthropic API key" in payload["quota_note"]
def test_provider_status_does_not_degrade_a_healthy_lane_for_one_old_error():
status = sys.modules["hermes_auto_router"].provider_status_text.__globals__
module = sys.modules[status["provider_status_payload"].__module__]
summary = module._provider_summary(
"codex",
{
"route/codex/terra/medium": {
"calls": 257,
"errors": 2,
"total_tokens": 16_993_900,
}
},
)
assert summary["state"] == "available"
def test_provider_status_native_claude_health_and_full_effort_catalog(monkeypatch):
"""Native Claude readiness wins over stale counters and exposes xhigh."""
status = sys.modules["hermes_auto_router"].provider_status_text.__globals__
module = sys.modules[status["provider_status_payload"].__module__]
monkeypatch.setattr(
module,
"_get_json",
lambda url, timeout=3.0: (
{"status": "ok"}
if url.endswith("/health")
else {
"total_requests": 1,
"models": {
"route/claude/sonnet/high": {
"calls": 0,
"errors": 12,
}
},
}
),
)
monkeypatch.setattr(
module,
"_fresh_health",
lambda path, maximum_age=86400.0: {
"state": "available",
"transport": "native-claude-code-subscription",
"weekly_utilization": 0.92,
},
)
monkeypatch.setattr(
module,
"_configured_models",
lambda provider: (
["claude-haiku-4-5", "claude-fable-5", "claude-sonnet-5", "claude-opus-5"]
if provider == "claude"
else ["gpt-5.6-terra"]
),
)
monkeypatch.setattr(module, "_codex_account", lambda: {})
monkeypatch.setattr(module, "_claude_account", lambda: {})
payload = module.provider_status_payload()
assert payload["providers"]["claude"]["state"] == "available"
assert "claude-fable-5" in payload["providers"]["claude"]["configured_models"]
assert payload["providers"]["claude"]["supported_efforts"] == [
"low",
"medium",
"high",
"xhigh",
]
assert payload["providers"]["codex"]["supported_efforts"][-1] == "xhigh"
def test_provider_status_accepts_iso_and_epoch_credential_expiry(): def test_provider_status_accepts_iso_and_epoch_credential_expiry():
@ -241,6 +339,27 @@ def test_claude_account_treats_a_live_refresh_token_as_refreshable(
assert account["refreshable"] is True assert account["refreshable"] is True
def test_codex_account_treats_a_refresh_token_as_refreshable(tmp_path, monkeypatch):
"""An expired access token is ready when the first-party refresh exists."""
status = sys.modules["hermes_auto_router"].provider_status_text.__globals__
module = sys.modules[status["provider_status_payload"].__module__]
path = tmp_path / "auth.json"
path.write_text(json.dumps({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "expired-access",
"refresh_token": "live-refresh",
},
}), encoding="utf-8")
monkeypatch.setattr(module, "CODEX_AUTH_PATH", path)
account = module._codex_account()
assert account["authenticated"] is True
assert account["access_token_live"] is None
assert account["refreshable"] is True
def test_agent_mounts_provider_status_dashboard_into_auto_router_plugin(): def test_agent_mounts_provider_status_dashboard_into_auto_router_plugin():
import yaml import yaml

View File

@ -5,11 +5,12 @@ from __future__ import annotations
import base64 import base64
import importlib.util import importlib.util
import json import json
import sqlite3
import sys import sys
import time import time
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import ModuleType, SimpleNamespace
import pytest import pytest
import yaml import yaml
@ -503,6 +504,7 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
"atlas/manual/codex/terra", "atlas/manual/codex/terra",
"atlas/manual/codex/sol", "atlas/manual/codex/sol",
"atlas/manual/claude/haiku", "atlas/manual/claude/haiku",
"atlas/manual/claude/fable",
"atlas/manual/claude/sonnet", "atlas/manual/claude/sonnet",
"atlas/manual/claude/opus", "atlas/manual/claude/opus",
"atlas/manual/local/qwen-14b", "atlas/manual/local/qwen-14b",
@ -840,35 +842,151 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
assert module._access_token().startswith("header.") assert module._access_token().startswith("header.")
def test_claude_broker_exposes_capacity_exhaustion_as_retryable(monkeypatch): def test_codex_broker_refreshes_and_persists_first_party_oauth(
"""Subscription exhaustion must cross providers instead of surfacing as 400.""" tmp_path: Path, monkeypatch
):
"""Expired ChatGPT OAuth refreshes in the canonical Codex CLI store."""
module = _load_broker_module(
"hermes_codex_refresh_broker", "codex_broker.py", monkeypatch
)
auth_dir = tmp_path / ".codex"
auth_dir.mkdir()
def jwt(expires_at: float) -> str:
payload = base64.urlsafe_b64encode(
json.dumps({"exp": expires_at}).encode()
).decode().rstrip("=")
return f"header.{payload}.signature"
expired = jwt(time.time() - 60)
live = jwt(time.time() + 3600)
auth_path = auth_dir / "auth.json"
auth_path.write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": expired,
"refresh_token": "refresh-old",
},
}
)
)
calls = []
auth_module = ModuleType("hermes_cli.auth")
def refresh(access_token, refresh_token, *, timeout_seconds):
calls.append((access_token, refresh_token, timeout_seconds))
return {
"access_token": live,
"refresh_token": "refresh-new",
"last_refresh": "2026-08-12T20:00:00Z",
}
auth_module.refresh_codex_oauth_pure = refresh
package = ModuleType("hermes_cli")
package.auth = auth_module
monkeypatch.setitem(sys.modules, "hermes_cli", package)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", auth_module)
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
assert module._access_token() == live
persisted = json.loads(auth_path.read_text())
assert persisted["tokens"]["access_token"] == live
assert persisted["tokens"]["refresh_token"] == "refresh-new"
assert persisted["last_refresh"] == "2026-08-12T20:00:00Z"
assert calls == [(expired, "refresh-old", 30.0)]
assert auth_path.stat().st_mode & 0o777 == 0o600
# A healthy token is reused, so repeated routed turns do not spend a
# refresh token or create a second billing/authentication path.
assert module._access_token() == live
assert len(calls) == 1
def test_claude_broker_uses_native_subscription_without_api_billing(monkeypatch):
"""Claude traffic must use the native first-party CLI subscription lane."""
module = _load_broker_module( module = _load_broker_module(
"hermes_claude_broker", "claude_oauth_broker.py", monkeypatch "hermes_claude_broker", "claude_oauth_broker.py", monkeypatch
) )
exhausted = json.dumps( monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-leak")
{ monkeypatch.setenv("CLAUDE_API_KEY", "must-not-leak")
"type": "error", monkeypatch.setattr(
"error": { module,
"type": "invalid_request_error", "resolve_route",
"message": ( lambda route: "claude-fable-5" if "/fable/" in route else route,
"Third-party apps now draw from your extra usage, not your " )
"plan limits. Add more at claude.ai/settings/usage."
),
},
}
).encode()
malformed = b'{"error":{"message":"invalid tool schema"}}'
assert module._normalized_upstream_status(400, exhausted) == 429 model, effort = module._route(
assert module._normalized_upstream_status(400, malformed) == 400 "route/claude/fable/xhigh", {"output_config": {"effort": "xhigh"}}
assert module._normalized_upstream_status(403, exhausted) == 403 )
assert (model, effort) == ("claude-fable-5", "xhigh")
assert "ANTHROPIC_API_KEY" not in module._claude_environment()
assert "CLAUDE_API_KEY" not in module._claude_environment()
assert module.CAPACITY_PATTERN.search("weekly usage limit exhausted")
def test_switchyard_brokers_use_the_small_dedicated_image(): def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
"""Control-plane brokers must not pull the full multi-gigabyte agent image.""" """API-created workers must persist the originating Hermes session."""
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
spec = importlib.util.spec_from_file_location("patch_api_sessions", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
source = tmp_path / "api_server.py"
destination = tmp_path / "patched.py"
source.write_text("prefix\n" + module.BEFORE + "suffix\n", encoding="utf-8")
module.patch(source, destination)
patched = destination.read_text(encoding="utf-8")
assert "X-Hermes-Parent-Session-Id" in patched
assert "parent_session_id=parent_session_id" in patched
assert "Parent session not found" in patched
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
"""Known standalone API workers move under Cassandra without data loss."""
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
spec = importlib.util.spec_from_file_location("migrate_api_sessions", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
database = tmp_path / "state.db"
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"parent_session_id TEXT, title TEXT, transcript TEXT)"
)
connection.execute(
"INSERT INTO sessions VALUES (?, 'tui', NULL, 'Cassandra', 'parent-data')",
(module.LEGACY_CASSANDRA_PARENT,),
)
worker_id = next(iter(module.LEGACY_CASSANDRA_WORKERS))
connection.execute(
"INSERT INTO sessions VALUES (?, 'api_server', NULL, 'old', 'keep-me')",
(worker_id,),
)
assert module.migrate(database) == 1
assert module.migrate(database) == 0
with sqlite3.connect(database) as connection:
row = connection.execute(
"SELECT parent_session_id, title, transcript FROM sessions WHERE id = ?",
(worker_id,),
).fetchone()
assert row == (
module.LEGACY_CASSANDRA_PARENT,
module.LEGACY_CASSANDRA_WORKERS[worker_id],
"keep-me",
)
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
"""Thin brokers stay small while native Claude runs beside owner auth."""
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text() dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()
assert "httpx==0.28.1" in dockerfile assert "httpx==0.28.1" in dockerfile
assert "claude_oauth_broker.py" in dockerfile
assert "worker_route_broker.py" in dockerfile assert "worker_route_broker.py" in dockerfile
assert "routing_catalog.py" in dockerfile assert "routing_catalog.py" in dockerfile
@ -881,9 +999,22 @@ def test_switchyard_brokers_use_the_small_dedicated_image():
"registry.bstein.dev/bstein/hermes-switchyard-brokers@" "registry.bstein.dev/bstein/hermes-switchyard-brokers@"
"sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083" "sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083"
) )
assert containers["claude-oauth-broker"]["image"] == expected
assert containers["worker-route-broker"]["image"] == expected assert containers["worker-route-broker"]["image"] == expected
assert containers["classifier-broker"]["image"] == expected assert containers["classifier-broker"]["image"] == expected
assert "claude-oauth-broker" not in containers
agent = _documents(HERMES / "agent-deployment.yaml")[0]
agent_containers = {
container["name"]: container
for container in agent["spec"]["template"]["spec"]["containers"]
}
claude = agent_containers["claude-broker"]
assert claude["image"].startswith("registry.bstein.dev/bstein/hermes-agent@")
assert "unset ANTHROPIC_API_KEY CLAUDE_API_KEY" in claude["args"][0]
assert any(
mount["name"] == "home" and mount["mountPath"] == "/opt/data"
for mount in claude["volumeMounts"]
)
def test_classifier_broker_bounds_history_without_losing_routing_intent(monkeypatch): def test_classifier_broker_bounds_history_without_losing_routing_intent(monkeypatch):
@ -1136,15 +1267,15 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
assert 'id = "qwen2.5:14b-instruct-q4_0"' in switchyard assert 'id = "qwen2.5:14b-instruct-q4_0"' in switchyard
assert "qwen2.5:3b-instruct-q4_0" not in switchyard assert "qwen2.5:3b-instruct-q4_0" not in switchyard
assert "route/local/qwen2.5-14b/medium" in switchyard assert "route/local/qwen2.5-14b/medium" in switchyard
assert "Codex xhigh=worker_codex_sol_xhigh" in switchyard
assert "Claude xhigh=worker_claude_opus_xhigh" in switchyard
assert "Anthropic and Claude name the same provider" in switchyard assert "Anthropic and Claude name the same provider" in switchyard
assert "OpenAI and Codex name the same provider" in switchyard assert "OpenAI and Codex name the same provider" in switchyard
assert switchyard.count("Codex low=codex_luna_low") == 2 assert "Choose across every configured Codex and Claude family" in switchyard
assert switchyard.count("Claude low=claude_haiku_low") == 2 assert "Claude Fable" in switchyard
assert switchyard.count('Treat "think hard"') == 4 assert switchyard.count('Treat "think hard"') == 4
assert switchyard.count("Never choose below the") >= 5 assert switchyard.count("Never choose below the") >= 5
routes = tomllib.loads(switchyard)["routes"] switchyard_config = tomllib.loads(switchyard)
routes = switchyard_config["routes"]
configured_targets = switchyard_config["targets"]
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"): for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
leading_targets = set(routes[route_name]["targets"][:2]) leading_targets = set(routes[route_name]["targets"][:2])
assert leading_targets == {"codex_sol_xhigh", "claude_opus_xhigh"} assert leading_targets == {"codex_sol_xhigh", "claude_opus_xhigh"}
@ -1153,6 +1284,7 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
targets = routes[route_name]["targets"] targets = routes[route_name]["targets"]
selector_targets = routes[route_name]["response_schema"] selector_targets = routes[route_name]["response_schema"]
assert not any(target.startswith("local_") for target in targets) assert not any(target.startswith("local_") for target in targets)
assert any("fable" in target for target in targets)
assert "local_qwen" not in selector_targets assert "local_qwen" not in selector_targets
assert "not eligible for foreground" in routes[route_name]["prompt"] assert "not eligible for foreground" in routes[route_name]["prompt"]
for route_name in ("auto_deep", "auto_maximum"): for route_name in ("auto_deep", "auto_maximum"):
@ -1169,12 +1301,27 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
"manual_codex_terra", "manual_codex_terra",
"manual_codex_sol", "manual_codex_sol",
"manual_claude_haiku", "manual_claude_haiku",
"manual_claude_fable",
"manual_claude_sonnet", "manual_claude_sonnet",
"manual_claude_opus", "manual_claude_opus",
): ):
assert not any( assert not any(
target.startswith("local_") for target in routes[route_name]["targets"] target.startswith("local_") for target in routes[route_name]["targets"]
) )
for provider, families in {
"codex": ("luna", "terra", "sol"),
"claude": ("haiku", "fable", "sonnet", "opus"),
}.items():
for family in families:
for effort in ("low", "medium", "high", "xhigh"):
route = routes[f"manual_{provider}_{family}_{effort}"]
assert route["id"] == f"atlas/manual/{provider}/{family}/{effort}"
assert route["targets"][0] == f"{provider}_{family}_{effort}"
worker_target = f"worker_{provider}_{family}_{effort}"
assert worker_target in routes["worker_auto_maximum"]["targets"]
assert configured_targets[worker_target]["id"] == (
f"worker/{provider}/{family}/{effort}"
)
for route_name in ("auto_fast", "auto_balanced"): for route_name in ("auto_fast", "auto_balanced"):
prompt = routes[route_name]["prompt"] prompt = routes[route_name]["prompt"]
assert "image tool—not the conversational model" in prompt assert "image tool—not the conversational model" in prompt