feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
|
|
|
|
|
OpenAI-compatible API server platform adapter.
|
|
|
|
|
|
|
|
|
|
|
|
Exposes an HTTP server with endpoints:
|
2026-05-05 05:34:47 -07:00
|
|
|
|
- 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)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
- GET /v1/responses/{response_id} — Retrieve a stored response
|
|
|
|
|
|
- DELETE /v1/responses/{response_id} — Delete a stored response
|
|
|
|
|
|
- GET /v1/models — lists hermes-agent as an available model
|
2026-04-29 06:36:56 -07:00
|
|
|
|
- GET /v1/capabilities — machine-readable API capabilities for external UIs
|
2026-05-20 08:45:55 -04:00
|
|
|
|
- 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
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
- POST /v1/runs — start a run, returns run_id immediately (202)
|
2026-04-29 06:36:56 -07:00
|
|
|
|
- GET /v1/runs/{run_id} — retrieve current run status
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
- GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events
|
2026-05-05 18:34:58 +02:00
|
|
|
|
- POST /v1/runs/{run_id}/approval — resolve a pending run approval
|
|
|
|
|
|
- POST /v1/runs/{run_id}/stop — interrupt a running agent
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
- GET /health — health check
|
2026-04-14 05:17:17 +00:00
|
|
|
|
- GET /health/detailed — rich status for cross-container dashboard probing
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
|
|
|
|
|
|
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
|
2026-05-25 18:19:00 +03:00
|
|
|
|
through this adapter by pointing at http://localhost:8642/v1 and
|
|
|
|
|
|
authenticating with API_SERVER_KEY.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
Requires:
|
|
|
|
|
|
- aiohttp (already available in the gateway)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-04-10 04:56:35 -07:00
|
|
|
|
import hashlib
|
fix(security): consolidated security hardening — SSRF, timing attack, tar traversal, credential leakage (#5944)
Salvaged from PRs #5800 (memosr), #5806 (memosr), #5915 (Ruzzgar), #5928 (Awsh1).
Changes:
- Use hmac.compare_digest for API key comparison (timing attack prevention)
- Apply provider env var blocklist to Docker containers (credential leakage)
- Replace tar.extractall() with safe extraction in TerminalBench2 (CVE-2007-4559)
- Add SSRF protection via is_safe_url to ALL platform adapters:
base.py (cache_image_from_url, cache_audio_from_url),
discord, slack, telegram, matrix, mattermost, feishu, wecom
(Signal and WhatsApp protected via base.py helpers)
- Update tests: mock is_safe_url in Mattermost download tests
- Add security tests for tar extraction (traversal, symlinks, safe files)
2026-04-07 17:28:37 -07:00
|
|
|
|
import hmac
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
2026-04-10 16:40:54 -07:00
|
|
|
|
import socket as _socket
|
2026-04-10 11:52:01 +08:00
|
|
|
|
import re
|
2026-03-22 04:56:06 -07:00
|
|
|
|
import sqlite3
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
import time
|
|
|
|
|
|
import uuid
|
2026-05-24 04:54:49 -07:00
|
|
|
|
from pathlib import Path
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from aiohttp import web
|
|
|
|
|
|
AIOHTTP_AVAILABLE = True
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
AIOHTTP_AVAILABLE = False
|
|
|
|
|
|
web = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
|
|
from gateway.platforms.base import (
|
|
|
|
|
|
BasePlatformAdapter,
|
|
|
|
|
|
SendResult,
|
2026-04-10 16:40:54 -07:00
|
|
|
|
is_network_accessible,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-06-07 18:38:54 -07:00
|
|
|
|
|
|
|
|
|
|
def _hermes_version() -> str:
|
|
|
|
|
|
"""Return the hermes-agent version string, or "dev" if it can't be resolved.
|
|
|
|
|
|
|
|
|
|
|
|
Tries the installed package metadata first (authoritative for a pip/uv
|
|
|
|
|
|
install), then the in-tree ``hermes_cli.__version__`` (covers editable /
|
|
|
|
|
|
source checkouts where metadata may be stale or absent). Never raises —
|
|
|
|
|
|
a version probe must not be able to break the health endpoint.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from importlib.metadata import version
|
|
|
|
|
|
|
|
|
|
|
|
return version("hermes-agent")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli import __version__
|
|
|
|
|
|
|
|
|
|
|
|
return __version__
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "dev"
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# Default settings
|
|
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
|
|
|
|
DEFAULT_PORT = 8642
|
|
|
|
|
|
MAX_STORED_RESPONSES = 100
|
2026-05-05 15:12:21 -07:00
|
|
|
|
MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversations with tool calls
|
2026-04-11 12:42:01 -06:00
|
|
|
|
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
|
2026-04-12 17:16:16 -07:00
|
|
|
|
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
|
|
|
|
|
|
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 00:03:36 +03:00
|
|
|
|
def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int:
|
|
|
|
|
|
"""Parse a listen port without letting malformed env/config values crash startup."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 02:08:40 +03:00
|
|
|
|
_TRUE_REQUEST_BOOL_STRINGS = frozenset({"1", "true", "yes", "on"})
|
|
|
|
|
|
_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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
|
return value
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
normalized = value.strip().lower()
|
|
|
|
|
|
if normalized in _TRUE_REQUEST_BOOL_STRINGS:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if normalized in _FALSE_REQUEST_BOOL_STRINGS:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return default
|
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
|
return bool(value)
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 17:16:16 -07:00
|
|
|
|
def _normalize_chat_content(
|
|
|
|
|
|
content: Any, *, _max_depth: int = 10, _depth: int = 0,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Normalize OpenAI chat message content into a plain text 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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if _depth > _max_depth:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if content is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
|
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
parts: List[str] = []
|
2026-06-14 03:25:49 -07:00
|
|
|
|
total_len = 0
|
2026-04-12 17:16:16 -07:00
|
|
|
|
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
|
if item:
|
2026-06-14 03:25:49 -07:00
|
|
|
|
part = item[:MAX_NORMALIZED_TEXT_LENGTH]
|
|
|
|
|
|
parts.append(part)
|
|
|
|
|
|
total_len += len(part)
|
2026-04-12 17:16:16 -07:00
|
|
|
|
elif isinstance(item, dict):
|
|
|
|
|
|
item_type = str(item.get("type") or "").strip().lower()
|
|
|
|
|
|
if item_type in {"text", "input_text", "output_text"}:
|
|
|
|
|
|
text = item.get("text", "")
|
|
|
|
|
|
if text:
|
|
|
|
|
|
try:
|
2026-06-14 03:25:49 -07:00
|
|
|
|
part = str(text)[:MAX_NORMALIZED_TEXT_LENGTH]
|
|
|
|
|
|
parts.append(part)
|
|
|
|
|
|
total_len += len(part)
|
2026-04-12 17:16:16 -07:00
|
|
|
|
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)
|
2026-06-14 03:25:49 -07:00
|
|
|
|
total_len += len(nested)
|
2026-04-12 17:16:16 -07:00
|
|
|
|
# Check accumulated size
|
2026-06-14 03:25:49 -07:00
|
|
|
|
if total_len >= MAX_NORMALIZED_TEXT_LENGTH:
|
2026-04-12 17:16:16 -07:00
|
|
|
|
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.)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = str(content)
|
|
|
|
|
|
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return ""
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
# 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.
|
|
|
|
|
|
_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_multimodal_content(content: Any) -> Any:
|
|
|
|
|
|
"""Validate and normalize multimodal content for the API server.
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 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
|
|
|
|
|
|
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:
|
|
|
|
|
|
if isinstance(part, str):
|
|
|
|
|
|
if part:
|
|
|
|
|
|
trimmed = part[:MAX_NORMALIZED_TEXT_LENGTH]
|
|
|
|
|
|
normalized_parts.append({"type": "text", "text": trimmed})
|
|
|
|
|
|
text_accum_len += len(trimmed)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
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:
|
|
|
|
|
|
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."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _content_has_visible_payload(content: Any) -> bool:
|
|
|
|
|
|
"""True when content has any text or image attachment. Used to reject empty turns."""
|
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
|
return bool(content.strip())
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
for part in content:
|
|
|
|
|
|
if isinstance(part, dict):
|
|
|
|
|
|
ptype = str(part.get("type") or "").strip().lower()
|
|
|
|
|
|
if ptype in _TEXT_PART_TYPES and str(part.get("text") or "").strip():
|
|
|
|
|
|
return True
|
|
|
|
|
|
if ptype in _IMAGE_PART_TYPES:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Response":
|
|
|
|
|
|
"""Translate a ``_normalize_multimodal_content`` ValueError into a 400 response."""
|
|
|
|
|
|
raw = str(exc)
|
|
|
|
|
|
code, _, message = raw.partition(":")
|
|
|
|
|
|
if not message:
|
|
|
|
|
|
code, message = "invalid_content_part", raw
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(message, code=code, param=param),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 16:00:59 -07:00
|
|
|
|
def _session_chat_user_message(body: Dict[str, Any], *, param: str = "message") -> tuple[Any, Optional["web.Response"]]:
|
|
|
|
|
|
"""Parse and normalize session chat ``message`` / ``input`` like chat completions."""
|
|
|
|
|
|
user_message = body.get("message") or body.get("input")
|
|
|
|
|
|
if not _content_has_visible_payload(user_message):
|
|
|
|
|
|
return None, web.json_response(
|
|
|
|
|
|
_openai_error("Missing 'message' field", code="missing_message"),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return _normalize_multimodal_content(user_message), None
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
return None, _multimodal_validation_error(exc, param=param)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
def check_api_server_requirements() -> bool:
|
|
|
|
|
|
"""Check if API server dependencies are available."""
|
|
|
|
|
|
return AIOHTTP_AVAILABLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResponseStore:
|
|
|
|
|
|
"""
|
2026-03-22 04:56:06 -07:00
|
|
|
|
SQLite-backed LRU store for Responses API state.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-03-22 04:56:06 -07:00
|
|
|
|
|
|
|
|
|
|
Persists across gateway restarts. Falls back to in-memory SQLite
|
|
|
|
|
|
if the on-disk path is unavailable.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-22 04:56:06 -07:00
|
|
|
|
def __init__(self, max_size: int = MAX_STORED_RESPONSES, db_path: str = None):
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._max_size = max_size
|
2026-03-22 04:56:06 -07:00
|
|
|
|
if db_path is None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
|
|
db_path = str(get_hermes_home() / "response_store.db")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db_path = ":memory:"
|
2026-05-24 04:54:49 -07:00
|
|
|
|
self._db_path: Optional[str] = db_path if db_path != ":memory:" else None
|
2026-03-22 04:56:06 -07:00
|
|
|
|
try:
|
|
|
|
|
|
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
2026-05-24 04:54:49 -07:00
|
|
|
|
self._db_path = None
|
fix(sqlite): fall back to journal_mode=DELETE on NFS/SMB/FUSE (#22043)
SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
byte-range locks that don't reliably work on network filesystems. Upstream
documents this explicitly:
https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
On NFS / SMB / some FUSE mounts / WSL1, 'PRAGMA journal_mode=WAL' raises
'sqlite3.OperationalError: locking protocol' (SQLITE_PROTOCOL). Before
this change, every feature backed by state.db or kanban.db broke silently:
- /resume, /title, /history, /branch returned 'Session database not
available.' with no cause
- gateway logged the init failure at DEBUG (invisible in errors.log)
- kanban dispatcher crashed every 60s, driving the known migration race
(duplicate column name: consecutive_failures, #21708 / #21374)
Changes:
- hermes_state.apply_wal_with_fallback(): shared helper that tries WAL
and falls back to DELETE on SQLITE_PROTOCOL-style errors with one
WARNING explaining why
- hermes_state.get_last_init_error() + format_session_db_unavailable():
capture the init failure cause and surface it in user-facing strings
(with an NFS/SMB pointer for 'locking protocol')
- hermes_cli/kanban_db.connect(): use the shared helper
- gateway/run.py: bump SessionDB init failure log DEBUG -> WARNING
(matches cli.py's existing correct behavior)
- cli.py (4 sites) + gateway/run.py (5 sites): replace bare
'Session database not available.' with format_session_db_unavailable()
Tests: 12 new tests in tests/test_hermes_state_wal_fallback.py + 1 new
test in tests/hermes_cli/test_kanban_db.py. Existing suites (state,
kanban, gateway, cli) remain green for all tests unrelated to pre-existing
failures on main.
Evidence: real-world user on NFSv3 mount (172.26.224.200:d2dfac12/home,
local_lock=none) reporting 'Session database not available.' on /resume;
'locking protocol' appears in 4 distinct log entries across backup,
kanban, TUI, and CLI paths in the same session.
closes #22032
2026-05-09 02:09:35 -07:00
|
|
|
|
# Use shared WAL-fallback helper so response_store.db degrades
|
|
|
|
|
|
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same filesystem
|
|
|
|
|
|
# issue addressed for state.db/kanban.db — see
|
|
|
|
|
|
# hermes_state._WAL_INCOMPAT_MARKERS).
|
|
|
|
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
|
|
apply_wal_with_fallback(self._conn, db_label="response_store.db")
|
2026-03-22 04:56:06 -07:00
|
|
|
|
self._conn.execute(
|
|
|
|
|
|
"""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
|
|
|
|
|
|
)"""
|
|
|
|
|
|
)
|
|
|
|
|
|
self._conn.commit()
|
2026-05-24 04:54:49 -07:00
|
|
|
|
# response_store.db contains conversation history (tool payloads,
|
|
|
|
|
|
# prompts, results). Tighten to owner-only after creation so other
|
|
|
|
|
|
# local users on a shared box can't read it. Run once at __init__
|
|
|
|
|
|
# rather than after every commit — chmod-on-every-write is wasted
|
|
|
|
|
|
# syscalls on a hot path.
|
|
|
|
|
|
self._tighten_file_permissions()
|
|
|
|
|
|
|
|
|
|
|
|
def _tighten_file_permissions(self) -> None:
|
|
|
|
|
|
"""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"),
|
|
|
|
|
|
):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if candidate.exists():
|
|
|
|
|
|
candidate.chmod(0o600)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"Failed to restrict response store permissions for %s",
|
|
|
|
|
|
candidate,
|
|
|
|
|
|
exc_info=True,
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
def get(self, response_id: str) -> Optional[Dict[str, Any]]:
|
2026-03-22 04:56:06 -07:00
|
|
|
|
"""Retrieve a stored response by ID (updates access time for LRU)."""
|
|
|
|
|
|
row = self._conn.execute(
|
|
|
|
|
|
"SELECT data FROM responses WHERE response_id = ?", (response_id,)
|
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
self._conn.execute(
|
|
|
|
|
|
"UPDATE responses SET accessed_at = ? WHERE response_id = ?",
|
|
|
|
|
|
(time.time(), response_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
self._conn.commit()
|
2026-06-03 17:43:39 +07:00
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(row[0])
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
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
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
def put(self, response_id: str, data: Dict[str, Any]) -> None:
|
|
|
|
|
|
"""Store a response, evicting the oldest if at capacity."""
|
2026-03-22 04:56:06 -07:00
|
|
|
|
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:
|
2026-03-23 14:23:32 +03:00
|
|
|
|
# Collect IDs that will be evicted
|
|
|
|
|
|
evict_ids = [
|
|
|
|
|
|
row[0]
|
|
|
|
|
|
for row in self._conn.execute(
|
|
|
|
|
|
"SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?",
|
|
|
|
|
|
(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,
|
|
|
|
|
|
)
|
2026-03-22 04:56:06 -07:00
|
|
|
|
self._conn.commit()
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
def delete(self, response_id: str) -> bool:
|
|
|
|
|
|
"""Remove a response from the store. Returns True if found and deleted."""
|
2026-03-23 14:23:32 +03:00
|
|
|
|
# Clear conversation mappings pointing to this response
|
|
|
|
|
|
self._conn.execute(
|
|
|
|
|
|
"DELETE FROM conversations WHERE response_id = ?", (response_id,)
|
|
|
|
|
|
)
|
2026-03-22 04:56:06 -07:00
|
|
|
|
cursor = self._conn.execute(
|
|
|
|
|
|
"DELETE FROM responses WHERE response_id = ?", (response_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
self._conn.commit()
|
|
|
|
|
|
return cursor.rowcount > 0
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
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.commit()
|
|
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
|
"""Close the database connection."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._conn.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
def __len__(self) -> int:
|
2026-03-22 04:56:06 -07:00
|
|
|
|
row = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()
|
|
|
|
|
|
return row[0] if row else 0
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# CORS middleware
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_CORS_HEADERS = {
|
|
|
|
|
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
2026-03-28 08:16:41 -07:00
|
|
|
|
"Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key",
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if AIOHTTP_AVAILABLE:
|
|
|
|
|
|
@web.middleware
|
|
|
|
|
|
async def cors_middleware(request, handler):
|
2026-03-22 04:08:48 -07:00
|
|
|
|
"""Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
|
|
|
|
|
|
adapter = request.app.get("api_server_adapter")
|
|
|
|
|
|
origin = request.headers.get("Origin", "")
|
|
|
|
|
|
cors_headers = None
|
|
|
|
|
|
if adapter is not None:
|
|
|
|
|
|
if not adapter._origin_allowed(origin):
|
|
|
|
|
|
return web.Response(status=403)
|
|
|
|
|
|
cors_headers = adapter._cors_headers_for_origin(origin)
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if request.method == "OPTIONS":
|
2026-03-22 04:08:48 -07:00
|
|
|
|
if cors_headers is None:
|
|
|
|
|
|
return web.Response(status=403)
|
|
|
|
|
|
return web.Response(status=200, headers=cors_headers)
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
response = await handler(request)
|
2026-03-22 04:08:48 -07:00
|
|
|
|
if cors_headers is not None:
|
|
|
|
|
|
response.headers.update(cors_headers)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
return response
|
|
|
|
|
|
else:
|
|
|
|
|
|
cors_middleware = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 19:31:08 -07:00
|
|
|
|
def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
|
|
|
|
|
|
"""OpenAI-style error envelope."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"error": {
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
"type": err_type,
|
|
|
|
|
|
"param": param,
|
|
|
|
|
|
"code": code,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if AIOHTTP_AVAILABLE:
|
|
|
|
|
|
@web.middleware
|
|
|
|
|
|
async def body_limit_middleware(request, handler):
|
|
|
|
|
|
"""Reject overly large request bodies early based on Content-Length."""
|
2026-05-11 11:13:25 -07:00
|
|
|
|
if request.method in {"POST", "PUT", "PATCH"}:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
cl = request.headers.get("Content-Length")
|
|
|
|
|
|
if cl is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if int(cl) > MAX_REQUEST_BYTES:
|
|
|
|
|
|
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
|
|
|
|
|
|
return await handler(request)
|
|
|
|
|
|
else:
|
|
|
|
|
|
body_limit_middleware = None # type: ignore[assignment]
|
|
|
|
|
|
|
2026-03-28 14:00:52 -07:00
|
|
|
|
_SECURITY_HEADERS = {
|
2026-05-16 16:41:03 +09:00
|
|
|
|
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
|
|
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
|
|
|
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
2026-03-28 14:00:52 -07:00
|
|
|
|
"X-Content-Type-Options": "nosniff",
|
2026-05-16 16:41:03 +09:00
|
|
|
|
"X-Frame-Options": "DENY",
|
|
|
|
|
|
"X-XSS-Protection": "0",
|
2026-03-28 14:00:52 -07:00
|
|
|
|
"Referrer-Policy": "no-referrer",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if AIOHTTP_AVAILABLE:
|
|
|
|
|
|
@web.middleware
|
|
|
|
|
|
async def security_headers_middleware(request, handler):
|
|
|
|
|
|
"""Add security headers to all responses (including errors)."""
|
|
|
|
|
|
response = await handler(request)
|
|
|
|
|
|
for k, v in _SECURITY_HEADERS.items():
|
|
|
|
|
|
response.headers.setdefault(k, v)
|
|
|
|
|
|
return response
|
|
|
|
|
|
else:
|
|
|
|
|
|
security_headers_middleware = None # type: ignore[assignment]
|
|
|
|
|
|
|
2026-03-24 19:31:08 -07:00
|
|
|
|
|
|
|
|
|
|
class _IdempotencyCache:
|
|
|
|
|
|
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
|
|
|
|
|
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
|
|
|
|
|
|
from collections import OrderedDict
|
|
|
|
|
|
self._store = OrderedDict()
|
2026-04-21 03:12:57 +03:00
|
|
|
|
self._inflight: Dict[tuple[str, str], "asyncio.Task[Any]"] = {}
|
2026-03-24 19:31:08 -07:00
|
|
|
|
self._ttl = ttl_seconds
|
|
|
|
|
|
self._max = max_items
|
|
|
|
|
|
|
|
|
|
|
|
def _purge(self):
|
2026-04-21 12:35:10 +05:30
|
|
|
|
now = time.time()
|
2026-03-24 19:31:08 -07:00
|
|
|
|
expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl]
|
|
|
|
|
|
for k in expired:
|
|
|
|
|
|
self._store.pop(k, None)
|
|
|
|
|
|
while len(self._store) > self._max:
|
|
|
|
|
|
self._store.popitem(last=False)
|
|
|
|
|
|
|
|
|
|
|
|
async def get_or_set(self, key: str, fingerprint: str, compute_coro):
|
|
|
|
|
|
self._purge()
|
|
|
|
|
|
item = self._store.get(key)
|
|
|
|
|
|
if item and item["fp"] == fingerprint:
|
|
|
|
|
|
return item["resp"]
|
2026-04-21 03:12:57 +03:00
|
|
|
|
|
|
|
|
|
|
inflight_key = (key, fingerprint)
|
|
|
|
|
|
task = self._inflight.get(inflight_key)
|
|
|
|
|
|
if task is None:
|
|
|
|
|
|
async def _compute_and_store():
|
|
|
|
|
|
resp = await compute_coro()
|
|
|
|
|
|
import time as _t
|
|
|
|
|
|
self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()}
|
|
|
|
|
|
self._purge()
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(_compute_and_store())
|
|
|
|
|
|
self._inflight[inflight_key] = task
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_inflight(done_task: "asyncio.Task[Any]") -> None:
|
|
|
|
|
|
if self._inflight.get(inflight_key) is done_task:
|
|
|
|
|
|
self._inflight.pop(inflight_key, None)
|
|
|
|
|
|
|
|
|
|
|
|
task.add_done_callback(_clear_inflight)
|
|
|
|
|
|
|
|
|
|
|
|
return await asyncio.shield(task)
|
2026-03-24 19:31:08 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_idem_cache = _IdempotencyCache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
|
|
|
|
|
|
from hashlib import sha256
|
|
|
|
|
|
subset = {k: body.get(k) for k in keys}
|
|
|
|
|
|
return sha256(repr(subset).encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 04:56:35 -07:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
|
|
|
|
|
seed = f"{system_prompt or ''}\n{first_user_message}"
|
|
|
|
|
|
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
return f"api-{digest}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 12:35:10 +05:30
|
|
|
|
_CRON_AVAILABLE = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
from cron.jobs import (
|
|
|
|
|
|
list_jobs as _cron_list,
|
|
|
|
|
|
get_job as _cron_get,
|
|
|
|
|
|
create_job as _cron_create,
|
|
|
|
|
|
update_job as _cron_update,
|
|
|
|
|
|
remove_job as _cron_remove,
|
|
|
|
|
|
pause_job as _cron_pause,
|
|
|
|
|
|
resume_job as _cron_resume,
|
|
|
|
|
|
trigger_job as _cron_trigger,
|
|
|
|
|
|
)
|
|
|
|
|
|
_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
|
|
|
|
|
|
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
|
|
|
|
|
|
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."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from cron.scheduler import _notify_provider_jobs_changed
|
|
|
|
|
|
_notify_provider_jobs_changed()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-07 06:37:49 -07:00
|
|
|
|
# Defense-in-depth: mirror the agent-facing cronjob tool, which scans the
|
|
|
|
|
|
# user-supplied prompt for exfiltration/injection payloads at create/update
|
|
|
|
|
|
# time (tools/cronjob_tools.py). The REST cron endpoints are authenticated
|
|
|
|
|
|
# (every handler runs _check_auth, and connect() refuses to start without
|
|
|
|
|
|
# API_SERVER_KEY), so this is not the trust boundary — it's parity with the
|
|
|
|
|
|
# tool path so a malicious prompt is rejected the same way regardless of
|
|
|
|
|
|
# which surface created the job. Imported defensively: a missing scanner
|
|
|
|
|
|
# must not disable the cron REST API.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.cronjob_tools import _scan_cron_prompt as _scan_cron_prompt
|
|
|
|
|
|
except Exception: # pragma: no cover - scanner is optional hardening
|
|
|
|
|
|
_scan_cron_prompt = None
|
|
|
|
|
|
|
2026-04-21 12:35:10 +05:30
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
class APIServerAdapter(BasePlatformAdapter):
|
|
|
|
|
|
"""
|
|
|
|
|
|
OpenAI-compatible HTTP API server adapter.
|
|
|
|
|
|
|
|
|
|
|
|
Runs an aiohttp web server that accepts OpenAI-format requests
|
|
|
|
|
|
and routes them through hermes-agent's AIAgent.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
|
|
|
|
super().__init__(config, Platform.API_SERVER)
|
|
|
|
|
|
extra = config.extra or {}
|
|
|
|
|
|
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
|
2026-05-04 00:03:36 +03:00
|
|
|
|
raw_port = extra.get("port")
|
|
|
|
|
|
if raw_port is None:
|
|
|
|
|
|
raw_port = os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))
|
|
|
|
|
|
self._port: int = _coerce_port(raw_port, DEFAULT_PORT)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
|
2026-03-22 04:08:48 -07:00
|
|
|
|
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
|
|
|
|
|
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
|
|
|
|
|
|
)
|
2026-04-09 17:07:29 -07:00
|
|
|
|
self._model_name: str = self._resolve_model_name(
|
|
|
|
|
|
extra.get("model_name", os.getenv("API_SERVER_MODEL_NAME", "")),
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._app: Optional["web.Application"] = None
|
|
|
|
|
|
self._runner: Optional["web.AppRunner"] = None
|
|
|
|
|
|
self._site: Optional["web.TCPSite"] = None
|
|
|
|
|
|
self._response_store = ResponseStore()
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# Active run streams: run_id -> asyncio.Queue of SSE event dicts
|
|
|
|
|
|
self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {}
|
|
|
|
|
|
# Creation timestamps for orphaned-run TTL sweep
|
|
|
|
|
|
self._run_streams_created: Dict[str, float] = {}
|
2026-04-25 21:45:40 +08:00
|
|
|
|
# Active run agent/task references for stop support
|
|
|
|
|
|
self._active_run_agents: Dict[str, Any] = {}
|
|
|
|
|
|
self._active_run_tasks: Dict[str, "asyncio.Task"] = {}
|
2026-04-29 06:36:56 -07:00
|
|
|
|
# Pollable run status for dashboards and external control-plane UIs.
|
|
|
|
|
|
self._run_statuses: Dict[str, Dict[str, Any]] = {}
|
2026-05-05 18:34:58 +02:00
|
|
|
|
# Active approval session key for each run_id. The approval core
|
|
|
|
|
|
# resolves requests by session key, while API clients address the
|
|
|
|
|
|
# in-flight run by run_id.
|
|
|
|
|
|
self._run_approval_sessions: Dict[str, str] = {}
|
2026-04-01 11:29:20 -07:00
|
|
|
|
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-03-22 04:08:48 -07:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
|
|
|
|
|
|
"""Normalize configured CORS origins into a stable tuple."""
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return ()
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
items = value.split(",")
|
|
|
|
|
|
elif isinstance(value, (list, tuple, set)):
|
|
|
|
|
|
items = value
|
|
|
|
|
|
else:
|
|
|
|
|
|
items = [str(value)]
|
|
|
|
|
|
|
|
|
|
|
|
return tuple(str(item).strip() for item in items if str(item).strip())
|
|
|
|
|
|
|
2026-04-09 17:07:29 -07:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _resolve_model_name(explicit: str) -> str:
|
|
|
|
|
|
"""Derive the advertised model name for /v1/models.
|
|
|
|
|
|
|
|
|
|
|
|
Priority:
|
|
|
|
|
|
1. Explicit override (config extra or API_SERVER_MODEL_NAME env var)
|
|
|
|
|
|
2. Active profile name (so each profile advertises a distinct model)
|
|
|
|
|
|
3. Fallback: "hermes-agent"
|
|
|
|
|
|
"""
|
|
|
|
|
|
if explicit and explicit.strip():
|
|
|
|
|
|
return explicit.strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
|
|
|
|
profile = get_active_profile_name()
|
2026-05-11 11:13:25 -07:00
|
|
|
|
if profile and profile not in {"default", "custom"}:
|
2026-04-09 17:07:29 -07:00
|
|
|
|
return profile
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return "hermes-agent"
|
|
|
|
|
|
|
2026-03-22 04:08:48 -07:00
|
|
|
|
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
|
|
|
|
|
|
"""Return CORS headers for an allowed browser origin."""
|
|
|
|
|
|
if not origin or not self._cors_origins:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if "*" in self._cors_origins:
|
|
|
|
|
|
headers = dict(_CORS_HEADERS)
|
|
|
|
|
|
headers["Access-Control-Allow-Origin"] = "*"
|
2026-03-28 14:00:03 -07:00
|
|
|
|
headers["Access-Control-Max-Age"] = "600"
|
2026-03-22 04:08:48 -07:00
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
if origin not in self._cors_origins:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
headers = dict(_CORS_HEADERS)
|
|
|
|
|
|
headers["Access-Control-Allow-Origin"] = origin
|
|
|
|
|
|
headers["Vary"] = "Origin"
|
2026-03-28 14:00:03 -07:00
|
|
|
|
headers["Access-Control-Max-Age"] = "600"
|
2026-03-22 04:08:48 -07:00
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
def _origin_allowed(self, origin: str) -> bool:
|
|
|
|
|
|
"""Allow non-browser clients and explicitly configured browser origins."""
|
|
|
|
|
|
if not origin:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
if not self._cors_origins:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return "*" in self._cors_origins or origin in self._cors_origins
|
|
|
|
|
|
|
2026-05-25 04:15:56 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _clean_log_value(value: Any, *, max_len: int = 200) -> str:
|
|
|
|
|
|
"""Sanitize request metadata before it reaches security logs."""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
|
|
|
|
|
return text[:max_len]
|
|
|
|
|
|
|
|
|
|
|
|
def _request_audit_context(self, request: "web.Request") -> Dict[str, str]:
|
|
|
|
|
|
"""Return non-secret source metadata for security/audit warnings."""
|
|
|
|
|
|
peer_ip = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
peer = request.transport.get_extra_info("peername") if request.transport else None
|
|
|
|
|
|
if isinstance(peer, (tuple, list)) and peer:
|
|
|
|
|
|
peer_ip = str(peer[0])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
peer_ip = ""
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"remote": self._clean_log_value(getattr(request, "remote", "") or peer_ip),
|
|
|
|
|
|
"peer_ip": self._clean_log_value(peer_ip),
|
|
|
|
|
|
"forwarded_for": self._clean_log_value(request.headers.get("X-Forwarded-For", "")),
|
|
|
|
|
|
"real_ip": self._clean_log_value(request.headers.get("X-Real-IP", "")),
|
|
|
|
|
|
"method": self._clean_log_value(request.method, max_len=16),
|
|
|
|
|
|
"path": self._clean_log_value(request.path_qs, max_len=500),
|
|
|
|
|
|
"user_agent": self._clean_log_value(request.headers.get("User-Agent", ""), max_len=300),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _request_audit_log_suffix(self, request: "web.Request") -> str:
|
|
|
|
|
|
ctx = self._request_audit_context(request)
|
|
|
|
|
|
fields = [f"{key}={value!r}" for key, value in ctx.items() if value]
|
|
|
|
|
|
return " ".join(fields) if fields else "source='unknown'"
|
|
|
|
|
|
|
|
|
|
|
|
def _cron_origin_from_request(self, request: "web.Request") -> Dict[str, str]:
|
|
|
|
|
|
"""Persist safe API source metadata on cron jobs created over HTTP."""
|
|
|
|
|
|
ctx = self._request_audit_context(request)
|
|
|
|
|
|
origin = {
|
|
|
|
|
|
"platform": "api_server",
|
|
|
|
|
|
"chat_id": "api",
|
|
|
|
|
|
}
|
|
|
|
|
|
if ctx.get("remote"):
|
|
|
|
|
|
origin["source_ip"] = ctx["remote"]
|
|
|
|
|
|
if ctx.get("peer_ip"):
|
|
|
|
|
|
origin["peer_ip"] = ctx["peer_ip"]
|
|
|
|
|
|
if ctx.get("forwarded_for"):
|
|
|
|
|
|
origin["forwarded_for"] = ctx["forwarded_for"]
|
|
|
|
|
|
if ctx.get("real_ip"):
|
|
|
|
|
|
origin["real_ip"] = ctx["real_ip"]
|
|
|
|
|
|
if ctx.get("user_agent"):
|
|
|
|
|
|
origin["user_agent"] = ctx["user_agent"]
|
|
|
|
|
|
return origin
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Auth helper
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Validate Bearer token from Authorization header.
|
|
|
|
|
|
|
|
|
|
|
|
Returns None if auth is OK, or a 401 web.Response on failure.
|
2026-05-25 18:19:00 +03:00
|
|
|
|
connect() refuses to start the API server without API_SERVER_KEY, so
|
|
|
|
|
|
the no-key branch only exists for tests or unsupported manual wiring.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
|
|
|
|
|
if not self._api_key:
|
2026-05-25 18:19:00 +03:00
|
|
|
|
return None
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
auth_header = request.headers.get("Authorization", "")
|
|
|
|
|
|
if auth_header.startswith("Bearer "):
|
|
|
|
|
|
token = auth_header[7:].strip()
|
fix(security): consolidated security hardening — SSRF, timing attack, tar traversal, credential leakage (#5944)
Salvaged from PRs #5800 (memosr), #5806 (memosr), #5915 (Ruzzgar), #5928 (Awsh1).
Changes:
- Use hmac.compare_digest for API key comparison (timing attack prevention)
- Apply provider env var blocklist to Docker containers (credential leakage)
- Replace tar.extractall() with safe extraction in TerminalBench2 (CVE-2007-4559)
- Add SSRF protection via is_safe_url to ALL platform adapters:
base.py (cache_image_from_url, cache_audio_from_url),
discord, slack, telegram, matrix, mattermost, feishu, wecom
(Signal and WhatsApp protected via base.py helpers)
- Update tests: mock is_safe_url in Mattermost download tests
- Add security tests for tar extraction (traversal, symlinks, safe files)
2026-04-07 17:28:37 -07:00
|
|
|
|
if hmac.compare_digest(token, self._api_key):
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
return None # Auth OK
|
|
|
|
|
|
|
2026-05-25 04:15:56 -04:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"API server rejected invalid API key: %s",
|
|
|
|
|
|
self._request_audit_log_suffix(request),
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}},
|
|
|
|
|
|
status=401,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Session header helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Soft length cap for session identifiers. Headers are bounded in
|
|
|
|
|
|
# aggregate by aiohttp (``client_max_size`` / default 8 KiB per
|
|
|
|
|
|
# header), but we impose a tighter limit on the session headers so a
|
|
|
|
|
|
# caller can't burn memory by passing a multi-kilobyte "session key".
|
|
|
|
|
|
# 256 chars is well above any realistic stable channel identifier
|
|
|
|
|
|
# (e.g. ``agent:main:webui:dm:user-42``) while staying small enough
|
|
|
|
|
|
# that the sanitized form is safe to pass into Honcho / state.db.
|
|
|
|
|
|
_MAX_SESSION_HEADER_LEN = 256
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_session_key_header(
|
|
|
|
|
|
self, request: "web.Request"
|
|
|
|
|
|
) -> tuple[Optional[str], Optional["web.Response"]]:
|
|
|
|
|
|
"""Extract and validate the ``X-Hermes-Session-Key`` header.
|
|
|
|
|
|
|
|
|
|
|
|
The session key is a stable per-channel identifier that scopes
|
|
|
|
|
|
long-term memory (e.g. Honcho sessions) across transcripts. It
|
|
|
|
|
|
is independent of ``X-Hermes-Session-Id``: callers may send
|
|
|
|
|
|
either, both, or neither.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``(session_key, None)`` on success (with an empty/absent
|
|
|
|
|
|
header yielding ``None`` for the key), or ``(None, error_response)``
|
|
|
|
|
|
on validation failure.
|
|
|
|
|
|
|
|
|
|
|
|
Security: like session continuation, accepting a caller-supplied
|
|
|
|
|
|
memory scope requires API-key authentication so that an
|
|
|
|
|
|
unauthenticated client on a local-only server can't inject itself
|
|
|
|
|
|
into another user's long-term memory scope by guessing a key.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = request.headers.get("X-Hermes-Session-Key", "").strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
if not self._api_key:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"X-Hermes-Session-Key rejected: no API key configured. "
|
|
|
|
|
|
"Set API_SERVER_KEY to enable long-term memory scoping."
|
|
|
|
|
|
)
|
|
|
|
|
|
return None, web.json_response(
|
|
|
|
|
|
_openai_error(
|
|
|
|
|
|
"X-Hermes-Session-Key requires API key authentication. "
|
|
|
|
|
|
"Configure API_SERVER_KEY to enable this feature."
|
|
|
|
|
|
),
|
|
|
|
|
|
status=403,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Reject control characters that could enable header injection on
|
|
|
|
|
|
# the echo path.
|
|
|
|
|
|
if re.search(r'[\r\n\x00]', raw):
|
|
|
|
|
|
return None, web.json_response(
|
|
|
|
|
|
{"error": {"message": "Invalid session key", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if len(raw) > self._MAX_SESSION_HEADER_LEN:
|
|
|
|
|
|
return None, web.json_response(
|
|
|
|
|
|
{"error": {"message": "Session key too long", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return raw, None
|
|
|
|
|
|
|
2026-04-03 10:31:11 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Session DB helper
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_session_db(self):
|
|
|
|
|
|
"""Lazily initialise and return the shared SessionDB instance.
|
|
|
|
|
|
|
|
|
|
|
|
Sessions are persisted to ``state.db`` so that ``hermes sessions list``
|
|
|
|
|
|
shows API-server conversations alongside CLI and gateway ones.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self._session_db is None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
self._session_db = SessionDB()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug("SessionDB unavailable for API server: %s", e)
|
|
|
|
|
|
return self._session_db
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Agent creation helper
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _create_agent(
|
|
|
|
|
|
self,
|
|
|
|
|
|
ephemeral_system_prompt: Optional[str] = None,
|
|
|
|
|
|
session_id: Optional[str] = None,
|
|
|
|
|
|
stream_delta_callback=None,
|
2026-03-30 18:50:27 -07:00
|
|
|
|
tool_progress_callback=None,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
tool_start_callback=None,
|
|
|
|
|
|
tool_complete_callback=None,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key: Optional[str] = None,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create an AIAgent instance using the gateway's runtime config.
|
|
|
|
|
|
|
|
|
|
|
|
Uses _resolve_runtime_agent_kwargs() to pick up model, api_key,
|
2026-03-26 18:02:26 -07:00
|
|
|
|
base_url, etc. from config.yaml / env vars. Toolsets are resolved
|
|
|
|
|
|
from config.yaml platform_toolsets.api_server (same as all other
|
|
|
|
|
|
gateway platforms), falling back to the hermes-api-server default.
|
2026-05-05 05:34:47 -07:00
|
|
|
|
|
|
|
|
|
|
``gateway_session_key`` is a stable per-channel identifier supplied
|
|
|
|
|
|
by the client (via ``X-Hermes-Session-Key``). Unlike ``session_id``
|
|
|
|
|
|
which scopes the short-term transcript and rotates on /new, this
|
|
|
|
|
|
key is meant to persist across transcripts so long-term memory
|
|
|
|
|
|
providers (e.g. Honcho) can scope their per-chat state correctly
|
|
|
|
|
|
— matching the semantics of the native gateway's ``session_key``.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
|
|
|
|
|
from run_agent import AIAgent
|
2026-06-18 07:28:28 +07:00
|
|
|
|
from gateway.run import (
|
|
|
|
|
|
_current_max_iterations,
|
|
|
|
|
|
_resolve_runtime_agent_kwargs,
|
|
|
|
|
|
_resolve_gateway_model,
|
|
|
|
|
|
_load_gateway_config,
|
|
|
|
|
|
GatewayRunner,
|
|
|
|
|
|
)
|
2026-03-26 18:02:26 -07:00
|
|
|
|
from hermes_cli.tools_config import _get_platform_tools
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
2026-05-03 21:10:30 +08:00
|
|
|
|
reasoning_config = GatewayRunner._load_reasoning_config()
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
model = _resolve_gateway_model()
|
|
|
|
|
|
|
2026-03-26 18:02:26 -07:00
|
|
|
|
user_config = _load_gateway_config()
|
|
|
|
|
|
enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))
|
|
|
|
|
|
|
2026-06-18 07:28:28 +07:00
|
|
|
|
max_iterations = _current_max_iterations()
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-04-04 11:32:41 +05:30
|
|
|
|
# Load fallback provider chain so the API server platform has the
|
|
|
|
|
|
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
|
2026-04-05 11:46:06 -07:00
|
|
|
|
fallback_model = GatewayRunner._load_fallback_model()
|
2026-04-04 11:32:41 +05:30
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
agent = AIAgent(
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
**runtime_kwargs,
|
|
|
|
|
|
max_iterations=max_iterations,
|
|
|
|
|
|
quiet_mode=True,
|
|
|
|
|
|
verbose_logging=False,
|
|
|
|
|
|
ephemeral_system_prompt=ephemeral_system_prompt or None,
|
2026-03-26 18:02:26 -07:00
|
|
|
|
enabled_toolsets=enabled_toolsets,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
platform="api_server",
|
|
|
|
|
|
stream_delta_callback=stream_delta_callback,
|
2026-03-30 18:50:27 -07:00
|
|
|
|
tool_progress_callback=tool_progress_callback,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
tool_start_callback=tool_start_callback,
|
|
|
|
|
|
tool_complete_callback=tool_complete_callback,
|
2026-04-03 10:31:11 -07:00
|
|
|
|
session_db=self._ensure_session_db(),
|
2026-04-04 11:32:41 +05:30
|
|
|
|
fallback_model=fallback_model,
|
2026-05-03 21:10:30 +08:00
|
|
|
|
reasoning_config=reasoning_config,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
|
|
|
|
|
return agent
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# HTTP Handlers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /health — simple health check."""
|
2026-06-07 18:38:54 -07:00
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"status": "ok", "platform": "hermes-agent", "version": _hermes_version()}
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-04-14 05:17:17 +00:00
|
|
|
|
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /health/detailed — rich status for cross-container dashboard probing.
|
|
|
|
|
|
|
|
|
|
|
|
Returns gateway state, connected platforms, PID, and uptime so the
|
|
|
|
|
|
dashboard can display full status without needing a shared PID file or
|
|
|
|
|
|
/proc access. No authentication required.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from gateway.status import read_runtime_status
|
|
|
|
|
|
|
|
|
|
|
|
runtime = read_runtime_status() or {}
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"status": "ok",
|
|
|
|
|
|
"platform": "hermes-agent",
|
2026-06-07 18:38:54 -07:00
|
|
|
|
"version": _hermes_version(),
|
2026-04-14 05:17:17 +00:00
|
|
|
|
"gateway_state": runtime.get("gateway_state"),
|
|
|
|
|
|
"platforms": runtime.get("platforms", {}),
|
|
|
|
|
|
"active_agents": runtime.get("active_agents", 0),
|
|
|
|
|
|
"exit_reason": runtime.get("exit_reason"),
|
|
|
|
|
|
"updated_at": runtime.get("updated_at"),
|
|
|
|
|
|
"pid": os.getpid(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
async def _handle_models(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/models — return hermes-agent as an available model."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "list",
|
|
|
|
|
|
"data": [
|
|
|
|
|
|
{
|
2026-04-09 17:07:29 -07:00
|
|
|
|
"id": self._model_name,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"object": "model",
|
|
|
|
|
|
"created": int(time.time()),
|
|
|
|
|
|
"owned_by": "hermes",
|
|
|
|
|
|
"permission": [],
|
2026-04-09 17:07:29 -07:00
|
|
|
|
"root": self._model_name,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"parent": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 06:36:56 -07:00
|
|
|
|
async def _handle_capabilities(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/capabilities — advertise the stable API surface.
|
|
|
|
|
|
|
|
|
|
|
|
External UIs and orchestrators use this endpoint to discover the API
|
|
|
|
|
|
server's plugin-safe contract without scraping docs or assuming that
|
|
|
|
|
|
every Hermes version exposes the same endpoints.
|
|
|
|
|
|
"""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "hermes.api_server.capabilities",
|
|
|
|
|
|
"platform": "hermes-agent",
|
|
|
|
|
|
"model": self._model_name,
|
|
|
|
|
|
"auth": {
|
|
|
|
|
|
"type": "bearer",
|
|
|
|
|
|
"required": bool(self._api_key),
|
|
|
|
|
|
},
|
2026-05-03 15:20:25 +08:00
|
|
|
|
"runtime": {
|
|
|
|
|
|
"mode": "server_agent",
|
|
|
|
|
|
"tool_execution": "server",
|
|
|
|
|
|
"split_runtime": False,
|
|
|
|
|
|
"description": (
|
|
|
|
|
|
"The API server creates a server-side Hermes AIAgent; "
|
|
|
|
|
|
"tools execute on the API-server host unless a future "
|
|
|
|
|
|
"explicit split-runtime mode is enabled."
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-04-29 06:36:56 -07:00
|
|
|
|
"features": {
|
|
|
|
|
|
"chat_completions": True,
|
|
|
|
|
|
"chat_completions_streaming": True,
|
|
|
|
|
|
"responses_api": True,
|
|
|
|
|
|
"responses_streaming": True,
|
|
|
|
|
|
"run_submission": True,
|
|
|
|
|
|
"run_status": True,
|
|
|
|
|
|
"run_events_sse": True,
|
|
|
|
|
|
"run_stop": True,
|
2026-05-05 18:34:58 +02:00
|
|
|
|
"run_approval_response": True,
|
2026-04-29 06:36:56 -07:00
|
|
|
|
"tool_progress_events": True,
|
2026-05-05 18:34:58 +02:00
|
|
|
|
"approval_events": True,
|
2026-05-20 08:45:55 -04:00
|
|
|
|
"session_resources": True,
|
|
|
|
|
|
"session_chat": True,
|
|
|
|
|
|
"session_chat_streaming": True,
|
|
|
|
|
|
"session_fork": True,
|
|
|
|
|
|
"admin_config_rw": False,
|
|
|
|
|
|
"jobs_admin": False,
|
|
|
|
|
|
"memory_write_api": False,
|
2026-05-27 01:41:20 -07:00
|
|
|
|
"skills_api": True,
|
2026-05-20 08:45:55 -04:00
|
|
|
|
"audio_api": False,
|
|
|
|
|
|
"realtime_voice": False,
|
2026-04-29 06:36:56 -07:00
|
|
|
|
"session_continuity_header": "X-Hermes-Session-Id",
|
2026-05-05 05:34:47 -07:00
|
|
|
|
"session_key_header": "X-Hermes-Session-Key",
|
2026-04-29 06:36:56 -07:00
|
|
|
|
"cors": bool(self._cors_origins),
|
|
|
|
|
|
},
|
|
|
|
|
|
"endpoints": {
|
|
|
|
|
|
"health": {"method": "GET", "path": "/health"},
|
|
|
|
|
|
"health_detailed": {"method": "GET", "path": "/health/detailed"},
|
|
|
|
|
|
"models": {"method": "GET", "path": "/v1/models"},
|
|
|
|
|
|
"chat_completions": {"method": "POST", "path": "/v1/chat/completions"},
|
|
|
|
|
|
"responses": {"method": "POST", "path": "/v1/responses"},
|
|
|
|
|
|
"runs": {"method": "POST", "path": "/v1/runs"},
|
|
|
|
|
|
"run_status": {"method": "GET", "path": "/v1/runs/{run_id}"},
|
|
|
|
|
|
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
2026-05-05 18:34:58 +02:00
|
|
|
|
"run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"},
|
2026-04-29 06:36:56 -07:00
|
|
|
|
"run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"},
|
2026-05-27 01:27:26 -07:00
|
|
|
|
"skills": {"method": "GET", "path": "/v1/skills"},
|
|
|
|
|
|
"toolsets": {"method": "GET", "path": "/v1/toolsets"},
|
2026-05-20 08:45:55 -04:00
|
|
|
|
"sessions": {"method": "GET", "path": "/api/sessions"},
|
|
|
|
|
|
"session_create": {"method": "POST", "path": "/api/sessions"},
|
|
|
|
|
|
"session": {"method": "GET", "path": "/api/sessions/{session_id}"},
|
|
|
|
|
|
"session_update": {"method": "PATCH", "path": "/api/sessions/{session_id}"},
|
|
|
|
|
|
"session_delete": {"method": "DELETE", "path": "/api/sessions/{session_id}"},
|
|
|
|
|
|
"session_messages": {"method": "GET", "path": "/api/sessions/{session_id}/messages"},
|
|
|
|
|
|
"session_fork": {"method": "POST", "path": "/api/sessions/{session_id}/fork"},
|
|
|
|
|
|
"session_chat": {"method": "POST", "path": "/api/sessions/{session_id}/chat"},
|
|
|
|
|
|
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
2026-04-29 06:36:56 -07:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-27 01:27:26 -07:00
|
|
|
|
async def _handle_skills(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/skills — list installed skills visible to the API-server agent.
|
|
|
|
|
|
|
|
|
|
|
|
Read-only listing intended for external clients that need to know
|
|
|
|
|
|
which skills are available without sending a chat message and asking
|
|
|
|
|
|
the model. Mirrors what the gateway/CLI surfaces through
|
|
|
|
|
|
``/skills list``, but as a deterministic JSON payload.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the same skill metadata (name, description, category) the
|
|
|
|
|
|
skills hub uses internally. Disabled skills are excluded so the
|
|
|
|
|
|
listing matches what the agent actually loads.
|
|
|
|
|
|
"""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.skills_tool import _find_all_skills, _sort_skills
|
|
|
|
|
|
skills = _sort_skills(_find_all_skills(skip_disabled=False))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception("GET /v1/skills failed")
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error("Failed to enumerate skills", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "list",
|
|
|
|
|
|
"data": skills,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_toolsets(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/toolsets — list toolsets and their resolved tools.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the toolset surface the api_server platform actually exposes
|
|
|
|
|
|
to its agent: each toolset's enabled/configured state plus the
|
|
|
|
|
|
concrete tool names it expands to. This is the deterministic
|
|
|
|
|
|
equivalent of what a client would otherwise have to recover by
|
|
|
|
|
|
asking the model what tools it can call.
|
|
|
|
|
|
"""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import load_config
|
|
|
|
|
|
from hermes_cli.tools_config import (
|
|
|
|
|
|
_get_effective_configurable_toolsets,
|
|
|
|
|
|
_get_platform_tools,
|
|
|
|
|
|
_toolset_has_keys,
|
|
|
|
|
|
)
|
|
|
|
|
|
from toolsets import resolve_toolset
|
|
|
|
|
|
|
|
|
|
|
|
config = load_config()
|
|
|
|
|
|
enabled_toolsets = _get_platform_tools(
|
|
|
|
|
|
config,
|
|
|
|
|
|
"api_server",
|
|
|
|
|
|
include_default_mcp_servers=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
data: List[Dict[str, Any]] = []
|
|
|
|
|
|
for name, label, desc in _get_effective_configurable_toolsets():
|
|
|
|
|
|
try:
|
|
|
|
|
|
tools = sorted(set(resolve_toolset(name)))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
tools = []
|
|
|
|
|
|
is_enabled = name in enabled_toolsets
|
|
|
|
|
|
data.append({
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"label": label,
|
|
|
|
|
|
"description": desc,
|
|
|
|
|
|
"enabled": is_enabled,
|
|
|
|
|
|
"configured": _toolset_has_keys(name, config),
|
|
|
|
|
|
"tools": tools,
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception("GET /v1/toolsets failed")
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error("Failed to enumerate toolsets", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "list",
|
|
|
|
|
|
"platform": "api_server",
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-20 08:45:55 -04:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# /api/sessions — thin client/session resource API
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _parse_nonnegative_int(value: Any, default: int, maximum: int) -> int:
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return default
|
|
|
|
|
|
if parsed < 0:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return min(parsed, maximum)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _session_response(session: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
"""Return a stable, client-safe session representation."""
|
|
|
|
|
|
safe_keys = (
|
|
|
|
|
|
"id", "source", "user_id", "model", "title", "started_at", "ended_at",
|
|
|
|
|
|
"end_reason", "message_count", "tool_call_count", "input_tokens",
|
|
|
|
|
|
"output_tokens", "cache_read_tokens", "cache_write_tokens",
|
|
|
|
|
|
"reasoning_tokens", "estimated_cost_usd", "actual_cost_usd",
|
|
|
|
|
|
"api_call_count", "parent_session_id", "last_active", "preview",
|
|
|
|
|
|
"_lineage_root_id",
|
|
|
|
|
|
)
|
|
|
|
|
|
payload = {key: session.get(key) for key in safe_keys if key in session}
|
|
|
|
|
|
# Avoid exposing full system prompts/model_config through the client API;
|
|
|
|
|
|
# callers only need to know whether those snapshots exist.
|
|
|
|
|
|
payload["has_system_prompt"] = bool(session.get("system_prompt"))
|
|
|
|
|
|
payload["has_model_config"] = bool(session.get("model_config"))
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _message_response(message: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
safe_keys = (
|
|
|
|
|
|
"id", "session_id", "role", "content", "tool_call_id", "tool_calls",
|
|
|
|
|
|
"tool_name", "timestamp", "token_count", "finish_reason", "reasoning",
|
|
|
|
|
|
"reasoning_content",
|
|
|
|
|
|
)
|
|
|
|
|
|
return {key: message.get(key) for key in safe_keys if key in message}
|
|
|
|
|
|
|
|
|
|
|
|
async def _read_json_body(self, request: "web.Request") -> tuple[Dict[str, Any], Optional["web.Response"]]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}, web.json_response(_openai_error("Invalid JSON in request body"), status=400)
|
|
|
|
|
|
if not isinstance(body, dict):
|
|
|
|
|
|
return {}, web.json_response(_openai_error("Request body must be a JSON object"), status=400)
|
|
|
|
|
|
return body, None
|
|
|
|
|
|
|
|
|
|
|
|
def _get_existing_session_or_404(self, session_id: str) -> tuple[Optional[Dict[str, Any]], Optional["web.Response"]]:
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
return None, web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503)
|
|
|
|
|
|
session = db.get_session(session_id)
|
|
|
|
|
|
if not session:
|
|
|
|
|
|
return None, web.json_response(_openai_error(f"Session not found: {session_id}", code="session_not_found"), status=404)
|
|
|
|
|
|
return session, None
|
|
|
|
|
|
|
|
|
|
|
|
def _conversation_history_for_session(self, session_id: str) -> List[Dict[str, Any]]:
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
try:
|
|
|
|
|
|
return db.get_messages_as_conversation(session_id)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("Failed to load session history for %s: %s", session_id, exc)
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_list_sessions(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /api/sessions — list persisted Hermes sessions."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503)
|
|
|
|
|
|
|
|
|
|
|
|
limit = self._parse_nonnegative_int(request.query.get("limit"), default=50, maximum=200)
|
|
|
|
|
|
offset = self._parse_nonnegative_int(request.query.get("offset"), default=0, maximum=1_000_000)
|
|
|
|
|
|
source = request.query.get("source") or None
|
|
|
|
|
|
include_children = _coerce_request_bool(request.query.get("include_children"), default=False)
|
|
|
|
|
|
sessions = db.list_sessions_rich(
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
|
|
|
|
|
include_children=include_children,
|
|
|
|
|
|
order_by_last_active=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "list",
|
|
|
|
|
|
"data": [self._session_response(s) for s in sessions],
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
"offset": offset,
|
|
|
|
|
|
"has_more": len(sessions) == limit,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_create_session(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/sessions — create an empty Hermes session row."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
body, err = await self._read_json_body(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503)
|
|
|
|
|
|
|
|
|
|
|
|
raw_id = body.get("id") or body.get("session_id")
|
|
|
|
|
|
session_id = str(raw_id).strip() if raw_id else f"api_{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
if not session_id or re.search(r'[\r\n\x00]', session_id):
|
|
|
|
|
|
return web.json_response(_openai_error("Invalid session ID", code="invalid_session_id"), status=400)
|
|
|
|
|
|
if len(session_id) > self._MAX_SESSION_HEADER_LEN:
|
|
|
|
|
|
return web.json_response(_openai_error("Session ID too long", code="invalid_session_id"), status=400)
|
|
|
|
|
|
if db.get_session(session_id):
|
|
|
|
|
|
return web.json_response(_openai_error(f"Session already exists: {session_id}", code="session_exists"), status=409)
|
|
|
|
|
|
|
|
|
|
|
|
model = body.get("model") or self._model_name
|
|
|
|
|
|
system_prompt = body.get("system_prompt")
|
|
|
|
|
|
if system_prompt is not None and not isinstance(system_prompt, str):
|
|
|
|
|
|
return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400)
|
|
|
|
|
|
db.create_session(session_id, "api_server", model=str(model) if model else None, system_prompt=system_prompt)
|
|
|
|
|
|
title = body.get("title")
|
|
|
|
|
|
if title is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.set_session_title(session_id, str(title))
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
db.delete_session(session_id)
|
|
|
|
|
|
return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400)
|
|
|
|
|
|
session = db.get_session(session_id) or {"id": session_id, "source": "api_server", "model": model, "title": title}
|
|
|
|
|
|
return web.json_response({"object": "hermes.session", "session": self._session_response(session)}, status=201)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_get_session(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /api/sessions/{session_id}."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
session, err = self._get_existing_session_or_404(request.match_info["session_id"])
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
return web.json_response({"object": "hermes.session", "session": self._session_response(session)})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_patch_session(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""PATCH /api/sessions/{session_id} — update client-safe session metadata."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
session_id = request.match_info["session_id"]
|
|
|
|
|
|
session, err = self._get_existing_session_or_404(session_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
body, err = await self._read_json_body(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
allowed = {"title", "end_reason"}
|
|
|
|
|
|
unknown = sorted(set(body) - allowed)
|
|
|
|
|
|
if unknown:
|
|
|
|
|
|
return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if "title" in body:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.set_session_title(session_id, "" if body["title"] is None else str(body["title"]))
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400)
|
|
|
|
|
|
if body.get("end_reason"):
|
|
|
|
|
|
db.end_session(session_id, str(body["end_reason"]))
|
|
|
|
|
|
session = db.get_session(session_id) or session
|
|
|
|
|
|
return web.json_response({"object": "hermes.session", "session": self._session_response(session)})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_delete_session(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""DELETE /api/sessions/{session_id}."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
session_id = request.match_info["session_id"]
|
|
|
|
|
|
session, err = self._get_existing_session_or_404(session_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
deleted = db.delete_session(session_id)
|
|
|
|
|
|
return web.json_response({"object": "hermes.session.deleted", "id": session_id, "deleted": bool(deleted)})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_session_messages(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /api/sessions/{session_id}/messages."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
session_id = request.match_info["session_id"]
|
|
|
|
|
|
_, err = self._get_existing_session_or_404(session_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
db = self._ensure_session_db()
|
2026-06-07 23:06:50 +02:00
|
|
|
|
resolved_id = db.resolve_resume_session_id(session_id)
|
|
|
|
|
|
messages = db.get_messages(resolved_id)
|
2026-05-20 08:45:55 -04:00
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "list",
|
2026-06-07 23:06:50 +02:00
|
|
|
|
"session_id": resolved_id,
|
2026-05-20 08:45:55 -04:00
|
|
|
|
"data": [self._message_response(m) for m in messages],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_fork_session(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/sessions/{session_id}/fork — branch via current SessionDB primitives."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
source_id = request.match_info["session_id"]
|
|
|
|
|
|
source, err = self._get_existing_session_or_404(source_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
body, err = await self._read_json_body(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
fork_id = str(body.get("id") or body.get("session_id") or f"api_{int(time.time())}_{uuid.uuid4().hex[:8]}").strip()
|
|
|
|
|
|
if not fork_id or re.search(r'[\r\n\x00]', fork_id):
|
|
|
|
|
|
return web.json_response(_openai_error("Invalid session ID", code="invalid_session_id"), status=400)
|
|
|
|
|
|
if db.get_session(fork_id):
|
|
|
|
|
|
return web.json_response(_openai_error(f"Session already exists: {fork_id}", code="session_exists"), status=409)
|
|
|
|
|
|
|
|
|
|
|
|
# Match the CLI /branch semantics: mark the original as branched, then
|
|
|
|
|
|
# create a child session that carries the transcript forward. This uses
|
|
|
|
|
|
# SessionDB's native parent_session_id/end_reason visibility model rather
|
|
|
|
|
|
# than inventing a parallel fork store.
|
|
|
|
|
|
db.end_session(source_id, "branched")
|
|
|
|
|
|
db.create_session(
|
|
|
|
|
|
fork_id,
|
|
|
|
|
|
"api_server",
|
|
|
|
|
|
model=source.get("model"),
|
|
|
|
|
|
system_prompt=source.get("system_prompt"),
|
|
|
|
|
|
parent_session_id=source_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
messages = db.get_messages(source_id)
|
|
|
|
|
|
db.replace_messages(fork_id, messages)
|
|
|
|
|
|
title = body.get("title")
|
|
|
|
|
|
if title is None:
|
|
|
|
|
|
base = source.get("title") or "fork"
|
|
|
|
|
|
try:
|
|
|
|
|
|
title = db.get_next_title_in_lineage(base)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
title = f"{base} fork"
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.set_session_title(fork_id, str(title))
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400)
|
|
|
|
|
|
fork = db.get_session(fork_id) or {"id": fork_id, "parent_session_id": source_id}
|
|
|
|
|
|
return web.json_response({"object": "hermes.session", "session": self._session_response(fork)}, status=201)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_session_chat(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/sessions/{session_id}/chat — one synchronous agent turn."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
|
|
|
|
if key_err is not None:
|
|
|
|
|
|
return key_err
|
|
|
|
|
|
session_id = request.match_info["session_id"]
|
|
|
|
|
|
_, err = self._get_existing_session_or_404(session_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
body, err = await self._read_json_body(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
2026-05-22 16:00:59 -07:00
|
|
|
|
user_message, err = _session_chat_user_message(body)
|
|
|
|
|
|
if err is not None:
|
|
|
|
|
|
return err
|
2026-05-20 08:45:55 -04:00
|
|
|
|
system_prompt = body.get("system_message") or body.get("instructions")
|
|
|
|
|
|
if system_prompt is not None and not isinstance(system_prompt, str):
|
|
|
|
|
|
return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400)
|
|
|
|
|
|
history = self._conversation_history_for_session(session_id)
|
|
|
|
|
|
result, usage = await self._run_agent(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=history,
|
|
|
|
|
|
ephemeral_system_prompt=system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
gateway_session_key=gateway_session_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
effective_session_id = result.get("session_id") if isinstance(result, dict) else session_id
|
|
|
|
|
|
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
|
|
|
|
headers = {"X-Hermes-Session-Id": effective_session_id or session_id}
|
|
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{
|
|
|
|
|
|
"object": "hermes.session.chat.completion",
|
|
|
|
|
|
"session_id": effective_session_id or session_id,
|
|
|
|
|
|
"message": {"role": "assistant", "content": final_response},
|
|
|
|
|
|
"usage": usage,
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_session_chat_stream(self, request: "web.Request") -> "web.StreamResponse":
|
|
|
|
|
|
"""POST /api/sessions/{session_id}/chat/stream — SSE wrapper over _run_agent."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
|
|
|
|
if key_err is not None:
|
|
|
|
|
|
return key_err
|
|
|
|
|
|
session_id = request.match_info["session_id"]
|
|
|
|
|
|
_, err = self._get_existing_session_or_404(session_id)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
body, err = await self._read_json_body(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
2026-05-22 16:00:59 -07:00
|
|
|
|
user_message, err = _session_chat_user_message(body)
|
|
|
|
|
|
if err is not None:
|
|
|
|
|
|
return err
|
2026-05-20 08:45:55 -04:00
|
|
|
|
system_prompt = body.get("system_message") or body.get("instructions")
|
|
|
|
|
|
if system_prompt is not None and not isinstance(system_prompt, str):
|
|
|
|
|
|
return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue()
|
|
|
|
|
|
message_id = f"msg_{uuid.uuid4().hex}"
|
|
|
|
|
|
run_id = f"run_{uuid.uuid4().hex}"
|
|
|
|
|
|
seq = 0
|
|
|
|
|
|
|
|
|
|
|
|
def _event_payload(name: str, payload: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
|
|
|
|
|
nonlocal seq
|
|
|
|
|
|
seq += 1
|
|
|
|
|
|
payload.setdefault("session_id", session_id)
|
|
|
|
|
|
payload.setdefault("run_id", run_id)
|
|
|
|
|
|
payload.setdefault("seq", seq)
|
|
|
|
|
|
payload.setdefault("ts", time.time())
|
|
|
|
|
|
return name, payload
|
|
|
|
|
|
|
|
|
|
|
|
def _enqueue(name: str, payload: Dict[str, Any]) -> None:
|
|
|
|
|
|
event = _event_payload(name, payload)
|
|
|
|
|
|
try:
|
|
|
|
|
|
running_loop = asyncio.get_running_loop()
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
running_loop = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
if running_loop is loop:
|
|
|
|
|
|
queue.put_nowait(event)
|
|
|
|
|
|
else:
|
|
|
|
|
|
loop.call_soon_threadsafe(queue.put_nowait, event)
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _delta(delta: str) -> None:
|
|
|
|
|
|
if delta:
|
|
|
|
|
|
_enqueue("assistant.delta", {"message_id": message_id, "delta": delta})
|
|
|
|
|
|
|
|
|
|
|
|
def _tool_progress(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs) -> None:
|
|
|
|
|
|
if event_type == "reasoning.available":
|
|
|
|
|
|
_enqueue("tool.progress", {"message_id": message_id, "tool_name": tool_name or "_thinking", "delta": preview or ""})
|
|
|
|
|
|
elif event_type in {"tool.started", "tool.completed", "tool.failed"}:
|
|
|
|
|
|
event_name = event_type.replace("tool.", "tool.")
|
|
|
|
|
|
_enqueue(event_name, {"message_id": message_id, "tool_name": tool_name, "preview": preview, "args": args})
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_and_signal() -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await queue.put(_event_payload("run.started", {"user_message": {"role": "user", "content": user_message}}))
|
|
|
|
|
|
await queue.put(_event_payload("message.started", {"message": {"id": message_id, "role": "assistant"}}))
|
|
|
|
|
|
history = self._conversation_history_for_session(session_id)
|
|
|
|
|
|
result, usage = await self._run_agent(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=history,
|
|
|
|
|
|
ephemeral_system_prompt=system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
stream_delta_callback=_delta,
|
|
|
|
|
|
tool_progress_callback=_tool_progress,
|
|
|
|
|
|
gateway_session_key=gateway_session_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
|
|
|
|
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
|
fix(api_server): emit per-turn transcript on run.completed (#34703) (#34804)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround
The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.
Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.
* fix(api_server): emit per-turn transcript on run.completed (#34703)
WebUI clients lost intermediate (pre-tool-call) assistant text after
switching session pages mid-stream. The session-chat SSE stream delivers
all assistant text as assistant.delta events under one message_id
interleaved with tool.* events, then a single assistant.completed
carrying only the final reply — so a client accumulating deltas into one
buffer cannot reconstruct intermediate text segments that preceded tool
calls, and they vanish from the live view (state.db persists them
correctly).
run.completed now carries the authoritative per-turn transcript
(assistant + tool messages for this turn, in client-safe shape) so any
SSE consumer can reconcile its live view against ground truth without a
separate GET /messages round-trip. Purely additive — clients that ignore
the field are unaffected.
2026-05-29 12:27:49 -07:00
|
|
|
|
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
|
2026-05-20 08:45:55 -04:00
|
|
|
|
await queue.put(_event_payload("assistant.completed", {
|
|
|
|
|
|
"session_id": effective_session_id,
|
|
|
|
|
|
"message_id": message_id,
|
|
|
|
|
|
"content": final_response,
|
|
|
|
|
|
"completed": True,
|
|
|
|
|
|
"partial": False,
|
|
|
|
|
|
"interrupted": False,
|
|
|
|
|
|
}))
|
|
|
|
|
|
await queue.put(_event_payload("run.completed", {
|
|
|
|
|
|
"session_id": effective_session_id,
|
|
|
|
|
|
"message_id": message_id,
|
|
|
|
|
|
"completed": True,
|
fix(api_server): emit per-turn transcript on run.completed (#34703) (#34804)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround
The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.
Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.
* fix(api_server): emit per-turn transcript on run.completed (#34703)
WebUI clients lost intermediate (pre-tool-call) assistant text after
switching session pages mid-stream. The session-chat SSE stream delivers
all assistant text as assistant.delta events under one message_id
interleaved with tool.* events, then a single assistant.completed
carrying only the final reply — so a client accumulating deltas into one
buffer cannot reconstruct intermediate text segments that preceded tool
calls, and they vanish from the live view (state.db persists them
correctly).
run.completed now carries the authoritative per-turn transcript
(assistant + tool messages for this turn, in client-safe shape) so any
SSE consumer can reconcile its live view against ground truth without a
separate GET /messages round-trip. Purely additive — clients that ignore
the field are unaffected.
2026-05-29 12:27:49 -07:00
|
|
|
|
"messages": turn_messages,
|
2026-05-20 08:45:55 -04:00
|
|
|
|
"usage": usage,
|
|
|
|
|
|
}))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.exception("[api_server] session chat stream failed")
|
|
|
|
|
|
await queue.put(_event_payload("error", {"message": str(exc)}))
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await queue.put(_event_payload("done", {}))
|
|
|
|
|
|
await queue.put(None)
|
|
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(_run_and_signal())
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
|
except TypeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if hasattr(task, "add_done_callback"):
|
|
|
|
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
|
"X-Hermes-Session-Id": session_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
|
|
|
|
response = web.StreamResponse(status=200, headers=headers)
|
|
|
|
|
|
await response.prepare(request)
|
|
|
|
|
|
last_write = time.monotonic()
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await asyncio.wait_for(queue.get(), timeout=CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
await response.write(b": keepalive\n\n")
|
|
|
|
|
|
last_write = time.monotonic()
|
|
|
|
|
|
continue
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
break
|
|
|
|
|
|
name, payload = item
|
|
|
|
|
|
data = json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
await response.write(f"event: {name}\ndata: {data}\n\n".encode("utf-8"))
|
|
|
|
|
|
last_write = time.monotonic()
|
|
|
|
|
|
except (asyncio.CancelledError, ConnectionResetError):
|
|
|
|
|
|
task.cancel()
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.debug("[api_server] session SSE stream error: %s", exc)
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
# Parse request body
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except (json.JSONDecodeError, Exception):
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error("Invalid JSON in request body"), status=400)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
messages = body.get("messages")
|
|
|
|
|
|
if not messages or not isinstance(messages, list):
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": {"message": "Missing or invalid 'messages' field", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-16 02:08:40 +03:00
|
|
|
|
stream = _coerce_request_bool(body.get("stream"), default=False)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
# Extract system message (becomes ephemeral system prompt layered ON TOP of core)
|
|
|
|
|
|
system_prompt = None
|
|
|
|
|
|
conversation_messages: List[Dict[str, str]] = []
|
|
|
|
|
|
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
for idx, msg in enumerate(messages):
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
role = msg.get("role", "")
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
raw_content = msg.get("content", "")
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if role == "system":
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
# System messages don't support images (Anthropic rejects, OpenAI
|
|
|
|
|
|
# text-model systems don't render them). Flatten to text.
|
|
|
|
|
|
content = _normalize_chat_content(raw_content)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if system_prompt is None:
|
|
|
|
|
|
system_prompt = content
|
|
|
|
|
|
else:
|
|
|
|
|
|
system_prompt = system_prompt + "\n" + content
|
2026-05-11 11:13:25 -07:00
|
|
|
|
elif role in {"user", "assistant"}:
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
try:
|
|
|
|
|
|
content = _normalize_multimodal_content(raw_content)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
return _multimodal_validation_error(exc, param=f"messages[{idx}].content")
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
conversation_messages.append({"role": role, "content": content})
|
|
|
|
|
|
|
|
|
|
|
|
# Extract the last user message as the primary input
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
user_message: Any = ""
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
history = []
|
|
|
|
|
|
if conversation_messages:
|
|
|
|
|
|
user_message = conversation_messages[-1].get("content", "")
|
|
|
|
|
|
history = conversation_messages[:-1]
|
|
|
|
|
|
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
if not _content_has_visible_payload(user_message):
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": {"message": "No user message found in messages", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
# Allow caller to scope long-term memory (e.g. Honcho) with a
|
|
|
|
|
|
# stable per-channel identifier via X-Hermes-Session-Key. This
|
|
|
|
|
|
# is independent of X-Hermes-Session-Id: the key persists across
|
|
|
|
|
|
# transcripts while the id rotates when the caller starts a new
|
|
|
|
|
|
# transcript (i.e. /new semantics). See _parse_session_key_header.
|
|
|
|
|
|
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
|
|
|
|
if key_err is not None:
|
|
|
|
|
|
return key_err
|
|
|
|
|
|
|
2026-03-31 12:56:10 -07:00
|
|
|
|
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
|
|
|
|
|
|
# When provided, history is loaded from state.db instead of from the request body.
|
fix(security): require auth for session continuation and warn on missing API key
Two security hardening changes for the API server:
1. **Startup warning when no API key is configured.**
When `API_SERVER_KEY` is not set, all endpoints accept unauthenticated
requests. This is the default configuration, but operators may not
realize the security implications. A prominent warning at startup
makes the risk visible.
2. **Require authentication for session continuation.**
The `X-Hermes-Session-Id` header allows callers to load and continue
any session stored in state.db. Without authentication, an attacker
who can reach the API server (e.g. via CORS from a malicious page,
or on a shared host) could enumerate session IDs and read conversation
history — which may contain API keys, passwords, code, or other
sensitive data shared with the agent.
Session continuation now returns 403 when no API key is configured,
with a clear error message explaining how to enable the feature.
When a key IS configured, the existing Bearer token check already
gates access.
This is defense-in-depth: the API server is intended for local use,
but defense against cross-origin and shared-host attacks is important
since the default binding is 127.0.0.1 which is reachable from
browsers via DNS rebinding or localhost CORS.
2026-04-10 11:56:23 +08:00
|
|
|
|
#
|
|
|
|
|
|
# Security: session continuation exposes conversation history, so it is
|
|
|
|
|
|
# only allowed when the API key is configured and the request is
|
|
|
|
|
|
# authenticated. Without this gate, any unauthenticated client could
|
|
|
|
|
|
# read arbitrary session history by guessing/enumerating session IDs.
|
2026-03-31 12:56:10 -07:00
|
|
|
|
provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
|
|
|
|
|
|
if provided_session_id:
|
fix(security): require auth for session continuation and warn on missing API key
Two security hardening changes for the API server:
1. **Startup warning when no API key is configured.**
When `API_SERVER_KEY` is not set, all endpoints accept unauthenticated
requests. This is the default configuration, but operators may not
realize the security implications. A prominent warning at startup
makes the risk visible.
2. **Require authentication for session continuation.**
The `X-Hermes-Session-Id` header allows callers to load and continue
any session stored in state.db. Without authentication, an attacker
who can reach the API server (e.g. via CORS from a malicious page,
or on a shared host) could enumerate session IDs and read conversation
history — which may contain API keys, passwords, code, or other
sensitive data shared with the agent.
Session continuation now returns 403 when no API key is configured,
with a clear error message explaining how to enable the feature.
When a key IS configured, the existing Bearer token check already
gates access.
This is defense-in-depth: the API server is intended for local use,
but defense against cross-origin and shared-host attacks is important
since the default binding is 127.0.0.1 which is reachable from
browsers via DNS rebinding or localhost CORS.
2026-04-10 11:56:23 +08:00
|
|
|
|
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 web.json_response(
|
|
|
|
|
|
_openai_error(
|
|
|
|
|
|
"Session continuation requires API key authentication. "
|
|
|
|
|
|
"Configure API_SERVER_KEY to enable this feature."
|
|
|
|
|
|
),
|
|
|
|
|
|
status=403,
|
|
|
|
|
|
)
|
2026-04-10 11:52:01 +08:00
|
|
|
|
# Sanitize: reject control characters that could enable header injection.
|
|
|
|
|
|
if re.search(r'[\r\n\x00]', provided_session_id):
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": {"message": "Invalid session ID", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
2026-03-31 12:56:10 -07:00
|
|
|
|
session_id = provided_session_id
|
|
|
|
|
|
try:
|
2026-04-03 10:31:11 -07:00
|
|
|
|
db = self._ensure_session_db()
|
|
|
|
|
|
if db is not None:
|
|
|
|
|
|
history = db.get_messages_as_conversation(session_id)
|
2026-03-31 12:56:10 -07:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("Failed to load session history for %s: %s", session_id, e)
|
|
|
|
|
|
history = []
|
|
|
|
|
|
else:
|
2026-04-10 04:56:35 -07:00
|
|
|
|
# Derive a stable session ID from the conversation fingerprint so
|
|
|
|
|
|
# that consecutive messages from the same Open WebUI (or similar)
|
|
|
|
|
|
# conversation map to the same Hermes session. The first user
|
|
|
|
|
|
# message + system prompt are constant across all turns.
|
|
|
|
|
|
first_user = ""
|
|
|
|
|
|
for cm in conversation_messages:
|
|
|
|
|
|
if cm.get("role") == "user":
|
|
|
|
|
|
first_user = cm.get("content", "")
|
|
|
|
|
|
break
|
|
|
|
|
|
session_id = _derive_chat_session_id(system_prompt, first_user)
|
2026-03-31 12:56:10 -07:00
|
|
|
|
# history already set from request body above
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
2026-04-09 17:07:29 -07:00
|
|
|
|
model_name = body.get("model", self._model_name)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
created = int(time.time())
|
|
|
|
|
|
|
|
|
|
|
|
if stream:
|
|
|
|
|
|
import queue as _q
|
|
|
|
|
|
_stream_q: _q.Queue = _q.Queue()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_delta(delta):
|
fix(api_server): streaming breaks when agent makes tool calls (#2985)
* fix(run_agent): ensure _fire_first_delta() is called for tool generation events
Added calls to _fire_first_delta() in the AIAgent class to improve the handling of tool generation events, ensuring timely notifications during the processing of function calls and tool usage.
* fix(run_agent): improve timeout handling for chat completions
Enhanced the timeout configuration for chat completions in the AIAgent class by introducing customizable connection, read, and write timeouts using environment variables. This ensures more robust handling of API requests during streaming operations.
* fix(run_agent): reduce default stream read timeout for chat completions
Updated the default stream read timeout from 120 seconds to 60 seconds in the AIAgent class, enhancing the timeout configuration for chat completions. This change aims to improve responsiveness during streaming operations.
* fix(run_agent): enhance streaming error handling and retry logic
Improved the error handling and retry mechanism for streaming requests in the AIAgent class. Introduced a configurable maximum number of stream retries and refined the handling of transient network errors, allowing for retries with fresh connections. Non-transient errors now trigger a fallback to non-streaming only when appropriate, ensuring better resilience during API interactions.
* fix(api_server): streaming breaks when agent makes tool calls
The agent fires stream_delta_callback(None) to signal the CLI display
to close its response box before tool execution begins. The API server's
_on_delta callback was forwarding this None directly into the SSE queue,
where the SSE writer treats it as end-of-stream and terminates the HTTP
response prematurely.
After tool calls complete, the agent streams the final answer through
the same callback, but the SSE response was already closed — so Open
WebUI (and similar frontends) never received the actual answer.
Fix: filter out None in _on_delta so the SSE stream stays open. The SSE
loop already detects completion via agent_task.done(), which handles
stream termination correctly without needing the None sentinel.
Reported by Rohit Paul on X.
2026-03-25 09:56:20 -07:00
|
|
|
|
# Filter out None — the agent fires stream_delta_callback(None)
|
|
|
|
|
|
# to signal the CLI display to close its response box before
|
|
|
|
|
|
# tool execution, but the SSE writer uses None as end-of-stream
|
|
|
|
|
|
# sentinel. Forwarding it would prematurely close the HTTP
|
|
|
|
|
|
# response, causing Open WebUI (and similar frontends) to miss
|
|
|
|
|
|
# the final answer after tool calls. The SSE loop detects
|
|
|
|
|
|
# completion via agent_task.done() instead.
|
|
|
|
|
|
if delta is not None:
|
|
|
|
|
|
_stream_q.put(delta)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-04-27 11:26:36 -07:00
|
|
|
|
# Track which tool_call_ids we've emitted a "running" lifecycle
|
|
|
|
|
|
# event for, so a "completed" event without a matching "running"
|
|
|
|
|
|
# (e.g. internal/filtered tools) is silently dropped instead of
|
|
|
|
|
|
# producing an orphaned event clients can't correlate.
|
|
|
|
|
|
_started_tool_call_ids: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_tool_start(tool_call_id, function_name, function_args):
|
|
|
|
|
|
"""Emit ``hermes.tool.progress`` with ``status: running``.
|
|
|
|
|
|
|
|
|
|
|
|
Replaces the old ``tool_progress_callback("tool.started",
|
|
|
|
|
|
...)`` emit so SSE consumers receive a single event per
|
|
|
|
|
|
tool start, carrying both the legacy ``tool``/``emoji``/
|
|
|
|
|
|
``label`` payload (for #6972 frontends) and the new
|
|
|
|
|
|
``toolCallId``/``status`` correlation fields (#16588).
|
|
|
|
|
|
|
|
|
|
|
|
Skips tools whose names start with ``_`` so internal
|
|
|
|
|
|
events (``_thinking``, …) stay off the wire — matching
|
|
|
|
|
|
the prior ``_on_tool_progress`` filter exactly.
|
2026-04-10 03:37:34 -04:00
|
|
|
|
"""
|
2026-04-27 11:26:36 -07:00
|
|
|
|
if not tool_call_id or function_name.startswith("_"):
|
2026-04-10 03:37:34 -04:00
|
|
|
|
return
|
2026-04-27 11:26:36 -07:00
|
|
|
|
_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
|
2026-04-10 03:37:34 -04:00
|
|
|
|
_stream_q.put(("__tool_progress__", {
|
2026-04-27 11:26:36 -07:00
|
|
|
|
"tool": function_name,
|
|
|
|
|
|
"emoji": get_tool_emoji(function_name),
|
2026-04-10 03:37:34 -04:00
|
|
|
|
"label": label,
|
2026-04-27 11:26:36 -07:00
|
|
|
|
"toolCallId": tool_call_id,
|
|
|
|
|
|
"status": "running",
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
def _on_tool_complete(tool_call_id, function_name, function_args, function_result):
|
|
|
|
|
|
"""Emit the matching ``status: completed`` event.
|
|
|
|
|
|
|
|
|
|
|
|
Dropped if the start was filtered (internal tool, missing
|
|
|
|
|
|
id, or never seen) so clients never get an orphaned
|
|
|
|
|
|
``completed`` they can't correlate to a prior ``running``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
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(("__tool_progress__", {
|
|
|
|
|
|
"tool": function_name,
|
|
|
|
|
|
"toolCallId": tool_call_id,
|
|
|
|
|
|
"status": "completed",
|
2026-04-10 03:37:34 -04:00
|
|
|
|
}))
|
2026-03-30 18:50:27 -07:00
|
|
|
|
|
2026-03-27 11:33:19 -07:00
|
|
|
|
# Start agent in background. agent_ref is a mutable container
|
|
|
|
|
|
# so the SSE writer can interrupt the agent on client disconnect.
|
2026-04-27 11:26:36 -07:00
|
|
|
|
#
|
|
|
|
|
|
# ``tool_progress_callback`` is intentionally not wired here:
|
|
|
|
|
|
# it would duplicate every emit because ``run_agent`` fires it
|
|
|
|
|
|
# side-by-side with ``tool_start_callback``/``tool_complete_callback``.
|
|
|
|
|
|
# The structured callbacks are strictly richer (they carry the
|
|
|
|
|
|
# tool_call id), so they own the chat-completions SSE channel.
|
2026-03-27 11:33:19 -07:00
|
|
|
|
agent_ref = [None]
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
agent_task = asyncio.ensure_future(self._run_agent(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=history,
|
|
|
|
|
|
ephemeral_system_prompt=system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
stream_delta_callback=_on_delta,
|
2026-04-27 11:26:36 -07:00
|
|
|
|
tool_start_callback=_on_tool_start,
|
|
|
|
|
|
tool_complete_callback=_on_tool_complete,
|
2026-03-27 11:33:19 -07:00
|
|
|
|
agent_ref=agent_ref,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
))
|
2026-05-13 00:56:32 +03:00
|
|
|
|
# Ensure SSE drain loops can terminate without relying on polling
|
|
|
|
|
|
# agent_task.done(), which can race with queue timeout checks.
|
|
|
|
|
|
agent_task.add_done_callback(lambda _fut: _stream_q.put(None))
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
return await self._write_sse_chat_completion(
|
2026-03-27 11:33:19 -07:00
|
|
|
|
request, completion_id, model_name, created, _stream_q,
|
2026-03-31 12:56:10 -07:00
|
|
|
|
agent_task, agent_ref, session_id=session_id,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-24 19:31:08 -07:00
|
|
|
|
# Non-streaming: run the agent (with optional Idempotency-Key)
|
|
|
|
|
|
async def _compute_completion():
|
|
|
|
|
|
return await self._run_agent(
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=history,
|
|
|
|
|
|
ephemeral_system_prompt=system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
2026-03-24 19:31:08 -07:00
|
|
|
|
|
|
|
|
|
|
idempotency_key = request.headers.get("Idempotency-Key")
|
|
|
|
|
|
if idempotency_key:
|
|
|
|
|
|
fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"])
|
|
|
|
|
|
try:
|
|
|
|
|
|
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result, usage = await _compute_completion()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-09 12:48:08 -07:00
|
|
|
|
final_response = result.get("final_response") or ""
|
|
|
|
|
|
is_partial = bool(result.get("partial"))
|
|
|
|
|
|
is_failed = bool(result.get("failed"))
|
|
|
|
|
|
completed = bool(result.get("completed", True))
|
|
|
|
|
|
err_msg = result.get("error")
|
|
|
|
|
|
|
|
|
|
|
|
# Decide finish_reason. 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():
|
|
|
|
|
|
finish_reason = "length"
|
|
|
|
|
|
elif is_failed or (not completed and err_msg):
|
|
|
|
|
|
finish_reason = "error"
|
|
|
|
|
|
else:
|
|
|
|
|
|
finish_reason = "stop"
|
|
|
|
|
|
|
|
|
|
|
|
response_headers = {
|
|
|
|
|
|
"X-Hermes-Session-Id": result.get("session_id", session_id),
|
|
|
|
|
|
}
|
|
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-09 12:48:08 -07:00
|
|
|
|
# Hard-fail path: no usable assistant text AND a real failure → 5xx
|
|
|
|
|
|
# with OpenAI-style error envelope so SDK clients raise instead of
|
|
|
|
|
|
# silently rendering the internal 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 path: we have *some* text but the run did not complete
|
|
|
|
|
|
# (e.g. truncation with partial buffered output). Still 200 but signal
|
|
|
|
|
|
# truncation via finish_reason="length" + Hermes-specific extras.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
response_data = {
|
|
|
|
|
|
"id": completion_id,
|
|
|
|
|
|
"object": "chat.completion",
|
|
|
|
|
|
"created": created,
|
|
|
|
|
|
"model": model_name,
|
|
|
|
|
|
"choices": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"index": 0,
|
|
|
|
|
|
"message": {
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": final_response,
|
|
|
|
|
|
},
|
2026-05-09 12:48:08 -07:00
|
|
|
|
"finish_reason": finish_reason,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"usage": {
|
|
|
|
|
|
"prompt_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"completion_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
2026-05-09 12:48:08 -07:00
|
|
|
|
if is_partial or is_failed or not completed:
|
|
|
|
|
|
response_data["hermes"] = {
|
|
|
|
|
|
"completed": completed,
|
|
|
|
|
|
"partial": is_partial,
|
|
|
|
|
|
"failed": is_failed,
|
|
|
|
|
|
"error": err_msg,
|
|
|
|
|
|
"error_code": "output_truncated" if finish_reason == "length" else "agent_error",
|
|
|
|
|
|
}
|
|
|
|
|
|
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"] = err_msg[:200]
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
return web.json_response(response_data, headers=response_headers)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
async def _write_sse_chat_completion(
|
|
|
|
|
|
self, request: "web.Request", completion_id: str, model: str,
|
2026-03-31 12:56:10 -07:00
|
|
|
|
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key: str = None,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
) -> "web.StreamResponse":
|
2026-03-27 11:33:19 -07:00
|
|
|
|
"""Write real streaming SSE from agent's stream_delta_callback queue.
|
|
|
|
|
|
|
|
|
|
|
|
If the client disconnects mid-stream (network drop, browser tab close),
|
|
|
|
|
|
the agent is interrupted via ``agent.interrupt()`` so it stops making
|
|
|
|
|
|
LLM API calls, and the asyncio task wrapper is cancelled.
|
|
|
|
|
|
"""
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
import queue as _q
|
|
|
|
|
|
|
2026-04-11 12:42:01 -06:00
|
|
|
|
sse_headers = {
|
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
|
}
|
2026-03-28 13:38:30 -07:00
|
|
|
|
# CORS middleware can't inject headers into StreamResponse after
|
|
|
|
|
|
# prepare() flushes them, so resolve CORS headers up front.
|
|
|
|
|
|
origin = request.headers.get("Origin", "")
|
|
|
|
|
|
cors = self._cors_headers_for_origin(origin) if origin else None
|
|
|
|
|
|
if cors:
|
|
|
|
|
|
sse_headers.update(cors)
|
2026-03-31 12:56:10 -07:00
|
|
|
|
if session_id:
|
|
|
|
|
|
sse_headers["X-Hermes-Session-Id"] = session_id
|
2026-05-05 05:34:47 -07:00
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
2026-03-28 13:38:30 -07:00
|
|
|
|
response = web.StreamResponse(status=200, headers=sse_headers)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
await response.prepare(request)
|
|
|
|
|
|
|
2026-03-27 11:33:19 -07:00
|
|
|
|
try:
|
2026-04-11 12:42:01 -06:00
|
|
|
|
last_activity = time.monotonic()
|
|
|
|
|
|
|
2026-03-27 11:33:19 -07:00
|
|
|
|
# Role chunk
|
|
|
|
|
|
role_chunk = {
|
|
|
|
|
|
"id": completion_id, "object": "chat.completion.chunk",
|
|
|
|
|
|
"created": created, "model": model,
|
|
|
|
|
|
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
|
|
|
|
|
}
|
|
|
|
|
|
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
|
2026-04-11 12:42:01 -06:00
|
|
|
|
last_activity = time.monotonic()
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-04-10 03:37:34 -04:00
|
|
|
|
# Helper — route a queue item to the correct SSE event.
|
|
|
|
|
|
async def _emit(item):
|
|
|
|
|
|
"""Write a single queue item to the SSE stream.
|
|
|
|
|
|
|
|
|
|
|
|
Plain strings are sent as normal ``delta.content`` chunks.
|
|
|
|
|
|
Tagged tuples ``("__tool_progress__", payload)`` are sent
|
|
|
|
|
|
as a custom ``event: hermes.tool.progress`` SSE event so
|
|
|
|
|
|
frontends can display them without storing the markers in
|
2026-04-27 11:26:36 -07:00
|
|
|
|
conversation history. See #6972 for the original event,
|
|
|
|
|
|
#16588 for the ``toolCallId``/``status`` lifecycle fields.
|
2026-04-10 03:37:34 -04:00
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__":
|
|
|
|
|
|
event_data = json.dumps(item[1])
|
|
|
|
|
|
await response.write(
|
|
|
|
|
|
f"event: hermes.tool.progress\ndata: {event_data}\n\n".encode()
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
content_chunk = {
|
|
|
|
|
|
"id": completion_id, "object": "chat.completion.chunk",
|
|
|
|
|
|
"created": created, "model": model,
|
|
|
|
|
|
"choices": [{"index": 0, "delta": {"content": item}, "finish_reason": None}],
|
|
|
|
|
|
}
|
|
|
|
|
|
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
2026-04-11 12:42:01 -06:00
|
|
|
|
return time.monotonic()
|
2026-04-10 03:37:34 -04:00
|
|
|
|
|
2026-03-27 11:33:19 -07:00
|
|
|
|
# Stream content chunks as they arrive from the agent
|
2026-04-16 05:13:39 -07:00
|
|
|
|
loop = asyncio.get_running_loop()
|
2026-03-27 11:33:19 -07:00
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
|
|
|
|
|
except _q.Empty:
|
|
|
|
|
|
if agent_task.done():
|
|
|
|
|
|
# Drain any remaining items
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
delta = stream_q.get_nowait()
|
|
|
|
|
|
if delta is None:
|
|
|
|
|
|
break
|
2026-04-11 12:42:01 -06:00
|
|
|
|
last_activity = await _emit(delta)
|
2026-03-27 11:33:19 -07:00
|
|
|
|
except _q.Empty:
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
break
|
2026-03-27 11:33:19 -07:00
|
|
|
|
break
|
2026-04-11 12:42:01 -06:00
|
|
|
|
if time.monotonic() - last_activity >= CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS:
|
|
|
|
|
|
await response.write(b": keepalive\n\n")
|
|
|
|
|
|
last_activity = time.monotonic()
|
2026-03-27 11:33:19 -07:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if delta is None: # End of stream sentinel
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
break
|
|
|
|
|
|
|
2026-04-11 12:42:01 -06:00
|
|
|
|
last_activity = await _emit(delta)
|
2026-03-27 11:33:19 -07:00
|
|
|
|
|
|
|
|
|
|
# Get usage from completed agent
|
|
|
|
|
|
usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
|
|
|
|
|
try:
|
|
|
|
|
|
result, agent_usage = await agent_task
|
|
|
|
|
|
usage = agent_usage or usage
|
2026-05-07 12:53:59 +03:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("Agent task %s failed, usage data lost: %s", completion_id, exc)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-03-27 11:33:19 -07:00
|
|
|
|
# Finish chunk
|
|
|
|
|
|
finish_chunk = {
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"id": completion_id, "object": "chat.completion.chunk",
|
|
|
|
|
|
"created": created, "model": model,
|
2026-03-27 11:33:19 -07:00
|
|
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
|
|
|
|
"usage": {
|
|
|
|
|
|
"prompt_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"completion_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
},
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
}
|
2026-03-27 11:33:19 -07:00
|
|
|
|
await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
|
|
|
|
|
|
await response.write(b"data: [DONE]\n\n")
|
|
|
|
|
|
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
|
|
|
|
|
# Client disconnected mid-stream. Interrupt the agent so it
|
|
|
|
|
|
# stops making LLM API calls at the next loop iteration, then
|
|
|
|
|
|
# cancel the asyncio task wrapper.
|
|
|
|
|
|
agent = agent_ref[0] if agent_ref else None
|
|
|
|
|
|
if agent is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
agent.interrupt("SSE client disconnected")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not agent_task.done():
|
|
|
|
|
|
agent_task.cancel()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await agent_task
|
|
|
|
|
|
except (asyncio.CancelledError, Exception):
|
|
|
|
|
|
pass
|
|
|
|
|
|
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
|
2026-05-05 15:12:21 -07:00
|
|
|
|
except Exception as _exc:
|
|
|
|
|
|
# Agent crashed mid-stream. Try to emit an error chunk
|
|
|
|
|
|
# so the client gets a proper response instead of a
|
|
|
|
|
|
# TransferEncodingError from incomplete chunked encoding.
|
|
|
|
|
|
import traceback as _tb
|
|
|
|
|
|
logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
|
|
|
|
|
|
try:
|
|
|
|
|
|
error_chunk = {
|
|
|
|
|
|
"id": completion_id, "object": "chat.completion.chunk",
|
|
|
|
|
|
"created": created, "model": model,
|
|
|
|
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "error"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
await response.write(f"data: {json.dumps(error_chunk)}\n\n".encode())
|
|
|
|
|
|
await response.write(b"data: [DONE]\n\n")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
2026-04-14 09:48:49 -04:00
|
|
|
|
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,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key: Optional[str] = None,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
) -> "web.StreamResponse":
|
|
|
|
|
|
"""Write an SSE stream for POST /v1/responses (OpenAI Responses API).
|
|
|
|
|
|
|
|
|
|
|
|
Emits spec-compliant event types as the agent runs:
|
|
|
|
|
|
|
|
|
|
|
|
- ``response.created`` — initial envelope (status=in_progress)
|
|
|
|
|
|
- ``response.output_text.delta`` / ``response.output_text.done`` —
|
|
|
|
|
|
streamed assistant text
|
|
|
|
|
|
- ``response.output_item.added`` / ``response.output_item.done``
|
|
|
|
|
|
with ``item.type == "function_call"`` — when the agent invokes a
|
|
|
|
|
|
tool (both events fire; the ``done`` event carries the finalized
|
|
|
|
|
|
``arguments`` string)
|
|
|
|
|
|
- ``response.output_item.added`` with
|
|
|
|
|
|
``item.type == "function_call_output"`` — tool result with
|
|
|
|
|
|
``{call_id, output, status}``
|
|
|
|
|
|
- ``response.completed`` — terminal event carrying the full
|
|
|
|
|
|
response object with all output items + usage (same payload
|
|
|
|
|
|
shape as the non-streaming path for parity)
|
|
|
|
|
|
- ``response.failed`` — terminal event on agent error
|
|
|
|
|
|
|
|
|
|
|
|
If the client disconnects mid-stream, ``agent.interrupt()`` is
|
|
|
|
|
|
called so the agent stops issuing upstream LLM calls, then the
|
2026-04-24 14:47:48 +01:00
|
|
|
|
asyncio task is cancelled. When ``store=True`` an initial
|
|
|
|
|
|
``in_progress`` snapshot is persisted immediately after
|
|
|
|
|
|
``response.created`` and disconnects update it to an
|
|
|
|
|
|
``incomplete`` snapshot so GET /v1/responses/{id} and
|
|
|
|
|
|
``previous_response_id`` chaining still have something to
|
|
|
|
|
|
recover from.
|
2026-04-14 09:48:49 -04:00
|
|
|
|
"""
|
|
|
|
|
|
import queue as _q
|
|
|
|
|
|
|
|
|
|
|
|
sse_headers = {
|
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
|
}
|
|
|
|
|
|
origin = request.headers.get("Origin", "")
|
|
|
|
|
|
cors = self._cors_headers_for_origin(origin) if origin else None
|
|
|
|
|
|
if cors:
|
|
|
|
|
|
sse_headers.update(cors)
|
|
|
|
|
|
if session_id:
|
|
|
|
|
|
sse_headers["X-Hermes-Session-Id"] = session_id
|
2026-05-05 05:34:47 -07:00
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
sse_headers["X-Hermes-Session-Key"] = gateway_session_key
|
2026-04-14 09:48:49 -04:00
|
|
|
|
response = web.StreamResponse(status=200, headers=sse_headers)
|
|
|
|
|
|
await response.prepare(request)
|
|
|
|
|
|
|
|
|
|
|
|
# State accumulated during the stream
|
|
|
|
|
|
final_text_parts: List[str] = []
|
|
|
|
|
|
# Track open function_call items by name so we can emit a matching
|
|
|
|
|
|
# ``done`` event when the tool completes. Order preserved.
|
|
|
|
|
|
pending_tool_calls: List[Dict[str, Any]] = []
|
|
|
|
|
|
# Output items we've emitted so far (used to build the terminal
|
|
|
|
|
|
# response.completed payload). Kept in the order they appeared.
|
|
|
|
|
|
emitted_items: List[Dict[str, Any]] = []
|
|
|
|
|
|
# Monotonic counter for output_index (spec requires it).
|
|
|
|
|
|
output_index = 0
|
|
|
|
|
|
# Monotonic counter for call_id generation if the agent doesn't
|
|
|
|
|
|
# provide one (it doesn't, from tool_progress_callback).
|
|
|
|
|
|
call_counter = 0
|
2026-04-14 13:14:26 -04:00
|
|
|
|
# Canonical Responses SSE events include a monotonically increasing
|
|
|
|
|
|
# sequence_number. Add it server-side for every emitted event so
|
|
|
|
|
|
# clients that validate the OpenAI event schema can parse our stream.
|
|
|
|
|
|
sequence_number = 0
|
2026-04-14 09:48:49 -04:00
|
|
|
|
# Track the assistant message item id + content index for text
|
|
|
|
|
|
# delta events — the spec ties deltas to a specific item.
|
|
|
|
|
|
message_item_id = f"msg_{uuid.uuid4().hex[:24]}"
|
|
|
|
|
|
message_output_index: Optional[int] = None
|
|
|
|
|
|
message_opened = False
|
|
|
|
|
|
|
|
|
|
|
|
async def _write_event(event_type: str, data: Dict[str, Any]) -> None:
|
2026-04-14 13:14:26 -04:00
|
|
|
|
nonlocal sequence_number
|
|
|
|
|
|
if "sequence_number" not in data:
|
|
|
|
|
|
data["sequence_number"] = sequence_number
|
|
|
|
|
|
sequence_number += 1
|
2026-04-14 09:48:49 -04:00
|
|
|
|
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
|
|
|
|
|
await response.write(payload.encode())
|
|
|
|
|
|
|
|
|
|
|
|
def _envelope(status: str) -> Dict[str, Any]:
|
|
|
|
|
|
env: Dict[str, Any] = {
|
|
|
|
|
|
"id": response_id,
|
|
|
|
|
|
"object": "response",
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"created_at": created_at,
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
}
|
|
|
|
|
|
return env
|
|
|
|
|
|
|
|
|
|
|
|
final_response_text = ""
|
|
|
|
|
|
agent_error: Optional[str] = None
|
|
|
|
|
|
usage: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
2026-04-24 14:47:48 +01:00
|
|
|
|
terminal_snapshot_persisted = False
|
|
|
|
|
|
|
|
|
|
|
|
def _persist_response_snapshot(
|
|
|
|
|
|
response_env: Dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
conversation_history_snapshot: Optional[List[Dict[str, Any]]] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if not store:
|
|
|
|
|
|
return
|
|
|
|
|
|
if conversation_history_snapshot is None:
|
|
|
|
|
|
conversation_history_snapshot = list(conversation_history)
|
|
|
|
|
|
conversation_history_snapshot.append({"role": "user", "content": user_message})
|
|
|
|
|
|
self._response_store.put(response_id, {
|
|
|
|
|
|
"response": response_env,
|
|
|
|
|
|
"conversation_history": conversation_history_snapshot,
|
|
|
|
|
|
"instructions": instructions,
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
})
|
|
|
|
|
|
if conversation:
|
|
|
|
|
|
self._response_store.set_conversation(conversation, response_id)
|
2026-04-14 09:48:49 -04:00
|
|
|
|
|
2026-04-24 15:21:39 -07:00
|
|
|
|
def _persist_incomplete_if_needed() -> None:
|
|
|
|
|
|
"""Persist an ``incomplete`` snapshot if no terminal one was written.
|
|
|
|
|
|
|
|
|
|
|
|
Called from both the client-disconnect (``ConnectionResetError``)
|
|
|
|
|
|
and server-cancellation (``asyncio.CancelledError``) paths so
|
|
|
|
|
|
GET /v1/responses/{id} and ``previous_response_id`` chaining keep
|
|
|
|
|
|
working after abrupt stream termination.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not store or terminal_snapshot_persisted:
|
|
|
|
|
|
return
|
|
|
|
|
|
incomplete_text = "".join(final_text_parts) or final_response_text
|
|
|
|
|
|
incomplete_items: List[Dict[str, Any]] = list(emitted_items)
|
|
|
|
|
|
if incomplete_text:
|
|
|
|
|
|
incomplete_items.append({
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [{"type": "output_text", "text": incomplete_text}],
|
|
|
|
|
|
})
|
|
|
|
|
|
incomplete_env = _envelope("incomplete")
|
|
|
|
|
|
incomplete_env["output"] = incomplete_items
|
|
|
|
|
|
incomplete_env["usage"] = {
|
|
|
|
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
incomplete_history = list(conversation_history)
|
|
|
|
|
|
incomplete_history.append({"role": "user", "content": user_message})
|
|
|
|
|
|
if incomplete_text:
|
|
|
|
|
|
incomplete_history.append({"role": "assistant", "content": incomplete_text})
|
|
|
|
|
|
_persist_response_snapshot(
|
|
|
|
|
|
incomplete_env,
|
|
|
|
|
|
conversation_history_snapshot=incomplete_history,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-14 09:48:49 -04:00
|
|
|
|
try:
|
|
|
|
|
|
# response.created — initial envelope, status=in_progress
|
|
|
|
|
|
created_env = _envelope("in_progress")
|
|
|
|
|
|
created_env["output"] = []
|
|
|
|
|
|
await _write_event("response.created", {
|
|
|
|
|
|
"type": "response.created",
|
|
|
|
|
|
"response": created_env,
|
|
|
|
|
|
})
|
2026-04-24 14:47:48 +01:00
|
|
|
|
_persist_response_snapshot(created_env)
|
2026-04-14 09:48:49 -04:00
|
|
|
|
last_activity = time.monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
async def _open_message_item() -> None:
|
|
|
|
|
|
"""Emit response.output_item.added for the assistant message
|
|
|
|
|
|
the first time any text delta arrives."""
|
|
|
|
|
|
nonlocal message_opened, message_output_index, output_index
|
|
|
|
|
|
if message_opened:
|
|
|
|
|
|
return
|
|
|
|
|
|
message_opened = True
|
|
|
|
|
|
message_output_index = output_index
|
|
|
|
|
|
output_index += 1
|
|
|
|
|
|
item = {
|
|
|
|
|
|
"id": message_item_id,
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"status": "in_progress",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
await _write_event("response.output_item.added", {
|
|
|
|
|
|
"type": "response.output_item.added",
|
|
|
|
|
|
"output_index": message_output_index,
|
|
|
|
|
|
"item": item,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def _emit_text_delta(delta_text: str) -> None:
|
|
|
|
|
|
await _open_message_item()
|
|
|
|
|
|
final_text_parts.append(delta_text)
|
|
|
|
|
|
await _write_event("response.output_text.delta", {
|
|
|
|
|
|
"type": "response.output_text.delta",
|
|
|
|
|
|
"item_id": message_item_id,
|
|
|
|
|
|
"output_index": message_output_index,
|
|
|
|
|
|
"content_index": 0,
|
|
|
|
|
|
"delta": delta_text,
|
2026-04-14 13:14:26 -04:00
|
|
|
|
"logprobs": [],
|
2026-04-14 09:48:49 -04:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async def _emit_tool_started(payload: Dict[str, Any]) -> str:
|
|
|
|
|
|
"""Emit response.output_item.added for a function_call.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the call_id so the matching completion event can
|
|
|
|
|
|
reference it. Prefer the real ``tool_call_id`` from the
|
|
|
|
|
|
agent when available; fall back to a generated call id for
|
|
|
|
|
|
safety in tests or older code paths.
|
|
|
|
|
|
"""
|
|
|
|
|
|
nonlocal output_index, call_counter
|
|
|
|
|
|
call_counter += 1
|
|
|
|
|
|
call_id = payload.get("tool_call_id") or f"call_{response_id[5:]}_{call_counter}"
|
|
|
|
|
|
args = payload.get("arguments", {})
|
|
|
|
|
|
if isinstance(args, dict):
|
|
|
|
|
|
arguments_str = json.dumps(args)
|
|
|
|
|
|
else:
|
|
|
|
|
|
arguments_str = str(args)
|
|
|
|
|
|
item = {
|
|
|
|
|
|
"id": f"fc_{uuid.uuid4().hex[:24]}",
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"status": "in_progress",
|
|
|
|
|
|
"name": payload.get("name", ""),
|
|
|
|
|
|
"call_id": call_id,
|
|
|
|
|
|
"arguments": arguments_str,
|
|
|
|
|
|
}
|
|
|
|
|
|
idx = output_index
|
|
|
|
|
|
output_index += 1
|
|
|
|
|
|
pending_tool_calls.append({
|
|
|
|
|
|
"call_id": call_id,
|
|
|
|
|
|
"name": payload.get("name", ""),
|
|
|
|
|
|
"arguments": arguments_str,
|
|
|
|
|
|
"item_id": item["id"],
|
|
|
|
|
|
"output_index": idx,
|
|
|
|
|
|
})
|
|
|
|
|
|
emitted_items.append({
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"name": payload.get("name", ""),
|
|
|
|
|
|
"arguments": arguments_str,
|
|
|
|
|
|
"call_id": call_id,
|
|
|
|
|
|
})
|
|
|
|
|
|
await _write_event("response.output_item.added", {
|
|
|
|
|
|
"type": "response.output_item.added",
|
|
|
|
|
|
"output_index": idx,
|
|
|
|
|
|
"item": item,
|
|
|
|
|
|
})
|
|
|
|
|
|
return call_id
|
|
|
|
|
|
|
|
|
|
|
|
async def _emit_tool_completed(payload: Dict[str, Any]) -> None:
|
|
|
|
|
|
"""Emit response.output_item.done (function_call) followed
|
|
|
|
|
|
by response.output_item.added (function_call_output)."""
|
|
|
|
|
|
nonlocal output_index
|
|
|
|
|
|
call_id = payload.get("tool_call_id")
|
|
|
|
|
|
result = payload.get("result", "")
|
|
|
|
|
|
pending = None
|
|
|
|
|
|
if call_id:
|
|
|
|
|
|
for i, p in enumerate(pending_tool_calls):
|
|
|
|
|
|
if p["call_id"] == call_id:
|
|
|
|
|
|
pending = pending_tool_calls.pop(i)
|
|
|
|
|
|
break
|
|
|
|
|
|
if pending is None:
|
|
|
|
|
|
# Completion without a matching start — skip to avoid
|
|
|
|
|
|
# emitting orphaned done events.
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# function_call done
|
|
|
|
|
|
done_item = {
|
|
|
|
|
|
"id": pending["item_id"],
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"name": pending["name"],
|
|
|
|
|
|
"call_id": pending["call_id"],
|
|
|
|
|
|
"arguments": pending["arguments"],
|
|
|
|
|
|
}
|
|
|
|
|
|
await _write_event("response.output_item.done", {
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": pending["output_index"],
|
|
|
|
|
|
"item": done_item,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# function_call_output added (result)
|
|
|
|
|
|
result_str = result if isinstance(result, str) else json.dumps(result)
|
2026-04-14 13:14:26 -04:00
|
|
|
|
output_parts = [{"type": "input_text", "text": result_str}]
|
2026-04-14 09:48:49 -04:00
|
|
|
|
output_item = {
|
|
|
|
|
|
"id": f"fco_{uuid.uuid4().hex[:24]}",
|
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
|
"call_id": pending["call_id"],
|
2026-04-14 13:14:26 -04:00
|
|
|
|
"output": output_parts,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
"status": "completed",
|
|
|
|
|
|
}
|
|
|
|
|
|
idx = output_index
|
|
|
|
|
|
output_index += 1
|
|
|
|
|
|
emitted_items.append({
|
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
|
"call_id": pending["call_id"],
|
2026-04-14 13:14:26 -04:00
|
|
|
|
"output": output_parts,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
})
|
|
|
|
|
|
await _write_event("response.output_item.added", {
|
|
|
|
|
|
"type": "response.output_item.added",
|
|
|
|
|
|
"output_index": idx,
|
|
|
|
|
|
"item": output_item,
|
|
|
|
|
|
})
|
2026-04-14 13:14:26 -04:00
|
|
|
|
await _write_event("response.output_item.done", {
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": idx,
|
|
|
|
|
|
"item": output_item,
|
|
|
|
|
|
})
|
2026-04-14 09:48:49 -04:00
|
|
|
|
|
|
|
|
|
|
# Main drain loop — thread-safe queue fed by agent callbacks.
|
|
|
|
|
|
async def _dispatch(it) -> None:
|
|
|
|
|
|
"""Route a queue item to the correct SSE emitter.
|
|
|
|
|
|
|
2026-05-05 15:12:21 -07:00
|
|
|
|
Plain strings are text deltas — they are batched (50ms)
|
|
|
|
|
|
to reduce Open WebUI re-render storms. Tagged tuples
|
|
|
|
|
|
with ``__tool_started__`` / ``__tool_completed__``
|
|
|
|
|
|
prefixes are tool lifecycle events and flush the buffer
|
|
|
|
|
|
before emitting.
|
2026-04-14 09:48:49 -04:00
|
|
|
|
"""
|
2026-05-05 15:12:21 -07:00
|
|
|
|
nonlocal _batch_timer
|
2026-04-14 09:48:49 -04:00
|
|
|
|
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
|
|
|
|
|
|
tag, payload = it
|
2026-05-05 15:12:21 -07:00
|
|
|
|
# Flush batched text before tool events
|
|
|
|
|
|
if _batch_buf:
|
|
|
|
|
|
await _flush_batch()
|
2026-04-14 09:48:49 -04:00
|
|
|
|
if tag == "__tool_started__":
|
|
|
|
|
|
await _emit_tool_started(payload)
|
|
|
|
|
|
elif tag == "__tool_completed__":
|
|
|
|
|
|
await _emit_tool_completed(payload)
|
|
|
|
|
|
elif isinstance(it, str):
|
2026-05-05 15:12:21 -07:00
|
|
|
|
# Batch text deltas — append to buffer, flush on timer
|
|
|
|
|
|
_batch_buf.append(it)
|
|
|
|
|
|
if _batch_timer is None:
|
|
|
|
|
|
_batch_timer = asyncio.create_task(_batch_flush_after(0.05))
|
|
|
|
|
|
# Other types are silently dropped.
|
|
|
|
|
|
|
|
|
|
|
|
# ── Batching state ──
|
|
|
|
|
|
_batch_buf: List[str] = []
|
|
|
|
|
|
_batch_timer: Optional[asyncio.Task] = None
|
|
|
|
|
|
_batch_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
async def _batch_flush_after(delay: float) -> None:
|
|
|
|
|
|
"""Wait delay seconds, then flush accumulated text deltas."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.sleep(delay)
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
return
|
|
|
|
|
|
# Clear timer reference BEFORE flush so new deltas
|
|
|
|
|
|
# can start a fresh timer while we emit
|
|
|
|
|
|
nonlocal _batch_buf, _batch_timer
|
|
|
|
|
|
_batch_timer = None
|
|
|
|
|
|
await _flush_batch()
|
|
|
|
|
|
|
|
|
|
|
|
async def _flush_batch() -> None:
|
|
|
|
|
|
"""Emit a single SSE delta for all accumulated text."""
|
|
|
|
|
|
nonlocal _batch_buf
|
|
|
|
|
|
async with _batch_lock:
|
|
|
|
|
|
if _batch_buf:
|
|
|
|
|
|
combined = "".join(_batch_buf)
|
|
|
|
|
|
_batch_buf = []
|
|
|
|
|
|
await _emit_text_delta(combined)
|
2026-04-14 09:48:49 -04:00
|
|
|
|
|
2026-04-16 05:13:39 -07:00
|
|
|
|
loop = asyncio.get_running_loop()
|
2026-04-14 09:48:49 -04:00
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
|
|
|
|
|
|
except _q.Empty:
|
|
|
|
|
|
if agent_task.done():
|
|
|
|
|
|
# Drain remaining
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = stream_q.get_nowait()
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
break
|
|
|
|
|
|
await _dispatch(item)
|
|
|
|
|
|
last_activity = time.monotonic()
|
|
|
|
|
|
except _q.Empty:
|
|
|
|
|
|
break
|
|
|
|
|
|
break
|
|
|
|
|
|
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: # EOS sentinel
|
2026-05-05 15:12:21 -07:00
|
|
|
|
# Cancel pending timer and flush remaining batched text
|
|
|
|
|
|
if _batch_timer and not _batch_timer.done():
|
|
|
|
|
|
_batch_timer.cancel()
|
|
|
|
|
|
_batch_timer = None
|
|
|
|
|
|
if _batch_buf:
|
|
|
|
|
|
await _flush_batch()
|
2026-04-14 09:48:49 -04:00
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
await _dispatch(item)
|
|
|
|
|
|
last_activity = time.monotonic()
|
|
|
|
|
|
|
2026-05-05 15:12:21 -07:00
|
|
|
|
# Flush any final batched text before processing result
|
|
|
|
|
|
if _batch_buf:
|
|
|
|
|
|
await _flush_batch()
|
|
|
|
|
|
|
2026-04-14 09:48:49 -04:00
|
|
|
|
# Pick up agent result + usage from the completed task
|
|
|
|
|
|
try:
|
|
|
|
|
|
result, agent_usage = await agent_task
|
|
|
|
|
|
usage = agent_usage or usage
|
|
|
|
|
|
# If the agent produced a final_response but no text
|
|
|
|
|
|
# deltas were streamed (e.g. some providers only emit
|
|
|
|
|
|
# the full response at the end), emit a single fallback
|
|
|
|
|
|
# delta so Responses clients still receive a live text part.
|
|
|
|
|
|
agent_final = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
|
|
|
|
if agent_final and not final_text_parts:
|
|
|
|
|
|
await _emit_text_delta(agent_final)
|
|
|
|
|
|
if agent_final and not final_response_text:
|
|
|
|
|
|
final_response_text = agent_final
|
|
|
|
|
|
if isinstance(result, dict) and result.get("error") and not final_response_text:
|
|
|
|
|
|
agent_error = result["error"]
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
logger.error("Error running agent for streaming responses: %s", e, exc_info=True)
|
|
|
|
|
|
agent_error = str(e)
|
|
|
|
|
|
|
|
|
|
|
|
# Close the message item if it was opened
|
|
|
|
|
|
final_response_text = "".join(final_text_parts) or final_response_text
|
|
|
|
|
|
if message_opened:
|
|
|
|
|
|
await _write_event("response.output_text.done", {
|
|
|
|
|
|
"type": "response.output_text.done",
|
|
|
|
|
|
"item_id": message_item_id,
|
|
|
|
|
|
"output_index": message_output_index,
|
|
|
|
|
|
"content_index": 0,
|
|
|
|
|
|
"text": final_response_text,
|
2026-04-14 13:14:26 -04:00
|
|
|
|
"logprobs": [],
|
2026-04-14 09:48:49 -04:00
|
|
|
|
})
|
|
|
|
|
|
msg_done_item = {
|
|
|
|
|
|
"id": message_item_id,
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "output_text", "text": final_response_text}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
await _write_event("response.output_item.done", {
|
|
|
|
|
|
"type": "response.output_item.done",
|
|
|
|
|
|
"output_index": message_output_index,
|
|
|
|
|
|
"item": msg_done_item,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Always append a final message item in the completed
|
|
|
|
|
|
# response envelope so clients that only parse the terminal
|
|
|
|
|
|
# payload still see the assistant text. This mirrors the
|
|
|
|
|
|
# shape produced by _extract_output_items in the batch path.
|
|
|
|
|
|
final_items: List[Dict[str, Any]] = list(emitted_items)
|
2026-05-05 15:12:21 -07:00
|
|
|
|
|
|
|
|
|
|
# Trim large content from tool call arguments to keep the
|
|
|
|
|
|
# response.completed event under ~100KB. Clients already
|
|
|
|
|
|
# received full details via incremental events.
|
|
|
|
|
|
for _item in final_items:
|
|
|
|
|
|
if _item.get("type") == "function_call":
|
|
|
|
|
|
try:
|
|
|
|
|
|
_args = json.loads(_item.get("arguments", "{}")) if isinstance(_item.get("arguments"), 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] = "[" + str(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]
|
|
|
|
|
|
|
2026-04-14 09:48:49 -04:00
|
|
|
|
final_items.append({
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "output_text", "text": final_response_text or (agent_error or "")}
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if agent_error:
|
|
|
|
|
|
failed_env = _envelope("failed")
|
|
|
|
|
|
failed_env["output"] = final_items
|
|
|
|
|
|
failed_env["error"] = {"message": agent_error, "type": "server_error"}
|
|
|
|
|
|
failed_env["usage"] = {
|
|
|
|
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
}
|
2026-04-24 14:47:48 +01:00
|
|
|
|
_failed_history = list(conversation_history)
|
|
|
|
|
|
_failed_history.append({"role": "user", "content": user_message})
|
|
|
|
|
|
if final_response_text or agent_error:
|
|
|
|
|
|
_failed_history.append({
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": final_response_text or agent_error,
|
|
|
|
|
|
})
|
|
|
|
|
|
_persist_response_snapshot(
|
|
|
|
|
|
failed_env,
|
|
|
|
|
|
conversation_history_snapshot=_failed_history,
|
|
|
|
|
|
)
|
|
|
|
|
|
terminal_snapshot_persisted = True
|
2026-04-14 09:48:49 -04:00
|
|
|
|
await _write_event("response.failed", {
|
|
|
|
|
|
"type": "response.failed",
|
|
|
|
|
|
"response": failed_env,
|
|
|
|
|
|
})
|
|
|
|
|
|
else:
|
|
|
|
|
|
completed_env = _envelope("completed")
|
|
|
|
|
|
completed_env["output"] = final_items
|
|
|
|
|
|
completed_env["usage"] = {
|
|
|
|
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
}
|
2026-05-03 00:17:49 +02:00
|
|
|
|
full_history = self._build_response_conversation_history(
|
|
|
|
|
|
conversation_history,
|
|
|
|
|
|
user_message,
|
|
|
|
|
|
result,
|
|
|
|
|
|
final_response_text,
|
|
|
|
|
|
)
|
2026-04-24 14:47:48 +01:00
|
|
|
|
_persist_response_snapshot(
|
|
|
|
|
|
completed_env,
|
|
|
|
|
|
conversation_history_snapshot=full_history,
|
|
|
|
|
|
)
|
|
|
|
|
|
terminal_snapshot_persisted = True
|
2026-04-14 09:48:49 -04:00
|
|
|
|
await _write_event("response.completed", {
|
|
|
|
|
|
"type": "response.completed",
|
|
|
|
|
|
"response": completed_env,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
2026-04-24 15:21:39 -07:00
|
|
|
|
_persist_incomplete_if_needed()
|
2026-04-14 09:48:49 -04:00
|
|
|
|
# Client disconnected — interrupt the agent so it stops
|
|
|
|
|
|
# making upstream LLM calls, then cancel the task.
|
|
|
|
|
|
agent = agent_ref[0] if agent_ref else None
|
|
|
|
|
|
if agent is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
agent.interrupt("SSE client disconnected")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not agent_task.done():
|
|
|
|
|
|
agent_task.cancel()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await agent_task
|
|
|
|
|
|
except (asyncio.CancelledError, Exception):
|
|
|
|
|
|
pass
|
|
|
|
|
|
logger.info("SSE client disconnected; interrupted agent task %s", response_id)
|
2026-04-24 15:21:39 -07:00
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
# Server-side cancellation (e.g. shutdown, request timeout) —
|
|
|
|
|
|
# persist an incomplete snapshot so GET /v1/responses/{id} and
|
|
|
|
|
|
# previous_response_id chaining still work, then re-raise so the
|
|
|
|
|
|
# runtime's cancellation semantics are respected.
|
|
|
|
|
|
_persist_incomplete_if_needed()
|
|
|
|
|
|
agent = agent_ref[0] if agent_ref else None
|
|
|
|
|
|
if agent is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
agent.interrupt("SSE task cancelled")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not agent_task.done():
|
|
|
|
|
|
agent_task.cancel()
|
|
|
|
|
|
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
|
|
|
|
|
|
raise
|
2026-05-05 15:12:21 -07:00
|
|
|
|
except Exception as _exc:
|
|
|
|
|
|
# Agent crashed with an unhandled error (e.g. model API error like
|
|
|
|
|
|
# BadRequestError, AuthenticationError). Emit a response.failed
|
|
|
|
|
|
# event and properly terminate the SSE stream so the client doesn't
|
|
|
|
|
|
# get a TransferEncodingError from incomplete chunked encoding.
|
|
|
|
|
|
import traceback as _tb
|
|
|
|
|
|
_persist_incomplete_if_needed()
|
|
|
|
|
|
agent_error = _tb.format_exc()
|
|
|
|
|
|
try:
|
|
|
|
|
|
failed_env = _envelope("failed")
|
|
|
|
|
|
failed_env["output"] = list(emitted_items)
|
|
|
|
|
|
failed_env["error"] = {"message": str(_exc)[:500], "type": "server_error"}
|
|
|
|
|
|
failed_env["usage"] = {
|
|
|
|
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
await _write_event("response.failed", {
|
|
|
|
|
|
"type": "response.failed",
|
|
|
|
|
|
"response": failed_env,
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
logger.error("Agent crashed mid-stream for %s: %s", response_id, str(agent_error)[:300])
|
2026-04-14 09:48:49 -04:00
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
async def _handle_responses(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /v1/responses — OpenAI Responses API format."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
# Long-term memory scope header (see chat_completions for details).
|
|
|
|
|
|
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
|
|
|
|
if key_err is not None:
|
|
|
|
|
|
return key_err
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# Parse request body
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except (json.JSONDecodeError, Exception):
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}},
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
raw_input = body.get("input")
|
|
|
|
|
|
if raw_input is None:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error("Missing 'input' field"), status=400)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
instructions = body.get("instructions")
|
|
|
|
|
|
previous_response_id = body.get("previous_response_id")
|
|
|
|
|
|
conversation = body.get("conversation")
|
2026-05-16 02:08:40 +03:00
|
|
|
|
store = _coerce_request_bool(body.get("store"), default=True)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
# conversation and previous_response_id are mutually exclusive
|
|
|
|
|
|
if conversation and previous_response_id:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
# Resolve conversation name to latest response_id
|
|
|
|
|
|
if conversation:
|
2026-03-22 04:56:06 -07:00
|
|
|
|
previous_response_id = self._response_store.get_conversation(conversation)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# No error if conversation doesn't exist yet — it's a new conversation
|
|
|
|
|
|
|
|
|
|
|
|
# Normalize input to message list
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
input_messages: List[Dict[str, Any]] = []
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if isinstance(raw_input, str):
|
|
|
|
|
|
input_messages = [{"role": "user", "content": raw_input}]
|
|
|
|
|
|
elif isinstance(raw_input, list):
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
for idx, item in enumerate(raw_input):
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
|
input_messages.append({"role": "user", "content": item})
|
|
|
|
|
|
elif isinstance(item, dict):
|
|
|
|
|
|
role = item.get("role", "user")
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
try:
|
|
|
|
|
|
content = _normalize_multimodal_content(item.get("content", ""))
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
return _multimodal_validation_error(exc, param=f"input[{idx}].content")
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
input_messages.append({"role": role, "content": content})
|
|
|
|
|
|
else:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-04-07 17:41:05 -07:00
|
|
|
|
# Accept explicit conversation_history from the request body.
|
|
|
|
|
|
# This lets stateless clients supply their own history instead of
|
|
|
|
|
|
# relying on server-side response chaining via previous_response_id.
|
|
|
|
|
|
# Precedence: explicit conversation_history > previous_response_id.
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
conversation_history: List[Dict[str, Any]] = []
|
2026-04-07 17:41:05 -07:00
|
|
|
|
raw_history = body.get("conversation_history")
|
|
|
|
|
|
if raw_history:
|
|
|
|
|
|
if not isinstance(raw_history, list):
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error("'conversation_history' must be an array of message objects"),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
for i, entry in enumerate(raw_history):
|
|
|
|
|
|
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
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})
|
2026-04-07 17:41:05 -07:00
|
|
|
|
if previous_response_id:
|
|
|
|
|
|
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
|
|
|
|
|
|
2026-04-14 21:06:32 -07:00
|
|
|
|
stored_session_id = None
|
2026-04-07 17:41:05 -07:00
|
|
|
|
if not conversation_history and previous_response_id:
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
stored = self._response_store.get(previous_response_id)
|
|
|
|
|
|
if stored is None:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
conversation_history = list(stored.get("conversation_history", []))
|
2026-04-14 21:06:32 -07:00
|
|
|
|
stored_session_id = stored.get("session_id")
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# If no instructions provided, carry forward from previous
|
|
|
|
|
|
if instructions is None:
|
|
|
|
|
|
instructions = stored.get("instructions")
|
|
|
|
|
|
|
|
|
|
|
|
# Append new input messages to history (all but the last become history)
|
|
|
|
|
|
for msg in input_messages[:-1]:
|
|
|
|
|
|
conversation_history.append(msg)
|
|
|
|
|
|
|
|
|
|
|
|
# Last input message is the user_message
|
feat(api-server): inline image inputs on /v1/chat/completions and /v1/responses (#12969)
OpenAI-compatible clients (Open WebUI, LobeChat, etc.) can now send vision
requests to the API server. Both endpoints accept the canonical OpenAI
multimodal shape:
Chat Completions: {type: text|image_url, image_url: {url, detail?}}
Responses: {type: input_text|input_image, image_url: <str>, detail?}
The server validates and converts both into a single internal shape that the
existing agent pipeline already handles (Anthropic adapter converts,
OpenAI-wire providers pass through). Remote http(s) URLs and data:image/*
URLs are supported.
Uploaded files (file, input_file, file_id) and non-image data: URLs are
rejected with 400 unsupported_content_type.
Changes:
- gateway/platforms/api_server.py
- _normalize_multimodal_content(): validates + normalizes both Chat and
Responses content shapes. Returns a plain string for text-only content
(preserves prompt-cache behavior on existing callers) or a canonical
[{type:text|image_url,...}] list when images are present.
- _content_has_visible_payload(): replaces the bare truthy check so a
user turn with only an image no longer rejects as 'No user message'.
- _handle_chat_completions and _handle_responses both call the new helper
for user/assistant content; system messages continue to flatten to text.
- Codex conversation_history, input[], and inline history paths all share
the same validator. No duplicated normalizers.
- run_agent.py
- _summarize_user_message_for_log(): produces a short string summary
('[1 image] describe this') from list content for logging, spinner
previews, and trajectory writes. Fixes AttributeError when list
user_message hit user_message[:80] + '...' / .replace().
- _chat_content_to_responses_parts(): module-level helper that converts
chat-style multimodal content to Responses 'input_text'/'input_image'
parts. Used in _chat_messages_to_responses_input for Codex routing.
- _preflight_codex_input_items() now validates and passes through list
content parts for user/assistant messages instead of stringifying.
- tests/gateway/test_api_server_multimodal.py (new, 38 tests)
- Unit coverage for _normalize_multimodal_content, including both part
formats, data URL gating, and all reject paths.
- Real aiohttp HTTP integration on /v1/chat/completions and /v1/responses
verifying multimodal payloads reach _run_agent intact.
- 400 coverage for file / input_file / non-image data URL.
- tests/run_agent/test_run_agent_multimodal_prologue.py (new)
- Regression coverage for the prologue no-crash contract.
- _chat_content_to_responses_parts round-trip coverage.
- website/docs/user-guide/features/api-server.md
- Inline image examples for both endpoints.
- Updated Limitations: files still unsupported, images now supported.
Validated live against openrouter/anthropic/claude-opus-4.6:
POST /v1/chat/completions → 200, vision-accurate description
POST /v1/responses → 200, same image, clean output_text
POST /v1/chat/completions [file] → 400 unsupported_content_type
POST /v1/responses [input_file] → 400 unsupported_content_type
POST /v1/responses [non-image data URL] → 400 unsupported_content_type
Closes #5621, #8253, #4046, #6632.
Co-authored-by: Paul Bergeron <paul@gamma.app>
Co-authored-by: zhangxicen <zhangxicen@example.com>
Co-authored-by: Manuel Schipper <manuelschipper@users.noreply.github.com>
Co-authored-by: pradeep7127 <pradeep7127@users.noreply.github.com>
2026-04-20 04:16:13 -07:00
|
|
|
|
user_message: Any = input_messages[-1].get("content", "") if input_messages else ""
|
|
|
|
|
|
if not _content_has_visible_payload(user_message):
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error("No user message found in input"), status=400)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
# Truncation support
|
|
|
|
|
|
if body.get("truncation") == "auto" and len(conversation_history) > 100:
|
|
|
|
|
|
conversation_history = conversation_history[-100:]
|
|
|
|
|
|
|
2026-04-14 21:06:32 -07:00
|
|
|
|
# Reuse session from previous_response_id chain so the dashboard
|
|
|
|
|
|
# groups the entire conversation under one session entry.
|
|
|
|
|
|
session_id = stored_session_id or str(uuid.uuid4())
|
2026-03-24 19:31:08 -07:00
|
|
|
|
|
2026-05-16 02:08:40 +03:00
|
|
|
|
stream = _coerce_request_bool(body.get("stream"), default=False)
|
2026-04-14 09:48:49 -04:00
|
|
|
|
if stream:
|
|
|
|
|
|
# Streaming branch — emit OpenAI Responses SSE events as the
|
|
|
|
|
|
# agent runs so frontends can render text deltas and tool
|
|
|
|
|
|
# calls in real time. See _write_sse_responses for details.
|
|
|
|
|
|
import queue as _q
|
|
|
|
|
|
_stream_q: _q.Queue = _q.Queue()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_delta(delta):
|
|
|
|
|
|
# None from the agent is a CLI box-close signal, not EOS.
|
|
|
|
|
|
# Forwarding would kill the SSE stream prematurely; the
|
|
|
|
|
|
# SSE writer detects completion via agent_task.done().
|
|
|
|
|
|
if delta is not None:
|
|
|
|
|
|
_stream_q.put(delta)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_tool_progress(event_type, name, preview, args, **kwargs):
|
|
|
|
|
|
"""Queue non-start tool progress events if needed in future.
|
|
|
|
|
|
|
|
|
|
|
|
The structured Responses stream uses ``tool_start_callback``
|
|
|
|
|
|
and ``tool_complete_callback`` for exact call-id correlation,
|
|
|
|
|
|
so progress events are currently ignored here.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def _on_tool_start(tool_call_id, function_name, function_args):
|
|
|
|
|
|
"""Queue a started tool for live function_call streaming."""
|
|
|
|
|
|
_stream_q.put(("__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):
|
|
|
|
|
|
"""Queue a completed tool result for live function_call_output streaming."""
|
|
|
|
|
|
_stream_q.put(("__tool_completed__", {
|
|
|
|
|
|
"tool_call_id": tool_call_id,
|
|
|
|
|
|
"name": function_name,
|
|
|
|
|
|
"arguments": function_args or {},
|
|
|
|
|
|
"result": function_result,
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
agent_ref = [None]
|
|
|
|
|
|
agent_task = asyncio.ensure_future(self._run_agent(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=conversation_history,
|
|
|
|
|
|
ephemeral_system_prompt=instructions,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
stream_delta_callback=_on_delta,
|
|
|
|
|
|
tool_progress_callback=_on_tool_progress,
|
|
|
|
|
|
tool_start_callback=_on_tool_start,
|
|
|
|
|
|
tool_complete_callback=_on_tool_complete,
|
|
|
|
|
|
agent_ref=agent_ref,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
))
|
2026-05-13 00:56:32 +03:00
|
|
|
|
# Ensure SSE drain loops can terminate without relying on polling
|
|
|
|
|
|
# agent_task.done(), which can race with queue timeout checks.
|
|
|
|
|
|
agent_task.add_done_callback(lambda _fut: _stream_q.put(None))
|
2026-04-14 09:48:49 -04:00
|
|
|
|
|
|
|
|
|
|
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
|
|
|
|
model_name = body.get("model", self._model_name)
|
|
|
|
|
|
created_at = int(time.time())
|
|
|
|
|
|
|
|
|
|
|
|
return await self._write_sse_responses(
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
response_id=response_id,
|
|
|
|
|
|
model=model_name,
|
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
|
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,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-24 19:31:08 -07:00
|
|
|
|
async def _compute_response():
|
|
|
|
|
|
return await self._run_agent(
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=conversation_history,
|
|
|
|
|
|
ephemeral_system_prompt=instructions,
|
|
|
|
|
|
session_id=session_id,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
2026-03-24 19:31:08 -07:00
|
|
|
|
|
|
|
|
|
|
idempotency_key = request.headers.get("Idempotency-Key")
|
|
|
|
|
|
if idempotency_key:
|
|
|
|
|
|
fp = _make_request_fingerprint(
|
|
|
|
|
|
body,
|
|
|
|
|
|
keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"],
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
2026-03-24 19:31:08 -07:00
|
|
|
|
try:
|
|
|
|
|
|
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
result, usage = await _compute_response()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
|
|
|
|
|
status=500,
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
final_response = result.get("final_response", "")
|
|
|
|
|
|
if not final_response:
|
|
|
|
|
|
final_response = result.get("error", "(No response generated)")
|
|
|
|
|
|
|
|
|
|
|
|
response_id = f"resp_{uuid.uuid4().hex[:28]}"
|
|
|
|
|
|
created_at = int(time.time())
|
|
|
|
|
|
|
|
|
|
|
|
# Build the full conversation history for storage
|
|
|
|
|
|
# (includes tool calls from the agent run)
|
2026-05-03 00:17:49 +02:00
|
|
|
|
full_history = self._build_response_conversation_history(
|
|
|
|
|
|
conversation_history,
|
|
|
|
|
|
user_message,
|
|
|
|
|
|
result,
|
|
|
|
|
|
final_response,
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-03 00:17:49 +02:00
|
|
|
|
# Build output items from the current turn only. AIAgent returns a
|
|
|
|
|
|
# full transcript in result["messages"], while older/mocked paths may
|
|
|
|
|
|
# return only the current turn suffix.
|
|
|
|
|
|
output_start_index = self._response_messages_turn_start_index(
|
|
|
|
|
|
conversation_history,
|
|
|
|
|
|
user_message,
|
|
|
|
|
|
result,
|
|
|
|
|
|
)
|
|
|
|
|
|
output_items = self._extract_output_items(result, start_index=output_start_index)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
response_data = {
|
|
|
|
|
|
"id": response_id,
|
|
|
|
|
|
"object": "response",
|
|
|
|
|
|
"status": "completed",
|
|
|
|
|
|
"created_at": created_at,
|
2026-04-09 17:07:29 -07:00
|
|
|
|
"model": body.get("model", self._model_name),
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"output": output_items,
|
|
|
|
|
|
"usage": {
|
|
|
|
|
|
"input_tokens": usage.get("input_tokens", 0),
|
|
|
|
|
|
"output_tokens": usage.get("output_tokens", 0),
|
|
|
|
|
|
"total_tokens": usage.get("total_tokens", 0),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Store the complete response object for future chaining / GET retrieval
|
|
|
|
|
|
if store:
|
|
|
|
|
|
self._response_store.put(response_id, {
|
|
|
|
|
|
"response": response_data,
|
|
|
|
|
|
"conversation_history": full_history,
|
|
|
|
|
|
"instructions": instructions,
|
2026-04-14 21:06:32 -07:00
|
|
|
|
"session_id": session_id,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
})
|
|
|
|
|
|
# Update conversation mapping so the next request with the same
|
|
|
|
|
|
# conversation name automatically chains to this response
|
|
|
|
|
|
if conversation:
|
2026-03-22 04:56:06 -07:00
|
|
|
|
self._response_store.set_conversation(conversation, response_id)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
response_headers = {"X-Hermes-Session-Id": session_id}
|
|
|
|
|
|
if gateway_session_key:
|
|
|
|
|
|
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
|
|
|
|
|
return web.json_response(response_data, headers=response_headers)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# GET / DELETE response endpoints
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_get_response(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/responses/{response_id} — retrieve a stored 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:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
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."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
response_id = request.match_info["response_id"]
|
|
|
|
|
|
deleted = self._response_store.delete(response_id)
|
|
|
|
|
|
if not deleted:
|
2026-03-24 19:31:08 -07:00
|
|
|
|
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"id": response_id,
|
|
|
|
|
|
"object": "response",
|
|
|
|
|
|
"deleted": True,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-22 04:06:57 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Cron jobs API
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
_JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}")
|
|
|
|
|
|
# Allowed fields for update — prevents clients injecting arbitrary keys
|
|
|
|
|
|
_UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"}
|
|
|
|
|
|
_MAX_NAME_LENGTH = 200
|
|
|
|
|
|
_MAX_PROMPT_LENGTH = 5000
|
|
|
|
|
|
|
2026-04-21 12:35:10 +05:30
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _check_jobs_available() -> Optional["web.Response"]:
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
"""Return error response if cron module isn't available."""
|
2026-04-21 12:35:10 +05:30
|
|
|
|
if not _CRON_AVAILABLE:
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": "Cron module not available"}, status=501,
|
|
|
|
|
|
)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return None
|
|
|
|
|
|
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
def _check_job_id(self, request: "web.Request") -> tuple:
|
|
|
|
|
|
"""Validate and extract job_id. Returns (job_id, error_response)."""
|
|
|
|
|
|
job_id = request.match_info["job_id"]
|
|
|
|
|
|
if not self._JOB_ID_RE.fullmatch(job_id):
|
2026-05-25 04:15:56 -04:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Cron jobs API rejected invalid job_id %r: %s",
|
|
|
|
|
|
job_id,
|
|
|
|
|
|
self._request_audit_log_suffix(request),
|
|
|
|
|
|
)
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
return job_id, web.json_response(
|
|
|
|
|
|
{"error": "Invalid job ID format"}, status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
return job_id, None
|
|
|
|
|
|
|
2026-03-22 04:06:57 -07:00
|
|
|
|
async def _handle_list_jobs(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /api/jobs — list all cron jobs."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-05-11 11:13:25 -07:00
|
|
|
|
include_disabled = request.query.get("include_disabled", "").lower() in {"true", "1"}
|
2026-04-21 12:35:10 +05:30
|
|
|
|
jobs = _cron_list(include_disabled=include_disabled)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"jobs": jobs})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_create_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/jobs — create a new cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
name = (body.get("name") or "").strip()
|
|
|
|
|
|
schedule = (body.get("schedule") or "").strip()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
prompt = body.get("prompt", "")
|
|
|
|
|
|
deliver = body.get("deliver", "local")
|
|
|
|
|
|
skills = body.get("skills")
|
|
|
|
|
|
repeat = body.get("repeat")
|
|
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return web.json_response({"error": "Name is required"}, status=400)
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
if len(name) > self._MAX_NAME_LENGTH:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400,
|
|
|
|
|
|
)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not schedule:
|
|
|
|
|
|
return web.json_response({"error": "Schedule is required"}, status=400)
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
if len(prompt) > self._MAX_PROMPT_LENGTH:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
|
|
|
|
|
)
|
2026-06-07 06:37:49 -07:00
|
|
|
|
if prompt and _scan_cron_prompt is not None:
|
|
|
|
|
|
scan_error = _scan_cron_prompt(prompt)
|
|
|
|
|
|
if scan_error:
|
|
|
|
|
|
return web.json_response({"error": scan_error}, status=400)
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
if repeat is not None and (not isinstance(repeat, int) or repeat < 1):
|
|
|
|
|
|
return web.json_response({"error": "Repeat must be a positive integer"}, status=400)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
|
|
|
|
|
|
kwargs = {
|
|
|
|
|
|
"prompt": prompt,
|
|
|
|
|
|
"schedule": schedule,
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"deliver": deliver,
|
2026-05-25 04:15:56 -04:00
|
|
|
|
"origin": self._cron_origin_from_request(request),
|
2026-03-22 04:06:57 -07:00
|
|
|
|
}
|
|
|
|
|
|
if skills:
|
|
|
|
|
|
kwargs["skills"] = skills
|
|
|
|
|
|
if repeat is not None:
|
|
|
|
|
|
kwargs["repeat"] = repeat
|
|
|
|
|
|
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_create(**kwargs)
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
_notify_cron_provider_jobs_changed()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_get_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /api/jobs/{job_id} — get a single cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_get(job_id)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not job:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_update_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""PATCH /api/jobs/{job_id} — update a cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
# Whitelist allowed fields to prevent arbitrary key injection
|
|
|
|
|
|
sanitized = {k: v for k, v in body.items() if k in self._UPDATE_ALLOWED_FIELDS}
|
|
|
|
|
|
if not sanitized:
|
|
|
|
|
|
return web.json_response({"error": "No valid fields to update"}, status=400)
|
|
|
|
|
|
# Validate lengths if present
|
|
|
|
|
|
if "name" in sanitized and len(sanitized["name"]) > self._MAX_NAME_LENGTH:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": f"Name must be ≤ {self._MAX_NAME_LENGTH} characters"}, status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
if "prompt" in sanitized and len(sanitized["prompt"]) > self._MAX_PROMPT_LENGTH:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
|
|
|
|
|
|
)
|
2026-06-07 06:37:49 -07:00
|
|
|
|
if sanitized.get("prompt") and _scan_cron_prompt is not None:
|
|
|
|
|
|
scan_error = _scan_cron_prompt(sanitized["prompt"])
|
|
|
|
|
|
if scan_error:
|
|
|
|
|
|
return web.json_response({"error": scan_error}, status=400)
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_update(job_id, sanitized)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not job:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
_notify_cron_provider_jobs_changed()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_delete_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""DELETE /api/jobs/{job_id} — delete a cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-04-21 12:35:10 +05:30
|
|
|
|
success = _cron_remove(job_id)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not success:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
_notify_cron_provider_jobs_changed()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_pause_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/jobs/{job_id}/pause — pause a cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_pause(job_id)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not job:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
_notify_cron_provider_jobs_changed()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_resume_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/jobs/{job_id}/resume — resume a paused cron job."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_resume(job_id)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not job:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
feat(cron): wire on_jobs_changed, cron.chronos config, docs + agent↔NAS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
2026-06-18 15:11:32 +10:00
|
|
|
|
_notify_cron_provider_jobs_changed()
|
2026-03-22 04:06:57 -07:00
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_run_job(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/jobs/{job_id}/run — trigger immediate execution."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
fix(api-server): harden jobs API — input limits, field whitelist, startup check, tests
Five improvements to the /api/jobs endpoints:
1. Startup availability check — cron module imported once at class load,
endpoints return 501 if unavailable (not 500 per-request import error)
2. Input limits — name ≤ 200 chars, prompt ≤ 5000 chars, repeat must be
positive int
3. Update field whitelist — only name/schedule/prompt/deliver/skills/
repeat/enabled pass through to cron.jobs.update_job, preventing
arbitrary key injection
4. Deduplicated validation — _check_job_id and _check_jobs_available
helpers replace repeated boilerplate
5. 32 new tests covering all endpoints, validation, auth, and
cron-unavailable cases
2026-03-22 04:18:18 -07:00
|
|
|
|
cron_err = self._check_jobs_available()
|
|
|
|
|
|
if cron_err:
|
|
|
|
|
|
return cron_err
|
|
|
|
|
|
job_id, id_err = self._check_job_id(request)
|
|
|
|
|
|
if id_err:
|
|
|
|
|
|
return id_err
|
2026-03-22 04:06:57 -07:00
|
|
|
|
try:
|
2026-04-21 12:35:10 +05:30
|
|
|
|
job = _cron_trigger(job_id)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
if not job:
|
|
|
|
|
|
return web.json_response({"error": "Job not found"}, status=404)
|
|
|
|
|
|
return web.json_response({"job": job})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
|
|
|
feat(cron,gateway): NAS-JWT fire verifier + /api/cron/fire webhook (Chronos)
Phase 4E (E.1 + E.2). The inbound side of Chronos: NAS POSTs the agent when a
one-shot fires; the agent verifies a NAS-minted JWT and runs the job.
E.1 — plugins/cron/chronos/verify.py:
- verify_nas_fire_token(token, expected_audience, jwks_or_key, issuer): verifies
signature against the NAS JWKS (RS/ES family; symmetric rejected), aud == this
agent, exp/nbf, iss, and purpose == "cron_fire" (so a general agent JWT can't
be replayed against the fire endpoint). Returns claims or None; never raises.
Crypto delegated to PyJWT[crypto] (already a declared dep) — no hand-rolled
JWT, no new dependency. No key configured → refuse (never unsigned-decode a
security boundary).
- get_fire_verifier(): pluggable indirection so the DQ-4 escape hatch
(direct per-job cron-key) can swap in with no handler change.
E.2 — gateway/platforms/api_server.py:
- POST /api/cron/fire (registered only when _CRON_AVAILABLE). Authenticated by
the NAS-JWT via get_fire_verifier() — NOT API_SERVER_KEY (NAS holds no API
key; this is the only inbound that triggers remote job execution, so it gets
its own purpose-scoped check). Verifier args come from cron.chronos.* config.
401 on bad/missing/forged token. 400 on missing job_id. On success: 202 +
fire_due runs in the background (so a long agent turn never trips NAS's HTTP
timeout); the store CAS claim inside fire_due de-dupes a scheduler retry.
Tests:
- test_chronos_verify (11): REAL RS256 signing — valid→claims, wrong-aud,
missing/wrong purpose, expired, wrong-iss, tampered-signature (attacker key),
no-key-refuse, empty-token, JWKS-URL key resolution, get_fire_verifier.
- test_cron_fire_webhook (5): valid→202+fire, invalid→401+no-fire, missing
token→401, missing job_id→400, and fire path does NOT require API_SERVER_KEY.
api_server regression suites (214) green.
E.3 (NAS endpoints) is a separate cross-repo PR; the wire contract lands next
(docs/chronos-managed-cron-contract.md).
2026-06-18 14:46:33 +10:00
|
|
|
|
async def _handle_cron_fire(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /api/cron/fire — Chronos managed-cron fire webhook (NAS → agent).
|
|
|
|
|
|
|
|
|
|
|
|
Authenticated by a NAS-minted JWT (verified via the pluggable
|
|
|
|
|
|
fire-verifier), NOT API_SERVER_KEY — NAS holds no API server key, and
|
|
|
|
|
|
this is the only inbound that can trigger remote job execution, so it
|
|
|
|
|
|
gets its own purpose-scoped token check.
|
|
|
|
|
|
|
|
|
|
|
|
Returns 202 + runs the job in the background so a long agent turn never
|
|
|
|
|
|
trips NAS's HTTP timeout. The store CAS claim inside fire_due guards
|
|
|
|
|
|
against double-fire on a NAS/scheduler retry.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from hermes_cli.config import cfg_get, load_config
|
|
|
|
|
|
from plugins.cron.chronos.verify import get_fire_verifier
|
|
|
|
|
|
|
|
|
|
|
|
auth = request.headers.get("Authorization", "")
|
|
|
|
|
|
token = auth[7:].strip() if auth.startswith("Bearer ") else ""
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
claims = get_fire_verifier()(
|
|
|
|
|
|
token=token,
|
|
|
|
|
|
expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""),
|
|
|
|
|
|
jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None,
|
|
|
|
|
|
issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if claims is None:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"cron fire: rejected invalid token: %s",
|
|
|
|
|
|
self._request_audit_log_suffix(request),
|
|
|
|
|
|
)
|
|
|
|
|
|
return web.json_response({"error": "invalid fire token"}, status=401)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
body = {}
|
|
|
|
|
|
job_id = (body or {}).get("job_id")
|
|
|
|
|
|
if not job_id:
|
|
|
|
|
|
return web.json_response({"error": "missing job_id"}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
from cron.scheduler_provider import resolve_cron_scheduler
|
|
|
|
|
|
provider = resolve_cron_scheduler()
|
|
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
# Fire in the background (202 immediately). fire_due claims via the
|
|
|
|
|
|
# store CAS, so a retry while this is in flight is de-duped.
|
|
|
|
|
|
task = asyncio.create_task(
|
|
|
|
|
|
asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop)
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
|
|
except (TypeError, AttributeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({"status": "accepted", "job_id": job_id}, status=202)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Output extraction helper
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-05-03 00:17:49 +02:00
|
|
|
|
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."""
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
if turn_start:
|
|
|
|
|
|
return list(agent_messages)
|
|
|
|
|
|
|
|
|
|
|
|
full_history = prior
|
|
|
|
|
|
full_history.append(current_user)
|
|
|
|
|
|
full_history.extend(agent_messages)
|
|
|
|
|
|
return full_history
|
|
|
|
|
|
|
|
|
|
|
|
full_history = prior
|
|
|
|
|
|
full_history.append(current_user)
|
|
|
|
|
|
full_history.append({"role": "assistant", "content": final_response})
|
|
|
|
|
|
return full_history
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
current_user = {"role": "user", "content": user_message}
|
|
|
|
|
|
expected_prefix = prior + [current_user]
|
|
|
|
|
|
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
|
|
|
|
|
|
|
fix(api_server): emit per-turn transcript on run.completed (#34703) (#34804)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround
The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.
Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.
* fix(api_server): emit per-turn transcript on run.completed (#34703)
WebUI clients lost intermediate (pre-tool-call) assistant text after
switching session pages mid-stream. The session-chat SSE stream delivers
all assistant text as assistant.delta events under one message_id
interleaved with tool.* events, then a single assistant.completed
carrying only the final reply — so a client accumulating deltas into one
buffer cannot reconstruct intermediate text segments that preceded tool
calls, and they vanish from the live view (state.db persists them
correctly).
run.completed now carries the authoritative per-turn transcript
(assistant + tool messages for this turn, in client-safe shape) so any
SSE consumer can reconcile its live view against ground truth without a
separate GET /messages round-trip. Purely additive — clients that ignore
the field are unaffected.
2026-05-29 12:27:49 -07:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def _turn_transcript_messages(
|
|
|
|
|
|
cls,
|
|
|
|
|
|
conversation_history: List[Dict[str, Any]],
|
|
|
|
|
|
user_message: Any,
|
|
|
|
|
|
result: Dict[str, Any],
|
|
|
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""Return this turn's assistant/tool messages in client-safe shape.
|
|
|
|
|
|
|
|
|
|
|
|
The streaming SSE contract delivers all assistant text as
|
|
|
|
|
|
``assistant.delta`` events under one ``message_id`` interleaved with
|
|
|
|
|
|
``tool.*`` events, and a single ``assistant.completed`` carrying only
|
|
|
|
|
|
the final reply. A client that accumulates deltas into one buffer
|
|
|
|
|
|
cannot reconstruct *intermediate* assistant text segments that preceded
|
|
|
|
|
|
tool calls — so when the page is re-opened mid/post-stream those
|
|
|
|
|
|
segments appear lost, even though state.db persisted them correctly.
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
turn = agent_messages[start:]
|
|
|
|
|
|
out: List[Dict[str, Any]] = []
|
|
|
|
|
|
for msg in turn:
|
|
|
|
|
|
if not isinstance(msg, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if msg.get("role") not in {"assistant", "tool"}:
|
|
|
|
|
|
continue
|
|
|
|
|
|
out.append(cls._message_response(msg))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
2026-05-03 00:17:49 +02:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
2026-05-03 00:17:49 +02:00
|
|
|
|
Build the output item array from the agent's messages.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-03 00:17:49 +02:00
|
|
|
|
Walks *result["messages"]* starting at *start_index* and emits:
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
- ``function_call`` items for each tool_call on assistant messages
|
|
|
|
|
|
- ``function_call_output`` items for each tool-role message
|
|
|
|
|
|
- a final ``message`` item with the assistant's text reply
|
|
|
|
|
|
"""
|
|
|
|
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
|
|
messages = result.get("messages", [])
|
2026-05-03 00:17:49 +02:00
|
|
|
|
if start_index > 0:
|
|
|
|
|
|
messages = messages[start_index:]
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
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", {})
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
"type": "function_call",
|
|
|
|
|
|
"name": func.get("name", ""),
|
|
|
|
|
|
"arguments": func.get("arguments", ""),
|
|
|
|
|
|
"call_id": tc.get("id", ""),
|
|
|
|
|
|
})
|
|
|
|
|
|
elif role == "tool":
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
"type": "function_call_output",
|
|
|
|
|
|
"call_id": msg.get("tool_call_id", ""),
|
2026-04-14 20:47:06 -07:00
|
|
|
|
"output": msg.get("content", ""),
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Final assistant message
|
|
|
|
|
|
final = result.get("final_response", "")
|
|
|
|
|
|
if not final:
|
|
|
|
|
|
final = result.get("error", "(No response generated)")
|
|
|
|
|
|
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
"type": "message",
|
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "output_text",
|
|
|
|
|
|
"text": final,
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Agent execution
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_agent(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_message: str,
|
|
|
|
|
|
conversation_history: List[Dict[str, str]],
|
|
|
|
|
|
ephemeral_system_prompt: Optional[str] = None,
|
|
|
|
|
|
session_id: Optional[str] = None,
|
|
|
|
|
|
stream_delta_callback=None,
|
2026-03-30 18:50:27 -07:00
|
|
|
|
tool_progress_callback=None,
|
2026-04-14 09:48:49 -04:00
|
|
|
|
tool_start_callback=None,
|
|
|
|
|
|
tool_complete_callback=None,
|
2026-03-27 11:33:19 -07:00
|
|
|
|
agent_ref: Optional[list] = None,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key: Optional[str] = None,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
) -> tuple:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create an agent and run a conversation in a thread executor.
|
|
|
|
|
|
|
|
|
|
|
|
Returns ``(result_dict, usage_dict)`` where *usage_dict* contains
|
|
|
|
|
|
``input_tokens``, ``output_tokens`` and ``total_tokens``.
|
2026-03-27 11:33:19 -07:00
|
|
|
|
|
|
|
|
|
|
If *agent_ref* is a one-element list, the AIAgent instance is stored
|
|
|
|
|
|
at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
|
|
|
|
|
|
callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
|
|
|
|
|
|
another thread to stop in-progress LLM calls.
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
"""
|
2026-04-16 05:13:39 -07:00
|
|
|
|
loop = asyncio.get_running_loop()
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
def _run():
|
2026-06-08 17:52:03 -06:00
|
|
|
|
from gateway.session_context import clear_session_vars, set_session_vars
|
|
|
|
|
|
|
|
|
|
|
|
tokens = set_session_vars(
|
|
|
|
|
|
platform="api_server",
|
|
|
|
|
|
chat_id=session_id or "",
|
|
|
|
|
|
session_key=gateway_session_key or session_id or "",
|
|
|
|
|
|
session_id=session_id or "",
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
2026-06-08 17:52:03 -06:00
|
|
|
|
try:
|
|
|
|
|
|
agent = self._create_agent(
|
|
|
|
|
|
ephemeral_system_prompt=ephemeral_system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
stream_delta_callback=stream_delta_callback,
|
|
|
|
|
|
tool_progress_callback=tool_progress_callback,
|
|
|
|
|
|
tool_start_callback=tool_start_callback,
|
|
|
|
|
|
tool_complete_callback=tool_complete_callback,
|
|
|
|
|
|
gateway_session_key=gateway_session_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
if agent_ref is not None:
|
|
|
|
|
|
agent_ref[0] = agent
|
|
|
|
|
|
effective_task_id = session_id or str(uuid.uuid4())
|
|
|
|
|
|
result = agent.run_conversation(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=conversation_history,
|
|
|
|
|
|
task_id=effective_task_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
usage = {
|
|
|
|
|
|
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
|
|
|
|
|
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
|
|
|
|
|
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
# Include the effective session ID in the result so callers
|
|
|
|
|
|
# (e.g. X-Hermes-Session-Id header) can track compression-
|
|
|
|
|
|
# triggered session rotations. (#16938)
|
|
|
|
|
|
_eff_sid = getattr(agent, "session_id", session_id)
|
|
|
|
|
|
if isinstance(_eff_sid, str) and _eff_sid:
|
|
|
|
|
|
result["session_id"] = _eff_sid
|
|
|
|
|
|
return result, usage
|
|
|
|
|
|
finally:
|
|
|
|
|
|
clear_session_vars(tokens)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
|
|
|
|
|
return await loop.run_in_executor(None, _run)
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# /v1/runs — structured event streaming
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation
|
|
|
|
|
|
_RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept
|
2026-04-29 06:36:56 -07:00
|
|
|
|
_RUN_STATUS_TTL = 3600 # seconds to retain terminal run status for polling
|
|
|
|
|
|
|
|
|
|
|
|
def _set_run_status(self, run_id: str, status: str, **fields: Any) -> Dict[str, Any]:
|
|
|
|
|
|
"""Update pollable run status without exposing private agent objects."""
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
current = self._run_statuses.get(run_id, {})
|
|
|
|
|
|
current.update({
|
|
|
|
|
|
"object": "hermes.run",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"status": status,
|
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
|
})
|
|
|
|
|
|
current.setdefault("created_at", fields.pop("created_at", now))
|
|
|
|
|
|
current.update(fields)
|
|
|
|
|
|
self._run_statuses[run_id] = current
|
|
|
|
|
|
return current
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
|
|
|
|
|
|
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
|
|
|
|
|
|
"""Return a tool_progress_callback that pushes structured events to the run's SSE queue."""
|
|
|
|
|
|
def _push(event: Dict[str, Any]) -> None:
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
self._run_statuses.get(run_id, {}).get("status", "running"),
|
|
|
|
|
|
last_event=event.get("event"),
|
|
|
|
|
|
)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
q = self._run_streams.get(run_id)
|
|
|
|
|
|
if q is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
|
|
|
|
|
|
ts = time.time()
|
|
|
|
|
|
if event_type == "tool.started":
|
|
|
|
|
|
_push({
|
|
|
|
|
|
"event": "tool.started",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": ts,
|
|
|
|
|
|
"tool": tool_name,
|
|
|
|
|
|
"preview": preview,
|
|
|
|
|
|
})
|
|
|
|
|
|
elif event_type == "tool.completed":
|
|
|
|
|
|
_push({
|
|
|
|
|
|
"event": "tool.completed",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": ts,
|
|
|
|
|
|
"tool": tool_name,
|
|
|
|
|
|
"duration": round(kwargs.get("duration", 0), 3),
|
|
|
|
|
|
"error": kwargs.get("is_error", False),
|
|
|
|
|
|
})
|
|
|
|
|
|
elif event_type == "reasoning.available":
|
|
|
|
|
|
_push({
|
|
|
|
|
|
"event": "reasoning.available",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": ts,
|
|
|
|
|
|
"text": preview or "",
|
|
|
|
|
|
})
|
|
|
|
|
|
# _thinking and subagent_progress are intentionally not forwarded
|
|
|
|
|
|
|
|
|
|
|
|
return _callback
|
|
|
|
|
|
|
|
|
|
|
|
async def _handle_runs(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /v1/runs — start an agent run, return run_id immediately."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
# Long-term memory scope header (see chat_completions for details).
|
|
|
|
|
|
gateway_session_key, key_err = self._parse_session_key_header(request)
|
|
|
|
|
|
if key_err is not None:
|
|
|
|
|
|
return key_err
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# Enforce concurrency limit
|
|
|
|
|
|
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})", code="rate_limit_exceeded"),
|
|
|
|
|
|
status=429,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return web.json_response(_openai_error("Invalid JSON"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
raw_input = body.get("input")
|
|
|
|
|
|
if not raw_input:
|
|
|
|
|
|
return web.json_response(_openai_error("Missing 'input' field"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
user_message = raw_input if isinstance(raw_input, str) else (raw_input[-1].get("content", "") if isinstance(raw_input, list) else "")
|
|
|
|
|
|
if not user_message:
|
|
|
|
|
|
return web.json_response(_openai_error("No user message found in input"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
instructions = body.get("instructions")
|
|
|
|
|
|
previous_response_id = body.get("previous_response_id")
|
2026-04-07 17:41:05 -07:00
|
|
|
|
|
|
|
|
|
|
# Accept explicit conversation_history from the request body.
|
|
|
|
|
|
# Precedence: explicit conversation_history > previous_response_id.
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
conversation_history: List[Dict[str, str]] = []
|
2026-04-07 17:41:05 -07:00
|
|
|
|
raw_history = body.get("conversation_history")
|
|
|
|
|
|
if raw_history:
|
|
|
|
|
|
if not isinstance(raw_history, list):
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error("'conversation_history' must be an array of message objects"),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
for i, entry in enumerate(raw_history):
|
|
|
|
|
|
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])})
|
|
|
|
|
|
if previous_response_id:
|
|
|
|
|
|
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
|
|
|
|
|
|
|
2026-04-14 21:06:32 -07:00
|
|
|
|
stored_session_id = None
|
2026-04-07 17:41:05 -07:00
|
|
|
|
if not conversation_history and previous_response_id:
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
stored = self._response_store.get(previous_response_id)
|
|
|
|
|
|
if stored:
|
|
|
|
|
|
conversation_history = list(stored.get("conversation_history", []))
|
2026-04-14 21:06:32 -07:00
|
|
|
|
stored_session_id = stored.get("session_id")
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
if instructions is None:
|
|
|
|
|
|
instructions = stored.get("instructions")
|
|
|
|
|
|
|
2026-04-07 17:41:34 -07:00
|
|
|
|
# When input is a multi-message array, extract all but the last
|
|
|
|
|
|
# message as conversation history (the last becomes user_message).
|
|
|
|
|
|
# Only fires when no explicit history was provided.
|
|
|
|
|
|
if not conversation_history and isinstance(raw_input, list) and len(raw_input) > 1:
|
|
|
|
|
|
for msg in raw_input[:-1]:
|
|
|
|
|
|
if isinstance(msg, dict) and msg.get("role") and msg.get("content"):
|
|
|
|
|
|
content = msg["content"]
|
|
|
|
|
|
if isinstance(content, list):
|
|
|
|
|
|
# Flatten multi-part content blocks to text
|
|
|
|
|
|
content = " ".join(
|
|
|
|
|
|
part.get("text", "") for part in content
|
|
|
|
|
|
if isinstance(part, dict) and part.get("type") == "text"
|
|
|
|
|
|
)
|
|
|
|
|
|
conversation_history.append({"role": msg["role"], "content": str(content)})
|
|
|
|
|
|
|
2026-04-29 06:36:56 -07:00
|
|
|
|
run_id = f"run_{uuid.uuid4().hex}"
|
2026-04-14 21:06:32 -07:00
|
|
|
|
session_id = body.get("session_id") or stored_session_id or run_id
|
2026-05-05 18:34:58 +02:00
|
|
|
|
approval_session_key = gateway_session_key or session_id or run_id
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
ephemeral_system_prompt = instructions
|
2026-04-29 06:36:56 -07:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue()
|
|
|
|
|
|
created_at = time.time()
|
|
|
|
|
|
self._run_streams[run_id] = q
|
|
|
|
|
|
self._run_streams_created[run_id] = created_at
|
2026-05-05 18:34:58 +02:00
|
|
|
|
self._run_approval_sessions[run_id] = approval_session_key
|
2026-04-29 06:36:56 -07:00
|
|
|
|
|
|
|
|
|
|
event_cb = self._make_run_event_callback(run_id, loop)
|
|
|
|
|
|
|
|
|
|
|
|
# Also wire stream_delta_callback so message.delta events flow through.
|
|
|
|
|
|
def _text_cb(delta: Optional[str]) -> None:
|
|
|
|
|
|
if delta is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop.call_soon_threadsafe(q.put_nowait, {
|
|
|
|
|
|
"event": "message.delta",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"delta": delta,
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"queued",
|
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
model=body.get("model", self._model_name),
|
|
|
|
|
|
)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
|
|
|
|
|
|
async def _run_and_close():
|
|
|
|
|
|
try:
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._set_run_status(run_id, "running")
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
agent = self._create_agent(
|
|
|
|
|
|
ephemeral_system_prompt=ephemeral_system_prompt,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
stream_delta_callback=_text_cb,
|
|
|
|
|
|
tool_progress_callback=event_cb,
|
2026-05-05 05:34:47 -07:00
|
|
|
|
gateway_session_key=gateway_session_key,
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
)
|
2026-04-25 21:45:40 +08:00
|
|
|
|
self._active_run_agents[run_id] = agent
|
2026-05-05 18:34:58 +02:00
|
|
|
|
|
|
|
|
|
|
def _approval_notify(approval_data: Dict[str, Any]) -> None:
|
|
|
|
|
|
event = dict(approval_data or {})
|
|
|
|
|
|
event.update({
|
|
|
|
|
|
"event": "approval.request",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"choices": ["once", "session", "always", "deny"],
|
|
|
|
|
|
})
|
|
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"waiting_for_approval",
|
|
|
|
|
|
last_event="approval.request",
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
def _run_sync():
|
2026-05-05 18:34:58 +02:00
|
|
|
|
from gateway.session_context import clear_session_vars, set_session_vars
|
|
|
|
|
|
from tools.approval import (
|
|
|
|
|
|
register_gateway_notify,
|
|
|
|
|
|
reset_current_session_key,
|
|
|
|
|
|
set_current_session_key,
|
|
|
|
|
|
unregister_gateway_notify,
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
|
|
|
|
|
|
effective_task_id = session_id or run_id
|
|
|
|
|
|
approval_token = None
|
|
|
|
|
|
session_tokens = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Bind approval/session identity for this API run via
|
|
|
|
|
|
# contextvars so concurrent runs do not share process
|
|
|
|
|
|
# environment state.
|
|
|
|
|
|
approval_token = set_current_session_key(approval_session_key)
|
|
|
|
|
|
session_tokens = set_session_vars(
|
|
|
|
|
|
platform="api_server",
|
|
|
|
|
|
session_key=approval_session_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
register_gateway_notify(approval_session_key, _approval_notify)
|
|
|
|
|
|
r = agent.run_conversation(
|
|
|
|
|
|
user_message=user_message,
|
|
|
|
|
|
conversation_history=conversation_history,
|
|
|
|
|
|
task_id=effective_task_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
try:
|
|
|
|
|
|
unregister_gateway_notify(approval_session_key)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if approval_token is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
reset_current_session_key(approval_token)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if session_tokens:
|
|
|
|
|
|
try:
|
|
|
|
|
|
clear_session_vars(session_tokens)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
u = {
|
|
|
|
|
|
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
|
|
|
|
|
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
|
|
|
|
|
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
return r, u
|
|
|
|
|
|
|
|
|
|
|
|
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
|
2026-04-25 10:33:40 +02:00
|
|
|
|
# Check for structured failure (non-retryable client errors like
|
|
|
|
|
|
# 401/400 return failed=True instead of raising, so the except
|
|
|
|
|
|
# block below never fires — issue #15561).
|
|
|
|
|
|
if isinstance(result, dict) and result.get("failed"):
|
|
|
|
|
|
error_msg = result.get("error") or "agent run failed"
|
|
|
|
|
|
q.put_nowait({
|
|
|
|
|
|
"event": "run.failed",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"error": error_msg,
|
|
|
|
|
|
})
|
|
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"failed",
|
|
|
|
|
|
error=error_msg,
|
|
|
|
|
|
last_event="run.failed",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
|
|
|
|
|
q.put_nowait({
|
|
|
|
|
|
"event": "run.completed",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"output": final_response,
|
|
|
|
|
|
"usage": usage,
|
|
|
|
|
|
})
|
|
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"completed",
|
|
|
|
|
|
output=final_response,
|
|
|
|
|
|
usage=usage,
|
|
|
|
|
|
last_event="run.completed",
|
|
|
|
|
|
)
|
2026-04-29 06:36:56 -07:00
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"cancelled",
|
|
|
|
|
|
last_event="run.cancelled",
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait({
|
|
|
|
|
|
"event": "run.cancelled",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.exception("[api_server] run %s failed", run_id)
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._set_run_status(
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
"failed",
|
|
|
|
|
|
error=str(exc),
|
|
|
|
|
|
last_event="run.failed",
|
|
|
|
|
|
)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait({
|
|
|
|
|
|
"event": "run.failed",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
2026-05-05 18:34:58 +02:00
|
|
|
|
# If the asyncio wrapper is cancelled (for example via
|
|
|
|
|
|
# /stop), the executor thread can still be blocked waiting
|
|
|
|
|
|
# on an approval Event. Unregistering here releases those
|
|
|
|
|
|
# waits immediately; the in-thread unregister is harmlessly
|
|
|
|
|
|
# idempotent on normal completion.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.approval import unregister_gateway_notify
|
|
|
|
|
|
|
|
|
|
|
|
unregister_gateway_notify(approval_session_key)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# Sentinel: signal SSE stream to close
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait(None)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-04-25 21:45:40 +08:00
|
|
|
|
self._active_run_agents.pop(run_id, None)
|
|
|
|
|
|
self._active_run_tasks.pop(run_id, None)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
self._run_approval_sessions.pop(run_id, None)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
|
|
|
|
|
|
task = asyncio.create_task(_run_and_close())
|
2026-04-25 21:45:40 +08:00
|
|
|
|
self._active_run_tasks[run_id] = task
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
try:
|
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
|
except TypeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if hasattr(task, "add_done_callback"):
|
|
|
|
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
|
|
|
2026-05-05 05:34:47 -07:00
|
|
|
|
response_headers = (
|
|
|
|
|
|
{"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {}
|
|
|
|
|
|
)
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
{"run_id": run_id, "status": "started"},
|
|
|
|
|
|
status=202,
|
|
|
|
|
|
headers=response_headers,
|
|
|
|
|
|
)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
|
2026-04-29 06:36:56 -07:00
|
|
|
|
async def _handle_get_run(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
run_id = request.match_info["run_id"]
|
|
|
|
|
|
status = self._run_statuses.get(run_id)
|
|
|
|
|
|
if status is None:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Run not found: {run_id}", code="run_not_found"),
|
|
|
|
|
|
status=404,
|
|
|
|
|
|
)
|
|
|
|
|
|
return web.json_response(status)
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
async def _handle_run_events(self, request: "web.Request") -> "web.StreamResponse":
|
|
|
|
|
|
"""GET /v1/runs/{run_id}/events — SSE stream of structured agent lifecycle events."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
run_id = request.match_info["run_id"]
|
|
|
|
|
|
|
|
|
|
|
|
# Allow subscribing slightly before the run is registered (race condition window)
|
|
|
|
|
|
for _ in range(20):
|
|
|
|
|
|
if run_id in self._run_streams:
|
|
|
|
|
|
break
|
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
|
|
|
|
|
|
|
|
|
|
|
q = self._run_streams[run_id]
|
|
|
|
|
|
|
|
|
|
|
|
response = web.StreamResponse(
|
|
|
|
|
|
status=200,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Content-Type": "text/event-stream",
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
"X-Accel-Buffering": "no",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
await response.prepare(request)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
event = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
await response.write(b": keepalive\n\n")
|
|
|
|
|
|
continue
|
|
|
|
|
|
if event is None:
|
|
|
|
|
|
# Run finished — send final SSE comment and close
|
|
|
|
|
|
await response.write(b": stream closed\n\n")
|
|
|
|
|
|
break
|
|
|
|
|
|
payload = f"data: {json.dumps(event)}\n\n"
|
|
|
|
|
|
await response.write(payload.encode())
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._run_streams.pop(run_id, None)
|
|
|
|
|
|
self._run_streams_created.pop(run_id, None)
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
2026-05-05 18:34:58 +02:00
|
|
|
|
|
|
|
|
|
|
async def _handle_run_approval(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /v1/runs/{run_id}/approval — resolve a pending run approval."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
run_id = request.match_info["run_id"]
|
|
|
|
|
|
status = self._run_statuses.get(run_id)
|
|
|
|
|
|
if status is None:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(f"Run not found: {run_id}", code="run_not_found"),
|
|
|
|
|
|
status=404,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return web.json_response(_openai_error("Invalid JSON"), status=400)
|
|
|
|
|
|
|
|
|
|
|
|
raw_choice = str(body.get("choice", "")).strip().lower()
|
|
|
|
|
|
aliases = {"approve": "once", "approved": "once", "allow": "once"}
|
|
|
|
|
|
choice = aliases.get(raw_choice, raw_choice)
|
|
|
|
|
|
allowed = {"once", "session", "always", "deny"}
|
|
|
|
|
|
if choice not in allowed:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(
|
|
|
|
|
|
"Invalid approval choice; expected one of: once, session, always, deny",
|
|
|
|
|
|
code="invalid_approval_choice",
|
|
|
|
|
|
),
|
|
|
|
|
|
status=400,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
approval_session_key = self._run_approval_sessions.get(run_id)
|
|
|
|
|
|
if not approval_session_key:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(
|
|
|
|
|
|
f"Run has no active approval session: {run_id}",
|
|
|
|
|
|
code="approval_not_active",
|
|
|
|
|
|
),
|
|
|
|
|
|
status=409,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-16 02:08:40 +03:00
|
|
|
|
resolve_all = (
|
|
|
|
|
|
_coerce_request_bool(body.get("all"), default=False)
|
|
|
|
|
|
or _coerce_request_bool(body.get("resolve_all"), default=False)
|
|
|
|
|
|
)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
try:
|
|
|
|
|
|
from tools.approval import resolve_gateway_approval
|
|
|
|
|
|
|
|
|
|
|
|
resolved = resolve_gateway_approval(
|
|
|
|
|
|
approval_session_key,
|
|
|
|
|
|
choice,
|
|
|
|
|
|
resolve_all=resolve_all,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.exception("[api_server] approval resolution failed for run %s", run_id)
|
|
|
|
|
|
return web.json_response(_openai_error(str(exc)), status=500)
|
|
|
|
|
|
|
|
|
|
|
|
if resolved <= 0:
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
|
_openai_error(
|
|
|
|
|
|
f"Run has no pending approval: {run_id}",
|
|
|
|
|
|
code="approval_not_pending",
|
|
|
|
|
|
),
|
|
|
|
|
|
status=409,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
self._set_run_status(run_id, "running", last_event="approval.responded")
|
|
|
|
|
|
q = self._run_streams.get(run_id)
|
|
|
|
|
|
if q is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
q.put_nowait({
|
|
|
|
|
|
"event": "approval.responded",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
|
"choice": choice,
|
|
|
|
|
|
"resolved": resolved,
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({
|
|
|
|
|
|
"object": "hermes.run.approval_response",
|
|
|
|
|
|
"run_id": run_id,
|
|
|
|
|
|
"choice": choice,
|
|
|
|
|
|
"resolved": resolved,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-25 21:45:40 +08:00
|
|
|
|
async def _handle_stop_run(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
|
"""POST /v1/runs/{run_id}/stop — interrupt a running agent."""
|
|
|
|
|
|
auth_err = self._check_auth(request)
|
|
|
|
|
|
if auth_err:
|
|
|
|
|
|
return auth_err
|
|
|
|
|
|
|
|
|
|
|
|
run_id = request.match_info["run_id"]
|
|
|
|
|
|
agent = self._active_run_agents.get(run_id)
|
|
|
|
|
|
task = self._active_run_tasks.get(run_id)
|
|
|
|
|
|
|
|
|
|
|
|
if agent is None and task is None:
|
|
|
|
|
|
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
|
|
|
|
|
|
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._set_run_status(run_id, "stopping", last_event="run.stopping")
|
|
|
|
|
|
|
2026-04-25 21:45:40 +08:00
|
|
|
|
if agent is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
agent.interrupt("Stop requested via API")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
if task is not None and not task.done():
|
|
|
|
|
|
task.cancel()
|
2026-04-25 18:38:56 -07:00
|
|
|
|
# Bounded wait: run_conversation() executes in the default
|
|
|
|
|
|
# executor thread which task.cancel() cannot preempt — we rely on
|
|
|
|
|
|
# agent.interrupt() above to break the loop. Cap the wait so a
|
|
|
|
|
|
# slow/unresponsive interrupt can't hang this handler.
|
2026-04-25 21:45:40 +08:00
|
|
|
|
try:
|
2026-04-25 18:38:56 -07:00
|
|
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[api_server] stop for run %s timed out after 5s; "
|
|
|
|
|
|
"agent may still be finishing the current step",
|
|
|
|
|
|
run_id,
|
|
|
|
|
|
)
|
2026-04-25 21:45:40 +08:00
|
|
|
|
except (asyncio.CancelledError, Exception):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return web.json_response({"run_id": run_id, "status": "stopping"})
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
async def _sweep_orphaned_runs(self) -> None:
|
|
|
|
|
|
"""Periodically clean up run streams that were never consumed."""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
stale = [
|
|
|
|
|
|
run_id
|
|
|
|
|
|
for run_id, created_at in list(self._run_streams_created.items())
|
|
|
|
|
|
if now - created_at > self._RUN_STREAM_TTL
|
|
|
|
|
|
]
|
|
|
|
|
|
for run_id in stale:
|
|
|
|
|
|
logger.debug("[api_server] sweeping orphaned run %s", run_id)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
try:
|
|
|
|
|
|
from tools.approval import unregister_gateway_notify
|
|
|
|
|
|
|
|
|
|
|
|
approval_session_key = self._run_approval_sessions.get(run_id)
|
|
|
|
|
|
if approval_session_key:
|
|
|
|
|
|
unregister_gateway_notify(approval_session_key)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
self._run_streams.pop(run_id, None)
|
|
|
|
|
|
self._run_streams_created.pop(run_id, None)
|
2026-04-25 21:45:40 +08:00
|
|
|
|
self._active_run_agents.pop(run_id, None)
|
|
|
|
|
|
self._active_run_tasks.pop(run_id, None)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
self._run_approval_sessions.pop(run_id, None)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
|
2026-04-29 06:36:56 -07:00
|
|
|
|
stale_statuses = [
|
|
|
|
|
|
run_id
|
|
|
|
|
|
for run_id, status in list(self._run_statuses.items())
|
|
|
|
|
|
if status.get("status") in {"completed", "failed", "cancelled"}
|
|
|
|
|
|
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
|
|
|
|
|
|
]
|
|
|
|
|
|
for run_id in stale_statuses:
|
|
|
|
|
|
self._run_statuses.pop(run_id, None)
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# BasePlatformAdapter interface
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
|
|
|
|
|
"""Start the aiohttp web server."""
|
|
|
|
|
|
if not AIOHTTP_AVAILABLE:
|
|
|
|
|
|
logger.warning("[%s] aiohttp not installed", self.name)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-28 14:00:52 -07:00
|
|
|
|
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
2026-05-05 15:12:21 -07:00
|
|
|
|
self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)
|
2026-05-20 08:45:55 -04:00
|
|
|
|
assert self._app is not None
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._app.router.add_get("/health", self._handle_health)
|
2026-04-14 05:17:17 +00:00
|
|
|
|
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
2026-03-28 13:32:39 -07:00
|
|
|
|
self._app.router.add_get("/v1/health", self._handle_health)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._app.router.add_get("/v1/models", self._handle_models)
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._app.router.add_get("/v1/capabilities", self._handle_capabilities)
|
2026-05-27 01:27:26 -07:00
|
|
|
|
self._app.router.add_get("/v1/skills", self._handle_skills)
|
|
|
|
|
|
self._app.router.add_get("/v1/toolsets", self._handle_toolsets)
|
2026-05-20 08:45:55 -04:00
|
|
|
|
# Session/client control surface (thin wrappers over SessionDB + _run_agent)
|
|
|
|
|
|
self._app.router.add_get("/api/sessions", self._handle_list_sessions)
|
|
|
|
|
|
self._app.router.add_post("/api/sessions", self._handle_create_session)
|
|
|
|
|
|
self._app.router.add_get("/api/sessions/{session_id}", self._handle_get_session)
|
|
|
|
|
|
self._app.router.add_patch("/api/sessions/{session_id}", self._handle_patch_session)
|
|
|
|
|
|
self._app.router.add_delete("/api/sessions/{session_id}", self._handle_delete_session)
|
|
|
|
|
|
self._app.router.add_get("/api/sessions/{session_id}/messages", self._handle_session_messages)
|
|
|
|
|
|
self._app.router.add_post("/api/sessions/{session_id}/fork", self._handle_fork_session)
|
|
|
|
|
|
self._app.router.add_post("/api/sessions/{session_id}/chat", self._handle_session_chat)
|
|
|
|
|
|
self._app.router.add_post("/api/sessions/{session_id}/chat/stream", self._handle_session_chat_stream)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
|
|
|
|
|
self._app.router.add_post("/v1/responses", self._handle_responses)
|
|
|
|
|
|
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
|
|
|
|
|
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
|
2026-03-22 04:06:57 -07:00
|
|
|
|
# Cron jobs management API
|
|
|
|
|
|
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
|
|
|
|
|
|
self._app.router.add_post("/api/jobs", self._handle_create_job)
|
|
|
|
|
|
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
|
|
|
|
|
|
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
|
|
|
|
|
|
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
|
|
|
|
|
|
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
|
|
|
|
|
|
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
|
|
|
|
|
|
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
|
feat(cron,gateway): NAS-JWT fire verifier + /api/cron/fire webhook (Chronos)
Phase 4E (E.1 + E.2). The inbound side of Chronos: NAS POSTs the agent when a
one-shot fires; the agent verifies a NAS-minted JWT and runs the job.
E.1 — plugins/cron/chronos/verify.py:
- verify_nas_fire_token(token, expected_audience, jwks_or_key, issuer): verifies
signature against the NAS JWKS (RS/ES family; symmetric rejected), aud == this
agent, exp/nbf, iss, and purpose == "cron_fire" (so a general agent JWT can't
be replayed against the fire endpoint). Returns claims or None; never raises.
Crypto delegated to PyJWT[crypto] (already a declared dep) — no hand-rolled
JWT, no new dependency. No key configured → refuse (never unsigned-decode a
security boundary).
- get_fire_verifier(): pluggable indirection so the DQ-4 escape hatch
(direct per-job cron-key) can swap in with no handler change.
E.2 — gateway/platforms/api_server.py:
- POST /api/cron/fire (registered only when _CRON_AVAILABLE). Authenticated by
the NAS-JWT via get_fire_verifier() — NOT API_SERVER_KEY (NAS holds no API
key; this is the only inbound that triggers remote job execution, so it gets
its own purpose-scoped check). Verifier args come from cron.chronos.* config.
401 on bad/missing/forged token. 400 on missing job_id. On success: 202 +
fire_due runs in the background (so a long agent turn never trips NAS's HTTP
timeout); the store CAS claim inside fire_due de-dupes a scheduler retry.
Tests:
- test_chronos_verify (11): REAL RS256 signing — valid→claims, wrong-aud,
missing/wrong purpose, expired, wrong-iss, tampered-signature (attacker key),
no-key-refuse, empty-token, JWKS-URL key resolution, get_fire_verifier.
- test_cron_fire_webhook (5): valid→202+fire, invalid→401+no-fire, missing
token→401, missing job_id→400, and fire path does NOT require API_SERVER_KEY.
api_server regression suites (214) green.
E.3 (NAS endpoints) is a separate cross-repo PR; the wire contract lands next
(docs/chronos-managed-cron-contract.md).
2026-06-18 14:46:33 +10:00
|
|
|
|
|
|
|
|
|
|
# Chronos managed-cron fire webhook (NAS → agent). Authenticated by a
|
|
|
|
|
|
# NAS-minted JWT (NOT API_SERVER_KEY), so it has its own auth path.
|
|
|
|
|
|
if _CRON_AVAILABLE:
|
|
|
|
|
|
self._app.router.add_post("/api/cron/fire", self._handle_cron_fire)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# Structured event streaming
|
|
|
|
|
|
self._app.router.add_post("/v1/runs", self._handle_runs)
|
2026-04-29 06:36:56 -07:00
|
|
|
|
self._app.router.add_get("/v1/runs/{run_id}", self._handle_get_run)
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
|
2026-05-05 18:34:58 +02:00
|
|
|
|
self._app.router.add_post("/v1/runs/{run_id}/approval", self._handle_run_approval)
|
2026-04-25 21:45:40 +08:00
|
|
|
|
self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run)
|
2026-05-20 08:45:55 -04:00
|
|
|
|
# Store the adapter after native routes are registered. Local Hermes-Relay
|
|
|
|
|
|
# bootstrap shims use this key as a feature-detection hook; registering
|
|
|
|
|
|
# native routes first lets those shims no-op instead of shadowing the
|
|
|
|
|
|
# upstream session-control handlers.
|
|
|
|
|
|
self._app["api_server_adapter"] = self
|
|
|
|
|
|
|
feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).
Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.
Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.
Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.
Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 11:52:46 -07:00
|
|
|
|
# Start background sweep to clean up orphaned (unconsumed) run streams
|
|
|
|
|
|
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._background_tasks.add(sweep_task)
|
|
|
|
|
|
except TypeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if hasattr(sweep_task, "add_done_callback"):
|
|
|
|
|
|
sweep_task.add_done_callback(self._background_tasks.discard)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
|
2026-05-25 18:19:00 +03:00
|
|
|
|
# Refuse to start without authentication. The API server can
|
|
|
|
|
|
# dispatch terminal-capable agent work, so every deployment needs
|
|
|
|
|
|
# an explicit API_SERVER_KEY regardless of bind address.
|
|
|
|
|
|
if not self._api_key:
|
2026-04-10 16:40:54 -07:00
|
|
|
|
logger.error(
|
2026-05-25 18:19:00 +03:00
|
|
|
|
"[%s] Refusing to start: API_SERVER_KEY is required for the API server, "
|
|
|
|
|
|
"including loopback-only binds on %s.",
|
2026-04-10 16:40:54 -07:00
|
|
|
|
self.name, self._host,
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-04-12 18:05:02 -07:00
|
|
|
|
# Refuse to start network-accessible with a placeholder key.
|
|
|
|
|
|
# Ported from openclaw/openclaw#64586.
|
|
|
|
|
|
if is_network_accessible(self._host) and self._api_key:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.auth import has_usable_secret
|
|
|
|
|
|
if not has_usable_secret(self._api_key, min_length=8):
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"[%s] Refusing to start: API_SERVER_KEY is set to a "
|
|
|
|
|
|
"placeholder value. Generate a real secret "
|
|
|
|
|
|
"(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY "
|
|
|
|
|
|
"before exposing the API server on %s.",
|
|
|
|
|
|
self.name, self._host,
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
feat: add profiles — run multiple isolated Hermes instances (#3681)
Each profile is a fully independent HERMES_HOME with its own config,
API keys, memory, sessions, skills, gateway, cron, and state.db.
Core module: hermes_cli/profiles.py (~900 lines)
- Profile CRUD: create, delete, list, show, rename
- Three clone levels: blank, --clone (config), --clone-all (everything)
- Export/import: tar.gz archive for backup and migration
- Wrapper alias scripts (~/.local/bin/<name>)
- Collision detection for alias names
- Sticky default via ~/.hermes/active_profile
- Skill seeding via subprocess (handles module-level caching)
- Auto-stop gateway on delete with disable-before-stop for services
- Tab completion generation for bash and zsh
CLI integration (hermes_cli/main.py):
- _apply_profile_override(): pre-import -p/--profile flag + sticky default
- Full 'hermes profile' subcommand: list, use, create, delete, show,
alias, rename, export, import
- 'hermes completion bash/zsh' command
- Multi-profile skill sync in hermes update
Display (cli.py, banner.py, gateway/run.py):
- CLI prompt: 'coder ❯' when using a non-default profile
- Banner shows profile name
- Gateway startup log includes profile name
Gateway safety:
- Token locks: Discord, Slack, WhatsApp, Signal (extends Telegram pattern)
- Port conflict detection: API server, webhook adapter
Diagnostics (hermes_cli/doctor.py):
- Profile health section: lists profiles, checks config, .env, aliases
- Orphan alias detection: warns when wrapper points to deleted profile
Tests (tests/hermes_cli/test_profiles.py):
- 71 automated tests covering: validation, CRUD, clone levels, rename,
export/import, active profile, isolation, alias collision, completion
- Full suite: 6760 passed, 0 new failures
Documentation:
- website/docs/user-guide/profiles.md: full user guide (12 sections)
- website/docs/reference/profile-commands.md: command reference (12 commands)
- website/docs/reference/faq.md: 6 profile FAQ entries
- website/sidebars.ts: navigation updated
2026-03-29 10:41:20 -07:00
|
|
|
|
# Port conflict detection — fail fast if port is already in use
|
|
|
|
|
|
try:
|
|
|
|
|
|
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
|
|
|
|
|
_s.settimeout(1)
|
|
|
|
|
|
_s.connect(('127.0.0.1', self._port))
|
|
|
|
|
|
logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port)
|
|
|
|
|
|
return False
|
|
|
|
|
|
except (ConnectionRefusedError, OSError):
|
|
|
|
|
|
pass # port is free
|
|
|
|
|
|
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._runner = web.AppRunner(self._app)
|
|
|
|
|
|
await self._runner.setup()
|
|
|
|
|
|
self._site = web.TCPSite(self._runner, self._host, self._port)
|
|
|
|
|
|
await self._site.start()
|
|
|
|
|
|
|
|
|
|
|
|
self._mark_connected()
|
|
|
|
|
|
logger.info(
|
2026-04-09 17:07:29 -07:00
|
|
|
|
"[%s] API server listening on http://%s:%d (model: %s)",
|
|
|
|
|
|
self.name, self._host, self._port, self._model_name,
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("[%s] Failed to start API server: %s", self.name, e)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
async def disconnect(self) -> None:
|
fix(gateway): close ResponseStore + dispose unowned adapter on reconnect failure
Three separate code paths in the gateway's platform reconnect loop
leaked file descriptors every retry, exhausting the default 2560-fd
ulimit in ~12 hours of continuous failure and turning the gateway
into a zombie that raises OSError: [Errno 24] on every open() (#37011).
Root cause:
* APIServerAdapter.__init__ opens a ResponseStore SQLite connection
that holds 2 fds (db file + WAL sidecar).
* APIServerAdapter.disconnect() previously only stopped the aiohttp
web server — the ResponseStore connection was never closed.
* The reconnect watcher in _platform_reconnect_watcher constructs a
fresh adapter on every retry attempt. When the connect call fails
(3 paths: non-retryable error, retryable error, exception during
connect) the adapter is dropped without ever being installed on
self.adapters, so nothing else calls its disconnect(). Result: the
2 ResponseStore fds stay open until GC sweeps the unreachable
object, which Python's cyclic GC does not do promptly for
asyncio-bound native handles.
2 fds × 1 retry × (3600s / 300s backoff cap) ≈ 12 fds/hour.
2560 fds / 12 fds/hr ≈ 12h to ulimit exhaustion.
Fix:
* APIServerAdapter.disconnect() now also calls
self._response_store.close() (with a try/except so a SQLite
close failure doesn't abort the aiohttp teardown).
* New module-level helper _dispose_unused_adapter(adapter) in
gateway/run.py that calls adapter.disconnect() and swallows
any exception (so half-constructed adapters whose __init__
crashed don't kill the watcher loop).
* _platform_reconnect_watcher calls _dispose_unused_adapter() in
all three failure paths: non-retryable, retryable, and the
except Exception arm. adapter = None is initialized
before the try so the except arm can see the partial
construction.
Tests:
* New file tests/gateway/test_platform_reconnect_fd_leak.py with
7 regression tests covering all three failure paths, the
_dispose_unused_adapter helper (None + raising-disconnect cases),
and the APIServerAdapter ResponseStore close behavior (success +
close-exception cases). The _CountingAdapter fixture tracks
disconnect() invocations and an _open_fds counter that is
decremented on dispose, so the assertion is the literal
observable behavior of the leak.
Refs:
- Closes #37011 (the original fd-leak report)
- Supersedes #37018, #37110, #37238, #37260, #37394 (7 competing
open PRs all addressing the same root cause from different angles;
none of them rebased cleanly against current main, and none
covered all three failure paths in one fix with regression tests
for both the watcher and the platform-level close behavior)
2026-06-02 15:06:13 -07:00
|
|
|
|
"""Stop the aiohttp web server and release all owned resources.
|
|
|
|
|
|
|
|
|
|
|
|
Closes the ResponseStore SQLite connection in addition to stopping
|
|
|
|
|
|
the aiohttp web server. Without this, every adapter instance leaks
|
|
|
|
|
|
2 file descriptors (the database file and its WAL sidecar) — the
|
|
|
|
|
|
reconnect loop in ``gateway.run`` constructs a fresh adapter on
|
|
|
|
|
|
every retry, so 2 fds/retry × 300s backoff cap ≈ 12 fds/hour, which
|
|
|
|
|
|
exhausts the default 2560 fd limit after ~12h of failed reconnects
|
|
|
|
|
|
and turns the whole gateway into a zombie
|
|
|
|
|
|
(OSError: [Errno 24] Too many open files, #37011).
|
|
|
|
|
|
"""
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
self._mark_disconnected()
|
fix(gateway): close ResponseStore + dispose unowned adapter on reconnect failure
Three separate code paths in the gateway's platform reconnect loop
leaked file descriptors every retry, exhausting the default 2560-fd
ulimit in ~12 hours of continuous failure and turning the gateway
into a zombie that raises OSError: [Errno 24] on every open() (#37011).
Root cause:
* APIServerAdapter.__init__ opens a ResponseStore SQLite connection
that holds 2 fds (db file + WAL sidecar).
* APIServerAdapter.disconnect() previously only stopped the aiohttp
web server — the ResponseStore connection was never closed.
* The reconnect watcher in _platform_reconnect_watcher constructs a
fresh adapter on every retry attempt. When the connect call fails
(3 paths: non-retryable error, retryable error, exception during
connect) the adapter is dropped without ever being installed on
self.adapters, so nothing else calls its disconnect(). Result: the
2 ResponseStore fds stay open until GC sweeps the unreachable
object, which Python's cyclic GC does not do promptly for
asyncio-bound native handles.
2 fds × 1 retry × (3600s / 300s backoff cap) ≈ 12 fds/hour.
2560 fds / 12 fds/hr ≈ 12h to ulimit exhaustion.
Fix:
* APIServerAdapter.disconnect() now also calls
self._response_store.close() (with a try/except so a SQLite
close failure doesn't abort the aiohttp teardown).
* New module-level helper _dispose_unused_adapter(adapter) in
gateway/run.py that calls adapter.disconnect() and swallows
any exception (so half-constructed adapters whose __init__
crashed don't kill the watcher loop).
* _platform_reconnect_watcher calls _dispose_unused_adapter() in
all three failure paths: non-retryable, retryable, and the
except Exception arm. adapter = None is initialized
before the try so the except arm can see the partial
construction.
Tests:
* New file tests/gateway/test_platform_reconnect_fd_leak.py with
7 regression tests covering all three failure paths, the
_dispose_unused_adapter helper (None + raising-disconnect cases),
and the APIServerAdapter ResponseStore close behavior (success +
close-exception cases). The _CountingAdapter fixture tracks
disconnect() invocations and an _open_fds counter that is
decremented on dispose, so the assertion is the literal
observable behavior of the leak.
Refs:
- Closes #37011 (the original fd-leak report)
- Supersedes #37018, #37110, #37238, #37260, #37394 (7 competing
open PRs all addressing the same root cause from different angles;
none of them rebased cleanly against current main, and none
covered all three failure paths in one fix with regression tests
for both the watcher and the platform-level close behavior)
2026-06-02 15:06:13 -07:00
|
|
|
|
if self._response_store is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._response_store.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"Failed to close response store for %s", self.name, exc_info=True,
|
|
|
|
|
|
)
|
feat: OpenAI-compatible API server + WhatsApp configurable reply prefix (#1756)
* feat: OpenAI-compatible API server platform adapter
Salvaged from PR #956, updated for current main.
Adds an HTTP API server as a gateway platform adapter that exposes
hermes-agent via the OpenAI Chat Completions and Responses APIs.
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at
http://localhost:8642/v1.
Endpoints:
- POST /v1/chat/completions — stateless Chat Completions API
- POST /v1/responses — stateful Responses API with chaining
- GET /v1/responses/{id} — retrieve stored response
- DELETE /v1/responses/{id} — delete stored response
- GET /v1/models — list hermes-agent as available model
- GET /health — health check
Features:
- Real SSE streaming via stream_delta_callback (uses main's streaming)
- In-memory LRU response store for Responses API conversation chaining
- Named conversations via 'conversation' parameter
- Bearer token auth (optional, via API_SERVER_KEY)
- CORS support for browser-based frontends
- System prompt layering (frontend system messages on top of core)
- Real token usage tracking in responses
Integration points:
- Platform.API_SERVER in gateway/config.py
- _create_adapter() branch in gateway/run.py
- API_SERVER_* env vars in hermes_cli/config.py
- Env var overrides in gateway/config.py _apply_env_overrides()
Changes vs original PR #956:
- Removed streaming infrastructure (already on main via stream_consumer.py)
- Removed Telegram reply_to_mode (separate feature, not included)
- Updated _resolve_model() -> _resolve_gateway_model()
- Updated stream_callback -> stream_delta_callback
- Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected()
- Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK)
Tests: 72 new tests, all passing
Docs: API server guide, Open WebUI integration guide, env var reference
* feat(whatsapp): make reply prefix configurable via config.yaml
Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env.
The WhatsApp bridge prepends a header to every outgoing message.
This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize
or disable it via config.yaml:
whatsapp:
reply_prefix: '' # disable header
reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix
How it works:
- load_gateway_config() reads whatsapp.reply_prefix from config.yaml
and stores it in PlatformConfig.extra['reply_prefix']
- WhatsAppAdapter reads it from config.extra at init
- When spawning bridge.js, the adapter passes it as
WHATSAPP_REPLY_PREFIX in the subprocess environment
- bridge.js handles undefined (default), empty (no header),
or custom values with \\n escape support
- Self-chat echo suppression uses the configured prefix
Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a
key 10 (TAVILY_API_KEY), so existing users at v9 would never be
prompted for Tavily. Bumped to 10 to close the gap. Added a
regression test to prevent this from happening again.
Credit: ifrederico (PR #1764) for the bridge.js implementation
and the config version gap discovery.
---------
Co-authored-by: Test <test@test.com>
2026-03-17 10:44:37 -07:00
|
|
|
|
if self._site:
|
|
|
|
|
|
await self._site.stop()
|
|
|
|
|
|
self._site = None
|
|
|
|
|
|
if self._runner:
|
|
|
|
|
|
await self._runner.cleanup()
|
|
|
|
|
|
self._runner = None
|
|
|
|
|
|
self._app = None
|
|
|
|
|
|
logger.info("[%s] API server stopped", self.name)
|
|
|
|
|
|
|
|
|
|
|
|
async def send(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chat_id: str,
|
|
|
|
|
|
content: str,
|
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Not used — HTTP request/response cycle handles delivery directly.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return SendResult(success=False, error="API server uses HTTP request/response, not send()")
|
|
|
|
|
|
|
|
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
|
|
|
|
"""Return basic info about the API server."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": "API Server",
|
|
|
|
|
|
"type": "api",
|
|
|
|
|
|
"host": self._host,
|
|
|
|
|
|
"port": self._port,
|
|
|
|
|
|
}
|