Update skills_api.py

This commit is contained in:
Felipe Domingues 2026-04-27 22:07:39 -03:00 committed by GitHub
parent 315777671a
commit 14b7e35790
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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)