diff --git a/dockerfiles/hermes-webui-router.js b/dockerfiles/hermes-webui-router.js index 94a28c12d..471c049cf 100644 --- a/dockerfiles/hermes-webui-router.js +++ b/dockerfiles/hermes-webui-router.js @@ -14,6 +14,7 @@ 'atlas/manual/codex/terra':'Codex · Terra', 'atlas/manual/codex/sol':'Codex · SOL', 'atlas/manual/claude/haiku':'Claude · Haiku', + 'atlas/manual/claude/fable':'Claude · Fable', 'atlas/manual/claude/sonnet':'Claude · Sonnet', 'atlas/manual/claude/opus':'Claude · Opus', 'atlas/manual/local/qwen-14b':'Local · Qwen 14B' diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 0e6b2687e..a860a5700 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: 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/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/role: hermes-agent 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/cli-lanes \ /opt/data/logs \ + /opt/data/provider-health \ /opt/data/tools/bin \ /opt/data/workspace/coordinator \ /opt/data/workspace/projects \ @@ -153,6 +154,7 @@ spec: /opt/data/home/.kube \ /opt/data/cli-lanes \ /opt/data/logs \ + /opt/data/provider-health \ /opt/data/tools \ /opt/data/tools/bin \ /opt/data/workspace \ @@ -273,6 +275,29 @@ spec: resources: requests: {cpu: 25m, memory: 64Mi} 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 image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107 imagePullPolicy: IfNotPresent @@ -350,6 +375,7 @@ spec: set +a /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_api_session_lineage.py # Client configuration restores the persisted Codex CLI login. # Refresh routing afterwards so AUTO sees the app-server lane on # 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/auxiliary_client.py, subPath: auxiliary_client.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: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} - {name: tmp, mountPath: /tmp} @@ -800,6 +827,60 @@ spec: resources: requests: {cpu: 50m, memory: 128Mi} 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: - name: home persistentVolumeClaim: @@ -825,6 +906,8 @@ spec: emptyDir: {} - name: tui-gateway-patch emptyDir: {} + - name: api-server-patch + emptyDir: {} - name: codex-runtime-patch emptyDir: {} - name: auto-router-plugin diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index e96ed2edf..75f2ae524 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -63,6 +63,7 @@ data: atlas/manual/codex/terra: {provider: atlas-switchyard, model: atlas/manual/codex/terra} atlas/manual/codex/sol: {provider: atlas-switchyard, model: atlas/manual/codex/sol} atlas/manual/claude/haiku: {provider: atlas-switchyard, model: atlas/manual/claude/haiku} + atlas/manual/claude/fable: {provider: atlas-switchyard, model: atlas/manual/claude/fable} atlas/manual/claude/sonnet: {provider: atlas-switchyard, model: atlas/manual/claude/sonnet} atlas/manual/claude/opus: {provider: atlas-switchyard, model: atlas/manual/claude/opus} atlas/manual/local/qwen-14b: {provider: atlas-switchyard, model: atlas/manual/local/qwen-14b} diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index 314892924..fa9a4c347 100644 --- a/services/hermes/chat-router.yaml +++ b/services/hermes/chat-router.yaml @@ -20,7 +20,7 @@ spec: app: hermes-chat-router annotations: 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-pre-populate-only: "true" vault.hashicorp.com/agent-init-first: "true" @@ -62,7 +62,7 @@ spec: values: [rpi5] containers: - name: router - image: registry.bstein.dev/bstein/hermes-chat-router@sha256:72fbba10d108b1086e5620137f7549a838438ba510abb8cc8884bc592e7fd6bf + image: registry.bstein.dev/bstein/hermes-chat-router@sha256:4e318a35353772cf16e39b2038209abd8b774cd065f39d7a99b6dcc6e28e2474 imagePullPolicy: IfNotPresent ports: - {name: http, containerPort: 8080, protocol: TCP} diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index 63cd15396..ae3f13756 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -260,7 +260,7 @@ spec: requests: {cpu: 250m, memory: 512Mi} limits: {cpu: "1", memory: 2Gi} - name: webui - image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72 + image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1 imagePullPolicy: IfNotPresent command: [/bin/sh, -ec] args: diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml index b6a326779..26e215e82 100644 --- a/services/hermes/deployment.yaml +++ b/services/hermes/deployment.yaml @@ -351,7 +351,7 @@ spec: cpu: "2" memory: 4Gi - name: webui - image: registry.bstein.dev/bstein/hermes-webui@sha256:fb06acc864509d9aa367d1d3635c82c383dc14458bc8db69a917e1ddf4f71f72 + image: registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1 imagePullPolicy: IfNotPresent command: [/bin/sh, -ec] args: diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index d6f4ecd86..ef06b792f 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -70,6 +70,8 @@ configMapGenerator: - image_broker.py=scripts/image_broker.py - install_agent_tools.sh=scripts/install_agent_tools.sh - 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_codex_runtime.py=scripts/patch_codex_runtime.py - patch_stream_recovery.py=scripts/patch_stream_recovery.py diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml index ce5402919..570fc1d94 100644 --- a/services/hermes/networkpolicy.yaml +++ b/services/hermes/networkpolicy.yaml @@ -109,6 +109,7 @@ spec: app: hermes-switchyard ports: - {protocol: TCP, port: 9003} + - {protocol: TCP, port: 9006} # agent.hermes.bstein.dev is an owner-only engineering workstation. The # browser boundary remains OAuth-protected, while its workers need to reach # every cluster namespace, Atlas LAN service, and hosted provider endpoint. @@ -356,6 +357,7 @@ spec: app: hermes-agent ports: - {protocol: TCP, port: 9003} + - {protocol: TCP, port: 9006} - to: - ipBlock: cidr: 0.0.0.0/0 diff --git a/services/hermes/plugins/auto-router/__init__.py b/services/hermes/plugins/auto-router/__init__.py index ef49f5fc0..b79c4fb48 100644 --- a/services/hermes/plugins/auto-router/__init__.py +++ b/services/hermes/plugins/auto-router/__init__.py @@ -42,6 +42,7 @@ MANUAL_ROUTES = frozenset( "atlas/manual/codex/terra", "atlas/manual/codex/sol", "atlas/manual/claude/haiku", + "atlas/manual/claude/fable", "atlas/manual/claude/sonnet", "atlas/manual/claude/opus", "atlas/manual/local/qwen-14b", @@ -110,6 +111,8 @@ def _normalise_manual_route(provider: str, model: str = "") -> str: "claude": { "haiku": "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", "claude-sonnet-5": "atlas/manual/claude/sonnet", "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" +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: """Point one live Hermes agent at Switchyard and remove local failover.""" 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) if agent is None: 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) policy = _load_policy() _record_boundary(policy, route, effort, source, scope) diff --git a/services/hermes/plugins/auto-router/dashboard/dist/index.js b/services/hermes/plugins/auto-router/dashboard/dist/index.js index 1af4c9727..9cf509c85 100644 --- a/services/hermes/plugins/auto-router/dashboard/dist/index.js +++ b/services/hermes/plugins/auto-router/dashboard/dist/index.js @@ -29,6 +29,7 @@ function ProviderCard(props) { const item = props.item || {}; const account = item.account || null; + const nativeHealth = item.native_health || null; const authLabel = account && account.access_token_live === false && account.refreshable ? "Authentication refreshable" : account && account.authenticated @@ -47,6 +48,10 @@ 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, 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", { className: "provider-status-usage-link", href: account.usage_url, @@ -60,7 +65,20 @@ 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" : "—" }) ), + 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" }, + h("strong", null, "Observed routes"), item.models.map(function (model) { return h("div", { className: "provider-status-model", key: model.id }, h("code", null, model.id), diff --git a/services/hermes/plugins/auto-router/provider_status.py b/services/hermes/plugins/auto-router/provider_status.py index ee9a48aea..cb4dff0fc 100644 --- a/services/hermes/plugins/auto-router/provider_status.py +++ b/services/hermes/plugins/auto-router/provider_status.py @@ -25,6 +25,14 @@ CODEX_AUTH_PATH = Path( CLAUDE_AUTH_PATH = Path( os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude") ) / ".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]: @@ -104,9 +112,14 @@ def _codex_account() -> dict[str, Any]: subscription_until, subscription_live = _timestamp( 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 { "authenticated": authenticated, + "access_token_live": token_live, + "refreshable": refreshable, "auth_mode": auth.get("auth_mode") or "unknown", "plan": claims.get("chatgpt_plan_type") or "unknown", "token_expires_at": expires_at, @@ -193,7 +206,11 @@ def _provider_summary(name: str, models: dict[str, Any]) -> dict[str, Any]: calls = totals["calls"] 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" elif calls: 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]: """Build the owner-safe status document shared by dashboard and TUI.""" health = _get_json(f"{SWITCHYARD_ROOT}/health") @@ -221,8 +261,24 @@ def provider_status_payload() -> dict[str, Any]: name: _provider_summary(name, models) 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["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 = classifier if isinstance(classifier, dict) else {} fallbacks = stats.get("routing_fallbacks") @@ -231,9 +287,11 @@ def provider_status_payload() -> dict[str, Any]: "generated_at": datetime.now(timezone.utc).isoformat(), "window": "Since the last Switchyard restart", "quota_note": ( - "Codex and Claude subscription balances are not exposed to this " - "router. Open the provider usage page for authoritative remaining " - "capacity; the counters here show actual work observed by Switchyard." + "Codex uses the owner's ChatGPT Codex OAuth and Claude uses the " + "owner's native first-party Claude Code subscription; the Claude " + "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": { "state": "available" if router_ok else "unavailable", diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 67264b8c2..0cf9e16d0 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -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") { 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) { diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go index 58d168ac2..7c0334cfa 100644 --- a/services/hermes/router/web.go +++ b/services/hermes/router/web.go @@ -17,13 +17,13 @@ const telegramPage = ` Hermes on Telegram - +
← Back to Hermes

Hermes on Telegram

-

Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.

+

The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.

Checking Telegram…

- + ` @@ -42,7 +42,7 @@ const privateFilesPage = ` Hermes Private Files - +
@@ -73,19 +73,18 @@ const privateFilesPage = `
- + ` 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-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-files-sidebar,#hermes-telegram-sidebar{display:flex;align-items:center} .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 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} #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 = `(() => { @@ -96,23 +95,51 @@ const bridgeJS = `(() => { }; hideChatAdministration(); if (!page && !filesPage) { - if (!document.getElementById('hermes-chat-shortcuts')) { - const shortcuts = document.createElement('nav'); - shortcuts.id = 'hermes-chat-shortcuts'; + const labelLink = (link, id, href, label, ariaLabel) => { + if (link.id !== id) link.id = id; + if (link.getAttribute('href') !== href) link.setAttribute('href', href); + if (link.getAttribute('aria-label') !== ariaLabel) link.setAttribute('aria-label', ariaLabel); + const walker = document.createTreeWalker(link, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + let replaced = false; + while (textNode) { + if (textNode.textContent.trim()) { + const current = textNode.textContent.trim(); + if (current.toLowerCase() !== label.toLowerCase()) { + textNode.textContent = textNode.textContent.replace(current, label); + } + replaced = true; + break; + } + textNode = walker.nextNode(); + } + if (!replaced) link.append(document.createTextNode(label)); + }; + const installSidebarLinks = () => { const match = location.pathname.match(/^\/session\/([^/]+)/); - const files = document.createElement('a'); - files.id = 'hermes-files-shortcut'; - files.href = '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''); - files.textContent = 'Files'; - files.setAttribute('aria-label', 'Browse private Hermes files'); - const telegram = document.createElement('a'); - telegram.id = 'hermes-telegram-shortcut'; - telegram.href = '/telegram'; - telegram.textContent = 'Telegram'; - telegram.setAttribute('aria-label', 'Connect Hermes to Telegram'); - shortcuts.append(files, telegram); - document.body.appendChild(shortcuts); - } + 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; } if (filesPage) { @@ -383,8 +410,8 @@ func injectChatBridge(response *http.Response) error { _ = response.Body.Close() content := string(body) if !strings.Contains(content, "hermes-chat-bridge.js") { - content = strings.Replace(content, "", ``, 1) - content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) } response.Body = io.NopCloser(strings.NewReader(content)) response.ContentLength = int64(len(content)) diff --git a/services/hermes/scripts/claude_oauth_broker.py b/services/hermes/scripts/claude_oauth_broker.py index c8a382889..02e24bf75 100644 --- a/services/hermes/scripts/claude_oauth_broker.py +++ b/services/hermes/scripts/claude_oauth_broker.py @@ -1,72 +1,77 @@ #!/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 import hmac import json 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 pathlib import Path -from typing import Final +from typing import Any, Final -import httpx - -from routing_catalog import resolve_route +from routing_catalog import load_catalog, resolve_route HOST: Final = os.environ.get("HERMES_CLAUDE_BROKER_HOST", "0.0.0.0") PORT: Final = int(os.environ.get("HERMES_CLAUDE_BROKER_PORT", "9006")) -UPSTREAM: Final = os.environ.get( - "HERMES_CLAUDE_BROKER_UPSTREAM", "https://api.anthropic.com" -).rstrip("/") +CLAUDE_BIN: Final = os.environ.get("HERMES_CLAUDE_BIN", "/opt/coordinator/claude") MAX_BODY_BYTES: Final = int( os.environ.get("HERMES_CLAUDE_BROKER_MAX_BODY", str(64 << 20)) ) -READ_TIMEOUT_SECONDS: Final = float( - os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "900") +TIMEOUT_SECONDS: Final = float( + os.environ.get("HERMES_CLAUDE_BROKER_READ_TIMEOUT", "1800") ) -ALLOWED_PATHS: Final = { - "/v1/messages", - "/v1/messages/count_tokens", - "/v1/models", -} -REQUIRED_BETAS: Final = ( - "interleaved-thinking-2025-05-14", - "fine-grained-tool-streaming-2025-05-14", - "claude-code-20250219", - "oauth-2025-04-20", +MAX_CONCURRENCY: Final = int( + os.environ.get("HERMES_CLAUDE_BROKER_CONCURRENCY", "4") +) +HEALTH_PATH: Final = Path( + os.environ.get( + "HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json" + ) ) ROUTED_MODEL_PREFIX: Final = "route/claude/" -CAPACITY_ERROR_MARKERS: Final = ( - "extra usage", - "plan limits", - "usage limit", - "rate limit", - "credit balance", - "claude.ai/settings/usage", +EFFORTS: Final = {"low", "medium", "high", "xhigh"} +CAPACITY_PATTERN: Final = re.compile( + r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529)", + re.I, ) - - -def _translate_model(body: bytes) -> bytes: - """Translate a Switchyard effort-qualified target into a Claude model.""" - if not body: - return body - try: - payload = json.loads(body) - except (TypeError, ValueError, json.JSONDecodeError): - return body - if not isinstance(payload, dict): - return body - model = payload.get("model") - if isinstance(model, str) and model.startswith(ROUTED_MODEL_PREFIX): - payload["model"] = resolve_route(model) - return json.dumps(payload, separators=(",", ":")).encode("utf-8") - return body +STRUCTURED_SCHEMA: Final = { + "type": "object", + "additionalProperties": False, + "required": ["type", "text", "tool_calls"], + "properties": { + "type": {"type": "string", "enum": ["final", "tool_calls"]}, + "text": {"type": "string"}, + "tool_calls": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": ["name", "input"], + "properties": { + "name": {"type": "string"}, + "input": {"type": "object"}, + }, + }, + }, + }, +} +_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: - """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() if value: return value @@ -80,63 +85,375 @@ def _read_secret(env_name: str, file_env_name: str) -> str: def _relay_key() -> str: - """Return the shared internal key without caching rotated file contents.""" - return _read_secret( - "HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE" + """Return the internal relay key shared with Switchyard.""" + return ( + _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: - """Accept Switchyard's x-api-key or an internal Bearer relay key.""" + """Accept Switchyard's API-key or bearer-key header.""" expected = _relay_key() if not expected: return False candidates = [api_key or ""] if authorization and authorization.startswith("Bearer "): candidates.append(authorization[7:].strip()) - return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates) + return any( + candidate and hmac.compare_digest(candidate, expected) + for candidate in candidates + ) -def _merge_betas(incoming: str | None) -> str: - """Preserve requested Anthropic betas while adding Claude Code OAuth betas.""" - values: list[str] = [] - for value in (*((incoming or "").split(",")), *REQUIRED_BETAS): - value = value.strip() - if value and value not in values: - values.append(value) - return ",".join(values) +def _route(model: str, payload: dict[str, Any]) -> tuple[str, str]: + """Resolve one Switchyard model and effort into native Claude CLI values.""" + effort = "medium" + if model.startswith(ROUTED_MODEL_PREFIX): + parts = model.split("/") + if parts[-1] in EFFORTS: + effort = parts[-1] + 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: - """Expose provider capacity exhaustion using Switchyard's retryable status.""" - if status != 400: - return status - text = body.decode("utf-8", errors="replace").lower() - if any(marker in text for marker in CAPACITY_ERROR_MARKERS): - return 429 - return status +def _prompt(payload: dict[str, Any]) -> str: + """Describe one Anthropic boundary without letting Claude run local tools.""" + tools = payload.get("tools") + tools = tools if isinstance(tools, list) else [] + contract = { + "system": payload.get("system") or "", + "messages": payload.get("messages") or [], + "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): - """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: - """Avoid logging paths or headers that could contain sensitive metadata.""" 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") self.send_response(status) self.send_header("Content-Type", "application/json") @@ -145,33 +462,54 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() 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: - if _authorized( - self.headers.get("Authorization"), self.headers.get("x-api-key") - ): + if _authorized(self.headers.get("Authorization"), self.headers.get("x-api-key")): return True - self._json(401, {"error": {"type": "authentication_error", "message": "unauthorized"}}) + self._error(401, "authentication_error", "unauthorized") return False - def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + def do_GET(self) -> None: # noqa: N802 if self.path == "/health": - try: - _oauth_token() - except RuntimeError as exc: - self._json(503, {"ok": False, "error": str(exc)}) - return - self._json(200, {"ok": True, "provider": "anthropic-oauth"}) + executable = shutil.which(CLAUDE_BIN) or ( + CLAUDE_BIN if Path(CLAUDE_BIN).is_file() else "" + ) + 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 - if self.path not in ALLOWED_PATHS: - self._json(404, {"error": {"type": "not_found", "message": "not found"}}) + if self.path != "/v1/models": + self._error(404, "not_found", "not found") return if not self._check_auth(): 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 - if self.path not in ALLOWED_PATHS: - self._json(404, {"error": {"type": "not_found", "message": "not found"}}) + def do_POST(self) -> None: # noqa: N802 + if self.path not in {"/v1/messages", "/v1/messages/count_tokens"}: + self._error(404, "not_found", "not found") return if not self._check_auth(): return @@ -180,93 +518,55 @@ class Handler(BaseHTTPRequestHandler): except ValueError: length = -1 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 - self._proxy(_translate_model(self.rfile.read(length))) - - def _proxy(self, body: bytes) -> None: - """Forward one bounded request and stream its response unchanged.""" - response_started = False try: - token = _oauth_token() - headers = { - "Accept": self.headers.get("Accept", "application/json"), - "Authorization": f"Bearer {token}", - "anthropic-version": self.headers.get( - "anthropic-version", "2023-06-01" - ), - "anthropic-beta": _merge_betas(self.headers.get("anthropic-beta")), - "Content-Type": self.headers.get("Content-Type", "application/json"), - "User-Agent": "claude-code/2.1.226 (external, cli)", - "x-app": "cli", - } - timeout = httpx.Timeout(30.0, read=READ_TIMEOUT_SECONDS) - with httpx.Client(timeout=timeout) as client: - with client.stream( - self.command, - f"{UPSTREAM}{self.path}", - headers=headers, - content=body or None, - ) as response: - 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 - - self.send_response(response.status_code) - for name, value in response.headers.items(): - if name.lower() in { - "content-type", - "cache-control", - "request-id", - "retry-after", - "anthropic-ratelimit-requests-limit", - "anthropic-ratelimit-requests-remaining", - "anthropic-ratelimit-requests-reset", - "anthropic-ratelimit-tokens-limit", - "anthropic-ratelimit-tokens-remaining", - "anthropic-ratelimit-tokens-reset", - }: - self.send_header(name, value) - self.send_header("Connection", "close") - self.end_headers() - response_started = True - for chunk in response.iter_bytes(): - if chunk: - self.wfile.write(chunk) - self.wfile.flush() - except (RuntimeError, httpx.HTTPError, OSError) as exc: - # Once streaming headers have crossed the wire, an upstream failure - # can only terminate the stream. Sending a second HTTP response - # would corrupt the Anthropic event stream seen by Switchyard. - if not response_started and not self.wfile.closed: - self._json( - 503, - {"error": {"type": "provider_unavailable", "message": str(exc)}}, - ) - finally: - self.close_connection = True + payload = json.loads(self.rfile.read(length)) + except (TypeError, ValueError, json.JSONDecodeError): + self._error(400, "invalid_request_error", "JSON object required") + return + if not isinstance(payload, dict): + self._error(400, "invalid_request_error", "JSON object required") + return + if self.path.endswith("count_tokens"): + self._json(200, {"input_tokens": max(1, len(_prompt(payload)) // 4)}) + return + try: + structured, usage, model = _invoke(payload) + message = _message(structured, usage, model) + except ValueError as exc: + self._error(400, "invalid_request_error", str(exc)) + return + except subprocess.TimeoutExpired: + self._error(504, "timeout_error", "Claude Code request timed out") + return + except (OSError, RuntimeError) as exc: + detail = str(exc) + status = 429 if detail.startswith("capacity:") else 503 + self._error( + status, + "rate_limit_error" if status == 429 else "api_error", + detail.partition(": ")[2] or detail, + ) + return + 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): - """Threaded server whose request workers do not block shutdown.""" + """Threaded broker with bounded provider-side concurrency.""" daemon_threads = True if __name__ == "__main__": + _subscription_health(force=True) Server((HOST, PORT), Handler).serve_forever() diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py index f998adb96..f25e99bf7 100644 --- a/services/hermes/scripts/codex_broker.py +++ b/services/hermes/scripts/codex_broker.py @@ -5,9 +5,11 @@ from __future__ import annotations import base64 import binascii +import fcntl import hmac import json import os +import tempfile import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -39,6 +41,9 @@ FALLBACK_ALLOWED_MODELS = { if value.strip() } 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: @@ -55,31 +60,86 @@ def _authorized(header: str | None) -> bool: return hmac.compare_digest(header[7:].strip(), TOKEN) -def _access_token() -> str: - """Read the current owner token; the Codex CLI remains refresh owner.""" - 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() +def _token_expiry(token: str) -> float: + """Return a JWT expiry timestamp, or zero for an opaque token.""" try: encoded = token.split(".")[1] 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): - expires_at = 0 - if expires_at and time.time() >= float(expires_at): - raise RuntimeError("owner Codex access token is expired") - return token + return 0.0 + + +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 + 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]: diff --git a/services/hermes/scripts/hermes_model_routing.py b/services/hermes/scripts/hermes_model_routing.py index 80cc5e1dd..92b2c7fa8 100644 --- a/services/hermes/scripts/hermes_model_routing.py +++ b/services/hermes/scripts/hermes_model_routing.py @@ -19,6 +19,12 @@ import yaml CODEX_BASELINE = "gpt-5.6-terra" 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") ATLAS_FALLBACK = { "provider": "custom", @@ -271,7 +277,12 @@ def build_routing_catalog( "claude", claude, choose_claude_for_effort, - {"haiku": "low", "sonnet": "medium", "opus": "xhigh"}, + { + "haiku": "low", + "fable": "medium", + "sonnet": "high", + "opus": "xhigh", + }, { "low": "claude-haiku-4-5-20251001", "medium": "claude-sonnet-5", @@ -444,7 +455,45 @@ def discover_codex_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() live: list[str] = [] if token: diff --git a/services/hermes/scripts/migrate_api_session_lineage.py b/services/hermes/scripts/migrate_api_session_lineage.py new file mode 100644 index 000000000..c21ed0420 --- /dev/null +++ b/services/hermes/scripts/migrate_api_session_lineage.py @@ -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") diff --git a/services/hermes/scripts/patch_api_server_sessions.py b/services/hermes/scripts/patch_api_server_sessions.py new file mode 100644 index 000000000..eb5596a40 --- /dev/null +++ b/services/hermes/scripts/patch_api_server_sessions.py @@ -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()) diff --git a/services/hermes/scripts/patch_codex_runtime.py b/services/hermes/scripts/patch_codex_runtime.py index d377114db..0dfd59440 100644 --- a/services/hermes/scripts/patch_codex_runtime.py +++ b/services/hermes/scripts/patch_codex_runtime.py @@ -347,8 +347,9 @@ AUXILIARY_TOKEN_AFTER = ''' except Exception as exc: # app-server and does not duplicate those credentials into Hermes' # provider auth store. Auxiliary tasks still use Hermes' native Codex # Responses adapter, so read the current CLI access token without - # copying or refreshing it here. The Codex CLI remains the sole owner - # of refresh-token rotation. + # copying or refreshing it here. The authenticated Codex broker and + # Codex CLI coordinate through this canonical file; this auxiliary + # path remains read-only and never creates a metered API-key lane. try: codex_home = os.environ.get("CODEX_HOME", "").strip() if not codex_home: @@ -361,8 +362,8 @@ AUXILIARY_TOKEN_AFTER = ''' except Exception as exc: return None # Match the native expiry check above. An expired CLI token is not - # refreshed from this side channel because that would race the - # app-server's canonical refresh-token owner. + # refreshed from this side channel because the broker owns the + # serialized, atomic refresh operation for chat boundaries. try: import base64 jwt_payload = access_token.split(".")[1] diff --git a/services/hermes/scripts/routing_catalog.py b/services/hermes/scripts/routing_catalog.py index 59c4ac38a..8c7cc5ef5 100644 --- a/services/hermes/scripts/routing_catalog.py +++ b/services/hermes/scripts/routing_catalog.py @@ -27,6 +27,19 @@ DEFAULTS = { }, } 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]: @@ -60,7 +73,11 @@ def resolve_model( mapping = record.get(mapping_name, {}) candidate = mapping.get(effort if selector == "auto" else selector, "") if isinstance(mapping, dict) else "" if not isinstance(candidate, str) or not candidate.startswith(prefix): - candidate = DEFAULTS[provider][effort] + candidate = ( + DEFAULTS[provider][effort] + if selector == "auto" + else TIER_DEFAULTS[provider].get(selector, DEFAULTS[provider][effort]) + ) return candidate diff --git a/services/hermes/service.yaml b/services/hermes/service.yaml index c6dfe39dc..00b85e6be 100644 --- a/services/hermes/service.yaml +++ b/services/hermes/service.yaml @@ -125,3 +125,20 @@ spec: port: 9004 targetPort: local-image 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 diff --git a/services/hermes/switchyard-configmap.yaml b/services/hermes/switchyard-configmap.yaml index af19736a5..cb04f5651 100644 --- a/services/hermes/switchyard-configmap.yaml +++ b/services/hermes/switchyard-configmap.yaml @@ -54,25 +54,25 @@ data: [llm_clients.claude_low] 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" max_retries = 1 [llm_clients.claude_medium] 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" max_retries = 1 [llm_clients.claude_high] 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" max_retries = 1 [llm_clients.claude_xhigh] 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" max_retries = 1 @@ -98,6 +98,21 @@ data: llm_client = "codex_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] id = "route/codex/terra/low" llm_client = "codex_low" @@ -113,6 +128,16 @@ data: llm_client = "codex_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] id = "route/codex/sol/medium" llm_client = "codex_medium" @@ -131,6 +156,47 @@ data: [targets.claude_haiku_low] id = "route/claude/haiku/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] id = "route/claude/sonnet/medium" @@ -142,6 +208,21 @@ data: llm_client = "claude_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] id = "route/claude/opus/high" llm_client = "claude_high" @@ -156,10 +237,42 @@ data: id = "worker/codex/luna/low" 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] id = "worker/codex/terra/medium" 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] id = "worker/codex/sol/high" llm_client = "worker_decision" @@ -172,6 +285,38 @@ data: id = "worker/claude/haiku/low" 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] id = "worker/claude/sonnet/medium" llm_client = "worker_decision" @@ -180,6 +325,22 @@ data: id = "worker/claude/sonnet/high" 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] id = "worker/claude/opus/xhigh" llm_client = "worker_decision" @@ -191,7 +352,7 @@ data: classifier_target = "classifier" # Switchyard falls through this list after a request-local target failure. # 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" session_affinity = false recent_turn_window = 4 @@ -240,15 +401,17 @@ data: to be unavailable, failed, exhausted, rate-limited, or out of capacity; use the other hosted provider at the same floor. - 5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, - high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, - medium=claude_sonnet_medium, high=claude_sonnet_high, - xhigh=claude_opus_xhigh. Re-evaluate every boundary and resolve "continue" + 5. Choose across the complete family catalog. Codex options are Luna, + Terra, and SOL at low through xhigh. Claude options are Haiku, Fable, + Sonnet, and Opus at low through xhigh. Prefer Fable for concise writing, + 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 only when the user explicitly selects it. """ 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] @@ -262,7 +425,7 @@ data: classifier_target = "classifier" # Switchyard falls through this list after a request-local target failure. # 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" session_affinity = false recent_turn_window = 4 @@ -312,15 +475,17 @@ data: to be unavailable, failed, exhausted, rate-limited, or out of capacity; use the other hosted provider at the same floor. - 5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, - high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, - medium=claude_sonnet_medium, high=claude_sonnet_high, - xhigh=claude_opus_xhigh. Re-evaluate every boundary and resolve "continue" + 5. Choose across the complete family catalog. Codex options are Luna, + Terra, and SOL at low through xhigh. Claude options are Haiku, Fable, + Sonnet, and Opus at low through xhigh. Prefer Fable for concise writing, + 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 only when the user explicitly selects it. """ 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] @@ -334,7 +499,7 @@ data: classifier_target = "classifier" # Switchyard falls through this list after a request-local target failure. # 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" session_affinity = false recent_turn_window = 6 @@ -376,15 +541,15 @@ data: unavailable, failed, exhausted, rate-limited, or out of capacity; use the other provider at the same floor. - 4. Map exactly: Codex medium=codex_terra_medium, - high=codex_sol_high, xhigh=codex_sol_xhigh; Claude - medium=claude_sonnet_medium, high=claude_sonnet_high, - xhigh=claude_opus_xhigh. Low-tier targets are intentionally unavailable on + 4. Choose across Codex Terra/SOL and Claude Fable/Sonnet/Opus at medium + through xhigh. Prefer Fable for writing and compact synthesis where it + clears the quality floor; use Sonnet or Opus for deeper diagnosis and + review. Low-tier targets are intentionally unavailable on this route. Re-evaluate every boundary and resolve "continue" or "do it" from recent context. """ 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] @@ -398,7 +563,7 @@ data: classifier_target = "classifier" # Switchyard falls through this list after a request-local target failure. # 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" session_affinity = false recent_turn_window = 6 @@ -440,15 +605,15 @@ data: unavailable, failed, exhausted, rate-limited, or out of capacity; use the other provider at the same floor. - 4. Map exactly: Codex medium=codex_terra_medium, - high=codex_sol_high, xhigh=codex_sol_xhigh; Claude - medium=claude_sonnet_medium, high=claude_sonnet_high, - xhigh=claude_opus_xhigh. Low-tier targets are intentionally unavailable on + 4. Choose across Codex Terra/SOL and Claude Fable/Sonnet/Opus at medium + through xhigh. Prefer Fable for writing and compact synthesis where it + clears the quality floor; use Sonnet or Opus for deeper analysis and + independent review. Low-tier targets are intentionally unavailable on this route. Re-evaluate every boundary and resolve "continue" or "do it" from recent context. """ 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] @@ -460,7 +625,7 @@ data: type = "llm_classifier" mode = "custom" classifier_target = "classifier" - targets = ["worker_codex_sol_high", "worker_claude_sonnet_high", "worker_codex_sol_xhigh", "worker_claude_opus_xhigh", "worker_codex_terra_medium", "worker_claude_sonnet_medium", "worker_codex_luna_low", "worker_claude_haiku_low"] + 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" session_affinity = false recent_turn_window = 6 @@ -499,15 +664,13 @@ data: analysis, and independent review. A final independent review is Claude; implementing review findings is Codex. - 4. Map provider and effort exactly: - Codex low=worker_codex_luna_low; - Codex medium=worker_codex_terra_medium; - Codex high=worker_codex_sol_high; - Codex xhigh=worker_codex_sol_xhigh. - Claude low=worker_claude_haiku_low; - Claude medium=worker_claude_sonnet_medium; - Claude high=worker_claude_sonnet_high; - Claude xhigh=worker_claude_opus_xhigh. + 4. Choose across every configured Codex and Claude family at the exact + effort floor. Codex Luna is the economical tier, Terra is balanced, and + SOL is the deepest implementation tier. Claude Haiku is the economical + tier, Fable is preferred for concise writing and synthesis, Sonnet is + balanced, and Opus is the deepest analysis and review tier. Every family + supports low, medium, high, and xhigh; choose the cheapest family that + clears the objective's quality floor without lowering its effort. Examples: Critical security migration final review -> worker_claude_opus_xhigh. @@ -524,7 +687,7 @@ data: the floor. Return only the required decision object. """ 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] @@ -627,6 +790,268 @@ data: tool_calling = 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] id = "atlas/manual/local/qwen-14b" type = "random" diff --git a/services/hermes/switchyard-deployment.yaml b/services/hermes/switchyard-deployment.yaml index 4e3090f42..8b39f3458 100644 --- a/services/hermes/switchyard-deployment.yaml +++ b/services/hermes/switchyard-deployment.yaml @@ -22,17 +22,12 @@ spec: labels: app: hermes-switchyard annotations: - ai.bstein.dev/config-rev: "20260812-hosted-foreground" + ai.bstein.dev/config-rev: "20260812-native-claude-subscription" prometheus.io/scrape: "true" prometheus.io/port: "9005" prometheus.io/path: /metrics vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent - vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens - vault.hashicorp.com/agent-inject-template-anthropic-token: | - {{- with secret "kv/data/atlas/hermes/agent-tokens" -}} - {{ .Data.data.anthropic_oauth_token }} - {{- end }} vault.hashicorp.com/agent-inject-secret-relay-key: kv/data/atlas/hermes/chat-telegram vault.hashicorp.com/agent-inject-template-relay-key: | {{- with secret "kv/data/atlas/hermes/chat-telegram" -}} @@ -126,63 +121,6 @@ spec: mountPath: /var/lib/switchyard - name: 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 image: registry.bstein.dev/bstein/hermes-switchyard-brokers@sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083 imagePullPolicy: IfNotPresent diff --git a/testing/tests/test_hermes_auto_router.py b/testing/tests/test_hermes_auto_router.py index 69ad268b4..f801230b7 100644 --- a/testing/tests/test_hermes_auto_router.py +++ b/testing/tests/test_hermes_auto_router.py @@ -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( 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"]["local"]["total_tokens"] == 300 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(): @@ -241,6 +339,27 @@ def test_claude_account_treats_a_live_refresh_token_as_refreshable( 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(): import yaml diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 2ae6130d5..8e26010af 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -5,11 +5,12 @@ from __future__ import annotations import base64 import importlib.util import json +import sqlite3 import sys import time import tomllib from pathlib import Path -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import pytest import yaml @@ -503,6 +504,7 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials(): "atlas/manual/codex/terra", "atlas/manual/codex/sol", "atlas/manual/claude/haiku", + "atlas/manual/claude/fable", "atlas/manual/claude/sonnet", "atlas/manual/claude/opus", "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.") -def test_claude_broker_exposes_capacity_exhaustion_as_retryable(monkeypatch): - """Subscription exhaustion must cross providers instead of surfacing as 400.""" +def test_codex_broker_refreshes_and_persists_first_party_oauth( + 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( "hermes_claude_broker", "claude_oauth_broker.py", monkeypatch ) - exhausted = json.dumps( - { - "type": "error", - "error": { - "type": "invalid_request_error", - "message": ( - "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"}}' + monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-leak") + monkeypatch.setenv("CLAUDE_API_KEY", "must-not-leak") + monkeypatch.setattr( + module, + "resolve_route", + lambda route: "claude-fable-5" if "/fable/" in route else route, + ) - assert module._normalized_upstream_status(400, exhausted) == 429 - assert module._normalized_upstream_status(400, malformed) == 400 - assert module._normalized_upstream_status(403, exhausted) == 403 + model, effort = module._route( + "route/claude/fable/xhigh", {"output_config": {"effort": "xhigh"}} + ) + + 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(): - """Control-plane brokers must not pull the full multi-gigabyte agent image.""" +def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): + """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() assert "httpx==0.28.1" in dockerfile - assert "claude_oauth_broker.py" in dockerfile assert "worker_route_broker.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@" "sha256:ee7e95e060ef8083da505162d7e9030daba15fdd828cc047bbcbe6aa409d2083" ) - assert containers["claude-oauth-broker"]["image"] == expected assert containers["worker-route-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): @@ -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 "qwen2.5:3b-instruct-q4_0" not 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 "OpenAI and Codex name the same provider" in switchyard - assert switchyard.count("Codex low=codex_luna_low") == 2 - assert switchyard.count("Claude low=claude_haiku_low") == 2 + assert "Choose across every configured Codex and Claude family" in switchyard + assert "Claude Fable" in switchyard assert switchyard.count('Treat "think hard"') == 4 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"): leading_targets = set(routes[route_name]["targets"][:2]) 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"] selector_targets = routes[route_name]["response_schema"] 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 "not eligible for foreground" in routes[route_name]["prompt"] 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_sol", "manual_claude_haiku", + "manual_claude_fable", "manual_claude_sonnet", "manual_claude_opus", ): assert not any( 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"): prompt = routes[route_name]["prompt"] assert "image tool—not the conversational model" in prompt