forked from retoor/devplacepy
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from typing import Any, Optional
|
||||
|
||||
import dataset
|
||||
|
||||
from . import console
|
||||
from .actions import Dispatcher
|
||||
from .agent import Agent
|
||||
from .agentic import AgenticController, LessonStore
|
||||
from .chunks import ChunkStore
|
||||
from .config import Settings, load_settings
|
||||
from .cost import CostTracker
|
||||
from .errors import AuthRequiredError
|
||||
from .http_client import PlatformClient
|
||||
from .llm import LLMClient
|
||||
from .registry import CATALOG
|
||||
from .tasks import Scheduler, TaskController, TaskStore
|
||||
|
||||
logger = logging.getLogger("devii.cli")
|
||||
|
||||
EXIT_COMMANDS = {"/exit", "/quit", "/q"}
|
||||
WELCOME = "Devii - DevPlace assistant"
|
||||
LOGIN_REQUEST = (
|
||||
"Welcome. I manage your DevPlace account, but you must log in first. "
|
||||
"Please tell me your email and password to continue."
|
||||
)
|
||||
GOODBYE = "Session ended."
|
||||
RESULT_NOTICE_CHARS = 20000
|
||||
CLI_TASKS_DB = "devii_tasks.db"
|
||||
|
||||
|
||||
def _label(row: dict[str, Any]) -> str:
|
||||
return row.get("label") or row.get("uid", "task")
|
||||
|
||||
|
||||
def _make_event_handler() -> Any:
|
||||
def handle(kind: str, row: dict[str, Any], payload: str) -> None:
|
||||
name = _label(row)
|
||||
if kind == "start":
|
||||
console.notice(f"\n[task {name}] running...")
|
||||
elif kind in ("done", "finished"):
|
||||
suffix = " (completed, no further runs)" if kind == "finished" else ""
|
||||
console.notice(f"[task {name}] finished{suffix}:")
|
||||
console.assistant(payload[:RESULT_NOTICE_CHARS])
|
||||
elif kind == "error":
|
||||
console.warn(f"[task {name}] failed: {payload}")
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
TRACE_GLYPHS = {
|
||||
"call": "↳",
|
||||
"ok": "✓",
|
||||
"err": "✗",
|
||||
"plan-gate": "plan required first",
|
||||
"reflect-trigger": "reflecting on error",
|
||||
"verify-gate": "verification gate",
|
||||
"compact": "context compaction",
|
||||
}
|
||||
|
||||
|
||||
def _trace(event: str, name: str = "", detail: str = "") -> None:
|
||||
glyph = TRACE_GLYPHS.get(event, event)
|
||||
if event in ("call", "ok", "err"):
|
||||
suffix = f" {detail}" if detail else ""
|
||||
console.notice(f" {glyph} {name}{suffix}")
|
||||
else:
|
||||
console.notice(f" ! {glyph}")
|
||||
|
||||
|
||||
def _make_executor(
|
||||
settings: Settings,
|
||||
llm: LLMClient,
|
||||
dispatcher: Dispatcher,
|
||||
lessons: LessonStore,
|
||||
cost_tracker: CostTracker,
|
||||
chunk_store: ChunkStore,
|
||||
) -> Any:
|
||||
tools = CATALOG.tool_schemas()
|
||||
|
||||
async def execute(prompt: str) -> str:
|
||||
worker = Agent(
|
||||
settings, llm, dispatcher, tools,
|
||||
lessons=lessons, cost_tracker=cost_tracker, chunk_store=chunk_store,
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
|
||||
return execute
|
||||
|
||||
|
||||
async def _bootstrap_auth(client: PlatformClient, settings: Settings) -> str:
|
||||
if client.authenticated:
|
||||
logger.info("Authenticated via API key")
|
||||
return "Authenticated via API key. How can I help?"
|
||||
if settings.login_email and settings.login_password:
|
||||
try:
|
||||
await client.login(settings.login_email, settings.login_password)
|
||||
return f"Logged in as {client.username}. How can I help?"
|
||||
except AuthRequiredError as exc:
|
||||
console.warn(f"[auth] automatic login failed: {exc.message}")
|
||||
return LOGIN_REQUEST
|
||||
|
||||
|
||||
def _resolve_owner(settings: Settings) -> tuple[str, str]:
|
||||
"""Resolve the local DevPlace user behind the CLI's credentials so the CLI shares the
|
||||
same per-user memory, tasks, and admin analytics as the web session for that account."""
|
||||
try:
|
||||
from devplacepy.database import db, get_table
|
||||
if "users" not in db.tables:
|
||||
return "user", "cli"
|
||||
users = get_table("users")
|
||||
user = None
|
||||
if settings.platform_api_key:
|
||||
user = users.find_one(api_key=settings.platform_api_key)
|
||||
if user is None and settings.login_email:
|
||||
user = users.find_one(email=settings.login_email.lower())
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
except Exception: # noqa: BLE001 - no local DB / remote-only: fall back to a standalone store
|
||||
return "user", "cli"
|
||||
return "user", "cli"
|
||||
|
||||
|
||||
def _build_stores(owner_kind: str, owner_id: str):
|
||||
if owner_id == "cli":
|
||||
owned_db = dataset.connect(f"sqlite:///{CLI_TASKS_DB}")
|
||||
else:
|
||||
from devplacepy.database import db as owned_db # share the platform DB for this account
|
||||
return TaskStore(owned_db, owner_kind, owner_id), LessonStore(owned_db, owner_kind, owner_id)
|
||||
|
||||
|
||||
async def run(settings: Settings, prompt: Optional[str] = None) -> None:
|
||||
client = PlatformClient(settings.base_url, settings.timeout_seconds, settings.platform_api_key)
|
||||
llm = LLMClient(settings)
|
||||
greeting = await _bootstrap_auth(client, settings)
|
||||
|
||||
owner_kind, owner_id = _resolve_owner(settings)
|
||||
store, lessons = _build_stores(owner_kind, owner_id)
|
||||
controller = TaskController(store)
|
||||
cost_tracker = CostTracker()
|
||||
chunk_store = ChunkStore()
|
||||
agentic = AgenticController(lessons, settings)
|
||||
dispatcher = Dispatcher(CATALOG, client, settings, controller, agentic)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated)
|
||||
agentic.bind(
|
||||
llm=llm, dispatcher=dispatcher, tools=tools,
|
||||
on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
|
||||
)
|
||||
agent = Agent(
|
||||
settings, llm, dispatcher, tools,
|
||||
lessons=lessons, on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
|
||||
)
|
||||
|
||||
if prompt is not None:
|
||||
try:
|
||||
reply = await agent.respond(prompt)
|
||||
print(reply)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await llm.aclose()
|
||||
return
|
||||
|
||||
scheduler = Scheduler(
|
||||
store,
|
||||
_make_executor(settings, llm, dispatcher, lessons, cost_tracker, chunk_store),
|
||||
_make_event_handler(),
|
||||
settings.scheduler_tick_seconds,
|
||||
)
|
||||
|
||||
console.banner(WELCOME)
|
||||
console.notice(f"Endpoint {settings.base_url} | model {settings.ai_model}")
|
||||
console.notice(
|
||||
"Type /exit to quit. Agentic ReAct loop with planning, self-learning memory, and "
|
||||
"background tasks is active.\n"
|
||||
)
|
||||
console.assistant(greeting)
|
||||
|
||||
scheduler.start()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
user_text = (await console.prompt("you> ")).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not user_text:
|
||||
continue
|
||||
if user_text.lower() in EXIT_COMMANDS:
|
||||
break
|
||||
|
||||
try:
|
||||
reply = await agent.respond(user_text)
|
||||
except Exception as exc: # noqa: BLE001 - keep the chat alive
|
||||
logger.exception("Unhandled error while responding")
|
||||
console.warn(f"[error] {exc}")
|
||||
continue
|
||||
|
||||
console.assistant(reply)
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
await client.aclose()
|
||||
await llm.aclose()
|
||||
console.notice(GOODBYE)
|
||||
|
||||
|
||||
def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="devii",
|
||||
description="Agentic chat assistant that manages a DevPlace account.",
|
||||
)
|
||||
parser.add_argument("-p", "--prompt", help="Run a single prompt non-interactively and exit.")
|
||||
parser.add_argument("--api-key", help="DevPlace API key for automatic authentication (Bearer).")
|
||||
parser.add_argument("--email", help="Login email for automatic basic-auth login.")
|
||||
parser.add_argument("--password", help="Login password for automatic basic-auth login.")
|
||||
parser.add_argument("--basic-auth", help="Shorthand credentials in the form email:password.")
|
||||
parser.add_argument("--base-url", help="Override the platform base URL.")
|
||||
parser.add_argument("--ai-url", help="Override the model endpoint URL.")
|
||||
parser.add_argument("--ai-key", help="Override the model API key.")
|
||||
parser.add_argument("--model", help="Override the model name.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _settings_from_args(args: argparse.Namespace) -> Settings:
|
||||
settings = load_settings()
|
||||
email = args.email or settings.login_email
|
||||
password = args.password or settings.login_password
|
||||
if args.basic_auth and ":" in args.basic_auth:
|
||||
email, password = args.basic_auth.split(":", 1)
|
||||
overrides: dict[str, Any] = {"login_email": email, "login_password": password}
|
||||
if args.api_key:
|
||||
overrides["platform_api_key"] = args.api_key
|
||||
if args.base_url:
|
||||
overrides["base_url"] = args.base_url.rstrip("/")
|
||||
if args.ai_url:
|
||||
overrides["ai_url"] = args.ai_url
|
||||
if args.ai_key:
|
||||
overrides["ai_key"] = args.ai_key
|
||||
if args.model:
|
||||
overrides["ai_model"] = args.model
|
||||
return replace(settings, **overrides)
|
||||
|
||||
|
||||
NOISY_LOGGERS = ("httpcore", "httpx", "httpcore.http11", "httpcore.connection", "asyncio", "urllib3")
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> None:
|
||||
args = _parse_args(argv)
|
||||
settings = _settings_from_args(args)
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level, logging.WARNING),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
for name in NOISY_LOGGERS:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
logger.info("Starting Devii")
|
||||
try:
|
||||
asyncio.run(run(settings, prompt=args.prompt))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user