feat(api): add fixed-session OpenAI-compatible endpoint
Expose OpenAI-compatible chat completions and models endpoints through a single persistent API session, keeping the integration simple without adding multi-session isolation yet.
This commit is contained in:
+37
-60
@@ -1,7 +1,7 @@
|
||||
"""OpenAI-compatible HTTP API server for nanobot.
|
||||
"""OpenAI-compatible HTTP API server for a fixed nanobot session.
|
||||
|
||||
Provides /v1/chat/completions and /v1/models endpoints.
|
||||
Session isolation is enforced via the x-session-key request header.
|
||||
All requests route to a single persistent API session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,38 +14,8 @@ from typing import Any
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
# Tools that must NOT run in multi-tenant API mode.
|
||||
# Filesystem tools allow the LLM to read/write the shared workspace (including
|
||||
# global MEMORY.md), and exec allows shell commands that can bypass filesystem
|
||||
# restrictions (e.g. `cat ~/.nanobot/workspace/memory/MEMORY.md`).
|
||||
_API_DISABLED_TOOLS: set[str] = {
|
||||
"read_file", "write_file", "edit_file", "list_dir", "exec",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-session-key lock manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _SessionLocks:
|
||||
"""Manages one asyncio.Lock per session key for serial execution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self._ref: dict[str, int] = {} # reference count for cleanup
|
||||
|
||||
def acquire(self, key: str) -> asyncio.Lock:
|
||||
if key not in self._locks:
|
||||
self._locks[key] = asyncio.Lock()
|
||||
self._ref[key] = 0
|
||||
self._ref[key] += 1
|
||||
return self._locks[key]
|
||||
|
||||
def release(self, key: str) -> None:
|
||||
self._ref[key] -= 1
|
||||
if self._ref[key] <= 0:
|
||||
self._locks.pop(key, None)
|
||||
self._ref.pop(key, None)
|
||||
API_SESSION_KEY = "api:default"
|
||||
API_CHAT_ID = "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,6 +46,15 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _response_text(value: Any) -> str:
|
||||
"""Normalize process_direct output to plain assistant text."""
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "content"):
|
||||
return str(getattr(value, "content") or "")
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -83,11 +62,6 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
"""POST /v1/chat/completions"""
|
||||
|
||||
# --- x-session-key validation ---
|
||||
session_key = request.headers.get("x-session-key", "").strip()
|
||||
if not session_key:
|
||||
return _error_json(400, "Missing required header: x-session-key")
|
||||
|
||||
# --- Parse body ---
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -119,53 +93,56 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
agent_loop = request.app["agent_loop"]
|
||||
timeout_s: float = request.app.get("request_timeout", 120.0)
|
||||
model_name: str = body.get("model") or request.app.get("model_name", "nanobot")
|
||||
locks: _SessionLocks = request.app["session_locks"]
|
||||
session_lock: asyncio.Lock = request.app["session_lock"]
|
||||
|
||||
safe_key = session_key[:32] + ("…" if len(session_key) > 32 else "")
|
||||
logger.info("API request session_key={} content={}", safe_key, user_content[:80])
|
||||
logger.info("API request session_key={} content={}", API_SESSION_KEY, user_content[:80])
|
||||
|
||||
_FALLBACK = "I've completed processing but have no response to give."
|
||||
|
||||
lock = locks.acquire(session_key)
|
||||
try:
|
||||
async with lock:
|
||||
async with session_lock:
|
||||
try:
|
||||
response_text = await asyncio.wait_for(
|
||||
response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=user_content,
|
||||
session_key=session_key,
|
||||
session_key=API_SESSION_KEY,
|
||||
channel="api",
|
||||
chat_id=session_key,
|
||||
isolate_memory=True,
|
||||
disabled_tools=_API_DISABLED_TOOLS,
|
||||
chat_id=API_CHAT_ID,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
response_text = _response_text(response)
|
||||
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning("Empty response for session {}, retrying", safe_key)
|
||||
response_text = await asyncio.wait_for(
|
||||
logger.warning(
|
||||
"Empty response for session {}, retrying",
|
||||
API_SESSION_KEY,
|
||||
)
|
||||
retry_response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=user_content,
|
||||
session_key=session_key,
|
||||
session_key=API_SESSION_KEY,
|
||||
channel="api",
|
||||
chat_id=session_key,
|
||||
isolate_memory=True,
|
||||
disabled_tools=_API_DISABLED_TOOLS,
|
||||
chat_id=API_CHAT_ID,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
response_text = _response_text(retry_response)
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning("Empty response after retry for session {}, using fallback", safe_key)
|
||||
logger.warning(
|
||||
"Empty response after retry for session {}, using fallback",
|
||||
API_SESSION_KEY,
|
||||
)
|
||||
response_text = _FALLBACK
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return _error_json(504, f"Request timed out after {timeout_s}s")
|
||||
except Exception:
|
||||
logger.exception("Error processing request for session {}", safe_key)
|
||||
logger.exception("Error processing request for session {}", API_SESSION_KEY)
|
||||
return _error_json(500, "Internal server error", err_type="server_error")
|
||||
finally:
|
||||
locks.release(session_key)
|
||||
except Exception:
|
||||
logger.exception("Unexpected API lock error for session {}", API_SESSION_KEY)
|
||||
return _error_json(500, "Internal server error", err_type="server_error")
|
||||
|
||||
return web.json_response(_chat_completion_response(response_text, model_name))
|
||||
|
||||
@@ -207,7 +184,7 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float =
|
||||
app["agent_loop"] = agent_loop
|
||||
app["model_name"] = model_name
|
||||
app["request_timeout"] = request_timeout
|
||||
app["session_locks"] = _SessionLocks()
|
||||
app["session_lock"] = asyncio.Lock()
|
||||
|
||||
app.router.add_post("/v1/chat/completions", handle_chat_completions)
|
||||
app.router.add_get("/v1/models", handle_models)
|
||||
|
||||
Reference in New Issue
Block a user