hermes: restore native provider routing
Some checks failed
Tests / Declarative: Post Actions failed: 3, passed: 260
Some checks failed
Tests / Declarative: Post Actions failed: 3, passed: 260
This commit is contained in:
parent
b01680ad65
commit
a1cd4969ad
@ -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'
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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")
|
||||||
)
|
)
|
||||||
ALLOWED_PATHS: Final = {
|
MAX_CONCURRENCY: Final = int(
|
||||||
"/v1/messages",
|
os.environ.get("HERMES_CLAUDE_BROKER_CONCURRENCY", "4")
|
||||||
"/v1/messages/count_tokens",
|
)
|
||||||
"/v1/models",
|
HEALTH_PATH: Final = Path(
|
||||||
}
|
os.environ.get(
|
||||||
REQUIRED_BETAS: Final = (
|
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
|
||||||
"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()
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
52
services/hermes/scripts/migrate_api_session_lineage.py
Normal file
52
services/hermes/scripts/migrate_api_session_lineage.py
Normal 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")
|
||||||
71
services/hermes/scripts/patch_api_server_sessions.py
Normal file
71
services/hermes/scripts/patch_api_server_sessions.py
Normal 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())
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user