# retoor import logging import os from devplacepy.services.base import BaseService, ConfigField from . import config from .config import ( DEFAULT_AI_MODEL, DEFAULT_AI_URL, build_settings, ) from .cost.tracker import Pricing logger = logging.getLogger("devii.service") INSTANCE_ORIGIN_DEFAULT = "http://127.0.0.1:10500" class DeviiService(BaseService): default_enabled = False min_interval = 10 title = "Devii" description = ( "An in-platform agentic assistant exposed as a WebSocket terminal at /devii/ws. " "Each signed-in user's Devii operates their own DevPlace account; guests get a " "sandbox session. Spend is capped per user and per guest over a rolling 24 hours, " "with every turn recorded to a usage ledger and an audit log." ) config_fields = [ ConfigField( config.FIELD_AI_URL, "AI URL", type="url", default=DEFAULT_AI_URL, help="OpenAI-compatible chat-completions endpoint Devii reasons with.", group="AI", ), ConfigField( config.FIELD_AI_MODEL, "AI model", type="str", default=DEFAULT_AI_MODEL, help="Model name sent to the AI endpoint.", group="AI", ), ConfigField( config.FIELD_AI_KEY, "AI API key", type="password", default="", secret=True, help="Defaults to the DEVII_AI_KEY env var, then the gateway's internal key.", group="AI", ), ConfigField( "devii_base_url", "Platform base URL", type="url", default="", help="Origin Devii drives via each user's API key. Blank uses this instance.", group="AI", ), ConfigField( config.FIELD_TIMEOUT, "AI request timeout (seconds)", type="float", default=config.DEFAULT_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS, help="Read timeout for each AI chat-completions call. Must be at least as large " "as the gateway's upstream timeout or large prompts abort early. Minimum five minutes.", group="Reliability", ), ConfigField( config.FIELD_FETCH_TIMEOUT, "Web fetch timeout (seconds)", type="float", default=config.DEFAULT_FETCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS, help="Read timeout for the fetch tool when Devii retrieves a URL. Minimum five minutes.", group="Reliability", ), ConfigField( config.FIELD_PLAN_REQUIRED, "Require plan step", type="bool", default=True, help="Force a plan() call before any tool use.", group="Agent", ), ConfigField( config.FIELD_VERIFY_REQUIRED, "Require verify step", type="bool", default=True, help="Force a verify() call after mutating actions.", group="Agent", ), ConfigField( config.FIELD_MAX_ITERATIONS, "Max tool iterations", type="int", default=40, minimum=1, maximum=200, help="Upper bound on tool-loop iterations per turn.", group="Agent", ), ConfigField( config.FIELD_ALLOW_EVAL, "Allow JavaScript execution", type="bool", default=True, help="Let Devii run JavaScript in the user's own browser (run_js tool). " "Other client tools (navigate, reload, highlight, toast, context) are unaffected.", group="Agent", ), ConfigField( config.FIELD_BROWSER_CONTROL, "Allow browser control", type="bool", default=True, help="Let Devii drive the user's own browser with structured tools " "(discover elements, click, fill, submit, wait, sequences). Independent of " "raw JavaScript execution; read-only tools (context, discover, read) are unaffected.", group="Agent", ), ConfigField( config.FIELD_INTERACTIONS_DEFAULT, "Interactive widgets default", type="bool", default=True, help="Site default for CA-IWP interactive prompts (ui_prompt). Guests always use " "this default. Signed-in users inherit it until they override it on their profile " "or with the interactions_set tool.", group="Agent", ), ConfigField( config.FIELD_USER_DAILY_USD, "Max USD per user / 24h", type="float", default=1.0, minimum=0, help="Rolling 24-hour spend cap for a signed-in user.", group="Limits", ), ConfigField( config.FIELD_GUEST_DAILY_USD, "Max USD per guest / 24h", type="float", default=0.05, minimum=0, help="Rolling 24-hour spend cap for an anonymous guest.", group="Limits", ), ConfigField( config.FIELD_ADMIN_DAILY_USD, "Max USD per admin / 24h", type="float", default=0.0, minimum=0, help="Rolling 24-hour spend cap for administrators. 0 = unlimited.", group="Limits", ), ConfigField( config.FIELD_GUESTS_ENABLED, "Allow guests", type="bool", default=True, help="When off, only signed-in users may use Devii.", group="Limits", ), ConfigField( config.FIELD_PRICE_CACHE_HIT, "Price cache-hit / 1M", type="float", default=0.0028, minimum=0, help="USD per million cache-hit input tokens.", group="Pricing", ), ConfigField( config.FIELD_PRICE_CACHE_MISS, "Price cache-miss / 1M", type="float", default=0.14, minimum=0, help="USD per million cache-miss input tokens.", group="Pricing", ), ConfigField( config.FIELD_PRICE_OUTPUT, "Price output / 1M", type="float", default=0.28, minimum=0, help="USD per million output tokens.", group="Pricing", ), ConfigField( config.FIELD_RSEARCH_ENABLED, "Enable web search tools", type="bool", default=True, help="Allow the external rsearch_* tools (web/image search, web-grounded AI answer, " "chat, image description). When off, those tools are refused. These reach an external " "public service, not this platform.", group="Web search", ), ConfigField( config.FIELD_RSEARCH_URL, "Web search service URL", type="url", default=config.DEFAULT_RSEARCH_URL, help="Base URL of the rsearch-compatible service the rsearch_* tools call.", group="Web search", ), ConfigField( config.FIELD_RSEARCH_TIMEOUT, "Web search timeout (seconds)", type="float", default=config.DEFAULT_RSEARCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS, help="Read timeout for rsearch_* calls. Web-grounded answers can take several " "minutes, so this is generous by default. Minimum five minutes.", group="Web search", ), ConfigField( config.FIELD_EMAIL_ENABLED, "Enable email tools", type="bool", default=True, help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete " "messages, send mail) for signed-in users. These connect to the user's own external " "mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.", group="Email", ), ConfigField( config.FIELD_EMAIL_TIMEOUT, "Email timeout (seconds)", type="float", default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS, minimum=1, help="Connection/read timeout for IMAP and SMTP calls.", group="Email", ), ConfigField( "devii_lessons_max_per_owner", "Max lessons per owner", type="int", default=500, minimum=0, help="Soft cap on active lessons per user/guest. When exceeded, the oldest lessons are pruned. 0 disables the cap.", group="Memory", ), ConfigField( "devii_lessons_max_age_days", "Max lesson age (days)", type="int", default=90, minimum=1, help="Lessons older than this are soft-deleted on the periodic housekeeping pass.", group="Memory", ), ] def __init__(self): super().__init__(name="devii", interval_seconds=60) self._hub = None def hub(self): if self._hub is None: from .hub import DeviiHub self._hub = DeviiHub(self._build_settings) return self._hub def effective_config(self) -> dict: from devplacepy.database import internal_gateway_key cfg = self.get_config() cfg[config.FIELD_AI_KEY] = ( cfg[config.FIELD_AI_KEY] or os.environ.get("DEVII_AI_KEY", "") or internal_gateway_key() ) return cfg def instance_base_url(self) -> str: cfg = self.get_config() configured = cfg.get("devii_base_url", "") if configured: return configured return os.environ.get("DEVII_BASE_URL", INSTANCE_ORIGIN_DEFAULT) def _pricing(self, cfg: dict) -> Pricing: return Pricing( model=cfg[config.FIELD_AI_MODEL] or DEFAULT_AI_MODEL, cache_hit_per_m=float(cfg[config.FIELD_PRICE_CACHE_HIT]), cache_miss_per_m=float(cfg[config.FIELD_PRICE_CACHE_MISS]), output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]), ) def _build_settings( self, api_key: str, base_url: str, owner_kind: str = "guest", is_admin: bool = False, ): cfg = self.effective_config() settings = build_settings(cfg, base_url, api_key, owner_kind, is_admin) return settings, self._pricing(cfg) def daily_limit_for(self, owner_kind: str, is_admin: bool = False) -> float: return config.effective_daily_limit(self.get_config(), owner_kind, is_admin) def guests_enabled(self) -> bool: return bool(self.get_config()[config.FIELD_GUESTS_ENABLED]) def spent_24h(self, owner_kind: str, owner_id: str) -> float: return self.hub().ledger.spent_24h(owner_kind, owner_id) def quota_exceeded( self, owner_kind: str, owner_id: str, is_admin: bool = False ) -> bool: limit = self.daily_limit_for(owner_kind, is_admin) return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit def reset_quota(self, owner_kind: str, owner_id: str) -> int: return self.hub().ledger.reset(owner_kind, owner_id) def reset_guest_quotas(self) -> int: return self.hub().ledger.reset_owner_kind("guest") def reset_all_quotas(self) -> int: return self.hub().ledger.reset_all() async def run_once(self) -> None: if not self.is_enabled(): return hub = self.hub() ensured = self._ensure_task_schedulers() pruned = hub.ledger.prune(48) removed = await hub.gc_idle() lessons_pruned = self._prune_old_lessons() if pruned or removed or ensured or lessons_pruned: self.log( f"Housekeeping: ensured {ensured} task scheduler(s), pruned {pruned} " f"ledger rows, pruned {lessons_pruned} lessons, closed {removed} idle sessions" ) def _prune_old_lessons(self) -> int: try: from devplacepy.database import db from .agentic.lessons import LessonStore, _read_retention_settings _, max_age = _read_retention_settings(db) store = LessonStore(db, "_global", "_global") pruned = store.prune_all_owners(max_age) if pruned: logger.info("Devii lessons housekeeping: pruned %d across all owners", pruned) return pruned except Exception: logger.exception("Devii lessons housekeeping failed") return 0 def _ensure_task_schedulers(self) -> int: from devplacepy.database import db from devplacepy.utils import is_admin, is_primary_admin from .tasks.store import pending_owner_ids try: owner_ids = pending_owner_ids(db) except Exception: # noqa: BLE001 - a bad read must not stop housekeeping logger.exception("Failed to scan for owners with pending Devii tasks") return 0 if not owner_ids: return 0 users = db["users"] if "users" in db.tables else None if users is None: return 0 ensured = 0 base_url = self.instance_base_url() for owner_id in owner_ids: user = users.find_one(uid=owner_id) if not user: continue session = self.hub().get_or_create( "user", owner_id, user.get("username", ""), user.get("api_key", ""), base_url, is_admin=is_admin(user), is_primary_admin=is_primary_admin(user), channel="main", ) session.set_timezone(user.get("timezone") or "") session.ensure_scheduler_started() ensured += 1 return ensured async def on_disable(self) -> None: if self._hub is not None: await self._hub.aclose() self._hub = None def collect_metrics(self) -> dict: cfg = self.get_config() hub = self._hub active = hub.active_sessions() if hub is not None else 0 conns = hub.connection_count() if hub is not None else 0 spent = hub.ledger.total_spent_24h() if hub is not None else 0.0 return { "stats": [ {"label": "Active sessions", "value": active}, {"label": "Connections", "value": conns}, {"label": "Spend 24h", "value": f"${spent:.4f}"}, { "label": "User cap 24h", "value": f"${float(cfg[config.FIELD_USER_DAILY_USD]):.2f}", }, { "label": "Guest cap 24h", "value": f"${float(cfg[config.FIELD_GUEST_DAILY_USD]):.2f}", }, { "label": "Admin cap 24h", "value": ( f"${float(cfg[config.FIELD_ADMIN_DAILY_USD]):.2f}" if float(cfg[config.FIELD_ADMIN_DAILY_USD]) > 0 else "unlimited" ), }, { "label": "Guests", "value": "on" if cfg[config.FIELD_GUESTS_ENABLED] else "off", }, {"label": "Model", "value": cfg[config.FIELD_AI_MODEL]}, ] }