hermes: add private image generation and repair xhigh fallback
All checks were successful
Tests / Declarative: Post Actions skipped: 6, passed: 229
All checks were successful
Tests / Declarative: Post Actions skipped: 6, passed: 229
This commit is contained in:
parent
5ecec6c60a
commit
ffffe98a3f
@ -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:
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
223
services/hermes/plugins/image-gen-broker/__init__.py
Normal file
223
services/hermes/plugins/image-gen-broker/__init__.py
Normal 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": "~30s–3min",
|
||||||
|
"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())
|
||||||
5
services/hermes/plugins/image-gen-broker/plugin.yaml
Normal file
5
services/hermes/plugins/image-gen-broker/plugin.yaml
Normal 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
|
||||||
@ -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",
|
||||||
|
|||||||
211
services/hermes/scripts/image_broker.py
Normal file
211
services/hermes/scripts/image_broker.py
Normal 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()
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" \
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user