|
import asyncio
|
|
import importlib
|
|
import logging
|
|
from collections import deque
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from devplacepy.services.base import BaseService, ConfigField
|
|
from devplacepy.services.bot import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BotsService(BaseService):
|
|
default_enabled = False
|
|
min_interval = 5
|
|
title = "Bots"
|
|
description = (
|
|
"Runs a fleet of Playwright-driven AI personas that browse a DevPlace "
|
|
"instance and post, comment, vote, and react like real users. Fleet size, "
|
|
"target site, and LLM settings are configurable, with live cost and usage "
|
|
"metrics per bot."
|
|
)
|
|
config_fields = [
|
|
ConfigField("bot_fleet_size", "Fleet size (bots)", type="int", default=1, minimum=0, maximum=20,
|
|
help="Number of concurrent bot accounts to run.", group="Fleet"),
|
|
ConfigField("bot_headless", "Headless browser", type="bool", default=True,
|
|
help="Run Chromium headless. Disable only for local debugging on a desktop.",
|
|
group="Fleet"),
|
|
ConfigField("bot_max_actions", "Max actions per bot", type="int", default=0, minimum=0,
|
|
help="Stop a bot after this many actions (0 = run indefinitely).", group="Fleet"),
|
|
ConfigField("bot_base_url", "Target site URL", type="url", default=config.BASE_URL_DEFAULT,
|
|
help="DevPlace instance the bots browse and post to.", group="Target"),
|
|
ConfigField("bot_news_api", "News API URL", type="url", default=config.NEWS_API_DEFAULT,
|
|
help="Source of articles the bots react to.", group="Target"),
|
|
ConfigField("bot_api_url", "LLM API URL", type="url", default=config.API_URL_DEFAULT,
|
|
help="OpenAI-compatible chat-completions endpoint.", group="LLM"),
|
|
ConfigField("bot_model", "LLM model", type="str", default=config.MODEL_DEFAULT,
|
|
help="Model name sent to the LLM API.", group="LLM"),
|
|
ConfigField("bot_api_key", "LLM API key", type="password", default="", secret=True,
|
|
help="Defaults to the gateway's internal key (the bots use the local AI gateway).",
|
|
group="LLM"),
|
|
ConfigField("bot_input_cost_per_1m", "Input cost per 1M tokens ($)", type="float",
|
|
default=config.INPUT_COST_PER_1M_DEFAULT, minimum=0,
|
|
help="Used to compute live spend per bot and across the fleet.", group="LLM"),
|
|
ConfigField("bot_output_cost_per_1m", "Output cost per 1M tokens ($)", type="float",
|
|
default=config.OUTPUT_COST_PER_1M_DEFAULT, minimum=0,
|
|
help="Used to compute live spend per bot and across the fleet.", group="LLM"),
|
|
]
|
|
|
|
def __init__(self):
|
|
super().__init__(name="bots", interval_seconds=15)
|
|
self._fleet = {}
|
|
self._state_dir = config.STATE_DIR
|
|
self._cost_samples = deque()
|
|
self._cost_anchor = None
|
|
|
|
def _resolve_api_key(self, cfg: dict) -> str:
|
|
from devplacepy.database import internal_gateway_key
|
|
return cfg["bot_api_key"] or internal_gateway_key()
|
|
|
|
@staticmethod
|
|
def _ensure_importable() -> None:
|
|
importlib.import_module("devplacepy.services.bot.bot")
|
|
|
|
async def run_once(self) -> None:
|
|
cfg = self.get_config()
|
|
desired = cfg["bot_fleet_size"]
|
|
api_key = self._resolve_api_key(cfg)
|
|
if not api_key:
|
|
if self._fleet:
|
|
await self._stop_fleet()
|
|
self.log("No LLM API key available (bot_api_key / gateway internal key); fleet idle")
|
|
return
|
|
try:
|
|
self._ensure_importable()
|
|
except ImportError as e:
|
|
if self._fleet:
|
|
await self._stop_fleet()
|
|
self.log(f"Bot dependencies unavailable ({e}); install the 'bots' extra")
|
|
return
|
|
|
|
for slot in list(self._fleet):
|
|
task = self._fleet[slot]["task"]
|
|
if task.done():
|
|
self._report_exit(slot, task)
|
|
del self._fleet[slot]
|
|
|
|
while len(self._fleet) > desired:
|
|
await self._stop_slot(max(self._fleet))
|
|
|
|
for slot in range(desired):
|
|
if slot not in self._fleet:
|
|
self._launch_slot(slot, cfg, api_key)
|
|
|
|
self.log(f"Fleet: {len(self._fleet)}/{desired} bots active")
|
|
|
|
def _report_exit(self, slot: int, task) -> None:
|
|
if task.cancelled():
|
|
return
|
|
exc = task.exception()
|
|
if exc:
|
|
self.log(f"bot{slot} exited with error: {exc}; relaunching")
|
|
else:
|
|
self.log(f"bot{slot} finished; relaunching")
|
|
|
|
def _launch_slot(self, slot: int, cfg: dict, api_key: str) -> None:
|
|
from devplacepy.services.bot.bot import DevPlaceBot
|
|
from devplacepy.services.bot.runtime import BotRuntimeConfig
|
|
rc = BotRuntimeConfig(
|
|
state_path=self._state_dir / f"state_slot{slot}.json",
|
|
base_url=cfg["bot_base_url"],
|
|
api_url=cfg["bot_api_url"],
|
|
news_api=cfg["bot_news_api"],
|
|
model=cfg["bot_model"],
|
|
api_key=api_key,
|
|
input_cost_per_1m=cfg["bot_input_cost_per_1m"],
|
|
output_cost_per_1m=cfg["bot_output_cost_per_1m"],
|
|
headless=cfg["bot_headless"],
|
|
registry_path=config.ARTICLE_REGISTRY_PATH,
|
|
)
|
|
bot = DevPlaceBot(rc, on_event=self.log)
|
|
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
|
|
self._fleet[slot] = {"bot": bot, "task": task}
|
|
self.log(f"Launched bot{slot}")
|
|
|
|
async def _run_slot(self, slot: int, bot, max_actions: int) -> None:
|
|
try:
|
|
await bot.run_forever(action_limit=max_actions)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
self.log(f"bot{slot} crashed: {e}")
|
|
|
|
async def on_disable(self) -> None:
|
|
await self._stop_fleet()
|
|
|
|
async def _stop_fleet(self) -> None:
|
|
for slot in list(self._fleet):
|
|
await self._stop_slot(slot)
|
|
|
|
async def _stop_slot(self, slot: int) -> None:
|
|
entry = self._fleet.pop(slot, None)
|
|
if not entry:
|
|
return
|
|
task = entry["task"]
|
|
task.cancel()
|
|
try:
|
|
await asyncio.wait_for(task, timeout=30)
|
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
pass
|
|
except Exception as e:
|
|
self.log(f"bot{slot} stop error: {e}")
|
|
self.log(f"Stopped bot{slot}")
|
|
|
|
def collect_metrics(self) -> dict:
|
|
rows = []
|
|
total_cost = 0.0
|
|
total_calls = total_in = total_out = 0
|
|
posts = comments = votes = running = 0
|
|
for slot in sorted(self._fleet):
|
|
entry = self._fleet[slot]
|
|
bot = entry["bot"]
|
|
alive = not entry["task"].done()
|
|
running += 1 if alive else 0
|
|
llm = getattr(bot, "llm", None)
|
|
st = getattr(bot, "state", None)
|
|
cost = llm.total_cost if llm else 0.0
|
|
calls = llm.total_calls if llm else 0
|
|
total_cost += cost
|
|
total_calls += calls
|
|
total_in += llm.total_in_tokens if llm else 0
|
|
total_out += llm.total_out_tokens if llm else 0
|
|
if st:
|
|
posts += st.created_posts
|
|
comments += st.comments_posted
|
|
votes += st.votes_cast
|
|
rows.append([
|
|
f"bot{slot}",
|
|
(st.username if st else "") or "-",
|
|
(st.persona if st else "") or "-",
|
|
"running" if alive else "stopped",
|
|
st.created_posts if st else 0,
|
|
st.comments_posted if st else 0,
|
|
st.votes_cast if st else 0,
|
|
calls,
|
|
f"${cost:.4f}",
|
|
])
|
|
window, observed_cost, ready = self._sample_cost(datetime.now(timezone.utc), total_cost)
|
|
cost_per_hour = observed_cost / window * 3600 if ready else 0.0
|
|
projected_24h = observed_cost / window * 86400 if ready else 0.0
|
|
rate_value = f"${cost_per_hour:.4f}/h" if ready else "warming up"
|
|
projected_value = f"${projected_24h:.2f}" if ready else "warming up"
|
|
stats = [
|
|
{"label": "Bots running", "value": running},
|
|
{"label": "Fleet cost", "value": f"${total_cost:.4f}"},
|
|
{"label": "Observed window", "value": f"{window:.0f}s"},
|
|
{"label": "Cost rate", "value": rate_value},
|
|
{"label": "Projected 24h cost", "value": projected_value},
|
|
{"label": "LLM calls", "value": total_calls},
|
|
{"label": "Tokens in", "value": total_in},
|
|
{"label": "Tokens out", "value": total_out},
|
|
{"label": "Posts", "value": posts},
|
|
{"label": "Comments", "value": comments},
|
|
{"label": "Votes", "value": votes},
|
|
]
|
|
table = {
|
|
"columns": ["Bot", "User", "Persona", "Status", "Posts", "Comments", "Votes", "Calls", "Cost"],
|
|
"rows": rows,
|
|
}
|
|
return {"stats": stats, "table": table}
|
|
|
|
def _sample_cost(self, now: datetime, total_cost: float) -> tuple[float, float, bool]:
|
|
if self._started_at is None:
|
|
self._cost_samples.clear()
|
|
self._cost_anchor = None
|
|
return 0.0, 0.0, False
|
|
if self._cost_anchor != self._started_at:
|
|
self._cost_samples.clear()
|
|
self._cost_anchor = self._started_at
|
|
self._cost_samples.append((now, total_cost))
|
|
cutoff = now - timedelta(seconds=config.COST_WINDOW_SECONDS)
|
|
while len(self._cost_samples) > 2 and self._cost_samples[0][0] < cutoff:
|
|
self._cost_samples.popleft()
|
|
start_at, start_cost = self._cost_samples[0]
|
|
window = (now - start_at).total_seconds()
|
|
observed_cost = max(0.0, total_cost - start_cost)
|
|
ready = window >= config.COST_WARMUP_SECONDS
|
|
return window, observed_cost, ready
|