hermes: add private image generation and repair xhigh fallback
All checks were successful
Tests / Declarative: Post Actions skipped: 6, passed: 229

This commit is contained in:
jenkins 2026-08-11 01:14:46 -03:00
parent 5ecec6c60a
commit ffffe98a3f
18 changed files with 764 additions and 29 deletions

View File

@ -24,7 +24,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260811-codex-cli-auxiliary" ai.bstein.dev/config-rev: "20260811-codex-image-broker"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -39,6 +39,11 @@ spec:
{{ . }} {{ . }}
{{- end -}} {{- end -}}
{{- end }} {{- end }}
vault.hashicorp.com/agent-inject-secret-chat-relay-key: kv/data/atlas/hermes/chat-telegram
vault.hashicorp.com/agent-inject-template-chat-relay-key: |
{{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
{{ .Data.data.relay_key }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/agent-oidc vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/agent-oidc
vault.hashicorp.com/agent-inject-template-oidc-config: | vault.hashicorp.com/agent-inject-template-oidc-config: |
{{- with secret "kv/data/atlas/hermes/agent-oidc" -}} {{- with secret "kv/data/atlas/hermes/agent-oidc" -}}
@ -127,6 +132,10 @@ spec:
token="$(tr -d '\r\n' < /vault/secrets/gitea-token)" token="$(tr -d '\r\n' < /vault/secrets/gitea-token)"
case "${token}" in ""|"<no value>"|"<nil>") ;; *) upsert_env GITEA_TOKEN "${token}" ;; esac case "${token}" in ""|"<no value>"|"<nil>") ;; *) upsert_env GITEA_TOKEN "${token}" ;; esac
fi fi
if [ -s /vault/secrets/chat-relay-key ]; then
token="$(tr -d '\r\n' < /vault/secrets/chat-relay-key)"
[ -z "${token}" ] || upsert_env HERMES_IMAGE_BROKER_KEY "${token}"
fi
upsert_env GITEA_USERNAME bstein upsert_env GITEA_USERNAME bstein
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
upsert_env GIT_TERMINAL_PROMPT 0 upsert_env GIT_TERMINAL_PROMPT 0
@ -677,6 +686,52 @@ spec:
resources: resources:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 512Mi} limits: {cpu: 250m, memory: 512Mi}
- name: image-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:cf07be056feea8e4f2d5512899f990732f3d26b4915257de8f90105d96cec67d
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
- |
set -a
. /opt/data/.env
set +a
exec /opt/hermes/.venv/bin/python /opt/coordinator/image_broker.py
ports:
- {name: image-broker, containerPort: 9002, 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: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_IMAGE_BROKER_DEFAULT_MODEL, value: gpt-image-2-high}
readinessProbe:
tcpSocket: {port: image-broker}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket: {port: image-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: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tmp, mountPath: /tmp}
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi}
volumes: volumes:
- name: home - name: home
persistentVolumeClaim: persistentVolumeClaim:

View File

@ -41,12 +41,15 @@ data:
local: local:
model: small model: small
language: auto language: auto
image_gen:
provider: atlas-broker
model: gpt-image-2-high
model_catalog: model_catalog:
enabled: true enabled: true
ttl_hours: 1 ttl_hours: 1
platform_toolsets: platform_toolsets:
cli: [browser, clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, vision, web] cli: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web]
api_server: [browser, clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, vision, web] api_server: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web]
dashboard: dashboard:
public_url: https://chat.hermes.bstein.dev public_url: https://chat.hermes.bstein.dev
display: display:
@ -97,6 +100,13 @@ data:
Voice conversations use the same assistant, session, AUTO route, tools, and Voice conversations use the same assistant, session, AUTO route, tools, and
private workspace as typed conversations. Whisper and speech synthesis are private workspace as typed conversations. Whisper and speech synthesis are
transport services only; they do not select or replace the answering model. transport services only; they do not select or replace the answering model.
When a user asks to create or edit an image, use the image generation tool.
Default to its highest-quality GPT Image tier, honor requested aspect ratio,
and use uploaded images as references when provided. Return the generated
image inline so the WebUI offers its normal preview and download controls.
Do not substitute Python drawing, SVG, diagrams, or placeholder artwork for
a requested generative image.
AGENTS.md: | AGENTS.md: |
# Private Hermes chat # Private Hermes chat
@ -108,5 +118,7 @@ data:
for bounded temporary work. Use for bounded temporary work. Use
delegation selectively for independent research or verification, then delegation selectively for independent research or verification, then
present a single final answer. present a single final answer.
Use the image generation tool for natural-language image creation and
editing requests; generated images remain in this tenant's private cache.
Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
users, the agent coordinator, or automated triage. users, the agent coordinator, or automated triage.

View File

@ -28,7 +28,7 @@ spec:
ai.bstein.dev/role: isolated-user-chat ai.bstein.dev/role: isolated-user-chat
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject
ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides
ai.bstein.dev/config-rev: "20260810-noise-gated-voice" ai.bstein.dev/config-rev: "20260811-private-image-studio"
vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-chat vault.hashicorp.com/role: hermes-chat
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
@ -120,6 +120,8 @@ spec:
fi fi
{ grep -v '^API_SERVER_KEY=' /opt/data/.env || true; printf 'API_SERVER_KEY=%s\n' "${relay_key}"; } > /opt/data/.env.tmp { grep -v '^API_SERVER_KEY=' /opt/data/.env || true; printf 'API_SERVER_KEY=%s\n' "${relay_key}"; } > /opt/data/.env.tmp
mv /opt/data/.env.tmp /opt/data/.env mv /opt/data/.env.tmp /opt/data/.env
{ grep -v '^HERMES_IMAGE_BROKER_KEY=' /opt/data/.env || true; printf 'HERMES_IMAGE_BROKER_KEY=%s\n' "${relay_key}"; } > /opt/data/.env.tmp
mv /opt/data/.env.tmp /opt/data/.env
if [ -s /vault/secrets/anthropic-token ]; then if [ -s /vault/secrets/anthropic-token ]; then
token="$(tr -d '\r\n' < /vault/secrets/anthropic-token)" token="$(tr -d '\r\n' < /vault/secrets/anthropic-token)"
if [ -n "${token}" ]; then if [ -n "${token}" ]; then
@ -183,6 +185,9 @@ spec:
- | - |
ordinal="${HOSTNAME##*-}" ordinal="${HOSTNAME##*-}"
export HERMES_CODE_SANDBOX_URL="http://hermes-chat-sandbox-${ordinal}.hermes-chat-sandbox.hermes.svc.cluster.local:9080/v1/execute" export HERMES_CODE_SANDBOX_URL="http://hermes-chat-sandbox-${ordinal}.hermes-chat-sandbox.hermes.svc.cluster.local:9080/v1/execute"
set -a
. /opt/data/.env
set +a
exec /opt/hermes/.venv/bin/hermes gateway run exec /opt/hermes/.venv/bin/hermes gateway run
ports: ports:
- {name: api, containerPort: 8642, protocol: TCP} - {name: api, containerPort: 8642, protocol: TCP}
@ -201,11 +206,16 @@ spec:
- {name: API_SERVER_HOST, value: 0.0.0.0} - {name: API_SERVER_HOST, value: 0.0.0.0}
- {name: API_SERVER_PORT, value: "8642"} - {name: API_SERVER_PORT, value: "8642"}
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev} - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
- {name: HERMES_IMAGE_BROKER_URL, value: http://hermes-image-broker.hermes.svc.cluster.local:9002}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: workspace, mountPath: /opt/data/workspace} - {name: workspace, mountPath: /opt/data/workspace}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true} # The shared provider pool uses auth.lock to serialize token refresh
# across tenant gateways. Tenant files and conversations remain on
# their own PVCs; only provider credentials are shared here.
- {name: provider-auth, mountPath: /shared-auth}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: image-plugin, mountPath: /opt/hermes/plugins/image_gen/atlas-broker, readOnly: true}
readinessProbe: readinessProbe:
tcpSocket: {port: api} tcpSocket: {port: api}
initialDelaySeconds: 30 initialDelaySeconds: 30
@ -299,6 +309,9 @@ spec:
defaultMode: 0555 defaultMode: 0555
- name: auth-patch - name: auth-patch
emptyDir: {} emptyDir: {}
- name: image-plugin
configMap:
name: hermes-chat-image-plugin
- name: tmp - name: tmp
emptyDir: emptyDir:
sizeLimit: 256Mi sizeLimit: 256Mi

View File

@ -55,6 +55,7 @@ configMapGenerator:
- hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_model_routing.py=scripts/hermes_model_routing.py
- hermes_stt_client.py=scripts/hermes_stt_client.py - hermes_stt_client.py=scripts/hermes_stt_client.py
- image_broker.py=scripts/image_broker.py
- install_agent_tools.sh=scripts/install_agent_tools.sh - install_agent_tools.sh=scripts/install_agent_tools.sh
- migrate_herdr_state.py=scripts/migrate_herdr_state.py - migrate_herdr_state.py=scripts/migrate_herdr_state.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py
@ -76,6 +77,13 @@ configMapGenerator:
- plugin.yaml=plugins/auto-router/plugin.yaml - plugin.yaml=plugins/auto-router/plugin.yaml
options: options:
disableNameSuffixHash: true disableNameSuffixHash: true
- name: hermes-chat-image-plugin
namespace: hermes
files:
- __init__.py=plugins/image-gen-broker/__init__.py
- plugin.yaml=plugins/image-gen-broker/plugin.yaml
options:
disableNameSuffixHash: true
- name: hermes-triage-skill - name: hermes-triage-skill
namespace: hermes namespace: hermes
files: files:

View File

@ -14,6 +14,8 @@ spec:
app: hermes-model-gate app: hermes-model-gate
template: template:
metadata: metadata:
annotations:
ai.bstein.dev/config-rev: "20260811-reasoning-clamp"
labels: labels:
app: hermes-model-gate app: hermes-model-gate
spec: spec:

View File

@ -76,6 +76,12 @@ spec:
app.kubernetes.io/name: traefik app.kubernetes.io/name: traefik
ports: ports:
- {protocol: TCP, port: 4180} - {protocol: TCP, port: 4180}
- from:
- podSelector:
matchLabels:
app: hermes-chat-tenant
ports:
- {protocol: TCP, port: 9002}
# agent.hermes.bstein.dev is an owner-only engineering workstation. The # agent.hermes.bstein.dev is an owner-only engineering workstation. The
# browser boundary remains OAuth-protected, while its workers need to reach # browser boundary remains OAuth-protected, while its workers need to reach
# every cluster namespace, Atlas LAN service, and hosted provider endpoint. # every cluster namespace, Atlas LAN service, and hosted provider endpoint.
@ -224,6 +230,12 @@ spec:
app: hermes-model-gate app: hermes-model-gate
ports: ports:
- {protocol: TCP, port: 8080} - {protocol: TCP, port: 8080}
- to:
- podSelector:
matchLabels:
app: hermes-agent
ports:
- {protocol: TCP, port: 9002}
- to: - to:
- podSelector: - podSelector:
matchLabels: matchLabels:

View File

@ -0,0 +1,223 @@
"""Hermes image backend that keeps owner OAuth outside family chat pods."""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
normalize_reference_images,
resolve_aspect_ratio,
save_b64_image,
success_response,
)
BROKER_URL = os.environ.get(
"HERMES_IMAGE_BROKER_URL",
"http://hermes-image-broker.hermes.svc.cluster.local:9002",
).rstrip("/")
DEFAULT_MODEL = "gpt-image-2-high"
MODELS = {
"gpt-image-2-low": ("GPT Image 2 (Fast)", "low"),
"gpt-image-2-medium": ("GPT Image 2 (Balanced)", "medium"),
"gpt-image-2-high": ("GPT Image 2 (Highest quality)", "high"),
}
MAX_INPUT_BYTES = 25 << 20
def _broker_key() -> str:
"""Load the internal relay key from process env or the private .env."""
value = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
if value:
return value
home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
try:
for line in (home / ".env").read_text(encoding="utf-8").splitlines():
if line.startswith("HERMES_IMAGE_BROKER_KEY="):
return line.split("=", 1)[1].strip()
except OSError:
pass
return ""
def _request(path: str, payload: dict[str, Any] | None, timeout: float) -> dict[str, Any]:
"""Call the internal broker with a bounded authenticated JSON request."""
key = _broker_key()
if not key:
raise RuntimeError("private image broker key is unavailable")
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = Request(
f"{BROKER_URL}{path}",
data=data,
method="GET" if data is None else "POST",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed internal URL
return json.loads(response.read())
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:1000]
raise RuntimeError(f"image broker returned HTTP {exc.code}: {body}") from exc
except URLError as exc:
raise RuntimeError(f"image broker is unavailable: {exc.reason}") from exc
def _local_image_data_url(value: str) -> str:
"""Convert a private uploaded image to a data URL for broker transport."""
candidate = value.strip()
lowered = candidate.lower()
if lowered.startswith(("http://", "https://", "data:image/")):
return candidate
try:
from agent.file_safety import get_read_block_error
blocked = get_read_block_error(candidate)
if blocked:
raise ValueError(blocked)
except ImportError:
pass
path = Path(os.path.expanduser(candidate)).resolve()
raw = path.read_bytes()
if not raw or len(raw) > MAX_INPUT_BYTES:
raise ValueError("reference image must be between 1 byte and 25 MiB")
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
mime = "image/png"
elif raw.startswith(b"\xff\xd8\xff"):
mime = "image/jpeg"
elif raw.startswith((b"GIF87a", b"GIF89a")):
mime = "image/gif"
elif raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
mime = "image/webp"
else:
raise ValueError("reference image must be PNG, JPEG, GIF, or WebP")
return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
class AtlasBrokerImageProvider(ImageGenProvider):
"""High-quality GPT Image generation through the owner-only broker."""
@property
def name(self) -> str:
return "atlas-broker"
@property
def display_name(self) -> str:
return "Atlas private image studio"
def is_available(self) -> bool:
try:
result = _request("/health", None, 5.0)
return bool(result.get("success") and result.get("available"))
except Exception:
return False
def list_models(self) -> list[dict[str, Any]]:
return [
{
"id": model,
"display": display,
"speed": "~30s3min",
"strengths": "OpenAI GPT Image 2 quality",
"price": "included account capacity",
}
for model, (display, _quality) in MODELS.items()
]
def default_model(self) -> str:
return DEFAULT_MODEL
def capabilities(self) -> dict[str, Any]:
return {"modalities": ["text", "image"], "max_reference_images": 16}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: str | None = None,
reference_image_urls: list[str] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
prompt = (prompt or "").strip()
aspect = resolve_aspect_ratio(aspect_ratio)
if not prompt:
return error_response(
error="Prompt is required",
error_type="invalid_argument",
provider=self.name,
aspect_ratio=aspect,
)
model = str(kwargs.get("model") or DEFAULT_MODEL)
if model not in MODELS:
model = DEFAULT_MODEL
try:
primary = _local_image_data_url(image_url) if image_url else None
references = [
_local_image_data_url(item)
for item in (normalize_reference_images(reference_image_urls) or [])[:16]
]
response = _request(
"/v1/images/generations",
{
"prompt": prompt,
"aspect_ratio": aspect,
"model": model,
"image_url": primary,
"reference_image_urls": references,
},
1200.0,
)
if not response.get("success"):
return error_response(
error=str(response.get("error") or "image generation failed"),
error_type=str(response.get("error_type") or "provider_error"),
provider=self.name,
model=model,
prompt=prompt,
aspect_ratio=aspect,
)
image = save_b64_image(
str(response["image_b64"]), prefix=f"atlas_{model}"
)
return success_response(
image=str(image),
model=str(response.get("model") or model),
prompt=prompt,
aspect_ratio=aspect,
provider=self.name,
modality="image" if primary or references else "text",
extra={
"quality": response.get("quality", MODELS[model][1]),
"size": response.get("size"),
"input_image_count": response.get(
"input_image_count", len(references) + bool(primary)
),
},
)
except Exception as exc:
return error_response(
error=f"Private image studio failed: {exc}",
error_type="broker_error",
provider=self.name,
model=model,
prompt=prompt,
aspect_ratio=aspect,
)
def register(ctx: Any) -> None:
"""Register the broker as a normal Hermes image backend."""
ctx.register_image_gen_provider(AtlasBrokerImageProvider())

View File

@ -0,0 +1,5 @@
name: atlas-broker
version: 1.0.0
description: "Private GPT Image broker for isolated family chat tenants."
author: bstein
kind: backend

View File

@ -368,10 +368,12 @@ def _profile_config(
"model": primary_model, "model": primary_model,
"openai_runtime": "codex_app_server", "openai_runtime": "codex_app_server",
} }
config["fallback_providers"] = [ config["fallback_providers"] = [fallback]
fallback, # Local OpenAI-compatible runtimes top out at the equivalent of high.
copy.deepcopy(ATLAS_FALLBACK), # Never silently downgrade an explicitly xhigh task after both hosted
] # provider lanes are exhausted.
if effort != "xhigh":
config["fallback_providers"].append(copy.deepcopy(ATLAS_FALLBACK))
agent = config.setdefault("agent", {}) agent = config.setdefault("agent", {})
if isinstance(agent, dict): if isinstance(agent, dict):
agent["reasoning_effort"] = effort agent["reasoning_effort"] = effort
@ -481,7 +483,7 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
), ),
env_values, env_values,
) )
local = ["custom/gpt-oss:20b"] local = [] if effort == "xhigh" else ["custom/gpt-oss:20b"]
routes[codex_name] = [ routes[codex_name] = [
f"openai-codex/{codex_model}", f"openai-codex/{codex_model}",
f"anthropic/{claude_model}", f"anthropic/{claude_model}",
@ -510,7 +512,6 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A
routes["synthesis-xhigh"] = [ routes["synthesis-xhigh"] = [
f"anthropic/{claude_models['xhigh']}", f"anthropic/{claude_models['xhigh']}",
f"openai-codex/{codex_models['xhigh']}", f"openai-codex/{codex_models['xhigh']}",
"custom/gpt-oss:20b",
] ]
_write_yaml( _write_yaml(
root / "profile.yaml", root / "profile.yaml",

View File

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Private GPT Image broker for isolated Hermes chat tenants."""
from __future__ import annotations
import base64
import hmac
import importlib.util
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
HOST = os.environ.get("HERMES_IMAGE_BROKER_HOST", "0.0.0.0")
PORT = int(os.environ.get("HERMES_IMAGE_BROKER_PORT", "9002"))
TOKEN = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
PROVIDER_PATH = Path(
os.environ.get(
"HERMES_IMAGE_PROVIDER_PATH",
"/opt/hermes/plugins/image_gen/openai-codex/__init__.py",
)
)
DEFAULT_MODEL = os.environ.get(
"HERMES_IMAGE_BROKER_DEFAULT_MODEL", "gpt-image-2-high"
)
MAX_BODY_BYTES = int(os.environ.get("HERMES_IMAGE_BROKER_MAX_BODY", str(96 << 20)))
QUEUE_TIMEOUT_SECONDS = float(
os.environ.get("HERMES_IMAGE_BROKER_QUEUE_TIMEOUT", "900")
)
ALLOWED_MODELS = {
"gpt-image-2-low",
"gpt-image-2-medium",
"gpt-image-2-high",
}
ALLOWED_ASPECTS = {"landscape", "square", "portrait"}
_generation_lock = threading.Lock()
def _load_provider() -> Any:
"""Load the pinned Hermes provider without duplicating its API client."""
spec = importlib.util.spec_from_file_location(
"atlas_openai_codex_image_provider", PROVIDER_PATH
)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load image provider from {PROVIDER_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.OpenAICodexImageGenProvider()
_PROVIDER: Any | None = None
def _provider() -> Any:
"""Load the provider once after the sidecar environment is ready."""
global _PROVIDER
if _PROVIDER is None:
_PROVIDER = _load_provider()
return _PROVIDER
def _authorized(header: str | None) -> bool:
"""Compare the bearer token without leaking timing information."""
if not TOKEN or not header or not header.startswith("Bearer "):
return False
return hmac.compare_digest(header[7:].strip(), TOKEN)
def _generate(payload: dict[str, Any]) -> dict[str, Any]:
"""Generate once, return bytes, and remove the broker-side cache copy."""
prompt = str(payload.get("prompt") or "").strip()
if not prompt:
raise ValueError("prompt is required")
aspect = str(payload.get("aspect_ratio") or "landscape").lower()
if aspect not in ALLOWED_ASPECTS:
raise ValueError("aspect_ratio must be landscape, square, or portrait")
model = str(payload.get("model") or DEFAULT_MODEL)
if model not in ALLOWED_MODELS:
raise ValueError("unsupported image quality tier")
image_url = payload.get("image_url")
references = payload.get("reference_image_urls")
if image_url is not None and not isinstance(image_url, str):
raise ValueError("image_url must be a string")
if references is not None and not (
isinstance(references, list)
and all(isinstance(item, str) for item in references)
):
raise ValueError("reference_image_urls must be a list of strings")
acquired = _generation_lock.acquire(timeout=QUEUE_TIMEOUT_SECONDS)
if not acquired:
raise TimeoutError("image generation queue is busy; retry shortly")
old_model = os.environ.get("OPENAI_IMAGE_MODEL")
try:
os.environ["OPENAI_IMAGE_MODEL"] = model
result = _provider().generate(
prompt,
aspect,
image_url=image_url,
reference_image_urls=references,
)
finally:
if old_model is None:
os.environ.pop("OPENAI_IMAGE_MODEL", None)
else:
os.environ["OPENAI_IMAGE_MODEL"] = old_model
_generation_lock.release()
if not result.get("success"):
return result
image_path = Path(str(result["image"]))
try:
raw = image_path.read_bytes()
finally:
image_path.unlink(missing_ok=True)
response = {key: value for key, value in result.items() if key != "image"}
response.update(
{
"image_b64": base64.b64encode(raw).decode("ascii"),
"mime_type": "image/png",
}
)
return response
class Handler(BaseHTTPRequestHandler):
"""Small authenticated JSON API; prompts and images are never logged."""
server_version = "HermesImageBroker/1"
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")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _check_auth(self) -> bool:
if _authorized(self.headers.get("Authorization")):
return True
self._json(401, {"success": False, "error": "unauthorized"})
return False
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path != "/health":
self._json(404, {"success": False, "error": "not found"})
return
if not self._check_auth():
return
self._json(
200,
{
"success": True,
"provider": "openai-codex",
"available": bool(_provider().is_available()),
"busy": _generation_lock.locked(),
},
)
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path != "/v1/images/generations":
self._json(404, {"success": False, "error": "not found"})
return
if not self._check_auth():
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length <= 0 or length > MAX_BODY_BYTES:
self._json(413, {"success": False, "error": "invalid request size"})
return
try:
payload = json.loads(self.rfile.read(length))
if not isinstance(payload, dict):
raise ValueError("JSON object required")
result = _generate(payload)
self._json(200 if result.get("success") else 502, result)
except ValueError as exc:
self._json(400, {"success": False, "error": str(exc)})
except TimeoutError as exc:
self._json(503, {"success": False, "error": str(exc)})
except Exception as exc: # keep provider details useful but omit prompts
self._json(
502,
{
"success": False,
"error": f"image provider failed: {type(exc).__name__}: {exc}",
},
)
def log_message(self, format: str, *args: Any) -> None:
"""Log only method/path/status metadata, never request bodies."""
print(f"image-broker {self.address_string()} {format % args}", flush=True)
def main() -> None:
"""Serve until Kubernetes terminates the sidecar."""
if not TOKEN:
raise SystemExit("HERMES_IMAGE_BROKER_KEY is required")
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -173,6 +173,35 @@ FALLBACK_DISPATCH_AFTER = ''' while (api_call_count < agent.max_iterations an
# Reset per-turn checkpoint dedup so each iteration can take one snapshot # Reset per-turn checkpoint dedup so each iteration can take one snapshot
''' '''
RETRY_FALLBACK_DISPATCH_BEFORE = ''' while retry_count < max_retries:
# ── Nous Portal rate limit guard ──────────────────────
'''
RETRY_FALLBACK_DISPATCH_AFTER = ''' while retry_count < max_retries:
# Fallback activation happens inside this retry loop. Dispatch a
# newly selected Codex app-server route before the next iteration
# tries to build OpenAI-compatible kwargs from the intentionally
# absent bearer-token client.
if agent.api_mode == "codex_app_server":
fallback_user_message = user_message
if getattr(agent, "_codex_cross_provider_fallback", False):
from agent.codex_runtime import build_cross_provider_codex_prompt
fallback_user_message = build_cross_provider_codex_prompt(
messages,
user_message,
)
agent._codex_cross_provider_fallback = False
return agent._run_codex_app_server_turn(
user_message=fallback_user_message,
original_user_message=original_user_message,
messages=messages,
effective_task_id=effective_task_id,
should_review_memory=_should_review_memory,
)
# ── Nous Portal rate limit guard ──────────────────────
'''
FALLBACK_CONTEXT_BEFORE = '''def run_codex_app_server_turn( FALLBACK_CONTEXT_BEFORE = '''def run_codex_app_server_turn(
''' '''
FALLBACK_CONTEXT_AFTER = '''def build_cross_provider_codex_prompt( FALLBACK_CONTEXT_AFTER = '''def build_cross_provider_codex_prompt(
@ -343,16 +372,20 @@ def patch_fallback(source: Path, destination: Path) -> None:
def patch_loop(source: Path, destination: Path) -> None: def patch_loop(source: Path, destination: Path) -> None:
"""Dispatch a mid-turn Codex fallback through app-server.""" """Dispatch a mid-turn Codex fallback through app-server."""
content = source.read_text(encoding="utf-8") content = source.read_text(encoding="utf-8")
destination.parent.mkdir(parents=True, exist_ok=True) content = _replace_once(
destination.write_text( content,
_replace_once( FALLBACK_DISPATCH_BEFORE,
content, FALLBACK_DISPATCH_AFTER,
FALLBACK_DISPATCH_BEFORE, "Codex outer-loop fallback dispatch",
FALLBACK_DISPATCH_AFTER,
"Codex fallback dispatch",
),
encoding="utf-8",
) )
content = _replace_once(
content,
RETRY_FALLBACK_DISPATCH_BEFORE,
RETRY_FALLBACK_DISPATCH_AFTER,
"Codex retry-loop fallback dispatch",
)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
def patch_auxiliary(source: Path, destination: Path) -> None: def patch_auxiliary(source: Path, destination: Path) -> None:

View File

@ -77,6 +77,23 @@ spec:
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata:
name: hermes-image-broker
namespace: hermes
labels:
app: hermes-agent
spec:
type: ClusterIP
selector:
app: hermes-agent
ports:
- name: http
port: 9002
targetPort: image-broker
protocol: TCP
---
apiVersion: v1
kind: Service
metadata: metadata:
name: hermes-ollama name: hermes-ollama
namespace: hermes namespace: hermes

View File

@ -115,12 +115,12 @@ spec:
annotations: annotations:
ai.bstein.dev/role: private-chat-text-to-speech ai.bstein.dev/role: private-chat-text-to-speech
ai.bstein.dev/model: piper-en-us-lessac-medium ai.bstein.dev/model: piper-en-us-lessac-medium
ai.bstein.dev/gpu: CPU-only on routing node ai.bstein.dev/gpu: CPU-only beside Whisper on the voice node
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
enableServiceLinks: false enableServiceLinks: false
nodeSelector: nodeSelector:
kubernetes.io/hostname: titan-20 kubernetes.io/hostname: titan-21
containers: containers:
- name: tts - name: tts
image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:5cb9e57faab46365bff606c559af57b9505892be2909aea1ac523568d478b2cc image: registry.bstein.dev/bstein/hermes-jetson-tts@sha256:5cb9e57faab46365bff606c559af57b9505892be2909aea1ac523568d478b2cc

View File

@ -3,7 +3,7 @@
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: vault-k8s-auth-hermes-4 name: vault-k8s-auth-hermes-5
namespace: vault namespace: vault
spec: spec:
backoffLimit: 2 backoffLimit: 2

View File

@ -256,7 +256,7 @@ write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \ write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
"hermes/triage-oidc hermes/agent-tokens" "" "hermes/triage-oidc hermes/agent-tokens" ""
write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \ write_policy_and_role "hermes-agent" "hermes" "hermes-agent" \
"hermes/agent-oidc hermes/agent-tokens" "" "hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram" ""
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \ write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
"hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" "" "hermes/chat-oidc hermes/chat-telegram hermes/agent-tokens" ""
write_policy_and_role "veles" "veles" "veles-backend,veles-generator,veles-postgres,veles-vault-sync" \ write_policy_and_role "veles" "veles" "veles-backend,veles-generator,veles-postgres,veles-vault-sync" \

View File

@ -12,6 +12,7 @@ import yaml
ROOT = Path(__file__).parents[2] ROOT = Path(__file__).parents[2]
HERMES = ROOT / "services" / "hermes" HERMES = ROOT / "services" / "hermes"
VAULT = ROOT / "services" / "vault"
def _documents(path: Path) -> list[dict]: def _documents(path: Path) -> list[dict]:
@ -39,6 +40,7 @@ def test_chat_config_enables_real_research_compute_and_delegation():
assert "python_sandbox" in toolsets assert "python_sandbox" in toolsets
assert "vision" in toolsets assert "vision" in toolsets
assert "web" in toolsets assert "web" in toolsets
assert "image_gen" in toolsets
assert "terminal" not in toolsets assert "terminal" not in toolsets
assert "code_execution" not in toolsets assert "code_execution" not in toolsets
@ -277,7 +279,7 @@ def test_voice_workloads_have_deliberate_xavier_placement():
assert "@sha256:" in stt["containers"][0]["image"] assert "@sha256:" in stt["containers"][0]["image"]
assert "@sha256:" in tts["containers"][0]["image"] assert "@sha256:" in tts["containers"][0]["image"]
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"} assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-20"} assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
assert stt["automountServiceAccountToken"] is False assert stt["automountServiceAccountToken"] is False
assert tts["automountServiceAccountToken"] is False assert tts["automountServiceAccountToken"] is False
assert stt["enableServiceLinks"] is False assert stt["enableServiceLinks"] is False
@ -302,6 +304,97 @@ def test_voice_workloads_have_deliberate_xavier_placement():
assert all("hostPath" not in volume for volume in tts["volumes"]) assert all("hostPath" not in volume for volume in tts["volumes"])
def test_chat_image_generation_uses_private_owner_broker():
"""Family pods get image bytes without receiving the owner's OAuth file."""
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
config = yaml.safe_load(configmap["data"]["config.yaml"])
assert config["image_gen"] == {
"provider": "atlas-broker",
"model": "gpt-image-2-high",
}
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
pod = statefulset["spec"]["template"]["spec"]
hermes = next(item for item in pod["containers"] if item["name"] == "hermes")
mounts = {item["name"]: item for item in hermes["volumeMounts"]}
assert mounts["image-plugin"]["mountPath"] == (
"/opt/hermes/plugins/image_gen/atlas-broker"
)
assert mounts["provider-auth"].get("readOnly") is not True
assert not any(mount["name"] == "home" and "agent" in str(mount) for mount in hermes["volumeMounts"])
env = {item["name"]: item["value"] for item in hermes["env"]}
assert env["HERMES_IMAGE_BROKER_URL"].startswith("http://hermes-image-broker.")
agent = _documents(HERMES / "agent-deployment.yaml")[0]
containers = agent["spec"]["template"]["spec"]["containers"]
broker = next(item for item in containers if item["name"] == "image-broker")
assert broker["ports"] == [
{"name": "image-broker", "containerPort": 9002, "protocol": "TCP"}
]
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
assert broker["securityContext"]["runAsNonRoot"] is True
services = _documents(HERMES / "service.yaml")
service = next(
item for item in services if item["metadata"]["name"] == "hermes-image-broker"
)
assert service["spec"]["selector"] == {"app": "hermes-agent"}
policies = _documents(HERMES / "networkpolicy.yaml")
agent_policy = next(
item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation"
)
image_ingress = next(
rule
for rule in agent_policy["spec"]["ingress"]
if rule["ports"] == [{"protocol": "TCP", "port": 9002}]
)
assert image_ingress["from"][0]["podSelector"]["matchLabels"] == {
"app": "hermes-chat-tenant"
}
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
assert (
'"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram"'
in vault_policy
)
def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch):
"""The broker must not retain a family user's generated image."""
broker_path = HERMES / "scripts" / "image_broker.py"
spec = importlib.util.spec_from_file_location("hermes_image_broker", broker_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
generated = tmp_path / "generated.png"
generated.write_bytes(b"\x89PNG\r\n\x1a\nprivate-image")
class Provider:
def generate(self, prompt, aspect, **kwargs):
assert prompt == "paint a blue sphere"
assert aspect == "square"
return {
"success": True,
"image": str(generated),
"model": "gpt-image-2-high",
"quality": "high",
}
monkeypatch.setattr(module, "_PROVIDER", Provider())
result = module._generate(
{
"prompt": "paint a blue sphere",
"aspect_ratio": "square",
"model": "gpt-image-2-high",
}
)
assert result["success"] is True
assert result["image_b64"]
assert "image" not in result
assert not generated.exists()
def test_chat_auth_file_mount_survives_atomic_provider_refresh(): def test_chat_auth_file_mount_survives_atomic_provider_refresh():
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0] statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
containers = statefulset["spec"]["template"]["spec"]["containers"] containers = statefulset["spec"]["template"]["spec"]["containers"]
@ -320,6 +413,22 @@ def test_chat_auth_file_mount_survives_atomic_provider_refresh():
item["name"]: item["value"] item["name"]: item["value"]
for item in next(item for item in containers if item["name"] == "hermes")["env"] for item in next(item for item in containers if item["name"] == "hermes")["env"]
} }
hermes_mount = next(
item
for item in next(
item for item in containers if item["name"] == "hermes"
)["volumeMounts"]
if item["name"] == "provider-auth"
)
assert hermes_mount.get("readOnly") is not True
webui_mount = next(
item
for item in next(
item for item in containers if item["name"] == "webui"
)["volumeMounts"]
if item["name"] == "provider-auth"
)
assert webui_mount["readOnly"] is True
assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith("/chrome-linux/headless_shell") assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith("/chrome-linux/headless_shell")
assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"] assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"]

View File

@ -789,7 +789,7 @@ def test_agent_auth_is_bstein_group_and_email_bounded():
assert '"full.path":"true"' in script assert '"full.path":"true"' in script
def test_agent_network_boundary_allows_only_authenticated_web_surfaces(): def test_agent_network_boundary_allows_only_authenticated_web_and_image_broker_surfaces():
documents = [ documents = [
item item
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text()) for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
@ -811,7 +811,17 @@ def test_agent_network_boundary_allows_only_authenticated_web_surfaces():
} }
], ],
"ports": [{"protocol": "TCP", "port": 4180}], "ports": [{"protocol": "TCP", "port": 4180}],
} },
{
"from": [
{
"podSelector": {
"matchLabels": {"app": "hermes-chat-tenant"}
}
}
],
"ports": [{"protocol": "TCP", "port": 9002}],
},
] ]
assert isolation["spec"]["egress"] == [{}] assert isolation["spec"]["egress"] == [{}]
@ -950,12 +960,21 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
assert "agent._codex_cross_provider_fallback = True" in fallback_content assert "agent._codex_cross_provider_fallback = True" in fallback_content
loop = tmp_path / "conversation_loop.py" loop = tmp_path / "conversation_loop.py"
loop.write_text(codex_runtime_patch.FALLBACK_DISPATCH_BEFORE, encoding="utf-8") loop.write_text(
codex_runtime_patch.FALLBACK_DISPATCH_BEFORE
+ codex_runtime_patch.RETRY_FALLBACK_DISPATCH_BEFORE,
encoding="utf-8",
)
loop_out = tmp_path / "patched/conversation_loop.py" loop_out = tmp_path / "patched/conversation_loop.py"
codex_runtime_patch.patch_loop(loop, loop_out) codex_runtime_patch.patch_loop(loop, loop_out)
loop_content = loop_out.read_text() loop_content = loop_out.read_text()
assert 'if agent.api_mode == "codex_app_server"' in loop_content assert loop_content.count('if agent.api_mode == "codex_app_server"') == 2
assert "build_cross_provider_codex_prompt" in loop_content assert "build_cross_provider_codex_prompt" in loop_content
retry_dispatch = loop_content.index(
"Fallback activation happens inside this retry loop"
)
api_kwargs = loop_content.find("agent._build_api_kwargs", retry_dispatch)
assert api_kwargs == -1 or retry_dispatch < api_kwargs
auxiliary = tmp_path / "auxiliary_client.py" auxiliary = tmp_path / "auxiliary_client.py"
auxiliary.write_text( auxiliary.write_text(

View File

@ -106,6 +106,9 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
codex_profile = yaml.safe_load( codex_profile = yaml.safe_load(
(tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8") (tmp_path / "profiles/codex-high/config.yaml").read_text(encoding="utf-8")
) )
codex_xhigh_profile = yaml.safe_load(
(tmp_path / "profiles/codex-xhigh/config.yaml").read_text(encoding="utf-8")
)
claude_profile = yaml.safe_load( claude_profile = yaml.safe_load(
(tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8") (tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8")
) )
@ -123,6 +126,18 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path
assert codex_profile["agent"]["reasoning_effort"] == "high" assert codex_profile["agent"]["reasoning_effort"] == "high"
assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK
assert len(codex_profile["fallback_providers"]) == 2 assert len(codex_profile["fallback_providers"]) == 2
assert codex_xhigh_profile["fallback_providers"] == [
{"provider": "anthropic", "model": "claude-opus-5"}
]
assert routes["codex-xhigh"] == [
"openai-codex/gpt-5.6-sol",
"anthropic/claude-opus-5",
]
assert routes["claude-xhigh"] == [
"anthropic/claude-opus-5",
"openai-codex/gpt-5.6-sol",
]
assert all("custom/" not in route for route in routes["synthesis-xhigh"])
assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra" assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra"
assert "custom/qwen2.5:14b-instruct-q4_0" not in routes["coordinator"] assert "custom/qwen2.5:14b-instruct-q4_0" not in routes["coordinator"]
assert "max" not in json.dumps(routes) assert "max" not in json.dumps(routes)