refactor(gateway/platforms): api_server module helpers — shared text/list caps, _normalize_image_part, compact docstrings (5055 -> 4826 LOC)

This commit is contained in:
Teknium
2026-09-02 18:57:49 -07:00
parent bfc48f8089
commit 4a3b4b691e
+161 -390
View File
@@ -1,43 +1,11 @@
"""
OpenAI-compatible API server platform adapter.
"""OpenAI-compatible API server platform adapter (aiohttp).
Exposes an HTTP server with endpoints:
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header; opt-in long-term memory scoping via X-Hermes-Session-Key header)
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id; X-Hermes-Session-Key supported)
- GET /v1/responses/{response_id} — Retrieve a stored response
- DELETE /v1/responses/{response_id} — Delete a stored response
- GET /v1/models — lists hermes-agent and any configured model_routes aliases
- GET /v1/capabilities — machine-readable API capabilities for external UIs
- GET /api/sessions — list client-visible Hermes sessions
- POST /api/sessions — create an empty Hermes session
- GET/PATCH/DELETE /api/sessions/{session_id} — read/update/delete a session
- GET /api/sessions/{session_id}/messages — read session message history
- POST /api/sessions/{session_id}/fork — branch a session using SessionDB lineage
- POST /api/sessions/{session_id}/chat[/stream] — chat with a persisted session
- POST /v1/runs — start a run, returns run_id immediately (202)
- GET /v1/runs/{run_id} — retrieve current run status
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
- POST /v1/runs/{run_id}/approval — resolve a pending run approval
- POST /v1/runs/{run_id}/steer — inject guidance into a running agent
- POST /v1/runs/{run_id}/stop — interrupt a running agent
- GET /health — health check
- GET /health/detailed — rich status for cross-container dashboard probing
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
through this adapter by pointing at http://localhost:8642/v1 and
authenticating with API_SERVER_KEY.
When ``gateway.multiplex_profiles`` is on, the default profile owns this
listener and secondary profiles are reached via a URL prefix — same contract
as the webhook adapter:
GET /p/<profile>/v1/models
POST /p/<profile>/v1/chat/completions
...
Requires:
- aiohttp (already available in the gateway)
Serves /v1/chat/completions, /v1/responses, /v1/models, /v1/capabilities, the
/api/sessions resource API, /v1/runs, /api/jobs and /health* (full table:
``APIServerAdapter._http_route_table``). Any OpenAI-compatible frontend can
connect at http://localhost:8642/v1 with API_SERVER_KEY. Under
``gateway.multiplex_profiles`` secondary profiles are reached via
``/p/<profile>/...`` (same contract as the webhook adapter).
"""
import asyncio
@@ -70,11 +38,8 @@ _PROFILE_REJECTED = object()
def _prefix_names_served_profile(profile: str) -> bool:
"""True when a /p/<profile>/ prefix names the profile this gateway serves.
Single-profile (non-multiplex) gateways historically ignored the prefix
and answered every /p/<x>/ request from their own profile's config —
which silently served the gateway owner's toolsets/capabilities under
another profile's URL (#91583 defect 2). Only a self-referential prefix
may fall through; anything else must be rejected. Fail closed.
Single-profile gateways must not answer /p/<x>/ from their own config (that
served the owner's toolsets under another profile's URL). Fail closed.
"""
try:
from hermes_cli.profiles import profile_matches_home
@@ -92,11 +57,8 @@ _api_request_browser_control_principal: ContextVar[str] = ContextVar(
_api_request_browser_control_transport_family: ContextVar[str] = ContextVar(
"api_server_browser_control_transport_family", default="")
#: Minimal scope shape accepted by :func:`gateway.browser_control_artifacts
#: .artifact_scope_key`: principal + session + transport family. The API
#: server authenticates the caller itself, so the facade carries only the
#: server-derived principal and the loopback/remote family.
class _ArtifactScopeFacade:
"""Minimal scope for ``artifact_scope_key``: server-derived principal + session + transport family."""
__slots__ = ("principal_id", "session_id", "transport_family")
def __init__(self, principal_id: str, *, session_id: str = "", transport_family: str = ""):
@@ -108,9 +70,7 @@ class _ArtifactScopeFacade:
return f"_ArtifactScopeFacade(principal={self.principal_id!r})"
#: Browser-extension control protocol version advertised in capabilities and
#: echoed in registration responses. Strict validation is centralized in the
#: broker's ``browser_control_protocol_supported`` helper.
# Advertised in capabilities and echoed in registration responses; validated by the broker.
_BROWSER_CONTROL_PROTOCOL_VERSION = 1
# /v1/capabilities static feature flags (order is part of the JSON shape).
@@ -153,11 +113,10 @@ _BROWSER_CONTROL_WS_PROTOCOL = "hermes-browser-control-v1"
_BROWSER_CONTROL_TICKET_PROTOCOL_PREFIX = "hermes-browser-control-ticket."
def _approval_event_choices(
*, smart_denied: bool, allow_session: bool, allow_permanent: bool) -> list[str]:
def _approval_event_choices(*, smart_denied: bool, allow_session: bool, allow_permanent: bool) -> list[str]:
if smart_denied or not allow_session:
return ["once", "deny"]
return(["once", "session", "always", "deny"] if allow_permanent else["once", "session", "deny"])
return ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
try:
@@ -243,15 +202,8 @@ def _browser_controller_ws_sender(ws, loop, *, wait_timeout: float = 10.0):
def _hermes_version() -> str:
"""Return the canonical Hermes Agent version string.
``hermes_cli.__version__`` is the runtime source of truth used by the CLI,
dashboard, portal tags, and release script. Prefer it over installed
distribution metadata because editable/source checkouts can retain stale
``hermes_agent-*.dist-info`` after a source update until the environment is
reinstalled. Never raises — a version probe must not be able to break the
health endpoint.
"""
"""Canonical Hermes version: ``hermes_cli.__version__`` (dist-info can be stale on
source checkouts), then distribution metadata, then "dev". Never raises."""
try:
from hermes_cli import __version__
return __version__
@@ -276,12 +228,8 @@ RESPONSES_AUTO_TRUNCATION_HISTORY_LIMIT = 100
class ThreadSafeAsyncQueue(asyncio.Queue):
"""An ``asyncio.Queue`` that a non-loop thread can push into safely.
``run_conversation`` runs on an executor thread, so its stream callbacks
call ``put_threadsafe``; the SSE consumer does a plain ``await get()`` and
is woken by ``call_soon_threadsafe`` — no executor hop, no poll latency.
"""
"""``asyncio.Queue`` a non-loop thread (run_conversation's executor) can push into via
``put_threadsafe``; the SSE consumer's ``await get()`` is woken by ``call_soon_threadsafe``."""
def put_threadsafe(self, item, *, loop: asyncio.AbstractEventLoop = None) -> None:
(loop or self._loop_ref).call_soon_threadsafe(self.put_nowait, item)
@@ -294,12 +242,8 @@ class ThreadSafeAsyncQueue(asyncio.Queue):
def _sse_frame(data: Any, *, event: str = None, ensure_ascii: bool = True) -> bytes:
"""Encode one SSE frame: optional ``event:`` line, then ``data: <json>\n\n``.
Single source of truth for every SSE writer (chat completions, Responses,
/v1/runs). ``ensure_ascii=True`` is byte-identical to bare ``json.dumps``;
writers that must keep raw non-ASCII on the wire pass ``ensure_ascii=False``.
"""
"""Encode one SSE frame (``event:`` line if given, then ``data: <json>\n\n``) for every
SSE writer. ``ensure_ascii=False`` keeps raw non-ASCII on the wire."""
prefix = f"event: {event}\n" if event else ""
return f"{prefix}data: {json.dumps(data, ensure_ascii=ensure_ascii)}\n\n".encode()
@@ -317,14 +261,8 @@ _FALSE_REQUEST_BOOL_STRINGS = frozenset({"0", "false", "no", "off"})
def _coerce_request_bool(value: Any, default: bool = False) -> bool:
"""Normalize boolean-like API payload values.
External clients should send real JSON booleans, but some OpenAI-compatible
frontends and middleware serialize flags like ``stream`` as strings. Using
Python truthiness on those values misroutes requests because ``"false"`` is
still truthy. Treat only explicit bool-ish scalars as booleans; everything
else falls back to the caller's default.
"""
"""Normalize boolean-like payload values; only explicit bool-ish scalars count (some
frontends send ``"false"`` for ``stream``, which is truthy), else ``default``."""
if isinstance(value, bool):
return value
if value is None:
@@ -347,9 +285,7 @@ _REQUEST_OPTION_MISSING = object()
_REASONING_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
_RUNTIME_AGENT_OVERRIDE_KEYS = (
"api_key", "base_url", "provider", "api_mode", "command", "args", "credential_pool",
"max_tokens",
)
"api_key", "base_url", "provider", "api_mode", "command", "args", "credential_pool", "max_tokens")
def _clean_request_string(value: Any) -> Optional[str]:
@@ -361,13 +297,8 @@ def _clean_request_string(value: Any) -> Optional[str]:
def _request_reasoning_config(model_options: Any) -> Optional[Dict[str, Any]]:
"""Translate browser/API model_options into AIAgent reasoning_config.
The browser extension sends both a structured ``reasoning`` object and a
compatibility ``reasoning_effort`` scalar. Keep this parser permissive so
older clients can send either shape, but ignore unknown effort values rather
than raising on a chat request.
"""
"""Translate model_options (structured ``reasoning`` or legacy ``reasoning_effort``) into
AIAgent reasoning_config; unknown effort values are ignored, never raised."""
if not isinstance(model_options, dict):
return None
reasoning = model_options.get("reasoning")
@@ -418,12 +349,8 @@ def _apply_runtime_agent_overrides(
def _resolve_request_runtime_agent_kwargs(provider: str, target_model: Optional[str] = None) -> Dict[str, Any]:
"""Resolve runtime kwargs for a one-request provider override.
This mirrors gateway.run._resolve_runtime_agent_kwargs(), but accepts an
explicit provider/model so an API caller can use the same authenticated
provider catalog as the TUI without mutating config.yaml.
"""
"""gateway.run._resolve_runtime_agent_kwargs() for an explicit provider/model, so an API
caller uses the same authenticated provider catalog without mutating config.yaml."""
from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error, _get_model_config
try:
runtime = resolve_runtime_provider(requested=provider, target_model=target_model)
@@ -461,20 +388,11 @@ def _request_agent_overrides(
) -> Dict[str, Any]:
"""Extract per-request model/provider/options for _run_agent.
``/v1/models`` advertises a stable virtual model (usually ``hermes-agent``)
for OpenAI-compatible clients. Treat that alias as "use the gateway
default"; real model picker selections from the browser extension send the
raw provider model id plus a provider slug and should override this turn.
``allow_bare_model`` controls whether a ``model`` value WITHOUT an
accompanying ``provider`` is honored. Generic OpenAI clients routinely
hardcode model names ("gpt-4o", ...), and existing deployments rely on
those falling back to the gateway default on the OpenAI-compatible
surfaces — so those handlers pass the opt-in
``direct_model_requests`` config value here, while Hermes-native
endpoints (session chat, /v1/runs) always allow it. A request that
sends an explicit ``provider`` is unambiguously Hermes-aware and is
always honored.
The advertised virtual model (``hermes-agent``) means "gateway default". A bare
``model`` without ``provider`` is honored only when ``allow_bare_model`` (generic
OpenAI clients hardcode "gpt-4o" and rely on the default; OpenAI-compatible
handlers pass the ``direct_model_requests`` opt-in, Hermes-native endpoints
always allow it). An explicit ``provider`` is always honored.
"""
if not isinstance(body, dict):
return {}
@@ -492,12 +410,8 @@ def _request_agent_overrides(
def _is_compressed_summary_message(message: Any) -> bool:
"""Recognize every model-side compaction carrier shape.
SessionDB does not persist the in-process metadata marker, so client
projections must share the compressor's content classifier rather than a
prefix-only approximation that misses merge-into-tail carriers.
"""
"""Recognize every compaction carrier shape via the compressor's own classifier
(SessionDB drops the in-process marker; a prefix scan misses merge-into-tail carriers)."""
if not isinstance(message, dict):
return False
from agent.context_compressor import is_compaction_summary_message
@@ -505,14 +419,8 @@ def _is_compressed_summary_message(message: Any) -> bool:
def _project_client_message(message: Dict[str, Any]) -> Dict[str, Any]:
"""Remove model-only compaction scaffolding from a client message.
Standalone handoffs have no transcript content and remain as hidden empty
rows so clients can reconcile stable message identities. Merged handoffs
preserve only the real prior-tail content that precedes the internal
summary delimiter. Tool calls are dropped from both shapes because a
carrier's inherited calls are historical context, not live client output.
"""
"""Strip compaction scaffolding: standalone handoffs become hidden empty rows (stable
ids), merged handoffs keep only the real prior-tail content; inherited tool calls dropped."""
from agent.compaction_display import (
_COMPACTION_INTERNAL_FIELDS, project_compaction_message_for_display)
projected = project_compaction_message_for_display(message)
@@ -529,19 +437,11 @@ def _auto_truncate_response_history(
conversation_history: List[Dict[str, Any]],
*,
limit: int = RESPONSES_AUTO_TRUNCATION_HISTORY_LIMIT) -> List[Dict[str, Any]]:
"""Keep recent Responses history without dropping the compaction handoff.
Compaction summaries are preserved wherever they sit in the history —
the gateway /compress path can leave them after a retained system head
(see ``context_compressor`` force-user-leading handling), so a
leading-block-only scan would silently drop them.
"""
"""Keep the most recent ``limit`` messages, always preserving compaction summaries
wherever they sit (the /compress path can leave them after a retained system head)."""
if limit <= 0 or len(conversation_history) <= limit:
return conversation_history
summary_indices = [
index
for index, message in enumerate(conversation_history)
if _is_compressed_summary_message(message)]
summary_indices = [i for i, m in enumerate(conversation_history) if _is_compressed_summary_message(m)]
if not summary_indices:
return conversation_history[-limit:]
kept_indices = set(summary_indices[:limit])
@@ -558,177 +458,138 @@ def _auto_truncate_response_history(
return [conversation_history[index] for index in sorted(kept_indices)]
def _cap_text(text: str) -> str:
return text[:MAX_NORMALIZED_TEXT_LENGTH] if len(text) > MAX_NORMALIZED_TEXT_LENGTH else text
def _cap_list(items: list) -> list:
return items[:MAX_CONTENT_LIST_SIZE] if len(items) > MAX_CONTENT_LIST_SIZE else items
def _normalize_chat_content(
content: Any, *, _max_depth: int = 10, _depth: int = 0) -> str:
"""Normalize OpenAI chat message content into a plain text string.
"""Flatten OpenAI chat content (string or typed-part array) into one plain string.
Some clients (Open WebUI, LobeChat, etc.) send content as an array of
typed parts instead of a plain string::
[{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}]
This function flattens those into a single string so the agent pipeline
(which expects strings) doesn't choke.
Defensive limits prevent abuse: recursion depth, list size, and output
length are all bounded.
Non-text parts (image_url, ...) are skipped. Recursion depth, list size and
output length are bounded.
"""
if _depth > _max_depth:
return ""
if content is None:
if _depth > _max_depth or content is None:
return ""
if isinstance(content, str):
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
return _cap_text(content)
if isinstance(content, list):
parts: List[str] = []
total_len = 0
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
for item in items:
for item in _cap_list(content):
part = ""
if isinstance(item, str):
if item:
part = item[:MAX_NORMALIZED_TEXT_LENGTH]
parts.append(part)
total_len += len(part)
part = item
elif isinstance(item, dict):
item_type = str(item.get("type") or "").strip().lower()
if item_type in {"text", "input_text", "output_text"}:
if str(item.get("type") or "").strip().lower() in _TEXT_PART_TYPES:
text = item.get("text", "")
if text:
try:
part = str(text)[:MAX_NORMALIZED_TEXT_LENGTH]
parts.append(part)
total_len += len(part)
part = str(text)
except Exception:
pass
# Silently skip image_url / other non-text parts
elif isinstance(item, list):
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
if nested:
parts.append(nested)
total_len += len(nested)
# Check accumulated size
part = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
if part:
part = _cap_text(part)
parts.append(part)
total_len += len(part)
if total_len >= MAX_NORMALIZED_TEXT_LENGTH:
break
result = "\n".join(parts)
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
# Fallback for unexpected types (int, float, bool, etc.)
return _cap_text("\n".join(parts))
try:
result = str(content)
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
return _cap_text(str(content))
except Exception:
return ""
# Content part type aliases used by the OpenAI Chat Completions and Responses
# APIs. We accept both spellings on input and emit a single canonical internal
# shape (``{"type": "text", ...}`` / ``{"type": "image_url", ...}``) that the
# rest of the agent pipeline already understands.
# Chat Completions / Responses part-type spellings; emitted shape is always the canonical
# ``{"type": "text", ...}`` / ``{"type": "image_url", ...}`` the agent pipeline understands.
_TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text"})
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image"})
_FILE_PART_TYPES = frozenset({"file", "input_file"})
def _normalize_image_part(part: Dict[str, Any]) -> Dict[str, Any]:
"""Validate one image part (Responses top-level ``image_url`` string or Chat Completions
``{"url", "detail"}`` dict) into the canonical vision shape; raises ValueError."""
detail = part.get("detail")
image_ref = part.get("image_url")
if isinstance(image_ref, dict):
url_value = image_ref.get("url")
detail = image_ref.get("detail", detail)
else:
url_value = image_ref
if not isinstance(url_value, str) or not url_value.strip():
raise ValueError("invalid_image_url:Image parts must include a non-empty image URL.")
url_value = url_value.strip()
lowered = url_value.lower()
if lowered.startswith("data:"):
if not lowered.startswith("data:image/") or "," not in url_value:
raise ValueError(
"unsupported_content_type:Only image data URLs are supported. "
"Non-image data payloads are not supported.")
elif not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError(
"invalid_image_url:Image inputs must use http(s) URLs or data:image/... URLs.")
image_part: Dict[str, Any] = {"type": "image_url", "image_url": {"url": url_value}}
if detail is not None:
if not isinstance(detail, str) or not detail.strip():
raise ValueError("invalid_content_part:Image detail must be a non-empty string when provided.")
image_part["image_url"]["detail"] = detail.strip()
return image_part
def _normalize_multimodal_content(content: Any) -> Any:
"""Validate and normalize multimodal content for the API server.
"""Validate multimodal content: a plain string when text-only, else a list of canonical
``text``/``image_url`` parts (native OpenAI vision format; Anthropic conversion happens
downstream).
Returns a plain string when the content is text-only, or a list of
``{"type": "text"|"image_url", ...}`` parts when images are present.
The output shape is the native OpenAI Chat Completions vision format,
which the agent pipeline accepts verbatim (OpenAI-wire providers) or
converts (``_preprocess_anthropic_content`` for Anthropic).
Raises ``ValueError`` with an OpenAI-style code on invalid input:
* ``unsupported_content_type`` — file/input_file/file_id parts, or
non-image ``data:`` URLs.
* ``invalid_image_url`` — missing URL or unsupported scheme.
* ``invalid_content_part`` — malformed text/image objects.
Callers translate the ValueError into a 400 response.
Raises ``ValueError("<code>:<message>")`` — codes ``unsupported_content_type`` (file
parts, non-image data URLs, unknown part types), ``invalid_image_url``,
``invalid_content_part``. Callers translate it into a 400.
"""
# Scalar passthrough mirrors ``_normalize_chat_content``.
if content is None:
return ""
if isinstance(content, str):
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
return _cap_text(content)
if not isinstance(content, list):
# Mirror the legacy text-normalizer's fallback so callers that
# pre-existed image support still get a string back.
return _normalize_chat_content(content)
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
normalized_parts: List[Dict[str, Any]] = []
text_accum_len = 0
for part in items:
for part in _cap_list(content):
if isinstance(part, str):
if part:
trimmed = part[:MAX_NORMALIZED_TEXT_LENGTH]
normalized_parts.append({"type": "text", "text": trimmed})
text_accum_len += len(trimmed)
normalized_parts.append({"type": "text", "text": _cap_text(part)})
continue
if not isinstance(part, dict):
# Ignore unknown scalars for forward compatibility with future
# Responses API additions (e.g. ``refusal``). The same policy
# the text normalizer applies.
continue
continue # unknown scalars are ignored for forward compatibility (e.g. ``refusal``)
raw_type = part.get("type")
part_type = str(raw_type or "").strip().lower()
if part_type in _TEXT_PART_TYPES:
text = part.get("text")
if text is None:
continue
if not isinstance(text, str):
text = str(text)
text = text if isinstance(text, str) else str(text)
if text:
trimmed = text[:MAX_NORMALIZED_TEXT_LENGTH]
normalized_parts.append({"type": "text", "text": trimmed})
text_accum_len += len(trimmed)
continue
if part_type in _IMAGE_PART_TYPES:
detail = part.get("detail")
image_ref = part.get("image_url")
# OpenAI Responses sends ``input_image`` with a top-level
# ``image_url`` string; Chat Completions sends ``image_url`` as
# ``{"url": "...", "detail": "..."}``. Support both.
if isinstance(image_ref, dict):
url_value = image_ref.get("url")
detail = image_ref.get("detail", detail)
else:
url_value = image_ref
if not isinstance(url_value, str) or not url_value.strip():
raise ValueError("invalid_image_url:Image parts must include a non-empty image URL.")
url_value = url_value.strip()
lowered = url_value.lower()
if lowered.startswith("data:"):
if not lowered.startswith("data:image/") or "," not in url_value:
raise ValueError(
"unsupported_content_type:Only image data URLs are supported. "
"Non-image data payloads are not supported.")
elif not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError(
"invalid_image_url:Image inputs must use http(s) URLs or data:image/... URLs.")
image_part: Dict[str, Any] = {"type": "image_url", "image_url": {"url": url_value}}
if detail is not None:
if not isinstance(detail, str) or not detail.strip():
raise ValueError("invalid_content_part:Image detail must be a non-empty string when provided.")
image_part["image_url"]["detail"] = detail.strip()
normalized_parts.append(image_part)
continue
if part_type in _FILE_PART_TYPES:
normalized_parts.append({"type": "text", "text": _cap_text(text)})
elif part_type in _IMAGE_PART_TYPES:
normalized_parts.append(_normalize_image_part(part))
elif part_type in _FILE_PART_TYPES:
raise ValueError(
"unsupported_content_type:Inline image inputs are supported, "
"but uploaded files and document inputs are not supported on this endpoint.")
# Unknown part type — reject explicitly so clients get a clear error
# instead of a silently dropped turn.
raise ValueError(
f"unsupported_content_type:Unsupported content part type {raw_type!r}. "
"Only text and image_url/input_image parts are supported.")
else:
raise ValueError(
f"unsupported_content_type:Unsupported content part type {raw_type!r}. "
"Only text and image_url/input_image parts are supported.")
if not normalized_parts:
return ""
# Text-only: collapse to a plain string so downstream logging/trajectory
# code sees the native shape and prompt caching on text-only turns is
# unaffected.
# Text-only collapses to a plain string so trajectory logging and prompt caching see
# the native shape.
if all(p.get("type") == "text" for p in normalized_parts):
return "\n".join(p["text"] for p in normalized_parts if p.get("text"))
return normalized_parts
@@ -760,14 +621,9 @@ def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Respons
def _reap_disconnected_agent_processes(
agent: Any, *, source: str = "api_server_sse_disconnect") -> None:
"""Reap background processes an abandoned API-server turn created.
API-server turns bypass ``TurnRunner``, so they need their own trigger for
the gateway's baseline-diff reap. Fire-and-forget on a daemon thread.
Epoch-gated: concurrent runs may share a task_id (conversation scope), so a
reaper holding a stale epoch declines rather than killing a newer run's
process; the newer run's own baseline covers its cleanup.
"""
"""Reap background processes an abandoned API-server turn created (these turns bypass
``TurnRunner``). Daemon-thread fire-and-forget; epoch-gated so a stale reaper never kills
a newer run's process on a shared task_id."""
process_task_id = getattr(agent, "_gateway_turn_process_task_id", "")
process_baseline = getattr(agent, "_gateway_turn_process_baseline", None)
if not process_task_id or process_baseline is None:
@@ -798,12 +654,8 @@ _TURN_PROCESS_EPOCH_COUNTER = itertools.count(1)
def _publish_turn_process_ownership(agent: Any, task_id: str) -> None:
"""Snapshot the process baseline and claim the task_id's current epoch.
Single place all API-server agent lifecycles (chat/responses ``_run_agent``
and ``/v1/runs``) record turn ownership, so the marker attribute names and
epoch bookkeeping cannot drift between surfaces.
"""
"""Snapshot the process baseline and claim the task_id's epoch — the single place every
API-server agent lifecycle records turn ownership (marker names cannot drift)."""
from tools.process_registry import process_registry
with _TURN_PROCESS_EPOCH_LOCK:
epoch = next(_TURN_PROCESS_EPOCH_COUNTER)
@@ -814,12 +666,8 @@ def _publish_turn_process_ownership(agent: Any, task_id: str) -> None:
def _clear_turn_process_ownership(agent: Any) -> None:
"""Clear turn ownership the moment the turn finishes (success or crash).
A disconnect/cancel landing after this point must not reap background
work the turn deliberately left running — mirrors the same race-window
guard in ``gateway/run.py``'s ``_run_sync_with_timeout_lifecycle``.
"""
"""Clear turn ownership as soon as the turn ends: a later disconnect/cancel must not reap
background work the turn deliberately left running (same guard as gateway/run.py)."""
task_id = getattr(agent, "_gateway_turn_process_task_id", "")
epoch = getattr(agent, "_gateway_turn_process_epoch", None)
if task_id and epoch is not None:
@@ -864,11 +712,7 @@ async def _abandon_agent_task(
agent_ref, agent_task, reason: str, *,
reap_source: str = "api_server_sse_disconnect", await_cancel: bool = True) -> None:
"""Interrupt + reap an abandoned SSE agent run, then cancel its task wrapper.
The run will never be resumed, so its background processes are reaped
(epoch-gated; no-op once the turn cleared its markers). ``await_cancel``
is False on the CancelledError path, which must not await inside the handler.
"""
``await_cancel=False`` on the CancelledError path, which must not await in the handler."""
agent = agent_ref[0] if agent_ref else None
if agent is not None:
try:
@@ -891,16 +735,8 @@ def check_api_server_requirements() -> bool:
class ResponseStore:
"""
SQLite-backed LRU store for Responses API state.
Each stored response includes the full internal conversation history
(with tool calls and results) so it can be reconstructed on subsequent
requests via previous_response_id.
Persists across gateway restarts. Falls back to in-memory SQLite
if the on-disk path is unavailable.
"""
"""SQLite-backed LRU store for Responses API state (full conversation history per response
for ``previous_response_id`` chaining). Persists across restarts; in-memory fallback."""
def __init__(self, max_size: int = MAX_STORED_RESPONSES, db_path: str = None):
self._max_size = max_size
@@ -920,18 +756,10 @@ class ResponseStore:
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="response_store.db")
self._conn.execute(
"""CREATE TABLE IF NOT EXISTS responses (
response_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
accessed_at REAL NOT NULL
)"""
)
"CREATE TABLE IF NOT EXISTS responses ("
"response_id TEXT PRIMARY KEY, data TEXT NOT NULL, accessed_at REAL NOT NULL)")
self._conn.execute(
"""CREATE TABLE IF NOT EXISTS conversations (
name TEXT PRIMARY KEY,
response_id TEXT NOT NULL
)"""
)
"CREATE TABLE IF NOT EXISTS conversations (name TEXT PRIMARY KEY, response_id TEXT NOT NULL)")
self._conn.commit()
# Conversation history lives here: owner-only perms, once at init (not per commit).
self._tighten_file_permissions()
@@ -940,15 +768,12 @@ class ResponseStore:
"""Force owner-only permissions on the DB and SQLite sidecars."""
if not self._db_path:
return
for candidate in (
Path(self._db_path), Path(f"{self._db_path}-wal"), Path(f"{self._db_path}-shm")):
for candidate in (Path(self._db_path), Path(f"{self._db_path}-wal"), Path(f"{self._db_path}-shm")):
try:
if candidate.exists():
candidate.chmod(0o600)
except OSError:
logger.debug(
"Failed to restrict response store permissions for %s", candidate, exc_info=True
)
logger.debug("Failed to restrict response store permissions for %s", candidate, exc_info=True)
def get(self, response_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve a stored response by ID (updates access time for LRU)."""
@@ -963,8 +788,7 @@ class ResponseStore:
try:
return json.loads(row[0])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Corrupted JSON in response store for id=%s, evicting entry", response_id)
logger.warning("Corrupted JSON in response store for id=%s, evicting entry", response_id)
self._conn.execute("DELETE FROM responses WHERE response_id = ?", (response_id,))
self._conn.commit()
return None
@@ -974,10 +798,8 @@ class ResponseStore:
self._conn.execute(
"INSERT OR REPLACE INTO responses (response_id, data, accessed_at) VALUES (?, ?, ?)",
(response_id, json.dumps(data, default=str), time.time()))
# Evict oldest entries beyond max_size
count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
if count > self._max_size:
# Collect IDs that will be evicted
evict_ids = [
row[0]
for row in self._conn.execute(
@@ -985,17 +807,13 @@ class ResponseStore:
(count - self._max_size,)).fetchall()]
if evict_ids:
placeholders = ",".join("?" for _ in evict_ids)
# Clear conversation mappings pointing to evicted responses
self._conn.execute(
f"DELETE FROM conversations WHERE response_id IN ({placeholders})", evict_ids)
# Delete evicted responses
self._conn.execute(
f"DELETE FROM responses WHERE response_id IN ({placeholders})", evict_ids)
# Conversation mappings pointing at evicted responses go too.
self._conn.execute(f"DELETE FROM conversations WHERE response_id IN ({placeholders})", evict_ids)
self._conn.execute(f"DELETE FROM responses WHERE response_id IN ({placeholders})", evict_ids)
self._conn.commit()
def delete(self, response_id: str) -> bool:
"""Remove a response from the store. Returns True if found and deleted."""
# Clear conversation mappings pointing to this response
"""Remove a response (and conversation mappings to it). True if found and deleted."""
self._conn.execute("DELETE FROM conversations WHERE response_id = ?", (response_id,))
cursor = self._conn.execute("DELETE FROM responses WHERE response_id = ?", (response_id,))
self._conn.commit()
@@ -1003,15 +821,12 @@ class ResponseStore:
def get_conversation(self, name: str) -> Optional[str]:
"""Get the latest response_id for a conversation name."""
row = self._conn.execute(
"SELECT response_id FROM conversations WHERE name = ?", (name,)).fetchone()
row = self._conn.execute("SELECT response_id FROM conversations WHERE name = ?", (name,)).fetchone()
return row[0] if row else None
def set_conversation(self, name: str, response_id: str) -> None:
"""Map a conversation name to its latest response_id."""
self._conn.execute(
"INSERT OR REPLACE INTO conversations (name, response_id) VALUES (?, ?)",
(name, response_id))
self._conn.execute("INSERT OR REPLACE INTO conversations (name, response_id) VALUES (?, ?)", (name, response_id))
self._conn.commit()
def close(self) -> None:
@@ -1026,10 +841,6 @@ class ResponseStore:
return row[0] if row else 0
# ---------------------------------------------------------------------------
# CORS middleware
# ---------------------------------------------------------------------------
_CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key"}
@@ -1056,26 +867,17 @@ if AIOHTTP_AVAILABLE:
else:
cors_middleware = None # type: ignore[assignment]
_MEDIA_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
_MEDIA_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp"}
_MEDIA_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
".webp": "image/webp", ".bmp": "image/bmp"}
_MEDIA_IMG_EXT = set(_MEDIA_MIME)
_MEDIA_DATA_URL_MAX_BYTES = 5 * 1024 * 1024 # skip images larger than 5MB
def _resolve_media_to_data_urls(text: str) -> str:
"""Replace ``MEDIA:<path>`` image tags with inline base64 data URLs.
Remote frontends can't read server paths. Small local images become
markdown data URLs; non-image/unreadable paths are left untouched.
Security: uses the shared ``MEDIA_TAG_CLEANUP_RE`` anchor +
``validate_media_delivery_path`` denylist — a bare-token match would let a
traversal path in the model's reply exfiltrate any readable image file.
"""
"""Replace ``MEDIA:<path>`` tags with inline base64 data URLs (remote frontends can't read
server paths); non-image/unreadable paths stay untouched. Security: the shared
``MEDIA_TAG_CLEANUP_RE`` anchor + ``validate_media_delivery_path`` denylist — a bare-token
match would let a traversal path in the reply exfiltrate any readable image."""
if not text or "MEDIA:" not in text:
return text
import base64
@@ -1143,12 +945,9 @@ _api_agent_request_reservation: ContextVar[Optional[dict[str, bool]]] = ContextV
def _admit_api_agent_request(handler):
"""Reserve an authenticated API turn before its handler first awaits.
Gateway shutdown and aiohttp requests share an event loop. Keeping the
drain check and reservation in one non-awaiting block prevents a request
admitted immediately before shutdown from becoming invisible while it is
still parsing its body or resolving session state. The mutable reservation
is intentionally shared with child tasks so agent/task bookkeeping releases
this one slot exactly once.
Drain check + reservation happen in one non-awaiting block so a request admitted
just before shutdown can't become invisible while parsing its body. The mutable
reservation is shared with child tasks so the slot is released exactly once.
"""
@wraps(handler)
async def _wrapped(self, request, *args, **kwargs):
@@ -1183,11 +982,8 @@ def _release_pending_api_work(adapter, reservation: dict[str, bool]) -> None:
@contextmanager
def _reserve_pending_api_work(adapter):
"""Keep externally-triggered background work visible across awaits.
A handler can detach the reservation to an asyncio task; its done callback
then owns release so shutdown cannot miss the handoff to background work.
"""
"""Keep externally-triggered background work visible across awaits; a handler may detach
the reservation to a task whose done callback then owns release."""
reservation = {"active": True, "detached": False}
adapter._pending_agent_requests += 1
try:
@@ -1212,9 +1008,8 @@ if AIOHTTP_AVAILABLE:
try:
return await handler(request)
except web.HTTPRequestEntityTooLarge:
# aiohttp's client_max_size tripped mid-read (chunked bodies carry
# no Content-Length) — return a proper 413 instead of letting the
# handler's broad JSON except turn it into 400 "Invalid JSON".
# client_max_size tripped mid-read (chunked bodies carry no Content-Length): a
# proper 413, not the handler's 400 "Invalid JSON".
return _error_response("Request body too large.", 413, code="body_too_large")
else:
body_limit_middleware = None # type: ignore[assignment]
@@ -1292,15 +1087,8 @@ def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
def _derive_chat_session_id(
system_prompt: Optional[str], first_user_message: str) -> str:
"""Derive a stable session ID from the conversation's first user message.
OpenAI-compatible frontends (Open WebUI, LibreChat, etc.) send the full
conversation history with every request. The system prompt and first user
message are constant across all turns of the same conversation, so hashing
them produces a deterministic session ID that lets the API server reuse
the same Hermes session (and therefore the same Docker container sandbox
directory) across turns.
"""
"""Stable session id from the system prompt + first user message (constant across all
turns of an Open WebUI-style conversation), so one Hermes session/sandbox is reused."""
seed = f"{system_prompt or ''}\n{first_user_message}"
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
return f"api-{digest}"
@@ -1321,22 +1109,15 @@ try:
create_job_with_scheduler_registration as _cron_create)
_CRON_AVAILABLE = True
except ImportError:
_cron_list = None
_cron_get = None
_cron_create = None
_cron_update = None
_cron_remove = None
_cron_pause = None
_cron_resume = None
_cron_trigger = None
_cron_list = _cron_get = _cron_create = _cron_update = None
_cron_remove = _cron_pause = _cron_resume = _cron_trigger = None
class _CronSchedulerRegistrationError(RuntimeError):
pass
def _notify_cron_provider_jobs_changed() -> None:
"""Tell the active cron scheduler provider the job set changed after a REST
mutation (no-op for the built-in). Best-effort — never breaks the handler."""
"""Best-effort notify of the active cron provider after a REST mutation (built-in: no-op)."""
try:
from cron.scheduler import _notify_provider_jobs_changed
_notify_provider_jobs_changed()
@@ -1354,19 +1135,9 @@ except Exception: # pragma: no cover - scanner is optional hardening
class _ProviderAuthResolutionError(RuntimeError):
"""Raised only when gateway.run._resolve_runtime_agent_kwargs() fails
to resolve provider credentials.
That function is the sole raiser of RuntimeError(format_runtime_
provider_error(...)) anywhere in _create_agent()'s call graph.
Re-raising it as this dedicated subclass -- instead of catching bare
RuntimeError around the much wider _create_agent()+run_conversation()
span -- lets callers distinguish "provider auth/credential failure"
from any other RuntimeError a provider adapter or run_conversation()
might legitimately raise (e.g. run_agent.py's "Failed to recreate
closed OpenAI client"), which a bare `except RuntimeError` there would
otherwise mislabel as an auth failure.
"""
"""Provider credential resolution failed (the sole RuntimeError raiser in _create_agent's
call graph). A typed subclass so callers never mislabel other RuntimeErrors from
run_conversation() (e.g. "Failed to recreate closed OpenAI client") as auth failures."""
class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
@@ -1841,7 +1612,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
# Multiplexing off: only a self-referential prefix may fall through. Ignoring
# any prefix served the owner's toolsets/capabilities (and misdelivered peer
# DMs) under another profile's URL — fail closed.
return(None if _prefix_names_served_profile(profile) else _PROFILE_REJECTED)
return None if _prefix_names_served_profile(profile) else _PROFILE_REJECTED
try:
from hermes_cli.profiles import profiles_to_serve
served = {