Files
hermes-agent/gateway/platforms/api_server_openai_routes.py
T
Teknium 055e307b48 refactor(api_server): trim the idempotency-scope salvage to shape gates
Keep the two invariants (cross-profile isolation, same-namespace dedup) and a short
docstring; the composite key already covers route and principal rotation.
2026-09-11 19:37:59 -07:00

1052 lines
59 KiB
Python

"""OpenAI-compatible routes for the API server adapter.
``OpenAICompatRoutesMixin`` (inherited by ``APIServerAdapter``) carries ``/v1/chat/completions``,
``/v1/responses`` (+ GET/DELETE), their SSE writers and the Responses-transcript helpers.
api_server-internal helpers are imported lazily inside each method: the origin imports this
module (top-level import = cycle), and lazy lookup keeps ``patch("...api_server.X")`` effective.
"""
import asyncio
import json
import logging
import re
import time
import uuid
from contextlib import suppress
from typing import Any, Dict, List, Optional
try:
from aiohttp import web
except ImportError: # pragma: no cover - mirrors api_server's optional import
web = None # type: ignore[assignment]
# Logger parity with the origin module (moved log records keep their name).
logger = logging.getLogger("gateway.platforms.api_server")
async def _iter_stream_items(stream_q, agent_task, response):
"""Yield agent stream items until EOS, writing SSE keepalives while idle.
Yields the ``None`` sentinel once so callers can run EOS-only work; when ``agent_task``
is already done the remaining queue is drained and the sentinel swallowed.
"""
from gateway.platforms.api_server import CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS
last_activity = time.monotonic()
while True:
try:
item = await asyncio.wait_for(stream_q.get(), timeout=0.5)
except asyncio.TimeoutError:
if agent_task.done():
while True:
try:
item = stream_q.get_nowait()
except asyncio.QueueEmpty:
return
if item is None:
return
yield item
last_activity = time.monotonic()
if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
await response.write(b": keepalive\n\n")
last_activity = time.monotonic()
continue
if item is None:
yield None
return
yield item
last_activity = time.monotonic()
def _result_flags(result: Any) -> tuple:
"""``(completed, partial, failed, error)`` from an agent result dict (defaults if not a dict)."""
if not isinstance(result, dict):
return True, False, False, None
return (bool(result.get("completed", True)), bool(result.get("partial")),
bool(result.get("failed")), result.get("error"))
def _finish_reason(completed, is_partial, is_failed, err_msg, agent_error=None) -> str:
"""OpenAI ``finish_reason``: "length" for truncation, "error" for failure, else "stop"."""
# OpenAI uses "length" for truncation, "stop" for normal completion, and downstream SDKs accept "error"
# / custom codes. See issue #22496.
if is_partial and err_msg and "truncat" in err_msg.lower():
return "length"
if agent_error is not None or is_failed or (not completed and err_msg):
return "error"
return "stop"
def _hermes_extras(completed, is_partial, is_failed, err_msg, finish_reason: str) -> Dict[str, Any]:
return {
"completed": completed, "partial": is_partial, "failed": is_failed, "error": err_msg,
"error_code": "output_truncated" if finish_reason == "length" else "agent_error"}
def _message_item(text: Any) -> Dict[str, Any]:
"""Responses ``message`` output item carrying one ``output_text`` part."""
return {"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": text}]}
def _trim_tool_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Trim large tool payloads in place so response.completed stays under ~100KB (clients
already received the full details via the incremental events)."""
for item in items:
if item.get("type") == "function_call":
try:
raw = item.get("arguments")
args = json.loads(raw) if isinstance(raw, str) else item.get("arguments", {})
if isinstance(args, dict):
for k in ("content", "query", "pattern", "old_string", "new_string"):
if isinstance(args.get(k), str) and len(args[k]) > 500:
args[k] = f"[{len(args[k])} chars — truncated for response.completed]"
item["arguments"] = json.dumps(args)
except Exception:
pass
elif item.get("type") == "function_call_output":
output = item.get("output", [])
if isinstance(output, list) and output:
first = output[0]
if isinstance(first, dict) and first.get("type") == "input_text":
text = first.get("text", "")
if len(text) > 1000:
first["text"] = text[:500] + "...[" + str(len(text) - 500) + " more chars]"
item["output"] = [first]
return items
class _ResponsesStream:
"""Per-request state and event emitters for the POST /v1/responses SSE writer.
Every event carries a monotonic ``sequence_number`` (canonical Responses SSE schema). Text
deltas are batched (50ms) against Open WebUI re-render storms; tool events flush first.
"""
def __init__(self, adapter, response, *, response_id: str, model: str, created_at: int,
conversation_history: List[Dict[str, str]], user_message: str,
instructions: Optional[str], conversation: Optional[str], store: bool, session_id: str):
from gateway.platforms import api_server as api
self._api = api
self.adapter, self.response, self.response_id = adapter, response, response_id
self.model, self.created_at, self.conversation_history = model, created_at, conversation_history
self.user_message, self.instructions = user_message, instructions
self.conversation, self.store, self.session_id = conversation, store, session_id
self.final_text_parts: List[str] = []
self.pending_tool_calls: List[Dict[str, Any]] = [] # open function_call items, in order
self.emitted_items: List[Dict[str, Any]] = [] # output items so far (terminal payload)
self.output_index = 0
self.call_counter = 0 # call_id fallback when the agent supplies no tool_call_id
self.sequence_number = 0
self.message_item_id = f"msg_{uuid.uuid4().hex[:24]}"
self.message_output_index: Optional[int] = None
self.message_opened = False
self.final_response_text = ""
self.agent_error: Optional[str] = None
self.usage: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
self.terminal_snapshot_persisted = False
self.result: Any = None
self._batch_buf: List[str] = []
self._batch_timer: Optional[asyncio.Task] = None
self._batch_lock = asyncio.Lock()
async def write_event(self, event_type: str, data: Dict[str, Any]) -> None:
if "sequence_number" not in data:
data["sequence_number"] = self.sequence_number
self.sequence_number += 1
await self.response.write(self._api._sse_frame(data, event=event_type))
def envelope(self, status: str) -> Dict[str, Any]:
return {"id": self.response_id, "object": "response", "status": status,
"created_at": self.created_at, "model": self.model}
def terminal_envelope(self, status: str, output: List[Dict[str, Any]], *, error=None) -> dict:
"""``envelope`` + ``output`` (+ ``error`` when given) + ``usage``, in wire key order."""
env = self.envelope(status)
env["output"] = output
if error is not None:
env["error"] = {"message": error, "type": "server_error"}
env["usage"] = self._api._responses_usage_payload(self.usage)
return env
def _history_with_user(self) -> List[Dict[str, Any]]:
return list(self.conversation_history) + [{"role": "user", "content": self.user_message}]
def persist_snapshot(self, response_env: Dict[str, Any], *, history=None, session_id=None):
if not self.store:
return
self.adapter._response_store.put(self.response_id, {
"response": response_env,
"conversation_history": self._history_with_user() if history is None else history,
"instructions": self.instructions,
"session_id": session_id or self.session_id})
if self.conversation:
self.adapter._response_store.set_conversation(self.conversation, self.response_id)
def persist_incomplete_if_needed(self) -> None:
"""Persist an ``incomplete`` snapshot when no terminal one was written (disconnect /
cancel paths), so GET /v1/responses/{id} and ``previous_response_id`` chaining survive."""
if not self.store or self.terminal_snapshot_persisted:
return
text = "".join(self.final_text_parts) or self.final_response_text
items = list(self.emitted_items)
history = self._history_with_user()
if text:
items.append(_message_item(text))
history.append({"role": "assistant", "content": text})
self.persist_snapshot(self.terminal_envelope("incomplete", items), history=history)
async def emit_created(self) -> None:
env = self.envelope("in_progress")
env["output"] = []
await self.write_event("response.created", {"type": "response.created", "response": env})
self.persist_snapshot(env)
async def _open_message_item(self) -> None:
"""Emit output_item.added for the assistant message on the first text delta."""
if self.message_opened:
return
self.message_opened = True
self.message_output_index = self.output_index
self.output_index += 1
await self.write_event("response.output_item.added", {
"type": "response.output_item.added", "output_index": self.message_output_index,
"item": {"id": self.message_item_id, "type": "message", "status": "in_progress",
"role": "assistant", "content": []}})
async def emit_text_delta(self, delta_text: str) -> None:
await self._open_message_item()
self.final_text_parts.append(delta_text)
await self.write_event("response.output_text.delta", {
"type": "response.output_text.delta", "item_id": self.message_item_id,
"output_index": self.message_output_index, "content_index": 0, "delta": delta_text,
"logprobs": []})
async def emit_tool_started(self, payload: Dict[str, Any]) -> None:
"""function_call ``output_item.added``; the agent's tool_call_id beats a generated call id."""
self.call_counter += 1
call_id = payload.get("tool_call_id") or f"call_{self.response_id[5:]}_{self.call_counter}"
args = payload.get("arguments", {})
arguments_str = json.dumps(args) if isinstance(args, dict) else str(args)
name = payload.get("name", "")
item = {"id": f"fc_{uuid.uuid4().hex[:24]}", "type": "function_call",
"status": "in_progress", "name": name, "call_id": call_id, "arguments": arguments_str}
idx = self.output_index
self.output_index += 1
self.pending_tool_calls.append({
"call_id": call_id, "name": name, "arguments": arguments_str, "item_id": item["id"],
"output_index": idx})
self.emitted_items.append(
{"type": "function_call", "name": name, "arguments": arguments_str, "call_id": call_id})
await self.write_event("response.output_item.added", {
"type": "response.output_item.added", "output_index": idx, "item": item})
async def emit_tool_completed(self, payload: Dict[str, Any]) -> None:
"""function_call ``output_item.done`` + function_call_output added/done; orphans skipped."""
call_id = payload.get("tool_call_id")
pending = next((p for p in self.pending_tool_calls if p["call_id"] == call_id), None)
if not call_id or pending is None:
return
self.pending_tool_calls.remove(pending)
done_item = {"id": pending["item_id"], "type": "function_call", "status": "completed",
"name": pending["name"], "call_id": pending["call_id"],
"arguments": pending["arguments"]}
await self.write_event("response.output_item.done", {
"type": "response.output_item.done", "output_index": pending["output_index"],
"item": done_item})
result = payload.get("result", "")
result_str = result if isinstance(result, str) else json.dumps(result)
output_parts = [{"type": "input_text", "text": result_str}]
output_item = {"id": f"fco_{uuid.uuid4().hex[:24]}", "type": "function_call_output",
"call_id": pending["call_id"], "output": output_parts, "status": "completed"}
idx = self.output_index
self.output_index += 1
self.emitted_items.append(
{"type": "function_call_output", "call_id": pending["call_id"], "output": output_parts})
for event in ("response.output_item.added", "response.output_item.done"):
await self.write_event(event, {"type": event, "output_index": idx, "item": output_item})
async def dispatch(self, item: Any) -> None:
"""Route one queue item: tool tuples emit immediately, strings are batched, others dropped."""
if isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], str):
tag, payload = item
await self.flush_batch()
if tag == "__tool_started__":
await self.emit_tool_started(payload)
elif tag == "__tool_completed__":
await self.emit_tool_completed(payload)
elif isinstance(item, str):
self._batch_buf.append(item)
if self._batch_timer is None:
self._batch_timer = asyncio.create_task(self._batch_flush_after(0.05))
async def _batch_flush_after(self, delay: float) -> None:
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
# Clear the timer BEFORE flushing so new deltas can start a fresh timer while we emit.
self._batch_timer = None
await self.flush_batch()
def cancel_batch_timer(self) -> None:
if self._batch_timer and not self._batch_timer.done():
self._batch_timer.cancel()
self._batch_timer = None
async def flush_batch(self) -> None:
"""Emit a single delta for all buffered text."""
if not self._batch_buf:
return
async with self._batch_lock:
if self._batch_buf:
combined = "".join(self._batch_buf)
self._batch_buf = []
await self.emit_text_delta(combined)
async def collect_result(self, agent_task) -> None:
"""Await the agent; when it produced a final_response but streamed no deltas
(some providers only emit the full text at the end), emit one fallback delta."""
try:
result, agent_usage = await agent_task
self.result = result
self.usage = agent_usage or self.usage
agent_final = result.get("final_response", "") if isinstance(result, dict) else ""
if agent_final and not self.final_text_parts:
await self.emit_text_delta(agent_final)
if agent_final and not self.final_response_text:
self.final_response_text = agent_final
if isinstance(result, dict) and result.get("error") and not self.final_response_text:
self.agent_error = self._api._redact_api_error_text(result["error"])
except Exception as e: # noqa: BLE001
logger.error("Error running agent for streaming responses: %s", e, exc_info=True)
self.agent_error = self._api._redact_api_error_text(e)
async def close_message_item(self) -> None:
self.final_response_text = "".join(self.final_text_parts) or self.final_response_text
if not self.message_opened:
return
await self.write_event("response.output_text.done", {
"type": "response.output_text.done", "item_id": self.message_item_id,
"output_index": self.message_output_index, "content_index": 0,
"text": self.final_response_text, "logprobs": []})
await self.write_event("response.output_item.done", {
"type": "response.output_item.done", "output_index": self.message_output_index,
"item": {"id": self.message_item_id, "type": "message", "status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": self.final_response_text}]}})
def _final_items(self) -> List[Dict[str, Any]]:
"""Emitted items (trimmed) plus a final message item, so clients that only parse
the terminal payload still see the assistant text (mirrors _extract_output_items)."""
items = _trim_tool_items(list(self.emitted_items))
redact = self._api._redact_api_error_text
text = self.final_response_text or (redact(self.agent_error) if self.agent_error else "")
items.append(_message_item(text))
return items
async def emit_failed(self) -> None:
redact = self._api._redact_api_error_text
env = self.terminal_envelope("failed", self._final_items(), error=redact(self.agent_error))
history = self._history_with_user()
history.append(
{"role": "assistant", "content": self.final_response_text or redact(self.agent_error)})
self.persist_snapshot(env, history=history)
self.terminal_snapshot_persisted = True
await self.write_event("response.failed", {"type": "response.failed", "response": env})
async def emit_completed(self) -> None:
env = self.terminal_envelope("completed", self._final_items())
result = self.result
full_history = self.adapter._build_response_conversation_history(
self.conversation_history, self.user_message, result, self.final_response_text)
# Transcript substitution for result["_compressed"] happens in the history builder; only
# a compression-rotated session_id is propagated so chaining resumes the child session.
sid = result.get("session_id") if isinstance(result, dict) else None
self.persist_snapshot(
env, history=full_history, session_id=sid if isinstance(sid, str) and sid else None)
self.terminal_snapshot_persisted = True
await self.write_event(
"response.completed", {"type": "response.completed", "response": env})
async def emit_crash(self, exc: BaseException) -> None:
error = self._api._redact_api_error_text(exc, limit=500)
env = self.terminal_envelope("failed", list(self.emitted_items), error=error)
await self.write_event("response.failed", {"type": "response.failed", "response": env})
class OpenAICompatRoutesMixin:
"""/v1/chat/completions and /v1/responses handlers + SSE writers."""
def _select_request_route(
self, body: Dict[str, Any], *, session_id, gateway_session_key, model_alias) -> tuple:
"""Resolve the model_routes alias + per-request overrides ->
``(route, agent_overrides, error_response_or_None)``."""
from gateway.platforms.api_server import _error_response, _request_agent_overrides
route = self._resolve_route(model_alias)
overrides = _request_agent_overrides(
body, virtual_model=self._model_name, allow_bare_model=self._direct_model_requests)
err = self._request_route_conflict_error(
session_id=session_id, gateway_session_key=gateway_session_key,
requested_model=overrides.get("requested_model"),
requested_provider=overrides.get("requested_provider"), route=route)
return route, overrides, (_error_response(err, 400) if err else None)
def _spawn_stream_agent(self, stream_q, **run_kwargs) -> tuple:
"""Start ``_run_agent`` for an SSE writer -> ``(agent_task, agent_ref)``. ``agent_ref[0]``
lets the writer interrupt on disconnect; the EOS sentinel is enqueued from the task's done
callback so drain loops never race a polled ``agent_task.done()``."""
def _on_delta(delta):
# None from the agent is a CLI box-close signal, not EOS — forwarding it would end
# the stream early. Called from the run_conversation worker thread: put_threadsafe.
if delta is not None:
stream_q.put_threadsafe(delta)
agent_ref = [None]
agent_task = asyncio.ensure_future(self._run_agent(
stream_delta_callback=_on_delta, agent_ref=agent_ref, **run_kwargs))
agent_task.add_done_callback(lambda _fut: stream_q.put_nowait(None))
return agent_task, agent_ref
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
from gateway.platforms.api_server import (
ThreadSafeAsyncQueue, _chat_usage_payload, _coerce_request_bool,
_content_has_visible_payload, _derive_chat_session_id, _error_response, _invalid_request,
_multimodal_validation_error, _normalize_chat_content, _normalize_multimodal_content,
_openai_error, _redact_api_error_text, _resolve_media_to_data_urls)
# Bound total in-flight agent runs (configurable; #7483).
limited = self._concurrency_limited_response()
if limited is not None:
return limited
try:
body = await request.json()
except Exception:
return _error_response("Invalid JSON in request body", 400)
from gateway.platforms.api_server import _request_relay_metadata
relay_metadata = _request_relay_metadata(body)
messages = body.get("messages")
if not messages or not isinstance(messages, list):
return _invalid_request("Missing or invalid 'messages' field")
stream = _coerce_request_bool(body.get("stream"), default=False)
# System messages -> ephemeral system prompt layered ON TOP of core, flattened to text
# (Anthropic rejects images there, OpenAI text models ignore them).
system_prompt = None
conversation_messages: List[Dict[str, str]] = []
for idx, msg in enumerate(messages):
role = msg.get("role", "")
raw_content = msg.get("content", "")
if role == "system":
content = _normalize_chat_content(raw_content)
system_prompt = content if system_prompt is None else system_prompt + "\n" + content
elif role in {"user", "assistant"}:
try:
content = _normalize_multimodal_content(raw_content)
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"messages[{idx}].content")
conversation_messages.append({"role": role, "content": content})
user_message: Any = (conversation_messages[-1].get("content", "") if conversation_messages else "")
history = conversation_messages[:-1]
if not _content_has_visible_payload(user_message):
return _invalid_request("No user message found in messages")
# X-Hermes-Session-Key scopes long-term memory per channel; independent of
# X-Hermes-Session-Id (the key persists across transcripts, the id rotates on /new).
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err
# X-Hermes-Session-Id continues an existing session (history from state.db, not the body);
# requires a configured API key or any client could read history by guessing ids.
provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
if provided_session_id:
if not self._api_key:
logger.warning(
"Session continuation via X-Hermes-Session-Id rejected: "
"no API key configured. Set API_SERVER_KEY to enable "
"session continuity.")
return _error_response("Session continuation requires API key authentication. "
"Configure API_SERVER_KEY to enable this feature.", 403)
# Same guard as the native gateway: ids are interpolated into on-disk filenames.
from gateway.session import _is_path_unsafe
if re.search(r'[\r\n\x00]', provided_session_id) or _is_path_unsafe(provided_session_id):
return _invalid_request("Invalid session ID")
if len(provided_session_id) > self._MAX_SESSION_HEADER_LEN:
return _invalid_request("Session ID too long")
session_id = provided_session_id
try:
db = await self._ensure_session_db_async()
if db is not None:
# #98619/#13437: a client-addressed id from before a compression rotation
# must adopt the live continuation tip — history loads from it, the turn and
# the wake target bind it, and a detached delegation delivery row persisted
# on the tip is what this continuation consumes. Same canonical resolution
# the delivery writer (gateway/wake.py) and /v1/runs use; fails open.
from gateway.platforms.api_server_runs import _resolve_live_session_id
session_id = await _resolve_live_session_id(self, provided_session_id)
history = await asyncio.to_thread(db.get_messages_as_conversation, session_id)
except Exception as e:
logger.warning("Failed to load session history for %s: %s", session_id, e)
history = []
else:
# Stable id from the conversation fingerprint so Open WebUI-style clients map onto
# one Hermes session.
first_user = next(
(cm.get("content", "") for cm in conversation_messages if cm.get("role") == "user"), "")
session_id = _derive_chat_session_id(system_prompt, first_user)
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
model_name = body.get("model", self._model_name)
created = int(time.time())
route, agent_overrides, selection_error = self._select_request_route(
body, session_id=session_id, gateway_session_key=gateway_session_key,
model_alias=model_name)
if selection_error is not None:
return selection_error
run_kwargs = dict(
user_message=user_message, conversation_history=history,
ephemeral_system_prompt=system_prompt, session_id=session_id,
gateway_session_key=gateway_session_key, **agent_overrides, route=route,
relay_metadata=relay_metadata,
# #98619: only an explicitly provided X-Hermes-Session-Id is wake-capable (the
# header is 403-gated on API_SERVER_KEY, so the wake self-post can authenticate
# and the client can resume the session by sending it again). A fingerprint-derived
# id from a header-less client is NOT: delegate_task keeps its forced-sync fallback
# there — the wake would hard-fail or land in history that client never reloads.
session_history_delivery=("1" if provided_session_id else ""))
if stream:
_stream_q = ThreadSafeAsyncQueue()
# tool_call_ids with an emitted "running": a "completed" without one (internal/
# filtered tools) is dropped rather than orphaned on the wire.
_started_tool_call_ids: set[str] = set()
def _on_tool_start(tool_call_id, function_name, function_args):
"""``hermes.tool.progress`` status=running; ``_``-prefixed tools stay off the wire."""
if not tool_call_id or function_name.startswith("_"):
return
_started_tool_call_ids.add(tool_call_id)
from agent.display import build_tool_preview, get_tool_emoji
label = build_tool_preview(function_name, function_args) or function_name
_stream_q.put_threadsafe(("__tool_progress__", {
"tool": function_name, "emoji": get_tool_emoji(function_name), "label": label,
"toolCallId": tool_call_id, "status": "running"}))
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
if not tool_call_id or tool_call_id not in _started_tool_call_ids:
return
_started_tool_call_ids.discard(tool_call_id)
_stream_q.put_threadsafe(("__tool_progress__", {
"tool": function_name, "toolCallId": tool_call_id, "status": "completed"}))
# tool_progress_callback deliberately NOT wired: it would duplicate the structured
# start/complete callbacks (which carry the tool_call id).
agent_task, agent_ref = self._spawn_stream_agent(
_stream_q, tool_start_callback=_on_tool_start,
tool_complete_callback=_on_tool_complete, **run_kwargs)
# #13437 identity contract: an explicit-header client keeps addressing the id it
# sent; the response echoes that stable id while reads/writes adopt the live tip,
# so a rotation mid-turn (after these headers are prepared) never changes what the
# client should send next — it re-sends the same id and the tip resolution above
# finds whatever session is live by then.
return await self._write_sse_chat_completion(
request, completion_id, model_name, created, _stream_q,
agent_task, agent_ref, session_id=(provided_session_id or session_id),
gateway_session_key=gateway_session_key)
async def _compute_completion():
return await self._run_agent(**run_kwargs)
outcome, err = await self._run_idempotent(
request, body, _compute_completion, log_label="chat completions",
fingerprint_keys=["model", "provider", "model_options", "messages", "tools", "tool_choice", "stream"],
route="chat_completions",
)
if err is not None:
return err
result, usage = outcome
final_response = _resolve_media_to_data_urls(result.get("final_response") or "")
completed, is_partial, is_failed, err_msg = _result_flags(result)
if err_msg:
err_msg = _redact_api_error_text(err_msg)
finish_reason = _finish_reason(completed, is_partial, is_failed, err_msg)
# Same #13437 identity contract as the SSE path: an explicit-header client is echoed
# the stable id it sent; a fingerprint-derived (header-less) turn keeps reporting the
# id the agent actually resolved, so headerless clients still learn where the turn went.
response_headers = {"X-Hermes-Session-Id": (provided_session_id or result.get("session_id", session_id))}
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
# Hard fail (no usable text AND a real failure) -> 502 OpenAI error envelope so SDK
# clients raise instead of rendering the failure string as message.content.
if not final_response and (is_failed or is_partial):
err_body = _openai_error(
err_msg or "Agent run did not produce a response.", err_type="server_error",
code="agent_incomplete")
err_body["error"]["hermes"] = {
"completed": completed, "partial": is_partial, "failed": is_failed}
response_headers["X-Hermes-Completed"] = "false"
response_headers["X-Hermes-Partial"] = "true" if is_partial else "false"
return web.json_response(err_body, status=502, headers=response_headers)
# Soft partial (some text, run incomplete): 200 + finish_reason="length"/Hermes extras.
response_data = {
"id": completion_id, "object": "chat.completion", "created": created,
"model": model_name,
"choices": [{"index": 0, "message": {"role": "assistant", "content": final_response},
"finish_reason": finish_reason}],
"usage": _chat_usage_payload(usage)}
if is_partial or is_failed or not completed:
response_data["hermes"] = _hermes_extras(
completed, is_partial, is_failed, err_msg, finish_reason)
response_headers["X-Hermes-Completed"] = "false"
response_headers["X-Hermes-Partial"] = "true" if is_partial else "false"
if err_msg:
response_headers["X-Hermes-Error"] = _redact_api_error_text(err_msg, limit=200)
return web.json_response(response_data, headers=response_headers)
async def _run_idempotent(
self, request: "web.Request", body: Dict[str, Any], compute, *,
log_label: str, fingerprint_keys: List[str], route: str) -> tuple:
"""Run ``compute()`` once per (principal scope, logical route, Idempotency-Key) + body fingerprint
-> ``((result, usage), None)`` or ``(None, 500 response)``.
``_idem_cache`` is process-global: under ``gateway.multiplex_profiles`` every profile's
``/p/<profile>/v1/...`` mirror shares it, so the key carries ``_run_idempotency_scope`` (the same
``sha256(profile, expected API key)`` namespace the durable ``/v1/runs`` API uses) — a client key
colliding across profiles, or a rotated API_SERVER_KEY, never replays another principal's response.
``route`` is the logical endpoint (``/v1/...`` and its ``/p/<profile>/v1/...`` alias are the same
route), folded into the key because the store keeps the fingerprint only as the slot's value.
"""
from gateway.platforms.api_server import _error_response, _idem_cache, _make_request_fingerprint
idempotency_key = request.headers.get("Idempotency-Key")
try:
if idempotency_key:
principal_scope = self._run_idempotency_scope(request)
scoped_key = f"{principal_scope}\0{route}\0{idempotency_key}"
fp = _make_request_fingerprint(body, keys=fingerprint_keys)
result, usage = await _idem_cache.get_or_set(scoped_key, fp, compute)
else:
result, usage = await compute()
return (result, usage), None
except Exception as e:
logger.error("Error running agent for %s: %s", log_label, e, exc_info=True)
return None, _error_response(f"Internal server error: {e}", 500, err_type="server_error")
async def _prepare_sse_response(
self, request: "web.Request", session_id: Optional[str], gateway_session_key: Optional[str],
) -> "web.StreamResponse":
"""Open a prepared SSE StreamResponse with CORS + session headers (the CORS middleware
can't inject headers after ``prepare()`` flushes them, so they are resolved here)."""
sse_headers = {
"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
origin = request.headers.get("Origin", "")
if origin:
sse_headers.update(self._cors_headers_for_origin(origin) or {})
if session_id:
sse_headers["X-Hermes-Session-Id"] = session_id
if gateway_session_key:
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
response = web.StreamResponse(status=200, headers=sse_headers)
await response.prepare(request)
return response
async def _write_sse_chat_completion(
self, request: "web.Request", completion_id: str, model: str,
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
gateway_session_key: str = None) -> "web.StreamResponse":
"""Stream ``chat.completion.chunk`` frames from the agent's delta queue. On client
disconnect the agent is interrupted (stops LLM calls), then its task wrapper cancelled."""
from gateway.platforms.api_server import (
_abandon_agent_task, _chat_usage_payload, _sse_frame)
response = await self._prepare_sse_response(request, session_id, gateway_session_key)
def _chunk(delta: Dict[str, Any], finish_reason=None, **extra) -> Dict[str, Any]:
return {"id": completion_id, "object": "chat.completion.chunk", "created": created,
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], **extra}
try:
await response.write(_sse_frame(_chunk({"role": "assistant"})))
async for delta in _iter_stream_items(stream_q, agent_task, response):
if delta is None:
break
if isinstance(delta, tuple) and len(delta) == 2 and delta[0] == "__tool_progress__":
# Custom event: tool lifecycle for frontends without markers in history.
await response.write(_sse_frame(delta[1], event="hermes.tool.progress"))
else:
await response.write(_sse_frame(_chunk({"content": delta})))
# The agent can fail after the queue drains (task raises / result flagged failed or
# partial): surface a non-"stop" finish_reason like the non-streaming path.
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
result = agent_error = None
try:
result, agent_usage = await agent_task
usage = agent_usage or usage
except Exception as exc:
agent_error = exc
logger.error("Agent task %s failed during SSE streaming: %s", completion_id, exc)
completed, is_partial, is_failed, err_msg = _result_flags(result)
if agent_error is not None:
is_failed = True
err_msg = err_msg or str(agent_error)
finish_reason = _finish_reason(completed, is_partial, is_failed, err_msg, agent_error)
finish_chunk = _chunk({}, finish_reason, usage=_chat_usage_payload(usage))
if finish_reason != "stop":
if err_msg:
finish_chunk["error"] = {
"message": err_msg,
"type": type(agent_error).__name__ if agent_error else "agent_error"}
finish_chunk["hermes"] = _hermes_extras(
completed, is_partial, is_failed, err_msg, finish_reason)
await response.write(_sse_frame(finish_chunk))
await response.write(b"data: [DONE]\n\n")
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
await _abandon_agent_task(agent_ref, agent_task, "SSE client disconnected")
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
except Exception:
# Agent crashed mid-stream: an error chunk beats a TransferEncodingError.
import traceback as _tb
logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
with suppress(Exception):
await response.write(_sse_frame(_chunk({}, "error")))
await response.write(b"data: [DONE]\n\n")
return response
async def _write_sse_responses(
self, request: "web.Request", response_id: str, model: str, created_at: int, stream_q,
agent_task, agent_ref, conversation_history: List[Dict[str, str]], user_message: str,
instructions: Optional[str], conversation: Optional[str], store: bool, session_id: str,
gateway_session_key: Optional[str] = None) -> "web.StreamResponse":
"""Write the SSE stream for POST /v1/responses.
Events: ``response.created`` -> ``output_text.delta/done`` + ``output_item.added/done``
(function_call / function_call_output) -> ``response.completed`` (non-streaming envelope)
or ``response.failed``. On disconnect the agent is interrupted and, with ``store=True``,
an ``incomplete`` snapshot replaces ``in_progress`` so GET / chaining still work.
"""
from gateway.platforms.api_server import _abandon_agent_task, _redact_api_error_text
response = await self._prepare_sse_response(request, session_id, gateway_session_key)
st = _ResponsesStream(
self, response, response_id=response_id, model=model, created_at=created_at,
conversation_history=conversation_history, user_message=user_message,
instructions=instructions, conversation=conversation, store=store, session_id=session_id)
try:
await st.emit_created()
async for item in _iter_stream_items(stream_q, agent_task, response):
if item is None: # EOS sentinel
st.cancel_batch_timer()
await st.flush_batch()
break
await st.dispatch(item)
await st.flush_batch()
await st.collect_result(agent_task)
await st.close_message_item()
if st.agent_error:
await st.emit_failed()
else:
await st.emit_completed()
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
st.persist_incomplete_if_needed()
await _abandon_agent_task(agent_ref, agent_task, "SSE client disconnected")
logger.info("SSE client disconnected; interrupted agent task %s", response_id)
except asyncio.CancelledError:
# Server-side cancellation (shutdown, timeout): persist incomplete, then re-raise.
st.persist_incomplete_if_needed()
await _abandon_agent_task(
agent_ref, agent_task, "SSE task cancelled",
reap_source="api_server_sse_cancelled", await_cancel=False)
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
raise
except Exception as exc:
# Unhandled agent error (BadRequestError, AuthenticationError, ...): emit
# response.failed and end the stream cleanly (no TransferEncodingError).
import traceback as _tb
st.persist_incomplete_if_needed()
st.agent_error = _redact_api_error_text(_tb.format_exc())
with suppress(Exception):
await st.emit_crash(exc)
logger.error("Agent crashed mid-stream for %s: %s", response_id, str(st.agent_error)[:300])
return response
async def _handle_responses(self, request: "web.Request") -> "web.Response":
"""POST /v1/responses — OpenAI Responses API format."""
from gateway.platforms.api_server import (
ThreadSafeAsyncQueue, _auto_truncate_response_history, _coerce_request_bool,
_content_has_visible_payload, _error_response, _invalid_request,
_multimodal_validation_error, _normalize_multimodal_content, _redact_api_error_text,
_resolve_media_to_data_urls, _responses_usage_payload)
# Bound total in-flight agent runs (configurable; #7483).
limited = self._concurrency_limited_response()
if limited is not None:
return limited
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err
try:
body = await request.json()
except Exception:
return _invalid_request("Invalid JSON in request body")
from gateway.platforms.api_server import _request_relay_metadata
relay_metadata = _request_relay_metadata(body)
raw_input = body.get("input")
if raw_input is None:
return _error_response("Missing 'input' field", 400)
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
conversation = body.get("conversation")
store = _coerce_request_bool(body.get("store"), default=True)
if conversation and previous_response_id:
return _error_response("Cannot use both 'conversation' and 'previous_response_id'", 400)
if conversation:
# A conversation name resolves to its latest response_id (unknown = new conversation).
previous_response_id = self._response_store.get_conversation(conversation)
input_messages: List[Dict[str, Any]] = []
if isinstance(raw_input, str):
input_messages = [{"role": "user", "content": raw_input}]
elif isinstance(raw_input, list):
for idx, item in enumerate(raw_input):
if isinstance(item, str):
input_messages.append({"role": "user", "content": item})
elif isinstance(item, dict):
try:
content = _normalize_multimodal_content(item.get("content", ""))
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"input[{idx}].content")
input_messages.append({"role": item.get("role", "user"), "content": content})
else:
return _error_response("'input' must be a string or array", 400)
# Explicit conversation_history (stateless clients) beats previous_response_id chaining.
conversation_history: List[Dict[str, Any]] = []
raw_history = body.get("conversation_history")
if raw_history:
if not isinstance(raw_history, list):
return _error_response("'conversation_history' must be an array of message objects", 400)
for i, entry in enumerate(raw_history):
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
return _error_response(f"conversation_history[{i}] must have 'role' and 'content' fields", 400)
try:
entry_content = _normalize_multimodal_content(entry["content"])
except ValueError as exc:
return _multimodal_validation_error(exc, param=f"conversation_history[{i}].content")
conversation_history.append({"role": str(entry["role"]), "content": entry_content})
if previous_response_id:
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
stored_session_id = None
if not conversation_history and previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is None:
return _error_response(f"Previous response not found: {previous_response_id}", 404)
conversation_history = list(stored.get("conversation_history", []))
stored_session_id = stored.get("session_id")
if instructions is None:
instructions = stored.get("instructions")
# All input messages but the last become history; the last is the user message.
conversation_history.extend(input_messages[:-1])
user_message: Any = input_messages[-1].get("content", "") if input_messages else ""
if not _content_has_visible_payload(user_message):
return _error_response("No user message found in input", 400)
if body.get("truncation") == "auto":
conversation_history = _auto_truncate_response_history(conversation_history)
# Session precedence: previous_response_id chain > declared X-Hermes-Session-Key > fresh
# id. Binding the declared key follows the same precedence: a chain-selected session must
# not have its routing key rewritten to this header.
_declared_selected = not stored_session_id and bool(gateway_session_key)
session_id = (
stored_session_id
or self._declared_conversation_session(gateway_session_key)
or str(uuid.uuid4()))
stream = _coerce_request_bool(body.get("stream"), default=False)
route, agent_overrides, selection_error = self._select_request_route(
body, session_id=session_id, gateway_session_key=gateway_session_key,
model_alias=body.get("model"))
if selection_error is not None:
return selection_error
run_kwargs = dict(
user_message=user_message, conversation_history=conversation_history,
ephemeral_system_prompt=instructions, session_id=session_id,
gateway_session_key=gateway_session_key, bind_declared_conversation=_declared_selected,
**agent_overrides, route=route, relay_metadata=relay_metadata)
if stream:
_stream_q = ThreadSafeAsyncQueue()
def _on_tool_progress(event_type, name, preview, args, **kwargs):
return # structured start/complete callbacks carry the call id; progress ignored
def _on_tool_start(tool_call_id, function_name, function_args):
_stream_q.put_threadsafe(("__tool_started__", {
"tool_call_id": tool_call_id, "name": function_name,
"arguments": function_args or {}}))
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
_stream_q.put_threadsafe(("__tool_completed__", {
"tool_call_id": tool_call_id, "name": function_name,
"arguments": function_args or {}, "result": function_result}))
agent_task, agent_ref = self._spawn_stream_agent(
_stream_q, tool_progress_callback=_on_tool_progress,
tool_start_callback=_on_tool_start, tool_complete_callback=_on_tool_complete,
**run_kwargs)
return await self._write_sse_responses(
request=request, response_id=f"resp_{uuid.uuid4().hex[:28]}",
model=body.get("model", self._model_name), created_at=int(time.time()),
stream_q=_stream_q, agent_task=agent_task, agent_ref=agent_ref,
conversation_history=conversation_history, user_message=user_message,
instructions=instructions, conversation=conversation, store=store,
session_id=session_id, gateway_session_key=gateway_session_key)
async def _compute_response():
return await self._run_agent(**run_kwargs)
outcome, err = await self._run_idempotent(
request, body, _compute_response, log_label="responses",
fingerprint_keys=["input", "instructions", "previous_response_id", "conversation", "model", "provider", "model_options", "tools"],
route="responses",
)
if err is not None:
return err
result, usage = outcome
final_response = _resolve_media_to_data_urls(result.get("final_response", ""))
if not final_response:
final_response = _redact_api_error_text(result.get("error", "(No response generated)"))
response_id = f"resp_{uuid.uuid4().hex[:28]}"
created_at = int(time.time())
full_history = self._build_response_conversation_history(
conversation_history, user_message, result, final_response)
# _run_agent's effective session id carries compression rotations; storing it keeps
# previous_response_id chaining off the pre-rotation session (else compression re-fires).
_result_sid = result.get("session_id") if isinstance(result, dict) else None
_effective_session_id = (
_result_sid if isinstance(_result_sid, str) and _result_sid else session_id)
# Output items = current turn only (AIAgent returns a full transcript; mocked paths
# only the current-turn suffix).
output_start_index = self._response_messages_turn_start_index(
conversation_history, user_message, result)
response_data = {
"id": response_id, "object": "response", "status": "completed",
"created_at": created_at, "model": body.get("model", self._model_name),
"output": self._extract_output_items(result, start_index=output_start_index),
"usage": _responses_usage_payload(usage)}
if store:
self._response_store.put(response_id, {
"response": response_data, "conversation_history": full_history,
"instructions": instructions, "session_id": _effective_session_id})
if conversation:
self._response_store.set_conversation(conversation, response_id)
response_headers = {"X-Hermes-Session-Id": _effective_session_id}
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
return web.json_response(response_data, headers=response_headers)
async def _handle_get_response(self, request: "web.Request") -> "web.Response":
"""GET /v1/responses/{response_id} — retrieve a stored response."""
from gateway.platforms.api_server import _error_response
auth_err = self._check_auth(request)
if auth_err:
return auth_err
response_id = request.match_info["response_id"]
stored = self._response_store.get(response_id)
if stored is None:
return _error_response(f"Response not found: {response_id}", 404)
return web.json_response(stored["response"])
async def _handle_delete_response(self, request: "web.Request") -> "web.Response":
"""DELETE /v1/responses/{response_id} — delete a stored response."""
from gateway.platforms.api_server import _error_response
auth_err = self._check_auth(request)
if auth_err:
return auth_err
response_id = request.match_info["response_id"]
if not self._response_store.delete(response_id):
return _error_response(f"Response not found: {response_id}", 404)
return web.json_response({"id": response_id, "object": "response", "deleted": True})
@staticmethod
def _build_response_conversation_history(
conversation_history: List[Dict[str, Any]], user_message: Any, result: Dict[str, Any],
final_response: Any) -> List[Dict[str, Any]]:
"""Build the stored Responses transcript without duplicating history.
A compressed transcript (``result["_compressed"]``) shares no input-history prefix, so
turn-start detection fails; prepending the uncompressed history would bloat the stored
context and re-trigger compression every request — it is stored as-is instead.
"""
from gateway.platforms.api_server import APIServerAdapter
prior = list(conversation_history)
current_user = {"role": "user", "content": user_message}
agent_messages = result.get("messages") if isinstance(result, dict) else None
if isinstance(agent_messages, list) and agent_messages:
turn_start = APIServerAdapter._response_messages_turn_start_index(
conversation_history, user_message, result)
# turn_start == 0: compression rewrote the transcript or agent_messages is turn-only.
if turn_start or result.get("_compressed"):
return list(agent_messages)
return prior + [current_user] + agent_messages
return prior + [current_user, {"role": "assistant", "content": final_response}]
@staticmethod
def _response_messages_turn_start_index(
conversation_history: List[Dict[str, Any]], user_message: Any, result: Dict[str, Any],
) -> int:
"""Detect transcript-shaped result["messages"] and return turn start."""
agent_messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(agent_messages, list) or not agent_messages:
return 0
prior = list(conversation_history)
expected_prefix = prior + [{"role": "user", "content": user_message}]
if agent_messages[:len(expected_prefix)] == expected_prefix:
return len(expected_prefix)
if prior and agent_messages[:len(prior)] == prior:
return len(prior)
return 0
@classmethod
def _turn_transcript_messages(
cls, conversation_history: List[Dict[str, Any]], user_message: Any, result: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""This turn's assistant/tool messages in client-safe shape: clients accumulating
``assistant.delta`` into one buffer cannot reconstruct assistant segments that preceded
tool calls, so ``run.completed`` carries the authoritative per-turn transcript.
Emitting the authoritative per-turn transcript on ``run.completed`` lets any SSE consumer reconcile
its live view against ground truth without a separate ``GET /messages`` round-trip. Purely additive:
clients that ignore the field are unaffected. Refs #34703.
"""
agent_messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(agent_messages, list) or not agent_messages:
return []
start = cls._response_messages_turn_start_index(conversation_history, user_message, result)
out: List[Dict[str, Any]] = []
for msg in agent_messages[start:]:
if not isinstance(msg, dict) or msg.get("role") not in {"assistant", "tool"}:
continue
# _message_response projects compaction scaffolding; pure handoffs are "hidden".
projected = cls._message_response(msg)
if projected.get("display_kind") != "hidden":
out.append(projected)
return out
@staticmethod
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
"""Output items from ``result["messages"][start_index:]``: ``function_call`` per assistant
tool_call, ``function_call_output`` per tool message, then the final ``message``."""
from gateway.platforms.api_server import _redact_api_error_text
items: List[Dict[str, Any]] = []
messages = result.get("messages", [])
if start_index > 0:
messages = messages[start_index:]
for msg in messages:
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
for tc in msg["tool_calls"]:
func = tc.get("function", {})
# Already executed server-side; replayed for structured tool UI only, so
# marked completed (matching the SSE path) — never pending client calls.
items.append({
"id": f"fc_{uuid.uuid4().hex[:24]}", "type": "function_call",
"status": "completed", "name": func.get("name", ""),
"arguments": func.get("arguments", ""),
"call_id": tc.get("id", "")})
elif role == "tool":
items.append({
"id": f"fco_{uuid.uuid4().hex[:24]}", "type": "function_call_output",
"status": "completed", "call_id": msg.get("tool_call_id", ""),
"output": msg.get("content", "")})
final = result.get("final_response", "") or _redact_api_error_text(
result.get("error", "(No response generated)"))
items.append(_message_item(final))
return items