Files
devplacepy/devplacepy/services/devii/cli.py
T
retoorandClaude Sonnet 5 569f1dcc64 Add OpenCode Zen support, model health/stats dashboard, and gateway fallback fixes
AI gateway:
- Add a generic, admin-selectable `client_profile` field on gateway_providers
  (e.g. "opencode") so a provider needing special request headers (OpenCode
  Zen's client-identity spoofing) is configured like any other provider, not
  hardcoded by name.
- Track per-(provider, model) reliability/speed/latency health in memory,
  seeded from the existing gateway_usage_ledger at startup - purely
  observational, never influences routing.
- New Stats tab on /admin/gateway: request volume, latency, per-model
  breakdowns, and reliability weight, charted with a vendored Chart.js and
  devplace's own theme tokens.
- Record which model a failed request actually fell back to
  (fallback_used_route), surfaced in the Recent Failures table.
- Stop excluding context_length errors from fallback, and skip a primary
  attempt outright when its known context window is already too small for
  the estimated request size, going straight to the fallback.
- gateway_usage_ledger's provider/fallback_used_route columns and indexes
  are ensured centrally in database/schema.py's init_db(), the single point
  of truth for this table's schema.
- Non-OpenAI upstream routing and client-model passthrough; trust only the
  upstream's own X-Gateway-Model header for served-model attribution.

Devii agent:
- Fix a real lockup: plan/verify tools could be individually disabled via
  the admin tool toggles while still being required by the protocol gate,
  permanently bricking any task that needed tools. They can no longer be
  disabled, and the gate now also checks the tool is actually offered.
- Fix compaction being silently calibrated for a 1M-token model while
  running a much smaller one: context budget is now percentage-based and
  the summarizer's own request is sized to fit the real model.
- Give a specific, actionable retry message when plan()'s own arguments get
  cut off by the output limit, and tighten its schema to discourage
  overlong plans.

Other:
- Backup service: offload completed backups to a remote Hetzner Storage Box.
- Container manager: fix orphan blob leaks from sync races, add a two-phase
  plan/execute `system prune` CLI command.
- Admin gateway UI: replace the JS-rendered model/provider tables with
  server-rendered forms and pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhmEkvutuwtzFVcLbTrhdo
2026-09-09 07:38:24 +02:00

330 lines
9.9 KiB
Python

# retoor <retoor@molodetz.nl>
from __future__ import annotations
import argparse
import asyncio
import logging
import os
from dataclasses import replace
from pathlib import Path
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
def _cli_tasks_db_path() -> Path:
base = os.environ.get("DEVII_HOME") or os.environ.get("XDG_DATA_HOME")
root = Path(base) if base else Path.home() / ".local" / "share"
target = root / "devii"
target.mkdir(parents=True, exist_ok=True)
return target / "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]:
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_path()}")
else:
from devplacepy.database import (
db as owned_db,
) # share the platform DB for this account
return TaskStore(owned_db, owner_kind, owner_id, operator=True), 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,
is_admin=True,
is_primary_admin=True,
)
from .tool_prefs import filter_disabled
tools = filter_disabled(
CATALOG.tool_schemas_for(
client.authenticated, is_admin=True, is_primary_admin=True
)
)
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()