mirror of
https://github.com/domfelipe/hermes-agent-custom.git
synced 2026-08-07 06:56:41 +00:00
Update skills_api.py
This commit is contained in:
parent
315777671a
commit
14b7e35790
1 changed files with 115 additions and 63 deletions
|
|
@ -1,82 +1,134 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Skills API (aiohttp) — injetado no api_server.py do Hermes via apply_patch.py.
|
skills_api.py — Reverse proxy + Skills API para Hermes Agent.
|
||||||
|
|
||||||
Expõe:
|
Roda na porta pública ($PORT) e:
|
||||||
GET /api/skills → lista skills do tenant
|
- Intercepta /api/skills/* → handlers locais
|
||||||
POST /api/skills/sync → upsert em lote (chamado pelo Lovable)
|
- Proxy todo o resto → Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT)
|
||||||
GET /api/skills/health → ping
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
import asyncio
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
import sys
|
||||||
from typing import Any
|
from aiohttp import web, ClientSession, ClientTimeout
|
||||||
|
|
||||||
from aiohttp import web
|
# ============================================================
|
||||||
|
# Configuração
|
||||||
|
# ============================================================
|
||||||
|
PUBLIC_PORT = int(os.environ.get("PORT", "8642"))
|
||||||
|
HERMES_INTERNAL_PORT = int(os.environ.get("HERMES_INTERNAL_PORT", "8000"))
|
||||||
|
HERMES_BASE = f"http://127.0.0.1:{HERMES_INTERNAL_PORT}"
|
||||||
|
API_SERVER_KEY = os.environ.get("API_SERVER_KEY", "")
|
||||||
|
SKILLS_SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "")
|
||||||
|
|
||||||
skills_routes = web.RouteTableDef()
|
# Hop-by-hop headers que NÃO devem ser repassados no proxy
|
||||||
|
HOP_BY_HOP = {
|
||||||
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||||
|
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
||||||
|
}
|
||||||
|
|
||||||
SKILLS_DIR = Path(os.environ.get("HERMES_SKILLS_DIR", "/opt/data/.hermes/skills"))
|
# ============================================================
|
||||||
SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "")
|
# Skills API handlers
|
||||||
|
# ============================================================
|
||||||
|
async def skills_health(request: web.Request) -> web.Response:
|
||||||
|
"""Health check — não requer auth."""
|
||||||
|
return web.json_response({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "skills_api",
|
||||||
|
"hermes_backend": HERMES_BASE,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dir() -> None:
|
def _check_auth(request: web.Request) -> bool:
|
||||||
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
"""Valida Authorization: Bearer <API_SERVER_KEY>."""
|
||||||
|
if not API_SERVER_KEY:
|
||||||
|
return True # sem chave configurada = aberto
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return False
|
||||||
|
return auth[7:].strip() == API_SERVER_KEY
|
||||||
|
|
||||||
|
|
||||||
def _check_auth(token: str | None) -> None:
|
async def skills_list(request: web.Request) -> web.Response:
|
||||||
if not SYNC_TOKEN:
|
if not _check_auth(request):
|
||||||
raise web.HTTPInternalServerError(reason="HERMES_SKILLS_SYNC_TOKEN não configurado")
|
return web.json_response({"error": "unauthorized"}, status=401)
|
||||||
if token != SYNC_TOKEN:
|
# TODO: integrar com sistema real de skills do Hermes
|
||||||
raise web.HTTPUnauthorized(reason="Token inválido")
|
return web.json_response({"skills": []})
|
||||||
|
|
||||||
|
|
||||||
@skills_routes.get("/api/skills/health")
|
async def skills_sync(request: web.Request) -> web.Response:
|
||||||
async def health(_request: web.Request) -> web.Response:
|
if not _check_auth(request):
|
||||||
_ensure_dir()
|
return web.json_response({"error": "unauthorized"}, status=401)
|
||||||
return web.json_response(
|
|
||||||
{"ok": True, "dir": str(SKILLS_DIR), "count": len(list(SKILLS_DIR.glob("*.md")))}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@skills_routes.get("/api/skills")
|
|
||||||
async def list_skills(_request: web.Request) -> web.Response:
|
|
||||||
_ensure_dir()
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for f in sorted(SKILLS_DIR.glob("*.md")):
|
|
||||||
items.append({"name": f.stem, "size": f.stat().st_size})
|
|
||||||
return web.json_response({"skills": items})
|
|
||||||
|
|
||||||
|
|
||||||
@skills_routes.post("/api/skills/sync")
|
|
||||||
async def sync_skills(request: web.Request) -> web.Response:
|
|
||||||
_check_auth(request.headers.get("X-Sync-Token"))
|
|
||||||
_ensure_dir()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except json.JSONDecodeError:
|
except Exception:
|
||||||
raise web.HTTPBadRequest(reason="JSON inválido")
|
return web.json_response({"error": "invalid json"}, status=400)
|
||||||
|
# TODO: persistir skills recebidas
|
||||||
|
return web.json_response({"ok": True, "received": len(payload.get("skills", []))})
|
||||||
|
|
||||||
skills = payload.get("skills") or []
|
|
||||||
if not isinstance(skills, list):
|
|
||||||
raise web.HTTPBadRequest(reason="skills deve ser uma lista")
|
|
||||||
|
|
||||||
written: list[str] = []
|
# ============================================================
|
||||||
for s in skills:
|
# Reverse proxy para o Hermes
|
||||||
name = (s.get("name") or "").strip()
|
# ============================================================
|
||||||
body = s.get("markdown") or ""
|
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||||
if not name or "/" in name or ".." in name:
|
"""Encaminha qualquer request para o Hermes interno."""
|
||||||
continue
|
target_url = f"{HERMES_BASE}{request.rel_url}"
|
||||||
target = SKILLS_DIR / f"{name}.md"
|
|
||||||
target.write_text(body, encoding="utf-8")
|
|
||||||
written.append(name)
|
|
||||||
|
|
||||||
if payload.get("prune"):
|
# Filtra headers hop-by-hop
|
||||||
keep = set(written)
|
headers = {
|
||||||
for f in SKILLS_DIR.glob("*.md"):
|
k: v for k, v in request.headers.items()
|
||||||
if f.stem not in keep:
|
if k.lower() not in HOP_BY_HOP
|
||||||
f.unlink(missing_ok=True)
|
}
|
||||||
|
|
||||||
return web.json_response({"written": written, "count": len(written)})
|
body = await request.read() if request.body_exists else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ClientSession(timeout=ClientTimeout(total=300)) as session:
|
||||||
|
async with session.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
data=body,
|
||||||
|
allow_redirects=False,
|
||||||
|
) as upstream:
|
||||||
|
# Stream da resposta de volta
|
||||||
|
resp_headers = {
|
||||||
|
k: v for k, v in upstream.headers.items()
|
||||||
|
if k.lower() not in HOP_BY_HOP
|
||||||
|
}
|
||||||
|
response = web.StreamResponse(
|
||||||
|
status=upstream.status,
|
||||||
|
headers=resp_headers,
|
||||||
|
)
|
||||||
|
await response.prepare(request)
|
||||||
|
async for chunk in upstream.content.iter_chunked(8192):
|
||||||
|
await response.write(chunk)
|
||||||
|
await response.write_eof()
|
||||||
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return web.json_response({"error": "upstream timeout"}, status=504)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[proxy] error: {e}", file=sys.stderr)
|
||||||
|
return web.json_response({"error": "bad gateway", "detail": str(e)}, status=502)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# App setup
|
||||||
|
# ============================================================
|
||||||
|
def make_app() -> web.Application:
|
||||||
|
app = web.Application(client_max_size=50 * 1024 * 1024) # 50MB
|
||||||
|
|
||||||
|
# Skills API (precedência sobre o proxy)
|
||||||
|
app.router.add_get("/api/skills/health", skills_health)
|
||||||
|
app.router.add_get("/api/skills", skills_list)
|
||||||
|
app.router.add_post("/api/skills/sync", skills_sync)
|
||||||
|
|
||||||
|
# Proxy catch-all (qualquer outro path)
|
||||||
|
app.router.add_route("*", "/{tail:.*}", proxy)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"[skills_api] listening on 0.0.0.0:{PUBLIC_PORT}")
|
||||||
|
print(f"[skills_api] proxying to {HERMES_BASE}")
|
||||||
|
web.run_app(make_app(), host="0.0.0.0", port=PUBLIC_PORT, access_log=None)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue