feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS protocol for the native Android client, behind Caddy: - Platform adapter (BasePlatformAdapter subclass) registered via the documented plugin system (plugins.platforms pattern, kind: platform) - Multiple conversations mapped to Hermes sessions (authoritative state) - Streaming chat via GatewayStreamConsumer edit path (message.delta) - Structured tool events (never fake tool text in chat), incl. post_tool_call hook relay with Hermes tool_call_ids - Native approval + clarification round-trips via tools.approval / tools.clarify_gateway primitives - Attachment delivery: adapter-owned copies, opaque IDs, persistent metadata, 7-day retention + safe cleanup, authenticated HTTPS download - Model + reasoning-effort query/change via Hermes picker data and session/global overrides - Shared-secret auth (constant-time, lockout), size limits, path- traversal-proof attachment resolution, minimal health endpoint - Protocol v1 spec with JSON examples (docs/PROTOCOL.md) - Install/config/Caddy/security docs + discovered Hermes limitations - 37 passing tests (auth, conversations, protocol, attachments incl. expiry/traversal, tool events, approvals, clarifications, cancel, reconnect re-sync, live WS smoke tests) — gateway-free fakes No Hermes core modifications required.
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
"""Pheby HTTP + WebSocket server (aiohttp).
|
||||
|
||||
Listens on localhost/plain HTTP behind Caddy. Routes:
|
||||
|
||||
* ``GET /health`` — unauthenticated liveness (minimal info).
|
||||
* ``GET /ws`` — WebSocket; first frame must be ``hello``.
|
||||
* ``GET /attachments/{id}`` — authenticated attachment download (streamed).
|
||||
|
||||
All message routing lives in :meth:`PhebyServer.handle_client_message`;
|
||||
Hermes integration (runs, approvals, clarifications, models) lives in
|
||||
:mod:`.hermes_bridge` to keep this module focused on protocol + transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import protocol as proto
|
||||
from .attachments import AttachmentStore, constant_time_equals
|
||||
from .config import PhebyConfig
|
||||
from .conversations import ConversationRouter
|
||||
from . import hermes_bridge
|
||||
from .ws_client import ClientConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PhebyServer:
|
||||
"""Owns the aiohttp app, connected clients, and shared subsystems."""
|
||||
|
||||
def __init__(self, config: PhebyConfig, adapter: Any = None):
|
||||
self.config = config
|
||||
self.adapter = adapter # PhebyAdapter (may be None in tests)
|
||||
self.router = ConversationRouter()
|
||||
from hermes_constants import get_hermes_home
|
||||
root = config.attachments_root or str(
|
||||
Path(get_hermes_home()) / "pheby-attachments")
|
||||
self.store = AttachmentStore(
|
||||
root=Path(root),
|
||||
retention_days=config.retention_days,
|
||||
index_path=Path(config.index_path) if config.index_path else None,
|
||||
)
|
||||
self.bridge = hermes_bridge # bridge function module
|
||||
self._clients: Dict[str, ClientConnection] = {}
|
||||
self._auth_failures: Dict[str, List[float]] = {}
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
self._app: Optional[web.Application] = None
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
self._conn_counter = 0
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────
|
||||
async def start(self) -> bool:
|
||||
from aiohttp import web as _web # local import keeps import light
|
||||
self.store.hydrate_legacy_meta()
|
||||
app = _web.Application(client_max_size=proto.MAX_WS_MESSAGE_BYTES)
|
||||
app.router.add_get("/health", self._handle_health)
|
||||
app.router.add_get("/ws", self._handle_ws)
|
||||
app.router.add_get("/attachments/{attachment_id}",
|
||||
self._handle_attachment_download)
|
||||
self._app = app
|
||||
self._runner = web.AppRunner(app, access_log=None)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, self.config.bind_host,
|
||||
self.config.port)
|
||||
try:
|
||||
await self._site.start()
|
||||
except OSError as exc:
|
||||
logger.error("[pheby] failed to bind %s:%s — %s",
|
||||
self.config.bind_host, self.config.port, exc)
|
||||
await self.stop()
|
||||
return False
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||
logger.info("[pheby] serving on http://%s:%d (attachments: %s)",
|
||||
self.config.bind_host, self.config.port,
|
||||
self.store.root)
|
||||
return True
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
for client in list(self._clients.values()):
|
||||
client.closed = True
|
||||
try:
|
||||
await client.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._clients.clear()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
self._site = None
|
||||
self._app = None
|
||||
logger.info("[pheby] server stopped")
|
||||
|
||||
# ── background cleanup ───────────────────────────────────────────────
|
||||
async def _cleanup_loop(self) -> None:
|
||||
"""Hourly expired-attachment sweep; first sweep after 5 minutes."""
|
||||
try:
|
||||
await asyncio.sleep(300)
|
||||
while True:
|
||||
try:
|
||||
await self.store.cleanup_expired()
|
||||
except Exception:
|
||||
logger.error("[pheby] attachment cleanup failed",
|
||||
exc_info=True)
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ── HTTP handlers ────────────────────────────────────────────────────
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""Minimal unauthenticated liveness probe."""
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
def _check_http_secret(self, request: web.Request) -> bool:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if header.startswith("Bearer "):
|
||||
token = header[7:].strip()
|
||||
elif header.startswith("ApiKey "):
|
||||
token = header[7:].strip()
|
||||
else:
|
||||
token = request.headers.get("X-Pheby-Secret", "").strip()
|
||||
if not token:
|
||||
return False
|
||||
return constant_time_equals(token, self.config.secret)
|
||||
|
||||
async def _handle_attachment_download(
|
||||
self, request: web.Request) -> web.StreamResponse:
|
||||
attachment_id = request.match_info.get("attachment_id", "")
|
||||
if not self._check_http_secret(request):
|
||||
return web.json_response(
|
||||
{"error": {"code": proto.ERR_UNAUTHORIZED,
|
||||
"message": "Authentication required"}},
|
||||
status=401)
|
||||
blob = self.store.resolve_blob(attachment_id)
|
||||
if blob is None:
|
||||
# Expired, unknown, or malformed — same minimal response so the
|
||||
# endpoint leaks nothing about implementation details.
|
||||
return web.json_response(
|
||||
{"error": {"code": proto.ERR_NOT_FOUND,
|
||||
"message": "Attachment unavailable"}},
|
||||
status=404)
|
||||
desc = self.store.describe(attachment_id) or {}
|
||||
safe_name = desc.get("filename", "file.bin")
|
||||
logger.info("[pheby] attachment download: id=%s bytes=%s",
|
||||
attachment_id, desc.get("size"))
|
||||
return web.FileResponse(
|
||||
blob,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{safe_name}"',
|
||||
"Content-Type": desc.get("mime_type",
|
||||
"application/octet-stream"),
|
||||
},
|
||||
)
|
||||
|
||||
# ── WebSocket handler ────────────────────────────────────────────────
|
||||
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse(max_msg_size=proto.MAX_WS_MESSAGE_BYTES,
|
||||
heartbeat=30.0, autoping=True)
|
||||
await ws.prepare(request)
|
||||
self._conn_counter += 1
|
||||
conn_id = f"c{self._conn_counter}"
|
||||
|
||||
peer = request.remote or "unknown"
|
||||
if self._is_locked_out(peer):
|
||||
logger.warning("[pheby] auth lockout active for %s — refusing",
|
||||
peer)
|
||||
await ws.close(code=4401, message=b"locked out")
|
||||
return ws
|
||||
|
||||
client = ClientConnection(ws, conn_id)
|
||||
self._clients[conn_id] = client
|
||||
logger.info("[pheby] client %s connected from %s", conn_id, peer)
|
||||
try:
|
||||
# Auth phase: hello must arrive within the window.
|
||||
try:
|
||||
authed = await asyncio.wait_for(
|
||||
self._authenticate(client, peer),
|
||||
timeout=proto.AUTH_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_AUTH_TIMEOUT, "hello not received in time"))
|
||||
await ws.close()
|
||||
return ws
|
||||
if not authed:
|
||||
await ws.close(code=4401, message=b"unauthorized")
|
||||
return ws
|
||||
|
||||
await client.send_json({
|
||||
"type": proto.S_READY,
|
||||
"protocol_version": proto.PROTOCOL_VERSION,
|
||||
"server": "pheby",
|
||||
"ts": proto.now_iso(),
|
||||
})
|
||||
await client.read_loop(self)
|
||||
finally:
|
||||
self._clients.pop(conn_id, None)
|
||||
logger.info("[pheby] client %s disconnected (authed=%s, %.0fs)",
|
||||
conn_id, client.authenticated,
|
||||
time.time() - client.connected_at)
|
||||
return ws
|
||||
|
||||
def _is_locked_out(self, peer: str) -> bool:
|
||||
fails = self._auth_failures.get(peer)
|
||||
if not fails:
|
||||
return False
|
||||
cutoff = time.time() - proto.AUTH_FAILURE_LOCKOUT_SECONDS
|
||||
recent = [t for t in fails if t > cutoff]
|
||||
self._auth_failures[peer] = recent
|
||||
return len(recent) >= proto.AUTH_FAILURE_THRESHOLD
|
||||
|
||||
def _record_auth_failure(self, peer: str) -> None:
|
||||
self._auth_failures.setdefault(peer, []).append(time.time())
|
||||
|
||||
async def _authenticate(self, client: ClientConnection,
|
||||
peer: str) -> bool:
|
||||
"""Wait for the hello frame and validate the shared secret."""
|
||||
msg = await client.ws.receive(timeout=proto.AUTH_TIMEOUT_SECONDS + 5)
|
||||
if msg.type != "text" and not hasattr(msg, "data"):
|
||||
return False
|
||||
message, err = proto.decode_message(msg.data)
|
||||
if err or message is None:
|
||||
await client.send_json(
|
||||
proto.error_event(err or proto.ERR_BAD_REQUEST,
|
||||
"Expected hello message"))
|
||||
return False
|
||||
if message.get("type") != proto.C_HELLO:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_UNAUTHORIZED, "First message must be hello"))
|
||||
self._record_auth_failure(peer)
|
||||
return False
|
||||
supplied = str(message.get("secret", ""))
|
||||
if not supplied or not constant_time_equals(supplied,
|
||||
self.config.secret):
|
||||
logger.warning("[pheby] auth failure from %s", peer)
|
||||
self._record_auth_failure(peer)
|
||||
# Small delay to slow brute force; constant-time compare already
|
||||
# used for the secret itself.
|
||||
await asyncio.sleep(0.5)
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_UNAUTHORIZED, "Invalid secret"))
|
||||
return False
|
||||
requested = message.get("protocol_version")
|
||||
if requested is not None and int(requested) != proto.PROTOCOL_VERSION:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_VERSION_MISMATCH,
|
||||
f"Protocol version mismatch: server={proto.PROTOCOL_VERSION}, "
|
||||
f"client={requested}"))
|
||||
return False
|
||||
client.authenticated = True
|
||||
client.protocol_version = proto.PROTOCOL_VERSION
|
||||
logger.info("[pheby] client %s authenticated", client.conn_id)
|
||||
return True
|
||||
|
||||
# ── broadcast ────────────────────────────────────────────────────────
|
||||
async def broadcast(self, payload: Dict[str, Any]) -> None:
|
||||
"""Send an event to every authenticated client."""
|
||||
for client in list(self._clients.values()):
|
||||
if client.authenticated and not client.closed:
|
||||
await client.send_json(payload)
|
||||
|
||||
def has_clients(self) -> bool:
|
||||
return any(c.authenticated and not c.closed
|
||||
for c in self._clients.values())
|
||||
|
||||
# ── inbound dispatch ─────────────────────────────────────────────────
|
||||
async def handle_client_message(self, client: ClientConnection,
|
||||
raw: str) -> None:
|
||||
message, err = proto.decode_message(raw)
|
||||
if err or message is None:
|
||||
await client.send_json(proto.error_event(
|
||||
err or proto.ERR_BAD_REQUEST, "Malformed message"))
|
||||
return
|
||||
|
||||
mtype = message.get("type", "")
|
||||
request_id = message.get("request_id")
|
||||
if self.config.debug:
|
||||
# Verbose protocol logging — never logs secrets; chat content
|
||||
# only when explicitly configured (privacy default).
|
||||
safe = {k: v for k, v in message.items()
|
||||
if k not in ("secret",)}
|
||||
if not self.config.log_chat_content and mtype == proto.C_CHAT_SEND:
|
||||
safe = dict(safe)
|
||||
safe["text"] = f"<{len(str(message.get('text', '')))} chars>"
|
||||
logger.info("[pheby] << %s", proto.safe_str(safe, 400))
|
||||
|
||||
try:
|
||||
handler = self._HANDLERS.get(mtype)
|
||||
if handler is None:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_UNKNOWN_TYPE, f"Unknown message type: {mtype}",
|
||||
request_id))
|
||||
return
|
||||
await handler(self, client, message, request_id)
|
||||
except Exception:
|
||||
logger.error("[pheby] handler failed for %s", mtype, exc_info=True)
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_INTERNAL, "Internal server error", request_id))
|
||||
|
||||
# ── simple handlers ──────────────────────────────────────────────────
|
||||
async def _handle_ping(self, client: ClientConnection, message: Dict,
|
||||
request_id: Optional[str]) -> None:
|
||||
await client.send_json({"type": proto.S_PONG,
|
||||
"ts": proto.now_iso(),
|
||||
**({"request_id": request_id}
|
||||
if request_id else {})})
|
||||
|
||||
async def _handle_conversation_list(self, client, message, request_id):
|
||||
conversations = await self.bridge.list_conversations()
|
||||
await client.send_json({
|
||||
"type": proto.S_CONVERSATION_SNAPSHOT,
|
||||
"conversations": conversations,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
|
||||
async def _handle_conversation_open(self, client, message, request_id):
|
||||
conversation_id = str(message.get("conversation_id", ""))
|
||||
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||
return
|
||||
limit = message.get("limit", proto.MAX_HISTORY_MESSAGES)
|
||||
try:
|
||||
limit = max(1, min(int(limit), proto.MAX_HISTORY_MESSAGES))
|
||||
except (TypeError, ValueError):
|
||||
limit = proto.MAX_HISTORY_MESSAGES
|
||||
history, found = await self.bridge.conversation_history(
|
||||
conversation_id, limit)
|
||||
if not found:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_CONVERSATION_NOT_FOUND,
|
||||
"Conversation not found", request_id))
|
||||
return
|
||||
await client.send_json({
|
||||
"type": proto.S_CONVERSATION_HISTORY,
|
||||
"conversation_id": conversation_id,
|
||||
"messages": history,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
|
||||
async def _handle_conversation_create(self, client, message, request_id):
|
||||
name = message.get("name")
|
||||
cid = await self.router.new_conversation(
|
||||
str(name) if name else None)
|
||||
await client.send_json({
|
||||
"type": proto.S_CONVERSATION_CREATED,
|
||||
"conversation_id": cid,
|
||||
"name": await self.router.get_name(cid),
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
await self.broadcast({
|
||||
"type": proto.S_CONVERSATION_UPDATED,
|
||||
"conversation_id": cid,
|
||||
"name": await self.router.get_name(cid),
|
||||
})
|
||||
|
||||
async def _handle_conversation_rename(self, client, message, request_id):
|
||||
conversation_id = str(message.get("conversation_id", ""))
|
||||
name = str(message.get("name", "")).strip()
|
||||
if not ConversationRouter.is_valid_conversation_id(conversation_id) \
|
||||
or not name or len(name) > 200:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_BAD_REQUEST,
|
||||
"conversation_id and name (≤200 chars) required", request_id))
|
||||
return
|
||||
ok = await self.router.rename(conversation_id, name)
|
||||
if not ok:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
||||
request_id))
|
||||
return
|
||||
event = {
|
||||
"type": proto.S_CONVERSATION_RENAMED,
|
||||
"conversation_id": conversation_id,
|
||||
"name": name,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
await client.send_json(event)
|
||||
await self.broadcast({k: v for k, v in event.items()
|
||||
if k != "request_id"})
|
||||
|
||||
async def _handle_conversation_delete(self, client, message, request_id):
|
||||
conversation_id = str(message.get("conversation_id", ""))
|
||||
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||
return
|
||||
ok = await self.bridge.delete_conversation(conversation_id)
|
||||
if not ok:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_CONVERSATION_NOT_FOUND, "Conversation not found",
|
||||
request_id))
|
||||
return
|
||||
event = {
|
||||
"type": proto.S_CONVERSATION_DELETED,
|
||||
"conversation_id": conversation_id,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
await client.send_json(event)
|
||||
await self.broadcast({k: v for k, v in event.items()
|
||||
if k != "request_id"})
|
||||
|
||||
async def _handle_chat_send(self, client, message, request_id):
|
||||
conversation_id = str(message.get("conversation_id", ""))
|
||||
text = message.get("text")
|
||||
if not ConversationRouter.is_valid_conversation_id(conversation_id):
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_BAD_REQUEST, "Invalid conversation_id", request_id))
|
||||
return
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_BAD_REQUEST, "text is required", request_id))
|
||||
return
|
||||
if len(text) > proto.MAX_TEXT_CHARS:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_TOO_LARGE,
|
||||
f"text exceeds {proto.MAX_TEXT_CHARS} chars", request_id))
|
||||
return
|
||||
await self.bridge.send_chat(conversation_id, text, client, request_id)
|
||||
|
||||
async def _handle_run_cancel(self, client, message, request_id):
|
||||
conversation_id = str(message.get("conversation_id", ""))
|
||||
run_id = message.get("run_id")
|
||||
ok = await self.bridge.cancel_run(conversation_id, run_id)
|
||||
await client.send_json({
|
||||
"type": proto.S_RUN_FINISHED if ok else proto.S_ERROR,
|
||||
**({"run_id": run_id, "status": "cancelled"}
|
||||
if ok else {"error": {"code": proto.ERR_NOT_FOUND,
|
||||
"message": "No active run"}}),
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
|
||||
async def _handle_approval_respond(self, client, message, request_id):
|
||||
approval_id = str(message.get("approval_id", ""))
|
||||
choice = str(message.get("choice", ""))
|
||||
reason = message.get("reason")
|
||||
resolved = await self.bridge.resolve_approval(
|
||||
approval_id, choice, str(reason) if reason else None)
|
||||
if not resolved:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_APPROVAL_NOT_FOUND,
|
||||
"Unknown or already-resolved approval", request_id))
|
||||
return
|
||||
await client.send_json({
|
||||
"type": proto.S_APPROVAL_RESOLVED,
|
||||
"approval_id": approval_id,
|
||||
"choice": choice,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
|
||||
async def _handle_clarify_respond(self, client, message, request_id):
|
||||
clarify_id = str(message.get("clarify_id", ""))
|
||||
response = message.get("response")
|
||||
resolved = await self.bridge.resolve_clarify(
|
||||
clarify_id, str(response) if response is not None else "")
|
||||
if not resolved:
|
||||
await client.send_json(proto.error_event(
|
||||
proto.ERR_CLARIFY_NOT_FOUND,
|
||||
"Unknown or already-resolved clarification", request_id))
|
||||
return
|
||||
await client.send_json({
|
||||
"type": proto.S_CLARIFY_RESOLVED,
|
||||
"clarify_id": clarify_id,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
})
|
||||
|
||||
async def _handle_models_list(self, client, message, request_id):
|
||||
snapshot = await self.bridge.models_snapshot()
|
||||
snapshot["type"] = proto.S_MODELS_SNAPSHOT
|
||||
if request_id:
|
||||
snapshot["request_id"] = request_id
|
||||
await client.send_json(snapshot)
|
||||
|
||||
async def _handle_model_current(self, client, message, request_id):
|
||||
snapshot = await self.bridge.current_model_snapshot()
|
||||
snapshot["type"] = proto.S_MODEL_CURRENT_SNAPSHOT
|
||||
if request_id:
|
||||
snapshot["request_id"] = request_id
|
||||
await client.send_json(snapshot)
|
||||
|
||||
async def _handle_model_set(self, client, message, request_id):
|
||||
model = str(message.get("model", "")).strip()
|
||||
provider = message.get("provider")
|
||||
conversation_id = message.get("conversation_id")
|
||||
result = await self.bridge.set_model(
|
||||
model, str(provider) if provider else None,
|
||||
str(conversation_id) if conversation_id else None)
|
||||
if not result.get("ok"):
|
||||
await client.send_json(proto.error_event(
|
||||
result.get("code", proto.ERR_BAD_REQUEST),
|
||||
result.get("message", "Model change failed"), request_id))
|
||||
return
|
||||
event = {
|
||||
"type": proto.S_MODEL_CHANGED,
|
||||
"model": result.get("model"),
|
||||
"provider": result.get("provider"),
|
||||
"scope": result.get("scope", "global"),
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
await client.send_json(event)
|
||||
await self.broadcast({k: v for k, v in event.items()
|
||||
if k != "request_id"})
|
||||
|
||||
async def _handle_reasoning_current(self, client, message, request_id):
|
||||
snapshot = await self.bridge.reasoning_snapshot()
|
||||
snapshot["type"] = proto.S_REASONING_SNAPSHOT
|
||||
if request_id:
|
||||
snapshot["request_id"] = request_id
|
||||
await client.send_json(snapshot)
|
||||
|
||||
async def _handle_reasoning_set(self, client, message, request_id):
|
||||
effort = str(message.get("effort", "")).strip().lower()
|
||||
conversation_id = message.get("conversation_id")
|
||||
result = await self.bridge.set_reasoning(
|
||||
effort, str(conversation_id) if conversation_id else None)
|
||||
if not result.get("ok"):
|
||||
await client.send_json(proto.error_event(
|
||||
result.get("code", proto.ERR_BAD_REQUEST),
|
||||
result.get("message", "Reasoning change failed"), request_id))
|
||||
return
|
||||
event = {
|
||||
"type": proto.S_REASONING_CHANGED,
|
||||
"effort": result.get("effort"),
|
||||
"scope": result.get("scope", "global"),
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
await client.send_json(event)
|
||||
await self.broadcast({k: v for k, v in event.items()
|
||||
if k != "request_id"})
|
||||
|
||||
_HANDLERS = {
|
||||
proto.C_PING: _handle_ping,
|
||||
proto.C_CONVERSATION_LIST: _handle_conversation_list,
|
||||
proto.C_CONVERSATION_OPEN: _handle_conversation_open,
|
||||
proto.C_CONVERSATION_CREATE: _handle_conversation_create,
|
||||
proto.C_CONVERSATION_RENAME: _handle_conversation_rename,
|
||||
proto.C_CONVERSATION_DELETE: _handle_conversation_delete,
|
||||
proto.C_CHAT_SEND: _handle_chat_send,
|
||||
proto.C_RUN_CANCEL: _handle_run_cancel,
|
||||
proto.C_APPROVAL_RESPOND: _handle_approval_respond,
|
||||
proto.C_CLARIFY_RESPOND: _handle_clarify_respond,
|
||||
proto.C_MODELS_LIST: _handle_models_list,
|
||||
proto.C_MODEL_SET: _handle_model_set,
|
||||
proto.C_MODEL_CURRENT: _handle_model_current,
|
||||
proto.C_REASONING_SET: _handle_reasoning_set,
|
||||
proto.C_REASONING_CURRENT: _handle_reasoning_current,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["PhebyServer"]
|
||||
Reference in New Issue
Block a user