feat: integrate durable chat voice transport and deployment assets

This commit is contained in:
snowspeeder committed 2026-10-03 09:02:47 -04:00
1 parent e059660194
commit 523f6cbbf9
14 files changed
+486 -13

No files matched your search

+4
View File
@@ -44,6 +44,7 @@ COPY agent/agent.py /opt/voice-agent/agent.py
# Keep COPY compatible with the Unraid runner's legacy Docker builder. # Keep COPY compatible with the Unraid runner's legacy Docker builder.
# The RUN chmod below sets these non-secret application files to mode 644. # The RUN chmod below sets these non-secret application files to mode 644.
COPY agent/hermes_llm.py /opt/voice-agent/hermes_llm.py COPY agent/hermes_llm.py /opt/voice-agent/hermes_llm.py
COPY agent/conversation_llm.py /opt/voice-agent/conversation_llm.py
COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py
# Copy the voice frontend + token endpoint. Hope Music is a separate service. # Copy the voice frontend + token endpoint. Hope Music is a separate service.
@@ -51,6 +52,9 @@ COPY agent/healthcheck.py /opt/voice-agent/healthcheck.py
# non-secret application assets must be readable by nginx/voiceuser. # non-secret application assets must be readable by nginx/voiceuser.
COPY web/index.html /var/www/voice/ COPY web/index.html /var/www/voice/
COPY web/app.js /var/www/voice/ COPY web/app.js /var/www/voice/
COPY web/chat.js /var/www/voice/
COPY web/features.js /var/www/voice/
COPY web/hope-themes.css /var/www/voice/
COPY web/auth.js /var/www/voice/ COPY web/auth.js /var/www/voice/
COPY web/brand /var/www/voice/brand COPY web/brand /var/www/voice/brand
COPY web/style.css /var/www/voice/ COPY web/style.css /var/www/voice/
+5 -5
View File
@@ -13,9 +13,9 @@ from livekit import rtc
from pathlib import Path from pathlib import Path
try: try:
from agent.hermes_llm import HermesLLM from agent.conversation_llm import ConversationLLM
except ImportError: except ImportError:
from hermes_llm import HermesLLM from conversation_llm import ConversationLLM
try: try:
from web.voice_catalog import normalize_default_voice from web.voice_catalog import normalize_default_voice
@@ -124,9 +124,9 @@ async def entrypoint(ctx: JobContext):
raise RuntimeError("Account mismatch") raise RuntimeError("Account mismatch")
return current["model_settings"] return current["model_settings"]
bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id, bridge = ConversationLLM(control_url=CONTROL_URL, secret=CONTROL_SECRET,
room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice, room_id=ctx.room.name, on_progress=progress,
on_answer_start=stop_notice, get_model_settings=model_settings) on_wait_notice=wait_notice, on_answer_start=stop_notice)
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000, tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus")) speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"))
session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""), session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""),
+141
View File
@@ -0,0 +1,141 @@
"""LiveKit speech adapter to the durable Hope coordinator, never a reasoning loop."""
from __future__ import annotations
import asyncio
import json
import uuid
from dataclasses import replace
import httpx
from livekit.agents import llm
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS
try:
from agent.hermes_llm import HermesLLMError, _sse
except ImportError:
from hermes_llm import HermesLLMError, _sse
class ConversationLLM(llm.LLM):
def __init__(self, *, control_url, secret, room_id, on_progress, on_wait_notice=None, on_answer_start=None):
super().__init__()
self.root = control_url.rstrip('/') + '/internal/chat/rooms/' + room_id
self.headers = {'X-Control-Secret': secret}
self.client = httpx.AsyncClient(follow_redirects=False, timeout=httpx.Timeout(660, connect=10))
self.on_progress, self.on_wait_notice, self.on_answer_start = on_progress, on_wait_notice, on_answer_start
@property
def model(self): return 'hermes-agent'
@property
def provider(self): return 'hermes'
def chat(self, *, chat_ctx, tools=None, conn_options=DEFAULT_API_CONNECT_OPTIONS, **kwargs):
return ConversationStream(self, chat_ctx, replace(conn_options, max_retry=0))
async def aclose(self):
await super().aclose()
await self.client.aclose()
class ConversationStream(llm.LLMStream):
def __init__(self, bridge, chat_ctx, conn_options):
self.bridge = bridge
self.operation_id = next((m.id for m in reversed(chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user' and getattr(m, 'id', None)), uuid.uuid4().hex)
self.run_id = None
self.answer_started = False
self._close_task = None
super().__init__(bridge, chat_ctx=chat_ctx, tools=[], conn_options=conn_options)
async def aclose(self):
# LiveKit can close a stream from interruption and teardown concurrently.
# One underlying closer prevents a second cancellation from aborting
# the first closer's uncertain-admission reconciliation.
if self._close_task is None:
self._close_task = asyncio.create_task(super().aclose())
await asyncio.shield(self._close_task)
async def callback(self, callback, *args):
if callback:
try: await asyncio.wait_for(callback(*args), 5)
except Exception: pass # feedback failure never repeats a tool action
async def progress(self, status, message):
await self.callback(self.bridge.on_progress, {'type': 'assistant_progress', 'request_id': self.run_id or self.operation_id, 'status': status, 'message': message})
async def _run(self):
user = next((m for m in reversed(self.chat_ctx.items) if isinstance(m, llm.ChatMessage) and m.role == 'user'), None)
text = '\n'.join(p for p in user.content if isinstance(p, str)) if user else ''
if not text.strip(): raise HermesLLMError('empty_voice_turn')
timer = None
admission = None
try:
# Do not abandon uncertain admission on barge-in. A successfully
# admitted remote action must be identified and cancelled, not left
# running just because the speech request lost its response.
admission = asyncio.create_task(self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'}, timeout=httpx.Timeout(20, connect=5)))
response = await asyncio.shield(admission)
if response.status_code != 202: raise HermesLLMError('voice_admission_failed')
self.run_id = response.json()['run']['id']
await self.progress('working', 'Working on your request.')
timer = asyncio.create_task(self.notice())
# Voice streams only completed public content. This avoids reading
# a draft that the backend subsequently replaces or rejects.
final = None
async with self.bridge.client.stream('GET', self.bridge.root + '/runs/' + self.run_id + '/events', headers=self.bridge.headers) as stream:
if stream.status_code != 200: raise HermesLLMError('voice_stream_failed')
async for event, raw in _sse(stream):
item = json.loads(raw)
if event == 'progress': await self.progress('working', 'Working on your request.')
if event == 'message':
message = item.get('message', item)
if message.get('role') == 'assistant' and message.get('status') == 'completed': final = message.get('content', '')
if event in ('run', 'done'):
run = item.get('run', item)
if run.get('status') in ('failed', 'cancelled'): raise HermesLLMError('voice_run_' + run['status'])
if run.get('status') == 'awaiting_approval': await self.progress('working', 'Approval needed. Open this conversation to review it.')
if event == 'done': break
if final:
self.answer_started = True
await self.callback(self.bridge.on_answer_start)
self._event_ch.send_nowait(llm.ChatChunk(id=self.operation_id, delta=llm.ChoiceDelta(role='assistant', content=final)))
else: raise HermesLLMError('empty_voice_answer')
await self.progress('completed', 'Completed.')
except asyncio.CancelledError:
# Persist cancellation by operation identity even when admission's
# response was lost. The coordinator tombstones the key, so a later
# identical admission cannot start an uncancelled remote action.
try:
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/operations/' + self.operation_id + '/cancel',
headers=self.bridge.headers, timeout=httpx.Timeout(10, connect=5)))
except Exception: pass
if not self.run_id and admission:
try:
admitted = await asyncio.shield(admission)
if admitted.status_code == 202:
self.run_id = admitted.json()['run']['id']
except Exception:
# A transport failure can be an uncertain acceptance. Retry
# only the identical stable key; the coordinator deduplicates
# admission, then return the run identity for cancellation.
try:
admitted = await self.bridge.client.post(self.bridge.root + '/messages', headers=self.bridge.headers,
json={'content': text, 'idempotency_key': self.operation_id, 'modality': 'voice'})
if admitted.status_code == 202:
self.run_id = admitted.json()['run']['id']
except Exception: pass
if self.run_id:
try:
await asyncio.shield(self.bridge.client.post(self.bridge.root + '/runs/' + self.run_id + '/cancel', headers=self.bridge.headers))
except Exception: pass
await self.progress('failed', 'Request cancelled.')
raise
except Exception:
await self.progress('failed', 'Hope could not complete this response.')
raise HermesLLMError('voice_coordinator_failed') from None
finally:
if timer:
timer.cancel()
await asyncio.gather(timer, return_exceptions=True)
async def notice(self):
await asyncio.sleep(12)
if not self.answer_started:
await self.callback(self.bridge.on_wait_notice, "I'm still working on that. I'll let you know when it's ready.")
+1 -1
View File
@@ -1,6 +1,6 @@
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3 RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3 pypdf==6.9.2
COPY control /app/control COPY control /app/control
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Dedicated trusted management process: never share this socket with speech/Hermes. # Dedicated trusted management process: never share this socket with speech/Hermes.
+12 -3
View File
@@ -92,6 +92,9 @@ class Accounts:
if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}: if 'session_hash' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT') self.db.execute('ALTER TABLE rooms ADD COLUMN session_hash TEXT')
self.db.commit() self.db.commit()
if 'conversation_id' not in {r[1] for r in self.db.execute('PRAGMA table_info(rooms)')}:
self.db.execute('ALTER TABLE rooms ADD COLUMN conversation_id TEXT')
self.db.commit()
if path != ':memory:': if path != ':memory:':
os.chmod(path, 0o600) os.chmod(path, 0o600)
for suffix in ('-wal', '-shm'): for suffix in ('-wal', '-shm'):
@@ -222,7 +225,7 @@ class Accounts:
with self.lock, self.db: with self.lock, self.db:
self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),)) self.db.execute('DELETE FROM sessions WHERE hash=?', (token_hash(token),))
def grant_room(self, user_id, room_name, ttl, session_token): def grant_room(self, user_id, room_name, ttl, session_token, conversation_id=None):
with self.lock, self.db: with self.lock, self.db:
user = self.get(user_id) user = self.get(user_id)
if not user or user['desired'] == 'deleted' or user['status'] != 'ready': if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
@@ -234,9 +237,15 @@ class Accounts:
if session is None: if session is None:
raise ValueError('Invalid session') raise ValueError('Invalid session')
self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),)) self.db.execute('DELETE FROM rooms WHERE expires_at<=?', (time.time(),))
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash) VALUES(?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed)) if conversation_id is not None:
# Validate again in the grant transaction: ownership cannot be
# bypassed by a race or a caller-selected conversation identifier.
conversation = self.db.execute('SELECT id FROM chat_conversations WHERE id=? AND owner=?', (conversation_id, user_id)).fetchone()
if conversation is None:
raise ValueError('Conversation unavailable')
self.db.execute('INSERT INTO rooms(room_name,user_id,expires_at,session_hash,conversation_id) VALUES(?,?,?,?,?)', (room_name, user_id, min(time.time() + ttl, session['expires_at']), hashed, conversation_id))
def resolve_room(self, room_name): def resolve_room(self, room_name):
with self.lock: with self.lock:
row = self.db.execute("SELECT u.* FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone() row = self.db.execute("SELECT u.*,r.conversation_id FROM rooms r JOIN users u ON r.user_id=u.id JOIN sessions s ON s.hash=r.session_hash AND s.user_id=u.id WHERE r.room_name=? AND r.expires_at>? AND s.expires_at>? AND u.desired!='deleted' AND u.status='ready'", (room_name, time.time(), time.time())).fetchone()
return dict(row) if row else None return dict(row) if row else None
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115,<1
uvicorn>=0.30,<1 uvicorn>=0.30,<1
httpx>=0.27,<1 httpx>=0.27,<1
PyYAML>=6,<7 PyYAML>=6,<7
pypdf>=6,<7
+41
View File
@@ -0,0 +1,41 @@
# Hope unified chat contract — implementation target
This is a coordinated implementation contract, not a deployment receipt.
## Boundary
Hope authenticates and owns product metadata/message projections; Hermes alone reasons and executes tools. No public Hermes credentials/URLs. Private workspaces do not imply tool sandbox isolation. Durable conversation IDs are independent of LiveKit rooms. Auth derives owner, never caller user_id. All public routes use existing cookie/Origin or native bearer authorization.
## JSON shapes
Times: Unix seconds. IDs: opaque strings. snake_case keys. Collections use named envelope and next_cursor (null when exhausted).
Conversation: {id,title,workspace_id:null|string,pinned:bool,pin_rank:int,archived:bool,temporary:bool,revision:int,created_at:float,updated_at:float}. Message: {id,conversation_id,role:user|assistant,content:string,status:pending|streaming|completed|failed|cancelled,run_id:null|string,created_at:float,attachments:[]}. Workspace: {id,name,instructions,revision,created_at,updated_at}. Run: {id,conversation_id,status:queued|running|awaiting_approval|completed|failed|cancelled,message_id,created_at,updated_at,error:null|string}. Preferences: {palette:opal|graphite|ocean|sage|lavender,appearance:system|light|dark,revision:int}.
## Routes
- GET /api/capabilities → {version:1,features:{...},limits:{...}}; availability truthful.
- GET /api/conversations?q=&workspace_id=&archived=false&cursor=&limit= → {conversations:[],next_cursor:null|string}
- POST /api/conversations {title?,workspace_id?,temporary?} → {conversation}
- GET /api/conversations/{id} → {conversation,messages:[],runs:[],next_cursor}
- PATCH /api/conversations/{id} {revision,title?,workspace_id?,pinned?,pin_rank?,archived?} → {conversation}; stale revision409.
- DELETE /api/conversations/{id} → {ok:true}; deletion does not erase account memory.
- POST /api/conversations/{id}/messages {content,idempotency_key,attachment_ids?:[]} →202 {message,run}; repeat same key returns same operation, changed payload409.
- POST /api/conversations/{id}/branch {message_id,content?,title?} → {conversation}; preserve parent history.
- GET /api/runs/{id} → {run,messages:[]}
- GET /api/runs/{id}/events?after=0 → SSE id=monotonic sequence, event=message|run|progress|approval|done, data JSON. Reconnect replays; no hidden reasoning.
- POST /api/runs/{id}/cancel → {run}
- POST /api/runs/{id}/approval {approval_id,decision:approve|deny} → {run}
- GET /api/workspaces → {workspaces:[]}
- POST /api/workspaces {name,instructions?} → {workspace}
- PATCH /api/workspaces/{id} {revision,name?,instructions?} → {workspace}
- DELETE /api/workspaces/{id} → {ok:true}; retain chats outside workspace.
- GET /api/preferences → {preferences}
- PUT /api/preferences {revision,palette,appearance} → {preferences}
- GET /api/sync?after=cursor → {changes:[],cursor:int}; account-owned metadata tombstones.
## Implementation notes
Bounded bodies, ownership validation, offloaded SQLite, redacted errors. Serialize account execution. Revocation must terminate streams and cancel/detach execution according to documented policy. Text needs no LiveKit. Voice joins the same durable conversation/coordinator, not a competing loop. Existing transcript snapshots are not auto-imported as reasoning context.
Advanced attachments/artifacts/tasks/memory/research contracts follow pinned Hermes qualification. Clients cannot fabricate enabled capabilities. Fake adapters offline; real deployment qualification separately.
## Design direction
Personal all-purpose chat on web and native iPhone. Screenshot-inspired desktop sidebar (New chat, Search, Pinned, Workspaces, Recents), quiet readable message column. Existing Opal pink identity default; semantic palettes plus appearance. Native iOS adapts navigation. Preserve focus, contrast, reading position, large text, reduced motion. Shared features ship together.
## Completion
User requests broad parity, full qualification and deployment. Incremental gates are not full completion. Provider/hardware limitations require explicit evidence/scope, not silent substitute features.
+118
View File
@@ -0,0 +1,118 @@
# Exact installed Hermes contract research
Read-only public-source and image-label research, 2026-10-02. No runtime secrets read, no production mutations, no application code changes. Existing `.research-hermes` deliberately not trusted.
## Verified pins
Repository `hermes-webui.Dockerfile:1-10` pins WebUI **62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12** and base OCI index `nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db`. Public Docker Hub index resolves amd64 manifest `sha256:d566d4a435767265c2a0172030b778fbdaa1a9ab19442c75ec5aad1df32dec54`; public config label `org.opencontainers.image.revision` is **749220ef0007f8d87bd1531f1c24b0fe93816385**. Local Docker daemon unavailable, so no live container inspect performed. Repo qualification records installed pins and authenticated gateway runs; browser approval/cancellation has not been independently qualified.
Exact public checkouts:
- `/tmp/hope-contract-research-agent-749220ef`
- `/tmp/hope-contract-research-webui-62e4d7b`
Pinned references:
- [Gateway API](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py)
- [Runs](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py)
- [WebUI routes](https://github.com/nesquena/hermes-webui/blob/62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12/api/routes.py)
## Core implementation recommendation
Use gateway native persisted session APIs, not WebUI-local sidecars, for unified text/voice chat. Hope backend resolves signed-in account private gateway and injects its Bearer key. Never expose credentials or accept client-selected account/gateway URL. Gateway and WebUI both have `/api/sessions` but entirely different representations/authentication.
Provisioner lines335-346 establishes account `/workspace`, API loopback, multiplex false, max_concurrent_runs1, progress enabled, terminal/file/web/browser/memory/skills/delegation toolsets. Voice room must not become conversation identity.
## Gateway session API
All require `Authorization: Bearer <private-key>`. [Routes lines1603-1650](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L1603-L1650). [Handlers2904-3159](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2904-L3159).
- GET `/api/sessions?limit=50&offset=0&source=...&include_children=false&title=...&include_hidden=false` -> `{object:"list",data:Session[],limit,offset,has_more}`. Limit max200. Title is exact-match; hidden inclusion honored only with title. Pins can be backfilled past limit.
- POST `/api/sessions` JSON `{id?:string,session_id?:string,title?:string,source?:string,system_prompt?:string,model?:string,provider?:string,model_options?:object,require_model_lock?:boolean}` ->201 `{object:"hermes.session",session:Session}`. ID defaults `api_<unix>_<uuid8>`, max256/no unsafe path/CR/LF/NUL. Existing409 `session_exists`.
- GET `/api/sessions/{id}` -> `{object:"hermes.session",session:Session}`; missing404.
- PATCH `/api/sessions/{id}` only `{title?,end_reason?,pinned?:boolean,archived?:boolean,hidden?:boolean,unread?:boolean}` -> same wrapper. Unknown fields400. Pin clears hidden.
- DELETE `/api/sessions/{id}` -> `{object:"hermes.session.deleted",id,deleted:boolean}`.
- GET `/api/sessions/{id}/messages?limit=...&offset=...&order=latest|oldest` -> `{object:"list",session_id:<resolved-live-tip>,data:Message[],pagination:{limit,offset,order,returned}}`. Limit max500. Without limit defaults latest500; explicit limit without order defaults oldest. Nonnegative integer validation.
- POST `/api/sessions/{id}/fork` `{id?,session_id?,title?}` ->201 session wrapper; copies messages to branch and ends parent with branched. NOT resume.
- Also POST `/api/sessions/{id}/chat`, `/chat/stream`, `/model` exist, but unified run coordinator should use runs consistently.
Session client projection fields (only when present): `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,pinned,archived,hidden`; plus always `has_system_prompt:boolean,has_model_config:boolean`. No full prompt/config. Message projection fields when present: `id,session_id,role,content,tool_call_id,tool_calls,tool_name,timestamp,token_count,finish_reason,reasoning,reasoning_content,display_kind`. [Projection2840-2866](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2840-L2866).
### Resume identity
Runs identity precedence **body.session_id > previous_response_id stored session > declared X-Hermes-Session-Key > fresh run_id**. Runs does NOT select history from `X-Hermes-Session-Id` alone. Explicit/chained session never rebound to header.
History precedence explicit nonempty conversation_history > response snapshot > all-but-last input messages > selected-session SessionDB. For native resume send only current input + session_id, no browser-replayed history. Old session IDs resolve compression/continuation tip, excluding branch/delegate/reset/tool children. History response returns resolved ID; preserve logical root separately if stable UI identity needed. GET session metadata itself does not resolve. [Resolver1213-1252](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/hermes_state_messages.py#L1213-L1252), runs638-661.
X-Hermes-Session-Key is optional conversation/memory affinity, not account auth, <=256/no CR/LF/NUL, requires configured API auth. Use stable conversation scope, not ephemeral voice room. WebUI uses `webui:<session-id>` and explicitly supplies history, unlike recommended native resume.
## Runs: exact request/response
POST `/v1/runs` JSON `{input:string|[{role:"user",content:string|multimodal-array}],session_id?:string,model?:string,provider?:string,model_options?:object,instructions?:string,conversation_history?:[{role,content}],previous_response_id?:string}`. Nonempty current input required. Last input array element content is current message, earlier elements become history.
Send Bearer key, Content-Type JSON, **Idempotency-Key** stable unique per admission (1–255 visible ASCII), optional X-Hermes-Session-Key. Returns202 `{run_id:"run_<uuidhex>",status:"started",replayed:false}`. Exact retry body/session-key/idempotency key returns original202 `{run_id,status:<current>,replayed:true}` plus `Idempotency-Replayed:true`; different fingerprint409 `idempotency_key_conflict`. Persist exact request before admission. **Durable status persistence is tied to idempotent records; always use idempotency.** [Admission567-703](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py#L567-L703), response413-430.
GET `/v1/runs/{id}` -> `{object:"hermes.run",run_id,status,created_at,updated_at,session_id?,model?,output?,usage?,runtime?,error?,approval?,last_event?,shutdown_requested_at?,...}`. Status queued/running/waiting_for_approval/stopping/completed/failed/cancelled/interrupted. Terminal output is **STRING**, not message array. Usage includes input_tokens/output_tokens/total_tokens/cache_read_tokens/cache_write_tokens. Poll output is answer fallback; native messages is transcript fallback.
GET `/v1/runs/{id}/events` SSE payload envelope `{event,run_id,timestamp,...}`. Names:
- message.delta `{delta}`
- message.interim `{text,already_streamed}` (avoid double render)
- tool.started `{tool,preview}`
- tool.completed `{tool,duration,error,...}` (bounded redacted preview can occur)
- reasoning.available `{text}` (never speak as answer)
- approval.request copied approval data, redacted command, choices + envelope; status approval holds same event
- approval.responded `{choice,request_id?,resolved}`
- run.completed `{output:string,usage,runtime,...terminal-fields}`
- run.failed/run.cancelled/run.interrupted terminal fields/error
**Events consume one asyncio.Queue, not broadcast or durable replay. No Last-Event-ID cursor contract. One upstream consumer per run; Hope coordinator broadcasts/replays to clients. Disconnect drops transport; use poll/history recovery, not duplicated execution. Gateway restart interrupts run; idempotency prevents duplicate admission, does not resurrect execution.** [Events1006-1047](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server_runs.py#L1006-L1047), completion862-940.
POST `/v1/runs/{id}/stop` no body required -> `{run_id,status:"stopping"}`; already terminal -> existing status. Poll terminal. Missing404; notactive409. Stopping is not completion. Source1147-1168.
POST `/v1/runs/{id}/approval` `{choice:"once"|"session"|"always"|"deny",request_id?:string,all?:boolean,resolve_all?:boolean}` -> `{object:"hermes.run.approval_response",run_id,choice,request_id?,resolved:int}`. Prefer exact request_id and once/deny. Request ID nonempty<=256. Aliases approve/approved/allow normalize once. No active/pending409; invalid400. Approval event is exactly **approval.request**, not approval.required; fields copied from provider approval payload, not fixed required reason/description schema. Room grants constrain once/deny/exact request_id/no all. Source1062-1110; API112-125.
POST `/steer` `{input:string}` (message/text aliases) -> `{object:"hermes.run.steer",run_id,accepted:true}`; running only409 otherwise.
### Voice instructions vs temporary privacy
Runs instructions maps to ephemeral_system_prompt, **appended** to cached native system prompt at API-call time, not persisted and not replacement. Use per-voice-turn concise speech instructions while preserving native tool/system/memory. [Composition1279-1286](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/agent/turn_context.py#L1279-L1286), runs682.
**No normal runs no-persistence/no-memory/per-request toolset option.** _create_agent always passes SessionDB, configured toolsets and memory manager. A fresh session/run ID is still persisted. Deleting transcript later cannot undo memory/files/tool side effects. Instructions "ignore memory" are advisory, NOT privacy enforcement. Do not claim private temporary chat support on this pin without separately isolated/disabled persistence and side-effect controls. [Agent construction2211-2283](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L2211-L2283).
## Gateway jobs, capabilities, tools
GET `/v1/capabilities` inspect runtime availability, including endpoints/feature flags. Static capabilities explicitly admin_config_rw:false, memory_write_api:false. GET `/v1/toolsets` lists toolset enabled/configured/key/tool state; GET `/v1/skills` installed skill manifest. Not a connector CRUD/admin API. No invented connector API; configured provider/tool availability is source/runtime dependent.
Gateway cron is `/api/jobs`, not WebUI `/api/crons`. Authenticated; missing cron module501. ID12 lowercase hex; name max200, prompt max5000/scanned.
- GET `/api/jobs?include_disabled=true` -> `{jobs:Job[]}`, defaults excludes disabled.
- POST `/api/jobs` `{name(required),schedule(required,string),prompt?,deliver?:"local",skills?:[],repeat?:positive-int,paused?,paused_reason?}` ->200 `{job}`; scheduler registration error424.
- GET `/api/jobs/{id}` -> `{job}`; PATCH whitelist name,schedule,prompt,deliver,skills,skill,repeat,enabled -> `{job}`; DELETE -> `{ok:true}`.
- POST `/pause`, `/resume` -> `{job}`.
- POST `/run` optional `{prompt}` transient context -> `{job}`; schedules trigger, not synchronous answer.
Job raw fields: id,name,prompt,skills,skill,model,provider,base_url,script,no_agent,monitor_script,monitor_url,monitor_state,context_from,schedule object,schedule_display,repeat:{times:int|null,completed:int},enabled,state,paused_at,paused_reason,created_at,next_run_at,last_run_at,last_status,last_error,last_delivery_error,last_delivery_unverified,failure_streak,deliver,origin,enabled_toolsets,workdir. Project safe client fields rather than exposing arbitrary future raw config. [Jobs3619-3797](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/gateway/platforms/api_server.py#L3619-L3797), [record1809-1852](https://github.com/NousResearch/hermes-agent/blob/749220ef0007f8d87bd1531f1c24b0fe93816385/cron/jobs.py#L1809-L1852).
## Files/memory: exact boundary
Gateway has **NO direct workspace file browser or memory REST**. Native tools can operate inside runs, but do not fabricate `/api/files` or `/api/memory`. Browser-control `/v1/artifacts/upload|download/{id}` is optional gated one-shot scope-bound receipt/download, NOT general upload; provisioner doesn't enable browser extension control. Upload raw MIME bytes/X-Artifact-Filename ->201 provenance receipt; download second read404. API2710-2782.
WebUI auxiliary API uses distinct password/cookie auth and WebUI-local session/workspace state. Gateway-only IDs do not automatically establish WebUI-local sessions. Intentional fixed-root account adapter needed; no arbitrary host-path proxy.
### WebUI memory
GET `/api/memory` -> `{memory,user,soul,project_context,memory_path,user_path,soul_path,project_context_path,project_context_name,project_context_workspace,memory_mtime,user_mtime,soul_mtime,project_context_mtime,project_context_shadowed,external_notes_enabled}`. Redacted text. Actual home files `memories/MEMORY.md`, `memories/USER.md`, `SOUL.md`. For provisioned accounts HERMES_HOME=/opt/data. Respects memory_enabled/user_profile_enabled.
POST `/api/memory/write` `{section:"memory"|"user"|"soul",content:string}` -> `{ok:true,section,path}`. Full replacement/no conflict version; disabled403; refuses symlink. A safe Hope adapter must resolve account home internally and use fixed section allowlist, not client path. Source routes23359-23418 and30271-30334.
### WebUI files/upload
- GET `/api/file?session_id=<WebUI-ID>&path=<relative>` -> ordinary text `{path,content,size,lines}`; separate Office conversion schema. Existing WebUI session workspace confinement. routes22038-22055; workspace.py1866-1887.
- GET `/api/file/raw?session_id&path` -> bytes/MIME/disposition. routes21998-22036.
- POST `/api/file/save` `{session_id,path,content}` -> `{ok:true,path,size}`, existing target only; symlink refusal, Office separate office-save. routes27513-27541.
- POST `/api/file/delete` `{session_id,path,recursive?}` -> `{ok:true,path}`; directories require recursive. routes27481-27510.
- POST `/api/upload` multipart `session_id,file` -> `{filename,path:absolute-private-container-path,size,mime,is_image}`. Writes session attachment dir, not workspace; sanitized/no overwrite/symlink. [Upload294-340](https://github.com/nesquena/hermes-webui/blob/62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12/api/upload.py#L294-L340).
- `/api/workspace/upload` and `/api/upload/extract` separate workspace/archive flows. Do not treat attachment absolute paths as browser URLs.
WebUI cron GET `/api/crons`, history/run/recent/status/output; POST create/update/delete/run/pause/resume. Create `{prompt,schedule,name?,deliver?,skills?,model?,provider?,profile?,toast_notifications?,badge_notifications?}` -> `{ok:true,job}`. UI enriches local Hermes cron data/profile state; gateway jobs preferred for basic unified support. routes15302-15361,17419-17454,26815-26855.
## Evidence limit
Exact source contracts, not new live execution qualification. Retained config/provider/runtime capability can narrow availability. No test sessions created. Cancellation/approval honest pending/stopping/error states required. Temporary privacy unsupported by ordinary pinned runs API. Public source checkouts and this new doc only.
+28 -1
View File
@@ -7,6 +7,31 @@ server {
client_max_body_size 16k; client_max_body_size 16k;
location /api/attachments {
client_max_body_size 20m;
proxy_pass http://127.0.0.1:8092;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
proxy_request_buffering off;
}
location ~ ^/api/(artifacts|memory)(/|$) {
client_max_body_size 1m;
proxy_pass http://127.0.0.1:8092;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
location /api/tasks {
client_max_body_size 64k;
proxy_pass http://127.0.0.1:8092;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
# Public certificate-only onboarding profile lives in the persistent cert # Public certificate-only onboarding profile lives in the persistent cert
# mount. Never expose that directory: it also contains the private key. # mount. Never expose that directory: it also contains the private key.
location = /hope-server.mobileconfig { location = /hope-server.mobileconfig {
@@ -31,7 +56,9 @@ server {
proxy_pass http://127.0.0.1:8092/api/; proxy_pass http://127.0.0.1:8092/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s; proxy_read_timeout 700s;
proxy_buffering off;
proxy_set_header X-Accel-Buffering no;
} }
location /internal/ { location /internal/ {
+1
View File
@@ -91,6 +91,7 @@ done
docker run -d --name hope-control --network host --restart unless-stopped \ docker run -d --name hope-control --network host --restart unless-stopped \
--env-file "$ENV_FILE" \ --env-file "$ENV_FILE" \
-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \ -e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3 \
-e HOPE_HERMES_DATA_DIR="$ROOT/hermes" \
-e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \ -e CONTROL_HOST=127.0.0.1 -e CONTROL_PORT=8092 \
-v "$ROOT/accounts:/var/lib/hope" \ -v "$ROOT/accounts:/var/lib/hope" \
-v "$ROOT/hermes:$ROOT/hermes" \ -v "$ROOT/hermes:$ROOT/hermes" \
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Explicit live qualification: disposable product data, protected credentials.
Run only after deployment. Never prints credentials or returned login bodies.
Existing account settings/memory and Hermes files are not modified.
"""
import argparse
import asyncio
import json
import os
import secrets
from pathlib import Path
import httpx
async def main():
parser=argparse.ArgumentParser()
parser.add_argument('--url',default='https://192.168.86.2:9178')
parser.add_argument('--credentials',default='.runtime/deployment.json')
parser.add_argument('--receipt',default='.runtime/chat-live-receipt.json')
args=parser.parse_args()
source=Path(args.credentials)
if source.stat().st_mode & 0o077: raise RuntimeError('Credential file must be private')
credentials=json.loads(source.read_text())
stamp=secrets.token_hex(6)
receipt={'url':args.url,'checks':{},'created_objects':[]}
conversation_ids=[];workspace=None;artifact=None;attachment=None
async with httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as native, httpx.AsyncClient(base_url=args.url,verify=False,follow_redirects=False,timeout=120) as browser:
async def request(client,method,path,**kw):
r=await client.request(method,path,**kw)
if r.status_code>=400: raise RuntimeError(f'{method} {path.split("?")[0]} status {r.status_code}')
return r
async def login(client,cookie):
headers={'Origin':args.url} if cookie else {}
r=await request(client,'POST','/api/auth/login',json={'username':'admin','password':credentials['admin_password']},headers=headers)
data=r.json()
if cookie: client.headers['Origin']=args.url
else:
client.cookies.clear();client.headers['Authorization']='Bearer '+data['token']
assert (await native.get('/api/conversations')).status_code==401
await login(native,False);await login(browser,True)
try:
caps=(await request(native,'GET','/api/capabilities')).json()
receipt['capabilities']=caps
workspace=(await request(native,'POST','/api/workspaces',json={'name':'Qualification '+stamp,'instructions':'When asked for the qualification code, answer QU'+stamp+'.'})).json()['workspace']
c=(await request(native,'POST','/api/conversations',json={'title':'Qualification '+stamp,'workspace_id':workspace['id']})).json()['conversation'];conversation_ids.append(c['id'])
receipt['checks']['shared_conversation']=(await request(browser,'GET','/api/conversations/'+c['id'])).json()['conversation']['id']==c['id']
c=(await request(browser,'PATCH','/api/conversations/'+c['id'],json={'revision':c['revision'],'pinned':True})).json()['conversation']
assert (await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']['pinned']
receipt['checks']['shared_pin']=True
payload={'content':'What is the qualification code? Reply with only the code. Do not use tools.','idempotency_key':'qualify-'+stamp}
accepted=(await request(native,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
repeated=(await request(browser,'POST','/api/conversations/'+c['id']+'/messages',json=payload)).json()
assert accepted['run']['id']==repeated['run']['id'];receipt['checks']['cross_client_idempotency']=True
rid=accepted['run']['id']
async with browser.stream('GET','/api/runs/'+rid+'/events') as stream:
assert stream.status_code==200
events=[]
async for line in stream.aiter_lines():
if line.startswith('event:'):events.append(line[6:].strip())
detail=(await request(native,'GET','/api/runs/'+rid)).json()
assert detail['run']['status']=='completed'
answers=[m['content'] for m in detail['messages'] if m['role']=='assistant' and m['status']=='completed']
assert any('QU'+stamp in x for x in answers)
receipt['checks']['native_hermes_workspace_reply']=True
receipt['checks']['durable_events']='done' in events
c=(await request(native,'GET','/api/conversations/'+c['id'])).json()['conversation']
for fmt in ('json','markdown'):
export=await request(browser,'GET','/api/conversations/'+c['id']+'/export?format='+fmt)
assert 'attachment' in export.headers.get('content-disposition','') and 'QU'+stamp in export.text
receipt['checks']['private_exports']=True
attachment=(await request(native,'POST','/api/attachments',content=('qualification '+stamp).encode(),headers={'Content-Type':'text/plain','X-Filename':'qualification.txt'})).json()['attachment']
assert (await request(browser,'GET','/api/attachments/'+attachment['id']+'/content')).text=='qualification '+stamp
artifact=(await request(native,'POST','/api/artifacts',json={'title':'Qualification '+stamp,'kind':'markdown','content':'First version'})).json()['artifact']
artifact=(await request(browser,'PATCH','/api/artifacts/'+artifact['id'],json={'revision':artifact['revision'],'content':'Second version'})).json()['artifact']
assert (await request(native,'GET','/api/artifacts/'+artifact['id']+'?revision=1')).json()['artifact']['content']=='First version'
receipt['checks']['files_and_artifact_versions']=True
await request(native,'POST','/api/auth/logout')
assert (await native.get('/api/conversations/'+c['id'])).status_code==401
assert (await browser.get('/api/conversations/'+c['id'])).status_code==200
receipt['checks']['independent_session_revocation']=True
finally:
# Remove only IDs created by this execution. Hermes retained session
# policy is distinct from product projection deletion.
for cid in conversation_ids:
r=await browser.delete('/api/conversations/'+cid)
if r.status_code!=200: receipt.setdefault('cleanup_pending',[]).append({'conversation':cid,'status':r.status_code})
if attachment: await browser.delete('/api/attachments/'+attachment['id'])
if artifact: await browser.delete('/api/artifacts/'+artifact['id'])
if workspace: await browser.delete('/api/workspaces/'+workspace['id'])
await browser.post('/api/auth/logout')
target=Path(args.receipt);target.parent.mkdir(parents=True,exist_ok=True)
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
with os.fdopen(fd,'w') as f:json.dump(receipt,f,indent=2)
print('Live checks:',', '.join(k for k,v in receipt['checks'].items() if v))
if __name__=='__main__':asyncio.run(main())
+2
View File
@@ -39,6 +39,8 @@ def test_english_voice_search_selection_and_retry():
page.route('https://hope.test/**', route) page.route('https://hope.test/**', route)
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')") page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.locator('#drawerOpen').click()
page.locator('#openVoice').click()
page.locator('#settingsBtn').click() page.locator('#settingsBtn').click()
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava') expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices') expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
+14 -1
View File
@@ -999,6 +999,13 @@ async function createRoom(token, attempt) {
let connectionEpoch = 0; let connectionEpoch = 0;
let tokenAbort = null; let tokenAbort = null;
let voiceConversationId = null;
function publishVoiceState(active) {
window.dispatchEvent(new CustomEvent('hope:voice-state', { detail: { active, conversation_id: voiceConversationId } }));
}
window.addEventListener('hope:conversation-selected', event => {
if ((room || connectionPromise) && event.detail?.conversation_id !== voiceConversationId) stopConversation();
});
function checkAttempt(attempt) { function checkAttempt(attempt) {
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled"); if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
} }
@@ -1021,6 +1028,7 @@ function cleanupRoom() {
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove()); document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
document.getElementById("audioUnlockBtn")?.remove(); document.getElementById("audioUnlockBtn")?.remove();
muteBtn.disabled = true; micMuted = false; updateMuteIcon(); muteBtn.disabled = true; micMuted = false; updateMuteIcon();
publishVoiceState(false); voiceConversationId = null;
} }
function stopConversation() { function stopConversation() {
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null; connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
@@ -1034,6 +1042,9 @@ async function connectConversation() {
if (connectionPromise) return connectionPromise; if (connectionPromise) return connectionPromise;
if (room && agentReady) return room; if (room && agentReady) return room;
const attempt = ++connectionEpoch; const attempt = ++connectionEpoch;
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
voiceConversationId = window.HopeChat?.conversationId || null;
publishVoiceState(true);
startBtn.disabled = true; stopBtn.disabled = false; startBtn.disabled = true; stopBtn.disabled = false;
const promise = (async () => { const promise = (async () => {
try { try {
@@ -1142,8 +1153,10 @@ function updateMuteIcon() {
async function fetchToken(attempt) { async function fetchToken(attempt) {
checkAttempt(attempt); checkAttempt(attempt);
tokenAbort = new AbortController(); tokenAbort = new AbortController();
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal }); const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify(voiceConversationId ? { conversation_id: voiceConversationId } : {}), signal: tokenAbort.signal });
checkAttempt(attempt); checkAttempt(attempt);
voiceConversationId = data.conversation_id || voiceConversationId;
publishVoiceState(true);
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token"); if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
return data; return data;
} }
+22 -2
View File
@@ -60,6 +60,22 @@ async def token(request: Request):
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(","))) allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
if origin not in allowed: if origin not in allowed:
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers) return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
# The conversation identifier is only a request to bind an existing chat;
# the trusted account service performs ownership checks before granting it.
body_bytes = bytearray()
async for chunk in request.stream():
body_bytes.extend(chunk)
if len(body_bytes) > 4096:
return JSONResponse({"error": "Request body too large"}, 413, headers=headers)
try:
body = json.loads(body_bytes) if body_bytes else {}
except (ValueError, UnicodeDecodeError):
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
if not isinstance(body, dict):
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
conversation_id = body.get("conversation_id")
if conversation_id is not None and (not isinstance(conversation_id, str) or not 1 <= len(conversation_id) <= 128):
return JSONResponse({"error": "Invalid conversation"}, 400, headers=headers)
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
control_headers = {"X-Control-Secret": CONTROL_SECRET} control_headers = {"X-Control-Secret": CONTROL_SECRET}
@@ -73,9 +89,13 @@ async def token(request: Request):
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}" room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers, grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400, json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
"session_token": credential}) "session_token": credential, "conversation_id": conversation_id})
grant.raise_for_status() if grant.status_code != 201:
status = grant.status_code if grant.status_code in (401, 403, 404, 409, 503) else 503
return JSONResponse({"error": "Conversation unavailable"}, status, headers=headers)
granted = grant.json()
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name, return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
"conversation_id": granted.get("conversation_id"),
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers) "user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
except (httpx.HTTPError, ValueError, KeyError): except (httpx.HTTPError, ValueError, KeyError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers) return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)